@anmol-srv/sigil 0.10.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  # Hermes integration
2
2
 
3
- Sigil ships as a [Hermes Agent](https://hermes.chat) memory provider plugin. The plugin source lives at [`plugin/`](./plugin) copy that directory into Hermes' plugin tree on whichever machine runs Hermes.
3
+ Sigil ships as a [Hermes Agent](https://hermes.chat) memory provider plugin. The plugin source lives at [`plugin/`](./plugin), copy that directory into Hermes' plugin tree on whichever machine runs Hermes.
4
4
 
5
5
  ## Quick deploy (manual)
6
6
 
@@ -23,7 +23,7 @@ Restart Hermes. Verify with `hermes memory status` (or whatever Hermes' status c
23
23
  | Hermes hook | Sigil call | Why |
24
24
  |---|---|---|
25
25
  | `is_available()` | `which sigil` | Avoid network calls; just check the binary exists. |
26
- | `initialize(session_id, platform, ...)` | sets namespace = `hermes-<platform>` | Per-platform classification see plugin/README.md. |
26
+ | `initialize(session_id, platform, ...)` | sets namespace = `hermes-<platform>` | Per-platform classification, see plugin/README.md. |
27
27
  | `prefetch(query)` | `sigil search <q> --namespace=hermes-<platform>,default --limit=5 --no-graph` | Fast cross-namespace recall = the shared brain. |
28
28
  | `sync_turn(user, assistant)` | `sigil remember --bg "<user>"` in a daemon thread | Non-blocking. Sigil's classifier decides what's worth keeping. |
29
29
  | `get_tool_schemas()` | `sigil_search`, `sigil_remember` | Lets the model explicitly drill down or save mid-turn. |
@@ -37,5 +37,5 @@ A `src/lib/clients/hermes.js` module (5th client alongside Claude Code / Cursor
37
37
  ## Caveats
38
38
 
39
39
  - **Sigil CLI must be on `PATH`** on whichever machine runs Hermes. If `which sigil` returns nothing, `is_available()` returns false and Hermes silently falls back to its built-in memory.
40
- - **`~/.sigil/.env` must be configured** run `sigil init` on the Hermes host before activating the plugin.
41
- - **The plugin shells out for every prefetch.** Latency is `sigil search` latency. The plugin keeps this path retrieval-only; if Hermes' per-turn budget is tighter, we could move to in-process via a Python<>Node bridge out of scope for v0.1.
40
+ - **`~/.sigil/.env` must be configured**, run `sigil init` on the Hermes host before activating the plugin.
41
+ - **The plugin shells out for every prefetch.** Latency is `sigil search` latency. The plugin keeps this path retrieval-only; if Hermes' per-turn budget is tighter, we could move to in-process via a Python<>Node bridge, out of scope for v0.1.
@@ -1,16 +1,16 @@
1
1
  # Sigil Memory Provider
2
2
 
3
- Persistent memory for Hermes Agent, backed by [Sigil](https://github.com/anmolsrv/sigil) a local-first knowledge engine with atomic facts, entity graph, and hybrid retrieval. Same memory store used by Claude Code, Cursor, Codex CLI, and Kiro.
3
+ Persistent memory for Hermes Agent, backed by [Sigil](https://github.com/anmolsrv/sigil), a local-first knowledge engine with atomic facts, entity graph, and hybrid retrieval. Same memory store used by Claude Code, Cursor, Codex CLI, and Kiro.
4
4
 
5
5
  ## Why this exists
6
6
 
7
- You're running Hermes on a server (e.g. via iMessage / Telegram / Discord gateway) and you also use Claude Code / Cursor / etc. on your laptop. You want **one brain** that all of them share without copying memories around or rebuilding them per tool.
7
+ You're running Hermes on a server (e.g. via iMessage / Telegram / Discord gateway) and you also use Claude Code / Cursor / etc. on your laptop. You want **one brain** that all of them share, without copying memories around or rebuilding them per tool.
8
8
 
9
9
  This plugin makes that real: every Hermes turn lands in a Sigil namespace, every laptop turn lands in `default`, and cross-namespace search means anyone can recall anything.
10
10
 
11
11
  ## Requirements
12
12
 
13
- - Sigil CLI on `PATH` `npm install -g @anmol-srv/sigil`
13
+ - Sigil CLI on `PATH`: `npm install -g @anmol-srv/sigil`
14
14
  - `sigil init` completed once (configures DB, embedder, LLM provider)
15
15
  - Postgres reachable from this machine (local install or shared via Tailscale / cloud)
16
16
 
@@ -20,7 +20,7 @@ This plugin makes that real: every Hermes turn lands in a Sigil namespace, every
20
20
  hermes config set memory.provider sigil
21
21
  ```
22
22
 
23
- No additional env vars or config files Sigil reads its own `~/.sigil/.env`.
23
+ No additional env vars or config files; Sigil reads its own `~/.sigil/.env`.
24
24
 
25
25
  ## How it classifies sources
26
26
 
@@ -51,13 +51,13 @@ sigil namespace list
51
51
  | `sigil_search` | Drill-down search across this platform + `default`. The model is told to use this only when the auto-injected context didn't surface what it needed. |
52
52
  | `sigil_remember` | Explicit save. The model is told to use this only when the user asks ("remember that...") or a critical fact arrives mid-turn. |
53
53
 
54
- Routine fact capture happens automatically via `sync_turn` no model action required.
54
+ Routine fact capture happens automatically via `sync_turn`, no model action required.
55
55
 
56
56
  ## What lives where
57
57
 
58
58
  | Layer | Where | Owns |
59
59
  |---|---|---|
60
- | This plugin | `~/.hermes/hermes-agent/plugins/memory/sigil/` | The Hermes ABC contract initialize, prefetch, sync_turn, tool dispatch. Thin subprocess wrapper. |
60
+ | This plugin | `~/.hermes/hermes-agent/plugins/memory/sigil/` | The Hermes ABC contract: initialize, prefetch, sync_turn, tool dispatch. Thin subprocess wrapper. |
61
61
  | Sigil CLI | `which sigil` | Hybrid search, fact extraction, AUDM dedup, pod-aware retrieval, embedder calls. |
62
62
  | Sigil config | `~/.sigil/.env` | DB connection, embedder choice, LLM provider. Run `sigil init` to reconfigure. |
63
63
  | Sigil data | Postgres (`SIGIL_DB_HOST` in `~/.sigil/.env`) | All facts, entities, pods, relations. Shared across machines when they point at the same Postgres. |
@@ -66,7 +66,7 @@ Routine fact capture happens automatically via `sync_turn` — no model action r
66
66
 
67
67
  Point `SIGIL_DB_HOST` in every machine's `~/.sigil/.env` at the *same* Postgres. Two common topologies:
68
68
 
69
- 1. **Server-hosted Postgres** Postgres on this server; laptop connects over Tailscale.
70
- 2. **Cloud Postgres** Supabase / Neon / RDS; both machines connect to it.
69
+ 1. **Server-hosted Postgres**: Postgres on this server; laptop connects over Tailscale.
70
+ 2. **Cloud Postgres**: Supabase / Neon / RDS; both machines connect to it.
71
71
 
72
72
  Either way: one DB, many writers, every namespace visible from everywhere.
package/knexfile.js CHANGED
@@ -1,15 +1,36 @@
1
- import 'dotenv/config';
1
+ import { config as dotenvConfig } from 'dotenv';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+
6
+ import { buildLocalConnection } from './src/db/drivers/local-postgres.js';
7
+ import { buildUrlConnection } from './src/db/drivers/url.js';
8
+
9
+ // Env precedence: shell env > project .env > global ~/.sigil/.env.
10
+ // Matches the CLI's loader so `npx knex migrate:latest` Just Works.
11
+ const projectEnv = resolve(process.cwd(), '.env');
12
+ const globalEnv = join(homedir(), '.sigil', '.env');
13
+ if (existsSync(projectEnv)) dotenvConfig({ path: projectEnv, quiet: true });
14
+ if (existsSync(globalEnv) && globalEnv !== projectEnv) dotenvConfig({ path: globalEnv, quiet: true });
2
15
 
3
16
  const env = (key, fallback) => process.env[key] ?? fallback;
4
17
 
18
+ const url = env('SIGIL_DATABASE_URL', env('DATABASE_URL', ''));
19
+
20
+ const connection = url
21
+ ? buildUrlConnection(url)
22
+ : buildLocalConnection({
23
+ db: {
24
+ host: env('SIGIL_DB_HOST', 'localhost'),
25
+ port: Number(env('SIGIL_DB_PORT', 5432)),
26
+ database: env('SIGIL_DB_NAME', 'sigil'),
27
+ user: env('SIGIL_DB_USER', 'sigil_app'),
28
+ password: env('SIGIL_DB_PASSWORD', ''),
29
+ },
30
+ });
31
+
5
32
  export default {
6
33
  client: 'pg',
7
- connection: {
8
- host: env('SIGIL_DB_HOST', 'localhost'),
9
- port: Number(env('SIGIL_DB_PORT', 5432)),
10
- database: env('SIGIL_DB_NAME', 'sigil'),
11
- user: env('SIGIL_DB_USER', 'sigil_app'),
12
- password: env('SIGIL_DB_PASSWORD', ''),
13
- },
34
+ connection,
14
35
  migrations: { directory: './src/db/migrations' },
15
36
  };
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@anmol-srv/sigil",
3
- "version": "0.10.3",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
- "description": "Local-first memory infrastructure for AI coding agents. One brain shared across Claude Code, Codex CLI, Cursor, Kiro, Continue, Cline, Windsurf any MCP client. Organized in pluggable pods, stored in your own Postgres. No cloud, no telemetry. Auto-captured from Claude Code via hooks; surfaced everywhere else as a 9-tool MCP server.",
5
+ "description": "Local-first memory infrastructure for AI coding agents. One brain shared across Claude Code, Codex CLI, Cursor, Kiro, Continue, Cline, Windsurf, or any MCP client. Organized in pluggable pods, stored in your own Postgres. No cloud, no telemetry. Auto-captured from Claude Code via hooks; surfaced everywhere else as a 9-tool MCP server.",
6
6
  "bin": {
7
7
  "sigil": "dist/cli.js"
8
8
  },
@@ -16,7 +16,10 @@
16
16
  "prepublishOnly": "npm run build",
17
17
  "migrate": "knex migrate:latest",
18
18
  "migrate:rollback": "knex migrate:rollback",
19
- "migrate:make": "knex migrate:make"
19
+ "migrate:make": "knex migrate:make",
20
+ "test:reliability": "node test/reliability/run.js",
21
+ "db:test:up": "docker compose up -d db",
22
+ "db:test:down": "docker compose down"
20
23
  },
21
24
  "engines": {
22
25
  "node": ">=20.0.0"
@@ -25,6 +28,7 @@
25
28
  "dist/",
26
29
  "prompts/",
27
30
  "src/db/migrations/",
31
+ "src/gui/web/",
28
32
  "integrations/",
29
33
  "knexfile.js",
30
34
  "LICENSE",
@@ -67,7 +71,7 @@
67
71
  "local-first"
68
72
  ],
69
73
  "author": "Anmol Srivastava",
70
- "license": "ISC",
74
+ "license": "MIT",
71
75
  "repository": {
72
76
  "type": "git",
73
77
  "url": "https://github.com/Anmol-Srv/sigil.git"
@@ -79,9 +83,11 @@
79
83
  "dependencies": {
80
84
  "@iarna/toml": "^2.2.5",
81
85
  "@modelcontextprotocol/sdk": "^1.27.1",
86
+ "@number0/iroh": "^0.35.0",
82
87
  "dotenv": "^17.3.1",
83
88
  "knex": "^3.1.0",
84
- "pg": "^8.20.0"
89
+ "pg": "^8.20.0",
90
+ "ws": "^8.21.0"
85
91
  },
86
92
  "optionalDependencies": {
87
93
  "@anthropic-ai/sdk": "^0.30.0"
@@ -0,0 +1,34 @@
1
+ /**
2
+ * device — devices that have paired with this Sigil cluster.
3
+ *
4
+ * Each row represents a remote Sigil install whose owner has been
5
+ * authorized to read/write this device's memory. `node_id` is the
6
+ * 64-char hex Ed25519 public key (Iroh NodeID + identity, same value).
7
+ *
8
+ * Roles:
9
+ * reader — can call read-side RPC methods only
10
+ * writer — can also remember/forget/ingest
11
+ * admin — can manage other devices (create pairing codes, revoke)
12
+ *
13
+ * `namespaces` scopes what this device can read/write. Empty array means
14
+ * "all namespaces this cluster knows about" (admin default).
15
+ *
16
+ * `active=false` is a soft revoke — preserves history of the device for
17
+ * audit purposes without allowing further calls.
18
+ */
19
+ exports.up = (knex) =>
20
+ knex.schema.createTable('device', (t) => {
21
+ t.increments('id').primary();
22
+ t.text('node_id').notNullable().unique();
23
+ t.text('name').notNullable();
24
+ t.text('role').notNullable().defaultTo('writer'); // reader | writer | admin
25
+ t.specificType('namespaces', 'text[]').notNullable().defaultTo('{}');
26
+ t.boolean('active').notNullable().defaultTo(true);
27
+ t.jsonb('meta').notNullable().defaultTo('{}');
28
+ t.timestamp('last_seen_at');
29
+ t.timestamps(false, true);
30
+
31
+ t.index(['node_id', 'active'], 'idx_device_lookup');
32
+ });
33
+
34
+ exports.down = (knex) => knex.schema.dropTable('device');
@@ -0,0 +1,27 @@
1
+ /**
2
+ * pairing_code — one-shot, time-limited tokens that authorize a new
3
+ * device to register itself.
4
+ *
5
+ * The plaintext code is shown to the operator (printed by `sigil pair
6
+ * create`); only its SHA-256 hash is stored. Joining device presents
7
+ * the plaintext during the pairing handshake.
8
+ *
9
+ * `consumed_by_device_id` is set once the code is redeemed; thereafter
10
+ * the row is kept for audit but cannot be redeemed again.
11
+ */
12
+ exports.up = (knex) =>
13
+ knex.schema.createTable('pairing_code', (t) => {
14
+ t.increments('id').primary();
15
+ t.text('code_hash').notNullable().unique();
16
+ t.text('name').notNullable(); // intended device name, e.g. "laptop-b"
17
+ t.text('role').notNullable().defaultTo('writer'); // role to assign on redemption
18
+ t.specificType('namespaces', 'text[]').notNullable().defaultTo('{}');
19
+ t.timestamp('expires_at').notNullable();
20
+ t.integer('consumed_by_device_id').references('device.id').onDelete('SET NULL');
21
+ t.timestamp('consumed_at');
22
+ t.timestamps(false, true);
23
+
24
+ t.index(['expires_at'], 'idx_pairing_code_expiry');
25
+ });
26
+
27
+ exports.down = (knex) => knex.schema.dropTable('pairing_code');
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Add cross-device provenance + embedding-shape columns to fact.
3
+ *
4
+ * embedding_model — the model that produced this fact's vector.
5
+ * Cross-device sync refuses if the master's manifest
6
+ * says a different model is in use.
7
+ * embedding_dim — dimensionality of the vector. Belt-and-braces
8
+ * alongside the model check.
9
+ * created_by_device_id — which paired device wrote this fact. NULL means
10
+ * "the local install" (back-compat with rows that
11
+ * existed before this migration).
12
+ *
13
+ * The columns are nullable so the migration is backfill-free; new ingests
14
+ * populate them from the embedder config + the authenticated caller's
15
+ * device row.
16
+ */
17
+ exports.up = (knex) =>
18
+ knex.schema.alterTable('fact', (t) => {
19
+ t.text('embedding_model');
20
+ t.integer('embedding_dim');
21
+ t.integer('created_by_device_id').references('device.id').onDelete('SET NULL');
22
+ t.index(['created_by_device_id'], 'idx_fact_by_device');
23
+ });
24
+
25
+ exports.down = (knex) =>
26
+ knex.schema.alterTable('fact', (t) => {
27
+ t.dropIndex(['created_by_device_id'], 'idx_fact_by_device');
28
+ t.dropColumn('created_by_device_id');
29
+ t.dropColumn('embedding_dim');
30
+ t.dropColumn('embedding_model');
31
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Distinguishes "paused" revokes (a key out for service / on a borrowed
3
+ * laptop, expected to come back) from "compromised" revokes (terminal —
4
+ * the keypair leaked and must never authenticate again).
5
+ *
6
+ * 'paused' → device.activate flips active=true (default)
7
+ * 'compromised' → device.activate refuses; requires re-pairing
8
+ *
9
+ * Nullable so existing rows are unaffected.
10
+ */
11
+ exports.up = (knex) =>
12
+ knex.schema.alterTable('device', (t) => {
13
+ t.text('revoked_reason'); // 'paused' | 'compromised' | NULL
14
+ });
15
+
16
+ exports.down = (knex) =>
17
+ knex.schema.alterTable('device', (t) => {
18
+ t.dropColumn('revoked_reason');
19
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * trace_event — persisted, queryable causal log of what the daemon did.
3
+ *
4
+ * Each row is one top-level operation (a search, an ingest/remember, a
5
+ * lifecycle sweep). `detail` (jsonb) holds the full structured trace:
6
+ * for search — the routing decision, matched entity, and every ranked
7
+ * fact with its similarity / RRF / ACT-R activation (decay) / final
8
+ * score; for ingest — classify → chunk → extract → per-fact AUDM verdict
9
+ * (with the similarity that drove it) → entity links.
10
+ *
11
+ * The live activity feed still streams over the event bus; this table is
12
+ * the durable history the GUI's Activity tab reads + filters.
13
+ */
14
+ exports.up = async (knex) => {
15
+ await knex.schema.createTable('trace_event', (t) => {
16
+ t.bigIncrements('id').primary();
17
+ t.text('uid').notNullable().unique(); // trace-<nanoid>
18
+ t.text('kind').notNullable(); // 'search' | 'ingest' | 'lifecycle' | ...
19
+ t.timestamp('ts', { useTz: true }).notNullable().defaultTo(knex.fn.now());
20
+ t.integer('duration_ms');
21
+ t.text('namespace');
22
+ t.text('summary'); // one-line human description
23
+ t.text('device_id'); // provenance (null = this device)
24
+ t.text('transport'); // cli | mcp | gui | iroh | null
25
+ t.jsonb('detail').notNullable().defaultTo('{}');
26
+ });
27
+
28
+ // Hot path is "latest N, optionally filtered by kind".
29
+ await knex.schema.alterTable('trace_event', (t) => {
30
+ t.index(['ts'], 'trace_event_ts_idx');
31
+ t.index(['kind', 'ts'], 'trace_event_kind_ts_idx');
32
+ });
33
+ };
34
+
35
+ exports.down = (knex) => knex.schema.dropTableIfExists('trace_event');
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Add `created_by_agent` provenance to fact.
3
+ *
4
+ * created_by_agent — which agent wrote this fact: 'claude-code', 'codex',
5
+ * 'cursor', 'mcp', 'cli', etc. NULL means unknown /
6
+ * pre-migration (back-compat).
7
+ *
8
+ * This is PROVENANCE, not SCOPE: it is recorded, surfaced, and filterable,
9
+ * but never a default retrieval partition. Cross-agent sharing is the product
10
+ * — Claude must still see what Cursor wrote — so agent never enters the
11
+ * default WHERE clause. Nullable = backfill-free; new ingests populate it from
12
+ * the authenticated caller's request-context (AsyncLocalStorage). Mirrors the
13
+ * created_by_device_id column added in 20260601000002.
14
+ */
15
+ exports.up = (knex) =>
16
+ knex.schema.alterTable('fact', (t) => {
17
+ t.text('created_by_agent');
18
+ t.index(['created_by_agent'], 'idx_fact_by_agent');
19
+ });
20
+
21
+ exports.down = (knex) =>
22
+ knex.schema.alterTable('fact', (t) => {
23
+ t.dropIndex(['created_by_agent'], 'idx_fact_by_agent');
24
+ t.dropColumn('created_by_agent');
25
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Central RPC client — the one door from the GUI to the daemon. Mirrors the
3
+ * cohort-live-web axios-wrapper convention: a single place that adds context
4
+ * and turns a structured daemon error ({code,message,hint}) into a toast.
5
+ *
6
+ * Pass { quiet: true } to suppress the auto-toast (e.g. when a caller renders
7
+ * the error inline). The thrown Error carries .code/.hint for callers.
8
+ */
9
+ import { toast } from './toast.js';
10
+
11
+ export async function rpc(method, params = {}, { quiet = false } = {}) {
12
+ let body;
13
+ try {
14
+ const res = await fetch('/api/v1/rpc', {
15
+ method: 'POST',
16
+ headers: { 'Content-Type': 'application/json' },
17
+ credentials: 'same-origin',
18
+ body: JSON.stringify({ method, params }),
19
+ });
20
+ body = await res.json();
21
+ } catch {
22
+ const e = {
23
+ code: 'NETWORK',
24
+ message: 'Could not reach the Sigil daemon.',
25
+ hint: 'Is it running? Try `sigil daemon status`.',
26
+ };
27
+ if (!quiet) toast({ variant: 'error', message: e.message, hint: e.hint, code: e.code });
28
+ throw Object.assign(new Error(e.message), e);
29
+ }
30
+
31
+ if (!body || body.ok !== true) {
32
+ const e = body?.error || { code: 'UNKNOWN', message: 'request failed' };
33
+ if (!quiet) toast({ variant: 'error', message: e.message, hint: e.hint, code: e.code });
34
+ throw Object.assign(new Error(e.message || 'request failed'), e);
35
+ }
36
+ return body.data;
37
+ }