• v0.14.0 db2c08709d

    v0.14.0 Stable

    x released this 2026-10-24 13:49:27 +00:00 | 2 commits to main since this release

    VSKI v0.14.0

    Pipelines: automations as data

    Workflows have been VSKI's answer to "run this reliably" — but they live in
    worker code you write, deploy and version yourself. v0.14.0 adds
    pipelines: multi-step automations defined as JSON and edited on a visual
    canvas, executed durably by the engine itself. No worker, no deployment —
    the definition is the automation.

    A pipeline is a DAG of steps — call a plugin function, make an HTTP request,
    compute values, wait on a timer, park for a human approval, spawn a child
    workflow or pipeline — with per-step dependencies, conditions, retries,
    timeouts and failure policies. Pipelines start from collection events
    (record created/updated/deleted), cron schedules, or manually via API/UI.

    They are not a parallel universe: every pipeline run is an ordinary
    workflow run named pipeline.<name>, driven by the same event log, job
    queues, waits and signals machinery. The run monitor, Gantt timeline, stats,
    circuit breaker, execution timeouts and crash recovery all apply unchanged —
    pipeline steps emit the standard step_* events with correlationId =
    step id, so the existing timeline renders them for free.

    The definition

    {
      "name": "note-intake",
      "triggers": [
        { "type": "collection", "database": "default", "collection": "notes", "actions": ["create"] }
      ],
      "steps": [
        { "id": "embed", "type": "plugin", "target": "indexer/reindex",
          "params": { "id": "{{trigger.record.id}}" }, "retries": 2 },
        { "id": "approve", "type": "signal", "name": "approval", "dependsOn": ["embed"] },
        { "id": "notify", "type": "http", "method": "POST", "url": "https://hooks.example.com/x",
          "body": { "id": "{{trigger.record.id}}", "by": "{{steps.approve.output.by}}" },
          "onError": "continue", "dependsOn": ["approve"] }
      ],
      "output": { "indexed": "{{steps.embed.output.count}}" }
    }
    

    Step types: plugin (warm in-process invoke, instance/function, timeout
    1-120s), http (synchronous, every attempt in _webhook_logs, output
    {status, body}), set (template evaluation into {{vars.*}}), delay
    (durable wait — survives restarts, fires exactly at the resume time),
    signal (approval gate — parks until a matching signal arrives, FIFO by
    name, resumable via sendSignal or workflow hook tokens), and
    workflow/pipeline spawn steps (fire-and-forget children linked via
    parentRunId).

    Common step fields: dependsOn (independent branches run in parallel,
    bounded by options.concurrency), condition (data-only expression
    language — comparisons, and/or/not, literals; sandboxed by construction),
    retries (linear backoff), timeout, onError: "fail" | "continue"
    (failed steps skip their transitive dependents while unrelated branches
    continue; continue captures the error as steps.<id>.error).

    Templates: {{trigger.*}}, {{input.*}}, {{steps.<id>.output|.status|.error}},
    {{vars.*}}, {{run.id}} - apply to any string in params/headers/body/input,
    with array indexing; a whole-string template preserves the value's type.

    Execution semantics worth knowing

    • Version pinning: each run snapshots the definition into its
      executionContext. Editing or deleting a pipeline never mutates an
      in-flight run.
    • At-least-once across crashes: the event log is the source of truth;
      after a restart the dispatcher replays events and re-executes only steps
      that hadn't completed (boot recovery re-pends orphaned jobs and requeues
      active runs).
    • Per-run serialization: runs of one pipeline execute one at a time;
      independent pipelines run concurrently. Steps of one run run in parallel
      up to options.concurrency (default 4).
    • True suspension: while a run waits on a delay or signal it holds no
      job; a failed branch doesn't fail the run while another branch still
      waits.
    • Replicas stay cold: triggers are only registered and pipelines only
      dispatched on the master.

    Triggers

    • Collection events: the pipeline subscribes from its definition, no
      collection-options changes, no auth-header webhook wiring. Disabled
      pipelines ignore events; the registry rebuilds on every definition change.
    • Cron: {"type": "cron", "schedule": "0 9 * * 1-5"} inside the
      definition registers an in-process named schedule (retired on
      disable/update/delete, no cron-job rows). Ad-hoc: any cron job with
      url: "pipeline://<name>" fires a pipeline.
    • Manual: POST /api/pipelines/:name/runs or the UI Run dialog.

    The builder

    The dashboard grows a Pipelines section: a list (trigger chips, version,
    last-run status) and a full-page visual builder on
    React Flow - drag nodes to declare dependsOn,
    per-type step inspectors parsed from live plugin catalogs (function lists
    come from manifests), a JSON⇄Canvas toggle that round-trips the definition,
    server-side Validate with per-step issues, and a Run dialog. A Runs
    button polls the last 10 runs with a dot indicator (pulsing green while
    anything is active), linking into the existing run views; the workflows page
    accepts ?workflowName=pipeline.<name> to pre-filter its list.

    API & SDKs

    GET/POST /api/pipelines, GET/PATCH/DELETE /api/pipelines/:name
    (definition replace bumps version; rename rejected),
    POST /api/pipelines/validate (per-step issue maps: cycles, refs, cron,
    durations, template/condition syntax, option ranges),
    POST /api/pipelines/:name/runs. Auth: the workflows role or admin, like
    the rest of the workflow API. pipelines is also queryable as a system
    collection.

    • JS/TS (@vski/sdk 2.3.0): client.pipelines — list, get,
      getDefinition, create, update, remove, validate, run; exports
      Pipeline, PipelineDefinition, PipelineStep, PipelineTrigger,
      ValidationIssue types.
    • Python (vski-sdk 0.7.0): client.pipelines — list, get,
      get_definition, create, update, delete, validate, run.

    Correctness fixes

    • plugin:// cron jobs never worked — documented since the plugins
      release, but the cron executor routed every command through the SQL
      branch, so plugin:// jobs failed as SQL syntax errors. Cron plugin://
      commands now dispatch through the webhook retry/log pipeline over the
      warm pipe, and replicas correctly skip all dispatch-type commands
      (http/plugin/pipeline).
    • ExecuteStandaloneWebhook dropped the job's database whenever a body was
      set, breaking plugin:// instance resolution for POST cron jobs.
    • The OpenAPI spec documented POST/GET/PATCH/DELETE /workflows
      definition CRUD endpoints that never existed (routes were never
      implemented). The dead spec block is removed.
    • Workflows dashboard: pre-existing 'sdk' possibly null type errors fixed;
      the run list prefills its filter from ?workflowName= deep links.

    Tests

    test/e2e/55_pipelines_test.ts (9 steps): validation + CRUD, DAG with
    parallel branches/join/condition skip/output templating, durable delay,
    signal approval with templated payload, failure semantics (transitive
    skips, unrelated branches complete, onError: continue), collection + cron
    triggers (definition schedule and pipeline:// command), spawn steps with
    parentRunId, and replay-skip resume through the real dispatcher. The
    engine gains its first Go unit-test suite (internal/pipelines): template
    resolver, condition grammar, validator (cycles, per-type fields), and the
    executor against a fake runtime (retries, suspension, resume-skip, vars
    fold order, terminal idempotency), plus trigger-registry tests.

    Compatibility

    • No breaking API or storage changes. One new table (pipelines in
      workflows.db, registered as a system collection) and new, additive
      endpoints/SDK namespaces. Existing workflows, triggers and cron jobs are
      untouched.
    • Pipeline dispatch is master-only; upgrading a replica is a no-op for
      pipelines.
    • The standalone Docker build now ships the SDK source beside the web app
      (build/client), since the web app compiles against local SDK source.
    • JS SDK 2.3.0 publishing to vski.sh/npm is part of the release run;
      external consumers need it for client.pipelines.

    What's next

    Deliberately out of v1: foreach fan-out over arrays, wait-for-completion
    on spawned children (model it with a signal step today), a public token
    endpoint for external triggers, realtime status overlay on the canvas, and
    a native llm step type. The Pipelines wiki
    page
    documents all of it.

    Downloads
  • v0.13.0 047932f508

    v0.13.0 Stable

    x released this 2026-10-22 14:11:52 +00:00 | 3 commits to main since this release

    VSKI v0.13.0

    Light graphs: calendar-grade indexing

    Light graphs landed in v0.10 as a relationship index for collections, and
    the first heavy users all converged on the same ask: index events by
    time. "What did this person do last week", "what changed on this date",
    "how many hours accumulated on this edge" - the graph data model already
    fit (nodes and edges carry labels, weights and free-form JSON properties),
    but the ingest and query primitives were missing. v0.13.0 closes that gap
    without growing the engine: no new tables, no migrations, one new index.

    Idempotent ingest: upsert=true

    Indexing pipelines re-run. POST /graphs/:name/nodes|edges?upsert=true
    (now also graph.createNodes(data, { upsert: true }) in the JS SDK)
    replaces an existing id wholesale instead of erroring - the graph
    equivalent of MERGE, and the primitive every timetree ingester needs.
    Upserts hitting an existing id additionally check the graph's updateRule
    (the item already passed createRule like any create). The whole batch is
    written in a single transaction: on failure nothing is persisted (batch
    creates previously could persist a prefix of the batch).

    Atomic accumulation: weightDelta

    PATCH /graphs/:name/edges/:id accepts weightDelta (mutually exclusive
    with weight): the server reads the current weight under the graph's
    write lock and adds to it, so concurrent increments cannot lose updates -
    the classic "hours accumulated", "stock consumed" counter. Weights also
    feed a new stats aggregate: GET .../stats now reports
    edgeWeightSums per edge label (computed in SQL, where NULL weights are
    distinguishable from 0) alongside a new nodeLabels histogram.

    Property filters: prop=path op value

    The JSON properties maps are no longer opaque. List endpoints accept
    repeatable prop filters - ref.collection=users, capacity>=5,
    status!=draft, title~meeting, archived=null - compiled to SQLite
    json_extract comparisons with bound parameters (numeric-looking values
    compare numerically, ~ is a substring match, null tests for
    absence). This makes properties.ref and capacity/stock metadata
    first-class query targets. Nodes also accept ids=a,b,c (max 500) and
    edge source/target accept comma lists, so "everything touching last
    week's day nodes" is one request.

    Node types in the index: nodeLabels

    Node labels are now interned in the in-memory topology index (alongside
    edge labels and weights; ~4 bytes/node). neighbors, traverse and
    subgraph accept nodeLabels=a,b to restrict visited/returned nodes by
    type - "documents linked to this day, not the people". Node label PATCHes
    keep the index in sync; a SQL index on nodes(label) is created for new
    graphs and backfilled idempotently for existing ones.

    Correctness fixes

    • Edge weight PATCHes left the in-memory index stale on the master:
      the index entry was rebuilt only when the label changed, and with the
      pre-update weight - weighted shortest paths kept answering with the old
      cost until restart. Weight and label changes now sync the index
      immediately.
    • Shortest path ignored edge direction - it always relaxed both
      adjacency lists, so directed graphs got undirected routes. The default
      stays both-ways (documented behavior), but path now accepts
      direction=out|in|both.

    Calendar timetree, packaged

    The Calendar Timetree recipe
    documents the pattern end to end: week --includes_day--> day backbone
    nodes, record --on_day--> day activity edges with magnitudes on the
    weight, deterministic ids so re-ingest is a no-op. The new
    calendar-index example plugin automates it - subscribe it to a
    collection with a date field and it maintains the tree on every
    create/update/delete (record deletes cascade their edges; the calendar
    backbone stays), with backfill and week_of helper functions. It uses
    native upsert (hence vski-sdk >= 0.6.0) and needs read rules on the
    source collection plus write rules on the target graph for its service
    account (the recipe shows both).

    The server stays calendar-agnostic: any bucketing (sprints, fiscal
    quarters) is just more backbone nodes with the same shape.

    SDKs

    • JS/TS: createNodes/createEdges take an optional
      { upsert } second argument; updateEdge takes weightDelta;
      path takes direction; getNodes takes ids and prop,
      getEdges takes array source/target and prop;
      neighbors/traverse/subgraph take nodeLabels; GraphStats adds
      nodeLabels and edgeWeightSums. All additive.
    • Python (vski-sdk v0.6.0): create_nodes/create_edges take
      upsert=; update_edge takes weight_delta=; get_nodes takes ids=
      and prop=, get_edges takes list-valued source=/target= and
      prop=; neighbors/traverse/subgraph take node_labels=; path
      takes direction=; GraphStats adds nodeLabels and edgeWeightSums.
      The sync wrapper mirrors everything automatically.

    Tests

    47_graph_test.ts grows a "Calendar indexing primitives" step (upsert
    replacement + duplicate rejection, weightDelta accumulation and
    exclusivity, weight-patch freshness on weighted paths, directed path,
    property/ids/multi-value filters, nodeLabels on neighbors and subgraph,
    stats sums); wiki_graph_verify.ts pins every new claim the wiki pages
    make; graphdb unit tests cover SetEdgeWeight, SetNodeLabel,
    directed paths and node-label filters. On the Python side, the SDK's
    test_47_graphs.py mirrors the new surface and test_53_calendar_index.py
    exercises the calendar-index plugin end to end (tree build, re-ingest
    idempotency, delete cascade, week_of, backfill). A pre-existing flaky
    SchemaFromMap test (map iteration order) is now order-independent.

    Compatibility

    • No breaking API or storage changes; everything new is opt-in query/body
      parameters. Path defaults to the historical both-ways walk; plain
      POST stays insert-only.
    • No migrations: the node-label index is CREATE INDEX IF NOT EXISTS,
      applied on graph creation and backfilled lazily (master only) when an
      existing graph's index loads.
    • Upsert replaces label/properties (and edge endpoints/weight)
      wholesale - it does not merge. Pipelines that relied on the
      create-then-update fallback in graph-sync keep working unchanged.
    Downloads
  • v0.12.0 615bc52393

    v0.12.0 Stable

    x released this 2026-10-22 00:30:07 +00:00 | 4 commits to main since this release

    VSKI v0.12.0

    Plugins: supervised Python beside the server (experimental)

    Every real deployment grows a system around VSKI: an embeddings indexer,
    a record-to-graph sync, a crawler, a chat bot. Until now those lived as
    undocumented sidecars - cron entries, hand-rolled daemons, HTTP triggers
    pointing at localhost - with no shared lifecycle, no configuration story
    and no observability. v0.12.0 makes that pattern first-class: plugins are
    Python processes supervised by the server, configured and observed through
    the admin API and dashboard, running in the same container with zero
    network hops.

    The design stance matters as much as the feature:

    • Plugins are reusable source, not packages. Think shadcn components:
      you copy one into your distribution, verify its dependencies against
      the rest of your environment, tune its config schema, and build one
      image that just works. The server never installs or manages Python
      dependencies - the official images ship no Python at all, and
      distributions that want plugins bring their own locked interpreter
      (the wiki has the Dockerfile snippet).
    • An instance is data. Plugin + database + JSON config + enabled
      flag. Changing an API key or cloning a bot for another chat group is a
      PATCH or a POST that restarts exactly one process - never the server,
      never the container.
    • Not a sandbox. Plugin code ships inside your image and is trusted,
      exactly like workflow workers. There is no upload endpoint; code
      arrives via the filesystem only.

    Plugins are experimental and disabled by default
    (PLUGINS_ENABLED=true to switch on; when off, every plugin endpoint
    answers 503 with setup instructions).

    How it works

    A plugin is a directory under PLUGINS_DIR (default
    DATA_DIR/plugins) containing a vski-plugin.json manifest (name,
    runtime entry, config schema with secret fields, declared deps as
    metadata) and a Python package. The supervisor spawns
    $PLUGIN_PYTHON -m vski.plugin.runner inside it - PLUGIN_PYTHON must
    have vski-sdk>=0.5.0 (and the plugin's dependencies) installed by the
    distribution.

    • Control plane: protocol frames on the process's stdin/stdout
      (initialize / ping / invoke / shutdown), logs on stderr. A warm
      invocation is a pipe write - no socket, no cold start.
    • Health: pinged every 10s; an unresponsive process is killed and
      restarted. Crashes restart with exponential backoff (1s doubling to
      60s, reset after 5 minutes of uptime); the restarts counter tracks
      crash restarts. kill -9 recovery is covered by the e2e suite.
    • Credentials: each process start mints a fresh API key with the
      plugin role and deletes it when the process exits - key material
      exists only in server memory and the child environment. The key is a
      normal authenticated principal: collection and graph access rules
      apply to it.
    • Events: plugin handlers subscribe through the existing realtime
      websocket using a per-instance consumer group - at-least-once delivery
      with ACK-committed group offsets, so events that occur while a plugin
      is down are redelivered when it comes back (see the realtime section
      below).
    • Replicas never supervise plugins (like webhooks and realtime);
      instance status stays readable through the synced registry.

    API

    All admin-only:

    • GET /api/plugins, POST /api/plugins/scan - catalog from manifests
    • GET/POST /api/plugin-instances (instances are unique per
      (dbName, name)), GET/PATCH/DELETE /api/plugin-instances/:name?db=
      • a config change bumps configVersion and restarts only that
        instance's process
    • POST .../:name/start|stop|restart
    • POST .../:name/invoke - {function, params, timeoutSeconds?}
      (default 30s, max 120s) over the warm pipe
    • GET .../:name/logs?level=&limit= - captured stderr (newest first;
      kept 7 days in _plugin_logs in stats.db)

    Collection triggers and HTTP cron jobs accept
    plugin://<instance>/<function> as their URL: the webhook engine
    dispatches it in-process with the same retry policy and the same
    _webhook_logs pipeline - a zero-hop alternative to pointing a trigger
    at localhost. Cron jobs resolve their own database for the instance
    lookup.

    Dashboard

    Settings → Plugins: catalog cards from the manifests, the instances
    table with live status chips / restart counts / uptime, a config editor
    generated from each manifest's schema (saving restarts just that
    process), an invoke console (function picker + params + result), a
    captured-log tail, start/stop/restart actions, and a disabled-state
    banner pointing at the env flags. The dashboard now depends on
    @vski/sdk@^2.2.0.

    Writing a plugin

    from vski.plugin import Plugin
    
    app = Plugin()
    
    @app.on_event("documents")      # realtime consumer group, at-least-once
    async def on_document(ctx, event): ...
    
    @app.function("reindex")        # callable via /invoke and plugin:// triggers
    async def reindex(ctx, params): ...
    
    @app.cron("*/5 * * * *")        # dependency-free 5-field cron
    async def sweep(ctx): ...
    

    ctx carries a db-scoped client, the instance config and a log
    whose output the server captures. Four working examples ship in
    examples/plugins/: hello (every handler type, doubles as the e2e
    fixture), note-bot (the wiki tutorial plugin), embeddings-indexer
    (event-driven chunking/embedding via an OpenAI-compatible endpoint -
    formalizes the Embeddings tutorial's "next steps") and graph-sync
    (mirrors a collection into a light graph with properties.ref links -
    formalizes the Graphs tutorial's sync pattern).

    Realtime: durable consumer groups and fixes

    Work on plugin event delivery hardened the realtime gateway for
    everyone:

    • API keys are now accepted on /api/realtime (?auth=<key>,
      any enabled key) - service processes can subscribe like any client.
    • Consumer groups are durable. ACKs now advance a per-group
      committed offset, and consumers joining later (or plugin processes
      restarting) resume after it instead of tailing from "now" - events
      that fired while nobody was connected are no longer skipped. Group
      deliveries now carry the ackId field both SDKs already expected
      (neither ever ACKed before, because the server never sent it).
    • Fixed: a fresh group consumer received one stale event from the
      head of the untrimmed event store (delivery position initialized
      from zero instead of the subscription position).
    • Fixed: event frames carried the id of the first stored event
      with a matching action rather than their own, so ACKs could commit
      the wrong position.
    • Event ids are zero-padded (<millis>-<seq>), making the id ordering
      used throughout the gateway numerically correct.

    Non-group subscriptions are unchanged. Delivery remains at-least-once
    for groups.

    Supervisor hardening

    Start/stop transitions are serialized per instance (a restart can no
    longer race the reconciler into double-spawning an orphaned process),
    shutdown waits are bounded, and no process is spawned once shutdown has
    begun.

    SDKs

    • TypeScript/JS (@vski/sdk v2.2.0): new client.plugins
      namespace - list, scan, listInstances, getInstance,
      createInstance, updateInstance, deleteInstance,
      startInstance/stopInstance/restartInstance, invoke, logs -
      with full types (PluginCatalogItem, PluginInstance,
      PluginRuntimeInfo, PluginLogEntry).
    • Python (vski-sdk v0.5.0): the vski.plugin module described
      above, a matching client.plugins management namespace, and three
      transport fixes: request paths with an embedded query string were
      silently dropped (httpx replaces a URL's query when given params,
      even empty), websocket auth tokens are now URL-quoted (raw-base64
      API keys containing + previously failed as spaces), and group
      subscriptions track lastId so a reconnecting consumer resumes
      after the last seen event.

    Documentation & tests

    Wiki: Plugins reference
    (architecture, manifest spec, env contract, API, security model,
    distribution Dockerfile) and a hands-on
    Plugins Tutorial
    whose flows are covered end-to-end by the new suites:
    48_plugins_test.ts (15 steps: catalog, lifecycle, warm invoke, event
    delivery, captured logs, per-instance restart on config change,
    plugin:// trigger delivery, clone-into-second-db, crash recovery,
    admin gating, disabled-503) and test_48_plugins.py (instance
    lifecycle, event delivery, plugin:// triggers, admin gating,
    including downtime redelivery). The e2e harnesses now start their
    servers with plugins enabled and the examples directory mounted.

    Compatibility

    • No breaking API or storage changes. Plugins are off by default; the
      plugins / plugin_instances tables (default.db) and _plugin_logs
      (stats.db) are created idempotently on boot.
    • Realtime group consumers start ACKing and resume from committed
      offsets - strictly fewer lost events; clients that manually ACKed are
      unaffected. The padded event-id format only matters within a single
      server process (the event store is in-memory).
    • Official docker images are unchanged (no Python); plugin-enabled
      distributions build FROM them per the wiki.
    Downloads
  • v0.11.1 20b9db4606

    v0.11.1 Stable

    x released this 2026-10-21 16:19:43 +00:00 | 5 commits to main since this release

    VSKI v0.11.1

    Fix: per-item access rules on graphs

    Graph list and read endpoints evaluate their access rule twice: once as
    a gate when the request arrives (no node or edge in hand yet), then
    again per item. The gate ran the rule with no item data, so any rule
    that referenced item fields - for example listRule: "label = 'public'" - failed evaluation at the gate and every non-admin request
    errored out. The per-item pass documented in v0.10.0 (filtering,
    scan-ahead, "filtered": true responses) never ran.

    The gate now defers to the per-item evaluation when a rule references
    item fields:

    • empty rules still deny, "*" and auth-only rules
      (@request.auth.id != '') still decide at the gate, and malformed
      rules still error;
    • single reads (GET .../nodes/:nodeId, .../edges/:edgeId) apply
      viewRule per item, so a field-based view rule hides individual
      items instead of failing the request;
    • list endpoints filter with the documented scan-ahead and report
      "filtered": true, counting only visible items;
    • traversal endpoints (neighbors, traverse, path, subgraph,
      stats) remain strictly gated by viewRule with no per-item pass,
      unchanged.

    The behaviour is covered end-to-end in both the Deno and Python graph
    suites: a field-based listRule/viewRule against a non-admin client,
    including the filtered flag and per-node denial.

    Documentation

    The project wiki gained a
    Graphs reference page and a
    knowledge-graph tutorial -
    linking collection records through a graph (works_on/authored/
    depends_on edges), traversal queries, a sync pattern for record
    changes, rules, and the dashboard explorer. The Collections page was
    corrected along the way: select fields take
    options: { values: [...] }, not an array.

    Compatibility

    • No API, storage or SDK changes: @vski/sdk stays at 2.1.1,
      vski-sdk at 0.4.1.
    • Rules that already worked behave identically; requests that
      previously errored (field-referencing rules) now return filtered
      results.
    • Full-suite runs still show the environment-flaky
      gates/realtime/workflow-hook tests known from earlier releases -
      failing sets vary between runs of the same binary, and they reproduce
      on the committed v0.10.x baseline with clean data. The graph suite
      passes consistently.
    Downloads
  • v0.11.0 20b9db4606

    v0.11.0 Stable

    x released this 2026-10-21 14:55:49 +00:00 | 5 commits to main since this release

    VSKI v0.11.0

    Graphs in the dashboard

    v0.10.0 shipped light graphs with SDK-only access; this release adds the
    follow-up web update. Graphs are now first-class in the dashboard: browse
    them alongside collections, explore a subgraph interactively, and edit
    nodes and edges without leaving the browser.

    What's in the UI

    • Sidebar: a Graphs section lists every graph grouped by database,
      live-updating as graphs are created or deleted. The collections-style
      header links to the index page and the create form.
    • Index (/graphs): all graphs across all databases with direction,
      caps and creation date; delete with confirmation.
    • Create (/graphs/create): name, target database, directed/undirected,
      optional per-graph maxNodes/maxEdges, and the five access-rule
      fields - the same inputs the SDK accepts.
    • Explorer (/graphs/:name?db=): the main page.
      • A stats strip (node/edge counts against effective caps with usage
        bars, average and max degree) and clickable edge-label chips that
        double as label filters for every query on the page.
      • A query bar: searchable center node, hops (1-5), result limit and
        direction. The page auto-seeds from the most recent node so a graph
        is never a blank screen.
      • The canvas renders the subgraph with Cytoscape.js and the fCoSE
        layout: distinct seed node, per-label edge colors, directed arrows,
        zoom/fit controls, and theme-aware colors for both light and dark.
      • Click a node or edge for a detail panel with properties and actions:
        Center here re-seeds the view, Expand merges one-hop neighbours
        into the canvas without a full reload, plus edit and delete.
      • A shortest-path finder: pick two nodes (same search), choose
        hops or weight cost, and the path is highlighted on the canvas with
        everything else dimmed.
      • Full node and edge CRUD from modals - including endpoints picked via
        node search when creating an edge.

    Cytoscape is lazy-loaded with the explorer page, so the main dashboard
    bundle does not grow. The embedded dashboard in the standalone binary
    ships the same pages.

    Substring search for nodes and edges

    Listing endpoints only supported an exact-match label filter - usable
    for slicing by label, useless for finding a node in a graph that may hold
    100k of them. Both list endpoints now accept q:

    • GET /api/graphs/:name/nodes?q= substring-matches node id and label
    • GET /api/graphs/:name/edges?q= substring-matches edge
      id, source, target and label

    q combines with the existing label/source/target filters and
    pagination, and the usual access rules still apply. The dashboard's node
    pickers are backed by it (debounced, 20 results per keystroke).

    SDKs

    • TypeScript/JS (@vski/sdk v2.1.1): getNodes/getEdges accept a
      q substring-search option.
    • Python (vski-sdk v0.4.1): get_nodes/get_edges accept the
      matching q= keyword (mirrored by the sync wrapper). No other surface
      changes.

    Fixes

    • Dashboard: edge-label chips moved below the canvas, next to the
      legend they explain.
    • Dashboard: the canvas now renders correctly on both themes. daisyUI
      5 serves theme colors as oklch() and Chromium preserves oklch in
      computed styles, which Cytoscape's canvas cannot parse - every color is
      now converted to sRGB through a canvas pixel readback, and theme flips
      re-resolve node, edge and label colors live.
    • Dashboard: the sidebar footer is decluttered - the OpenAPI link
      moved to the connection modal footer (left of Retry Connection),
      leaving user actions alone in the sidebar.

    Compatibility

    • No breaking changes. q is an additive query parameter ignored by
      older clients; older servers ignore it from newer SDKs (results are
      unfiltered).
    • Access rules, caps and realtime publishing behave exactly as in
      v0.10.0; nothing about the storage or index changed.
    • Python e2e: the graph suite covers substring search; one workflow
      websocket test is flaky under full-suite ordering (passes in
      isolation, reproduces on the v0.10.0 backend as well - not a
      regression from this release).
    Downloads
  • v0.10.0 ba405b633c

    v0.10.0 Stable

    x released this 2026-10-21 10:54:05 +00:00 | 6 commits to main since this release

    VSKI v0.10.0

    Light Graphs: retiring the Neo4J sidecar

    Most tenants that needed graph workloads ran Neo4J alongside VSKI.
    Looking at actual usage, 99% of those use cases - agent graphs,
    knowledge linking, dependency walking - never touched the advanced
    features (Cypher, clustering, multi-hop pattern matching at scale) and
    never exceeded 100k edges. What they did incur was the cost of running
    and maintaining a separate database service: another deployment unit,
    another backup/replication story, another source of truth to keep
    consistent with VSKI data, another thing on-call has to know.

    Light graphs move that workload into VSKI itself. Graphs are now a
    first-class entity type alongside collections - same
    (dbName, name) namespacing, same access-rule model, same single
    SQLite storage engine, same replication - so the graph and the content
    it indexes live in one place. For the workloads tenants actually run,
    this removes the Neo4J dependency and its engineering overhead
    entirely; no new infrastructure ships with it.

    How it works

    A graph is a named, db-scoped registry entry (same tombstone/revive
    semantics as collections) whose data lives in two physical tables in
    the tenant database: graph_<name>_nodes and graph_<name>_edges
    (labelled, directed edges with optional numeric weight, cascading
    deletes on endpoints). SQLite stays the source of truth - no separate
    service, nothing new to operate.

    Traversal performance comes from an in-memory index: on first access
    each graph's topology (ids, edge labels, weights - never properties)
    is loaded into a compact adjacency index with nodes and edges
    interned to uint32 slots. A fully-warm 100k-node / 100k-edge graph
    occupies ~20-25 MB and a worst-case BFS runs in single-digit
    milliseconds. Mutations serialize per graph (check + SQL write +
    index update under one lock), so size caps cannot be raced and the
    index cannot drift from committed state. Cold graphs are evicted LRU
    (GRAPH_INDEX_MAX_GRAPHS, default 32) and reload from SQLite on next
    touch; replicas revalidate against the database epoch and reload
    transparently.

    API

    Registry (mirrors /api/collections): GET/POST /api/graphs,
    GET/PATCH/DELETE /api/graphs/:name (soft delete keeps data;
    re-creating the same name revives the graph with its contents).

    Data & traversal:

    • GET/POST /api/graphs/:name/nodes, GET/PATCH/DELETE .../nodes/:nodeId
    • GET/POST /api/graphs/:name/edges, GET/PATCH/DELETE .../edges/:edgeId
    • GET .../nodes/:nodeId/neighbors?direction=out|in|both&labels=a,b&limit=
    • GET .../nodes/:nodeId/traverse?depth=N&direction=&labels=&limit= (BFS, max depth 10)
    • GET .../path?from=&to=&maxHops=&cost=hops|weight&labels= (BFS or Dijkstra over edge weights)
    • GET .../subgraph?nodes=a,b&hops=N - neighborhood around seed nodes
    • GET .../stats - node/edge counts, label histogram, degree stats, effective caps

    Nodes and edges accept single objects or arrays on create; ids are
    optional (15-char generated). Deleting a node cascades its edges.

    Rules

    The five collection rule fields apply with collection semantics: empty
    rule denies non-admins, "*" is public, admins bypass. List endpoints
    evaluate rules per item (non-trivial rules scan ahead to fill the page;
    the response carries "filtered": true and counts only visible items
    when that happened). Traversal endpoints are gated by viewRule.

    Caps

    GRAPH_MAX_EDGES / GRAPH_MAX_NODES (default 100000 each) bound every
    graph server-wide; a graph may declare a lower per-graph cap via
    options.maxEdges / options.maxNodes. Exceeding the cap is a clean
    400 (graph edge cap exceeded (...)) from the per-graph write lock.

    The caps exist to keep the memory story boring: every graph is bounded,
    so the total index footprint is bounded by the number of warm graphs,
    and nothing about the feature changes VSKI's single-binary, single-file
    storage profile.

    SDKs

    • TypeScript/JS (@vski/sdk v2.1.0): new client.graph(name, db?)
      / client.db(name).graph(name) namespace (node/edge CRUD,
      neighbors, traverse, path, subgraph, stats) and
      client.settings.graphs registry namespace. New types:
      GraphConfig, GraphNode, GraphEdge, GraphStats, and the
      traversal result types.
    • Python (vski-sdk v0.4.0): matching surface - client.graph(name, db) / client.db(name).graph(name) (get_nodes/create_nodes/
      get_node/update_node/delete_node, the same five for edges,
      neighbors, traverse, path, subgraph, stats) plus the
      client.settings.graphs registry namespace and GraphConfig/
      GraphNode/GraphEdge/GraphStats TypedDicts. The sync wrapper
      mirrors both (SyncVskiClient.graph(...)). The e2e harness (including
      the replica suite) now honours VSKI_BINARY/VSKI_DIR overrides, so
      it runs against any server checkout; the suite passes on pytest 8 and 9.

    Fixes

    • Collections: the drop index/view/FTS routes
      (DELETE /api/collections/:name/indexes/:indexName,
      .../views/:viewName, .../fts/:ftsIndexName) were documented and
      implemented but never registered by the router - every call 404ed. They
      are wired now; the SDK drop_view/drop_index/drop_fts_index
      methods work against them.
    • Python SDK docs: set_views was documented as full-list
      replacement, but the server only ever adds views - slugs missing
      from the new list stay until dropped individually. The docstring now
      states the additive semantics and points at drop_view (which takes
      the full materialised <collection>_view_<slug> name).

    Compatibility

    • No breaking changes. The graphs registry table and the unique index
      uq_graphs_db_name are created idempotently in default.db on boot;
      tenant tables are created per graph on demand.
    • Existing Neo4J deployments are unaffected; migration to light graphs
      is per-workload (create the graph, replay nodes/edges through the
      SDK or a workflow) and can be done incrementally.
    • Graph mutations publish realtime events on the db:<graph> topic
      (subscription UI support follows in the dashboard update).
    • Dashboard: graph management pages are not part of this release; use
      the SDK or the SQL console until the follow-up web update.
    Downloads
  • v0.9.0 c89afbd3b8

    v0.9.0 Stable

    x released this 2026-10-17 17:27:28 +00:00 | 7 commits to main since this release

    VSKI v0.9.0

    Embeddings: model tags, vector fetch, and a fixed vec0 layout

    The embeddings API grows the primitives downstream ML consumers need:
    provenance (which embedding model produced these vectors) and export
    (the actual vectors, not just distances).

    Model tags

    Upsert accepts an optional model form field. The first tagged write pins
    it for the collection in the new _vec_metadata system table (tenant db,
    next to the <collection>_vec table — deliberately not collection
    options, which dashboard PATCHes can rewrite). Later upserts declaring a
    different model are rejected with 409; search responses and the new
    info endpoint echo the pinned tag, so a consumer can refuse embeddings from
    a model it wasn't built for:

    curl -F recordId=$RID -F model=llama-backbone-q8 -F vector=@v.f32 \
      localhost:3000/api/collections/notes/embeddings
    

    New endpoints

    • GET /api/collections/:name/embeddings — {collection, model, dimensions, metric, count}; a collection with no vectors yet reports
      nulls and count: 0.
    • GET /api/collections/:name/embeddings/:recordId — the exact stored
      vector as a JSON float array (float32 values, lossless round-trip).
    • POST /api/collections/:name/embeddings/fetch — {"ids": [...]} →
      {"vectors": [{recordId, vector}], "missing": [...]}, indexed point
      lookups (max 1000 ids).

    Search additionally validates the query dimension against the pinned one
    (clear 400 instead of a deep vec0 error) and can be combined with
    vec_to_json/hex through the admin SQL console as before.

    The vec0 layout fix (migration required)

    The old virtual-table layout (rowid INTEGER PRIMARY KEY with record_id
    as an unindexed metadata column) had two problems: point lookups by
    record_id were full scans, and INSERT OR REPLACE never conflicted —
    every re-upsert of the same record appended a duplicate row that then
    duplicated search hits.

    The canonical layout is now record_id TEXT PRIMARY KEY, which indexes
    record_id in the shadow rowids table (true point queries) and makes
    re-upserts replace. vec0 tables cannot be ALTERed or renamed, so
    BootstrapDB rebuilds legacy tables transactionally on first boot after
    the upgrade — copying vectors through a temp table, deduplicating with
    last-write-wins — and seeds _vec_metadata (model: null, dimensions
    parsed from the old DDL, metric: "l2").

    Migration notes

    • The rebuild copies every vector once; on multi-GB collections expect a
      one-time boot proportional to table size. It runs inside a single
      transaction per table and is idempotent.
    • Duplicate rows accumulated by old re-upserts are collapsed to the
      latest vector per record_id.
    • Distance semantics are unchanged and now documented: sqlite-vec's
      default euclidean L2 over unnormalized float32 vectors, ascending (the vendored sqlite-vec l2 kernel returns the sqrt — plain distance, not the square).
    • make test-vec runs the vec0-dependent Go tests (plain go test
      compiles without the sqlite_vec extension).

    Compatibility

    API is additive (one new optional upsert field, three new endpoints, two
    new response fields). The TS client and the Python SDK gain matching
    methods. Existing clients see one behavior change: re-upserting a record
    no longer duplicates it in search results — that was the bug.

    The vendored sqlite driver fork also gained an unconditional
    #cgo LDFLAGS: -lm in sqlite3.go: cgo compiles every .c file in the
    package directory, so sqlite-vec.c (and its sqrt calls) links in every
    build, not just sqlite_vec-tagged ones — untagged go test binaries
    failed to link on toolchains where libm is not implicit.

    Downloads
  • v0.8.0 c89afbd3b8

    v0.8.0 Stable

    x released this 2026-09-19 16:09:30 +00:00 | 7 commits to main since this release

    VSKI v0.8.0

    vcli: the vski CLI ships as a release asset

    The vski-cli repo (submodule of platform since this release) builds
    vcli and attaches it to every release from now on:

    curl -fsSL https://vski.sh/x/platform/releases/download/latest/vcli -o vcli
    chmod +x vcli
    

    Two halves:

    • Model packs (info / verify / inspect / import, the
      pkg/pack + pkg/npy Go libraries) — unchanged tooling for the
      vski.model/2 opaque pack format.

    • VSKI LAB management (new): vcli lab init scaffolds a self-hosted
      lab — it downloads the lab compose from the app deploy (keeping the
      stack definition fresh independently of vcli releases), resolves the
      api/jupyter base images to exact registry digests, and renders the lab
      layout:

      data/     the platform's own data (multi-tenant SQLite)
      projects/ project data — the api's bind
      docker/   api + jupyter image shims (FROM the registry images) —
                the place to add local dependencies (GPU/rocm userland, …)
      .env      user-editable environment
      

      Because the api/jupyter images BUILD from the local shims, a lab can
      grow its own dependencies without forking the stack; host-side
      additions (device passthrough) go in a docker-compose.override.yml.

      Also: vcli lab update (refresh compose + pins, fill missing .env
      keys, never touching your edited shims), vcli lab up|down|status,
      and vcli lab migrate (split a pre-vcli cache/data lab into
      data/ + projects/, dry run by default).

    The release pipeline (release.ts) initializes the submodule, stamps the
    version in (vcli version), and uploads the binary alongside vski and
    vski-standalone.

    Compatibility

    No engine, API or storage changes — v0.8.0 adds the vski-cli submodule
    and the release-asset pipeline. Existing standalone binaries and images
    are unaffected. The latest/vcli download URL goes live with this
    release; the lab compose itself is served by the app deploy
    (/docker-compose.lab.yml).

    Downloads
  • v0.7.1 e7c332a318

    v0.7.1 Stable

    x released this 2026-08-23 18:31:41 +00:00 | 9 commits to main since this release

    Signed by x
    GPG key ID: A14ACA8AB45A9C27

    VSKI v0.7.1

    Dashboard: database-scoped collections (web UI)

    The standalone dashboard now works with the per-database collection
    registry introduced in v0.7.0. Browsing a collection that lives outside
    the default database previously failed with
    {"error":"Collection not found"} — every dashboard request resolved in
    default, where the collection does not exist.

    • Sidebar collection links carry their database
      (/collections/<name>?db=<dbName>), so the same bare name in two
      databases is addressable and never ambiguous.
    • Collection pages resolve the database from the link (falling back
      to the default db when the URL is unqualified) and issue every
      records operation — list, create, update, delete, bulk — through the
      db-scoped SDK namespace.
    • Collection settings (schema/indexes/views/rules editor) and the
      API Rules page PATCH the collection through its own database;
      relation pickers load their options from the same db.
    • System collections (_api_keys, _sql_snippets, …) keep resolving
      from any namespace, unchanged.

    SDK (TS/JS) v2.0.4

    Published to the package registry. client.db(name) now also exposes the
    settings namespace (client.db(name).settings.collections.*) so
    registry operations resolve within the named database; records namespaces
    were already scoped. This is the version the dashboard ships against.

    Compatibility

    No API or storage changes — v0.7.1 is the dashboard + SDK companion
    release for the v0.7.0 per-database registry.

    Downloads
  • v0.7.0 74ab95c23e

    v0.7.0 Stable

    x released this 2026-08-23 17:17:00 +00:00 | 11 commits to main since this release

    Signed by x
    GPG key ID: A14ACA8AB45A9C27

    VSKI v0.7.0

    Per-Database Collection Names

    Collection names are now scoped per database instead of globally unique.
    Two databases may each have an orders collection — they are fully
    independent namespaces with independent schemas, records, views, indexes,
    realtime topics, and tombstones. Multi-tenant consumers no longer need to
    prefix collection names to avoid collisions.

    Breaking Changes

    • Bare-name cross-database access no longer falls back to a global
      lookup.
      Collection names and IDs resolve strictly within the database
      addressed by the x-dbname header (default default); a wrong or
      missing header is a 404. Cross-database access must qualify the
      database (client.db(name).collection(...), collection(name, db), or
      the x-dbname header).
    • Registry uniqueness is now (dbName, name) instead of name
      alone. Creating a collection that duplicates a live name in the same
      database still fails with Collection name already exists; the same
      name in another database is allowed.
    • Realtime topics are keyed (db, name) and the connection's ?db=
      parameter is authoritative: SUBSCRIBE resolves the collection in that
      database and a miss is a loud ERROR frame. EVENT/SUBSCRIBED frames
      carry a new additive db field.
    • Duplicate relation targets must be qualified. Relation fields may
      declare an optional options.db for cross-database targets; relations
      without it resolve in the source collection's own database (then a
      unique global match). A name present in several databases with no
      qualifier is a 400 naming the candidates.

    Compatibility

    • System collections (_users, _stats_*, workflow queues) are
      platform-global and keep resolving from any namespace — stats and
      workflow surfaces are unchanged.
    • Legacy database files migrate automatically on boot: the inline
      UNIQUE(name) constraint is replaced by the uq_collections_db_name
      composite index via an idempotent table rebuild; rows are preserved.
    • Create-over-tombstone now revives the tombstoned row under its
      original id instead of a blind name-keyed resurrection followed by an
      error.
    • Options-based views in non-default databases now resolve (the options
      lookup previously ran against the target file, which never holds
      registry rows).

    SDKs

    • client (TS/JS): registry ops accept a database scope
      (client.db(name).settings), realtime subscriptions re-key to the
      db:name topic and dispatch on the frame's db.
    • client-python: every settings.collections method takes an
      optional db; realtime dispatch filters subscription entries by the
      frame's db. client.collection(name, db) was already scoped.
    Downloads