@anmol-srv/sigil 0.10.3
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.
- package/LICENSE +15 -0
- package/README.md +417 -0
- package/dist/cli.js +1019 -0
- package/dist/hooks/post-tool-use.js +70 -0
- package/dist/hooks/session-end.js +222 -0
- package/dist/hooks/stop.js +259 -0
- package/dist/hooks/user-prompt-submit.js +279 -0
- package/dist/server.js +573 -0
- package/integrations/hermes/README.md +41 -0
- package/integrations/hermes/plugin/README.md +72 -0
- package/integrations/hermes/plugin/__init__.py +353 -0
- package/integrations/hermes/plugin/plugin.yaml +10 -0
- package/knexfile.js +15 -0
- package/package.json +100 -0
- package/prompts/audm-decision.md +31 -0
- package/prompts/chunk-context.md +23 -0
- package/prompts/default-extraction.md +35 -0
- package/prompts/entity-extraction.md +37 -0
- package/prompts/input-classifier.md +23 -0
- package/prompts/query-router.md +18 -0
- package/src/db/migrations/20260310120000_create-cortex-document-table.cjs +21 -0
- package/src/db/migrations/20260310120001_create-cortex-chunk-table.cjs +37 -0
- package/src/db/migrations/20260310120002_create-cortex-fact-table.cjs +37 -0
- package/src/db/migrations/20260310120003_create-cortex-entity-table.cjs +26 -0
- package/src/db/migrations/20260310120004_create-cortex-relation-table.cjs +27 -0
- package/src/db/migrations/20260310120005_create-cortex-history-table.cjs +16 -0
- package/src/db/migrations/20260311120000_add-entity-namespace-and-relation-indexes.cjs +32 -0
- package/src/db/migrations/20260312120000_add-fact-entity-linking.cjs +22 -0
- package/src/db/migrations/20260313093130_create-api-key-table.cjs +15 -0
- package/src/db/migrations/20260313120000_add-entity-dedup-support.cjs +13 -0
- package/src/db/migrations/20260313150000_create-connector-tables.cjs +46 -0
- package/src/db/migrations/20260318120000_add-contextual-chunk-prefix.cjs +11 -0
- package/src/db/migrations/20260318120001_add-fact-temporal-validity.cjs +15 -0
- package/src/db/migrations/20260318120002_add-fact-importance.cjs +11 -0
- package/src/db/migrations/20260318120003_add-fact-access-tracking.cjs +13 -0
- package/src/db/migrations/20260405120000_add-unique-constraints.cjs +58 -0
- package/src/db/migrations/20260405140000_create-llm-log-table.cjs +21 -0
- package/src/db/migrations/20260424120000_split-fact-lifecycle.cjs +86 -0
- package/src/db/migrations/20260424120002_create-embedding-cache.cjs +26 -0
- package/src/db/migrations/20260429120000_halfvec-index-compression.cjs +34 -0
- package/src/db/migrations/20260429120100_create-hebbian-edge-table.cjs +37 -0
- package/src/db/migrations/20260429120200_upgrade-embedding-dim-1024.cjs +68 -0
- package/src/db/migrations/20260504120000_scope-document-source-path-uniqueness.cjs +45 -0
- package/src/db/migrations/20260508001733_add-entity-aliases.cjs +42 -0
- package/src/db/migrations/20260512120000_create-entity-hebbian-edge.cjs +42 -0
- package/src/db/migrations/20260512120000_create-pod-tables.cjs +71 -0
- package/src/db/migrations/20260512120100_create-pod-membership.cjs +50 -0
- package/src/db/migrations/20260512120200_add-document-source-metadata.cjs +32 -0
- package/src/db/migrations/20260514023428_rewrite-session-pods-and-add-fact-attribution-columns.cjs +86 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Hermes integration
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Quick deploy (manual)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 1. From the Sigil repo root on your laptop:
|
|
9
|
+
scp -r integrations/hermes/plugin/ \
|
|
10
|
+
claude@neutron:.hermes/hermes-agent/plugins/memory/sigil/
|
|
11
|
+
|
|
12
|
+
# 2. On the server:
|
|
13
|
+
ssh claude@neutron
|
|
14
|
+
sigil --help # confirm sigil CLI is on PATH
|
|
15
|
+
sigil init # configure DB + embedder + LLM (once)
|
|
16
|
+
hermes config set memory.provider sigil
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Restart Hermes. Verify with `hermes memory status` (or whatever Hermes' status command surfaces).
|
|
20
|
+
|
|
21
|
+
## What the plugin does
|
|
22
|
+
|
|
23
|
+
| Hermes hook | Sigil call | Why |
|
|
24
|
+
|---|---|---|
|
|
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. |
|
|
27
|
+
| `prefetch(query)` | `sigil search <q> --namespace=hermes-<platform>,default --limit=5 --no-graph` | Fast cross-namespace recall = the shared brain. |
|
|
28
|
+
| `sync_turn(user, assistant)` | `sigil remember --bg "<user>"` in a daemon thread | Non-blocking. Sigil's classifier decides what's worth keeping. |
|
|
29
|
+
| `get_tool_schemas()` | `sigil_search`, `sigil_remember` | Lets the model explicitly drill down or save mid-turn. |
|
|
30
|
+
|
|
31
|
+
The contract Hermes expects is documented at `~/.hermes/hermes-agent/website/docs/developer-guide/memory-provider-plugin.md` on any Hermes install.
|
|
32
|
+
|
|
33
|
+
## Future: one-shot install via `sigil init`
|
|
34
|
+
|
|
35
|
+
A `src/lib/clients/hermes.js` module (5th client alongside Claude Code / Cursor / Codex / Kiro) would let `sigil init` copy this plugin into `~/.hermes/hermes-agent/plugins/memory/sigil/` and flip `memory.provider: sigil` in `config.yaml` automatically. That lands when we're confident the manual deploy works end-to-end.
|
|
36
|
+
|
|
37
|
+
## Caveats
|
|
38
|
+
|
|
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.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Sigil Memory Provider
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
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.
|
|
8
|
+
|
|
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
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- Sigil CLI on `PATH` — `npm install -g @anmol-srv/sigil`
|
|
14
|
+
- `sigil init` completed once (configures DB, embedder, LLM provider)
|
|
15
|
+
- Postgres reachable from this machine (local install or shared via Tailscale / cloud)
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
hermes config set memory.provider sigil
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
No additional env vars or config files — Sigil reads its own `~/.sigil/.env`.
|
|
24
|
+
|
|
25
|
+
## How it classifies sources
|
|
26
|
+
|
|
27
|
+
Each Hermes platform writes to its own Sigil namespace:
|
|
28
|
+
|
|
29
|
+
| Hermes platform | Sigil namespace |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `cli` | `hermes-cli` |
|
|
32
|
+
| `imessage` | `hermes-imessage` |
|
|
33
|
+
| `telegram` | `hermes-telegram` |
|
|
34
|
+
| `discord` | `hermes-discord` |
|
|
35
|
+
| `cron` | `hermes-cron` |
|
|
36
|
+
|
|
37
|
+
Recall reads across **two namespaces**: the active platform's own (`hermes-imessage`) AND `default` (where the user's laptop tools write). Result: a fact captured in iMessage is reachable when you're back at your laptop in Claude Code, and vice versa.
|
|
38
|
+
|
|
39
|
+
To see what's in each namespace:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
sigil facts --namespace=hermes-imessage
|
|
43
|
+
sigil facts --namespace=default
|
|
44
|
+
sigil namespace list
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Tools exposed to the model
|
|
48
|
+
|
|
49
|
+
| Tool | Purpose |
|
|
50
|
+
|---|---|
|
|
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
|
+
| `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
|
+
|
|
54
|
+
Routine fact capture happens automatically via `sync_turn` — no model action required.
|
|
55
|
+
|
|
56
|
+
## What lives where
|
|
57
|
+
|
|
58
|
+
| Layer | Where | Owns |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| This plugin | `~/.hermes/hermes-agent/plugins/memory/sigil/` | The Hermes ABC contract — initialize, prefetch, sync_turn, tool dispatch. Thin subprocess wrapper. |
|
|
61
|
+
| Sigil CLI | `which sigil` | Hybrid search, fact extraction, AUDM dedup, pod-aware retrieval, embedder calls. |
|
|
62
|
+
| Sigil config | `~/.sigil/.env` | DB connection, embedder choice, LLM provider. Run `sigil init` to reconfigure. |
|
|
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. |
|
|
64
|
+
|
|
65
|
+
## Shared brain across machines
|
|
66
|
+
|
|
67
|
+
Point `SIGIL_DB_HOST` in every machine's `~/.sigil/.env` at the *same* Postgres. Two common topologies:
|
|
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.
|
|
71
|
+
|
|
72
|
+
Either way: one DB, many writers, every namespace visible from everywhere.
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Sigil memory provider for Hermes Agent.
|
|
2
|
+
|
|
3
|
+
Bridges Hermes' memory system to a local Sigil install via the `sigil` CLI.
|
|
4
|
+
No new network surface — the plugin shells out to the same subprocess
|
|
5
|
+
commands Claude Code uses through its hooks. This means Hermes inherits
|
|
6
|
+
all of Sigil's behavior for free: AUDM dedup, Hebbian retrieval, hot-context
|
|
7
|
+
budgets, pod-aware blending, the lot.
|
|
8
|
+
|
|
9
|
+
Architecture
|
|
10
|
+
------------
|
|
11
|
+
prefetch(query) → `sigil search <q> --namespace=<ns>,default`
|
|
12
|
+
sync_turn(u, a) → `sigil remember --bg "<user_content>"` (daemon thread)
|
|
13
|
+
is_available() → shell test: `sigil --help` returns 0
|
|
14
|
+
handle_tool_call() → explicit search / remember invocations from the model
|
|
15
|
+
|
|
16
|
+
Shared brain via namespaces
|
|
17
|
+
---------------------------
|
|
18
|
+
Each Hermes platform writes to its own Sigil namespace:
|
|
19
|
+
|
|
20
|
+
cli → hermes-cli
|
|
21
|
+
telegram → hermes-telegram
|
|
22
|
+
imessage → hermes-imessage
|
|
23
|
+
discord → hermes-discord
|
|
24
|
+
cron → hermes-cron
|
|
25
|
+
|
|
26
|
+
Search reads across the platform's own namespace AND `default` — the
|
|
27
|
+
namespace Claude Code's hooks write to from the user's laptop. Result:
|
|
28
|
+
facts captured anywhere are reachable from anywhere, with natural source
|
|
29
|
+
classification (a Hermes-iMessage fact lives in `hermes-imessage`, a
|
|
30
|
+
laptop-Claude-Code fact lives in `default`, but both surface in any
|
|
31
|
+
search).
|
|
32
|
+
|
|
33
|
+
Requires
|
|
34
|
+
--------
|
|
35
|
+
sigil CLI on PATH (the local install — `npm install -g @anmolsrv/sigil`
|
|
36
|
+
or wherever the binary is installed)
|
|
37
|
+
~/.sigil/.env configured (run `sigil init` once before activating
|
|
38
|
+
this plugin)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import logging
|
|
45
|
+
import os
|
|
46
|
+
import shutil
|
|
47
|
+
import subprocess
|
|
48
|
+
import threading
|
|
49
|
+
from typing import Any, Dict, List, Optional
|
|
50
|
+
|
|
51
|
+
from agent.memory_provider import MemoryProvider
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
# Subprocess timeouts. Search is on the prompt-critical path → tight budget.
|
|
56
|
+
# Remember is fire-and-forget via --bg, but we still cap the spawn-and-detach.
|
|
57
|
+
_SEARCH_TIMEOUT_S = 5
|
|
58
|
+
_REMEMBER_TIMEOUT_S = 10
|
|
59
|
+
_PREFETCH_LIMIT = 5
|
|
60
|
+
|
|
61
|
+
# Cap the prefetched context block — Hermes already has a memory_char_limit
|
|
62
|
+
# in config.yaml, but we trim early to avoid wasting characters on results
|
|
63
|
+
# the agent will never use.
|
|
64
|
+
_PREFETCH_CHAR_LIMIT = 2000
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _clean_text(value: Any) -> str:
|
|
68
|
+
"""Strip subprocess noise that can break Hermes' tool/result framing."""
|
|
69
|
+
if value is None:
|
|
70
|
+
return ""
|
|
71
|
+
return str(value).replace("\x00", "").strip()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ok(payload: Dict[str, Any]) -> str:
|
|
75
|
+
return json.dumps(payload, ensure_ascii=False)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _err(message: str) -> str:
|
|
79
|
+
return json.dumps({"error": message}, ensure_ascii=False)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _sigil_search_args(query: str, namespaces: str, limit: int) -> List[str]:
|
|
83
|
+
return [
|
|
84
|
+
"sigil", "search", query,
|
|
85
|
+
f"--namespace={namespaces}",
|
|
86
|
+
f"--limit={limit}",
|
|
87
|
+
"--no-graph",
|
|
88
|
+
"--no-route",
|
|
89
|
+
"--no-synthesize",
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Provider
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
class SigilProvider(MemoryProvider):
|
|
98
|
+
"""Hermes memory provider backed by a local Sigil install."""
|
|
99
|
+
|
|
100
|
+
def __init__(self) -> None:
|
|
101
|
+
self._session_id: str = ""
|
|
102
|
+
self._platform: str = "cli"
|
|
103
|
+
self._namespace: str = "hermes-cli"
|
|
104
|
+
self._search_namespaces: str = "hermes-cli,default"
|
|
105
|
+
self._hermes_home: str = ""
|
|
106
|
+
self._sync_thread: Optional[threading.Thread] = None
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def name(self) -> str:
|
|
110
|
+
return "sigil"
|
|
111
|
+
|
|
112
|
+
# -- Lifecycle -----------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def is_available(self) -> bool:
|
|
115
|
+
"""Check the sigil CLI is on PATH. No network calls."""
|
|
116
|
+
return shutil.which("sigil") is not None
|
|
117
|
+
|
|
118
|
+
def initialize(self, session_id: str, **kwargs: Any) -> None:
|
|
119
|
+
self._session_id = session_id
|
|
120
|
+
self._platform = kwargs.get("platform", "cli")
|
|
121
|
+
self._namespace = f"hermes-{self._platform}"
|
|
122
|
+
# Cross-namespace search: this platform's facts PLUS the default
|
|
123
|
+
# namespace where Claude Code writes from the user's other machines.
|
|
124
|
+
self._search_namespaces = f"{self._namespace},default"
|
|
125
|
+
self._hermes_home = kwargs.get("hermes_home", "")
|
|
126
|
+
logger.info(
|
|
127
|
+
"Sigil provider initialised: namespace=%s session=%s platform=%s",
|
|
128
|
+
self._namespace, session_id, self._platform,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def shutdown(self) -> None:
|
|
132
|
+
if self._sync_thread and self._sync_thread.is_alive():
|
|
133
|
+
self._sync_thread.join(timeout=5.0)
|
|
134
|
+
|
|
135
|
+
# -- Recall (per-turn) ---------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def system_prompt_block(self) -> str:
|
|
138
|
+
return (
|
|
139
|
+
"## Memory (Sigil)\n"
|
|
140
|
+
"Persistent memory across all your sessions and the user's other AI tools "
|
|
141
|
+
"(Claude Code, Cursor, Codex CLI, Kiro). Recent relevant facts are "
|
|
142
|
+
f"auto-injected at the top of each turn from namespaces `{self._search_namespaces}`. "
|
|
143
|
+
"Trust the injection — answer from it first.\n\n"
|
|
144
|
+
"Call `sigil_search` ONLY for drill-down questions when the injection "
|
|
145
|
+
"clearly missed something specific. Call `sigil_remember` ONLY when the "
|
|
146
|
+
"user explicitly asks (\"remember that...\", \"save this...\") or when "
|
|
147
|
+
"they share a critical fact mid-turn that the Stop-equivalent flush will "
|
|
148
|
+
"miss."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
|
152
|
+
"""Synchronous recall before the next API call.
|
|
153
|
+
|
|
154
|
+
Calls `sigil search` against this platform's namespace plus `default`
|
|
155
|
+
(the cross-machine shared brain). Returns the raw CLI output as
|
|
156
|
+
context text; Sigil's hybrid search already formats one fact per line
|
|
157
|
+
which is exactly what the system prompt wants.
|
|
158
|
+
"""
|
|
159
|
+
if not query or not query.strip():
|
|
160
|
+
return ""
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
result = subprocess.run(
|
|
164
|
+
_sigil_search_args(query, self._search_namespaces, _PREFETCH_LIMIT),
|
|
165
|
+
timeout=_SEARCH_TIMEOUT_S,
|
|
166
|
+
capture_output=True,
|
|
167
|
+
text=True,
|
|
168
|
+
check=False,
|
|
169
|
+
)
|
|
170
|
+
except subprocess.TimeoutExpired:
|
|
171
|
+
logger.warning("sigil search timed out after %ss", _SEARCH_TIMEOUT_S)
|
|
172
|
+
return ""
|
|
173
|
+
except Exception as exc: # noqa: BLE001 — never break the agent's turn
|
|
174
|
+
logger.warning("sigil search failed: %s", exc)
|
|
175
|
+
return ""
|
|
176
|
+
|
|
177
|
+
if result.returncode != 0:
|
|
178
|
+
logger.warning("sigil search exit %s: %s", result.returncode, _clean_text(result.stderr))
|
|
179
|
+
return ""
|
|
180
|
+
|
|
181
|
+
out = _clean_text(result.stdout)
|
|
182
|
+
if not out or out == "No results found.":
|
|
183
|
+
return ""
|
|
184
|
+
|
|
185
|
+
# Trim early — Hermes also enforces memory_char_limit but truncating
|
|
186
|
+
# here avoids feeding the model results it can't use.
|
|
187
|
+
return out[:_PREFETCH_CHAR_LIMIT]
|
|
188
|
+
|
|
189
|
+
# -- Write (per-turn) ----------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def sync_turn(self, user_content: str, assistant_content: str, *,
|
|
192
|
+
session_id: str = "") -> None:
|
|
193
|
+
"""Persist memorable content from the just-completed turn.
|
|
194
|
+
|
|
195
|
+
Sigil's `remember` command runs its own classifier + AUDM dedup, so
|
|
196
|
+
we don't try to be clever about what's "memorable" — just hand
|
|
197
|
+
the user message over and let Sigil decide.
|
|
198
|
+
|
|
199
|
+
Background thread is belt-and-braces: `sigil remember --bg` already
|
|
200
|
+
spawns a detached subprocess, but wrapping it in a daemon thread
|
|
201
|
+
means the .run() call itself can't block sync_turn.
|
|
202
|
+
"""
|
|
203
|
+
text = (user_content or "").strip()
|
|
204
|
+
if not text:
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
# Sigil's CLI takes facts as positional args. We send the raw user
|
|
208
|
+
# message — its ingestion pipeline classifies, extracts, dedupes.
|
|
209
|
+
# Trimming to a sensible upper bound avoids enormous argv on long
|
|
210
|
+
# pasted content.
|
|
211
|
+
snippet = text[:4000]
|
|
212
|
+
|
|
213
|
+
def _save() -> None:
|
|
214
|
+
try:
|
|
215
|
+
subprocess.run(
|
|
216
|
+
["sigil", "remember", "--bg", snippet],
|
|
217
|
+
env={**os.environ, "DEFAULT_NAMESPACE": self._namespace},
|
|
218
|
+
timeout=_REMEMBER_TIMEOUT_S,
|
|
219
|
+
capture_output=True,
|
|
220
|
+
)
|
|
221
|
+
except Exception as exc: # noqa: BLE001
|
|
222
|
+
logger.warning("sigil remember failed: %s", exc)
|
|
223
|
+
|
|
224
|
+
# If the previous turn's sync is still running, let it finish first
|
|
225
|
+
# so we don't pile up zombie threads on chatty sessions.
|
|
226
|
+
if self._sync_thread and self._sync_thread.is_alive():
|
|
227
|
+
self._sync_thread.join(timeout=5.0)
|
|
228
|
+
self._sync_thread = threading.Thread(target=_save, daemon=True)
|
|
229
|
+
self._sync_thread.start()
|
|
230
|
+
|
|
231
|
+
# -- Tools (explicit invocation by the model) ----------------------------
|
|
232
|
+
|
|
233
|
+
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
|
234
|
+
return [
|
|
235
|
+
{
|
|
236
|
+
"name": "sigil_search",
|
|
237
|
+
"description": (
|
|
238
|
+
"Search persistent memory across all of the user's AI sessions "
|
|
239
|
+
"(this Hermes platform + their laptop's Claude Code / Cursor / "
|
|
240
|
+
"Codex / Kiro). Use for drill-down questions when the "
|
|
241
|
+
"auto-injected context block didn't surface what you need."
|
|
242
|
+
),
|
|
243
|
+
"parameters": {
|
|
244
|
+
"type": "object",
|
|
245
|
+
"properties": {
|
|
246
|
+
"query": {
|
|
247
|
+
"type": "string",
|
|
248
|
+
"description": "Natural-language search query."
|
|
249
|
+
},
|
|
250
|
+
"limit": {
|
|
251
|
+
"type": "integer",
|
|
252
|
+
"description": "Max results (default 5).",
|
|
253
|
+
"default": _PREFETCH_LIMIT,
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
"required": ["query"],
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
"name": "sigil_remember",
|
|
261
|
+
"description": (
|
|
262
|
+
"Save a single self-contained fact to persistent memory. Use "
|
|
263
|
+
"ONLY when the user explicitly asks to remember something, or "
|
|
264
|
+
"when they share a critical mid-turn fact. Routine facts are "
|
|
265
|
+
"captured automatically — don't double-save."
|
|
266
|
+
),
|
|
267
|
+
"parameters": {
|
|
268
|
+
"type": "object",
|
|
269
|
+
"properties": {
|
|
270
|
+
"fact": {
|
|
271
|
+
"type": "string",
|
|
272
|
+
"description": (
|
|
273
|
+
"A short, self-contained statement that makes sense "
|
|
274
|
+
"out of context. Not a conversation summary."
|
|
275
|
+
)
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
"required": ["fact"],
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
def handle_tool_call(self, tool_name: str, args: Dict[str, Any]) -> Any:
|
|
284
|
+
if tool_name == "sigil_search":
|
|
285
|
+
return self._tool_search(args)
|
|
286
|
+
if tool_name == "sigil_remember":
|
|
287
|
+
return self._tool_remember(args)
|
|
288
|
+
return _err(f"unknown tool: {tool_name}")
|
|
289
|
+
|
|
290
|
+
def _tool_search(self, args: Dict[str, Any]) -> str:
|
|
291
|
+
query = (args.get("query") or "").strip()
|
|
292
|
+
if not query:
|
|
293
|
+
return _err("query is required")
|
|
294
|
+
limit = int(args.get("limit", _PREFETCH_LIMIT))
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
result = subprocess.run(
|
|
298
|
+
_sigil_search_args(query, self._search_namespaces, limit),
|
|
299
|
+
timeout=_SEARCH_TIMEOUT_S,
|
|
300
|
+
capture_output=True,
|
|
301
|
+
text=True,
|
|
302
|
+
check=False,
|
|
303
|
+
)
|
|
304
|
+
except Exception as exc: # noqa: BLE001
|
|
305
|
+
return _err(_clean_text(f"sigil search failed: {exc}"))
|
|
306
|
+
|
|
307
|
+
if result.returncode != 0:
|
|
308
|
+
return _err(_clean_text(result.stderr or "search exited non-zero"))
|
|
309
|
+
return _ok({"results": _clean_text(result.stdout)})
|
|
310
|
+
|
|
311
|
+
def _tool_remember(self, args: Dict[str, Any]) -> str:
|
|
312
|
+
fact = (args.get("fact") or "").strip()
|
|
313
|
+
if not fact:
|
|
314
|
+
return _err("fact is required")
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
result = subprocess.run(
|
|
318
|
+
["sigil", "remember", "--bg", fact],
|
|
319
|
+
env={**os.environ, "DEFAULT_NAMESPACE": self._namespace},
|
|
320
|
+
timeout=_REMEMBER_TIMEOUT_S,
|
|
321
|
+
capture_output=True,
|
|
322
|
+
text=True,
|
|
323
|
+
check=False,
|
|
324
|
+
)
|
|
325
|
+
except Exception as exc: # noqa: BLE001
|
|
326
|
+
return _err(_clean_text(f"sigil remember failed: {exc}"))
|
|
327
|
+
|
|
328
|
+
if result.returncode != 0:
|
|
329
|
+
return _err(_clean_text(result.stderr or "remember exited non-zero"))
|
|
330
|
+
return _ok({"ok": True, "namespace": self._namespace})
|
|
331
|
+
|
|
332
|
+
# -- Config --------------------------------------------------------------
|
|
333
|
+
#
|
|
334
|
+
# Sigil reads its own ~/.sigil/.env (DB connection, embedder, LLM provider).
|
|
335
|
+
# Hermes doesn't need to know any of that — we return an empty schema so
|
|
336
|
+
# `hermes memory setup` doesn't ask redundant questions.
|
|
337
|
+
|
|
338
|
+
def get_config_schema(self) -> List[Dict[str, Any]]:
|
|
339
|
+
return []
|
|
340
|
+
|
|
341
|
+
def save_config(self, values: Dict[str, Any], hermes_home: str) -> None:
|
|
342
|
+
# No-op — Sigil owns its own config at ~/.sigil/.env. Run `sigil init`
|
|
343
|
+
# to (re)configure it.
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
# Plugin entry point
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
def register(ctx: Any) -> None:
|
|
352
|
+
"""Called by Hermes' memory plugin discovery system."""
|
|
353
|
+
ctx.register_memory_provider(SigilProvider())
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
name: sigil
|
|
2
|
+
version: 0.1.0
|
|
3
|
+
description: >
|
|
4
|
+
Persistent shared-brain memory via a local Sigil install. Hermes turns are
|
|
5
|
+
saved to a per-platform namespace; recall reads across that namespace plus
|
|
6
|
+
`default` (where the user's laptop tools — Claude Code, Cursor, Codex, Kiro —
|
|
7
|
+
write). One Postgres backs every machine; sources/pods classify the source.
|
|
8
|
+
hooks:
|
|
9
|
+
- prefetch
|
|
10
|
+
- sync_turn
|
package/knexfile.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
|
|
3
|
+
const env = (key, fallback) => process.env[key] ?? fallback;
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
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
|
+
},
|
|
14
|
+
migrations: { directory: './src/db/migrations' },
|
|
15
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@anmol-srv/sigil",
|
|
3
|
+
"version": "0.10.3",
|
|
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.",
|
|
6
|
+
"bin": {
|
|
7
|
+
"sigil": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "node --watch src/server.js",
|
|
11
|
+
"start": "node src/server.js",
|
|
12
|
+
"test": "vitest",
|
|
13
|
+
"lint": "eslint src/",
|
|
14
|
+
"lint:fix": "eslint src/ --fix",
|
|
15
|
+
"build": "node build.js",
|
|
16
|
+
"prepublishOnly": "npm run build",
|
|
17
|
+
"migrate": "knex migrate:latest",
|
|
18
|
+
"migrate:rollback": "knex migrate:rollback",
|
|
19
|
+
"migrate:make": "knex migrate:make"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20.0.0"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist/",
|
|
26
|
+
"prompts/",
|
|
27
|
+
"src/db/migrations/",
|
|
28
|
+
"integrations/",
|
|
29
|
+
"knexfile.js",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"keywords": [
|
|
34
|
+
"sigil",
|
|
35
|
+
"memory",
|
|
36
|
+
"ai-memory",
|
|
37
|
+
"persistent-memory",
|
|
38
|
+
"agent-memory",
|
|
39
|
+
"long-term-memory",
|
|
40
|
+
"shared-memory",
|
|
41
|
+
"ai-agent",
|
|
42
|
+
"coding-agent",
|
|
43
|
+
"ai-infrastructure",
|
|
44
|
+
"mcp",
|
|
45
|
+
"mcp-server",
|
|
46
|
+
"model-context-protocol",
|
|
47
|
+
"claude",
|
|
48
|
+
"claude-code",
|
|
49
|
+
"codex",
|
|
50
|
+
"codex-cli",
|
|
51
|
+
"cursor",
|
|
52
|
+
"kiro",
|
|
53
|
+
"windsurf",
|
|
54
|
+
"continue-dev",
|
|
55
|
+
"cline",
|
|
56
|
+
"chatgpt",
|
|
57
|
+
"ollama",
|
|
58
|
+
"rag",
|
|
59
|
+
"knowledge-graph",
|
|
60
|
+
"knowledge-base",
|
|
61
|
+
"vector-search",
|
|
62
|
+
"hybrid-search",
|
|
63
|
+
"llm",
|
|
64
|
+
"context",
|
|
65
|
+
"pgvector",
|
|
66
|
+
"postgres",
|
|
67
|
+
"local-first"
|
|
68
|
+
],
|
|
69
|
+
"author": "Anmol Srivastava",
|
|
70
|
+
"license": "ISC",
|
|
71
|
+
"repository": {
|
|
72
|
+
"type": "git",
|
|
73
|
+
"url": "https://github.com/Anmol-Srv/sigil.git"
|
|
74
|
+
},
|
|
75
|
+
"homepage": "https://github.com/Anmol-Srv/sigil#readme",
|
|
76
|
+
"bugs": {
|
|
77
|
+
"url": "https://github.com/Anmol-Srv/sigil/issues"
|
|
78
|
+
},
|
|
79
|
+
"dependencies": {
|
|
80
|
+
"@iarna/toml": "^2.2.5",
|
|
81
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
82
|
+
"dotenv": "^17.3.1",
|
|
83
|
+
"knex": "^3.1.0",
|
|
84
|
+
"pg": "^8.20.0"
|
|
85
|
+
},
|
|
86
|
+
"optionalDependencies": {
|
|
87
|
+
"@anthropic-ai/sdk": "^0.30.0"
|
|
88
|
+
},
|
|
89
|
+
"devDependencies": {
|
|
90
|
+
"@clack/prompts": "^1.2.0",
|
|
91
|
+
"@electric-sql/pglite": "^0.4.4",
|
|
92
|
+
"dayjs": "^1.11.19",
|
|
93
|
+
"esbuild": "^0.28.0",
|
|
94
|
+
"eslint": "^10.0.3",
|
|
95
|
+
"lodash-es": "^4.17.23",
|
|
96
|
+
"nanoid": "^5.1.7",
|
|
97
|
+
"vitest": "^4.0.18",
|
|
98
|
+
"zod": "^3.24.0"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
You are comparing two facts from an organizational knowledge base. Decide if the NEW fact should be added as a separate entry or if it updates/replaces the EXISTING fact.
|
|
2
|
+
|
|
3
|
+
## Decisions
|
|
4
|
+
|
|
5
|
+
- **UPDATE** — The new fact covers the same topic/event/observation as the existing fact, even if worded differently. They describe the same underlying thing. Replace the existing fact with the new version. **Err on the side of UPDATE** — a knowledge base with fewer, better facts is more useful than one with many overlapping facts.
|
|
6
|
+
- **ADD** — The new fact describes a genuinely different piece of information. Different event, different metric, different insight. Not just a rephrase.
|
|
7
|
+
- **CONTRADICT** — The new fact directly contradicts the existing fact (e.g., different numbers for the same metric, opposite conclusions).
|
|
8
|
+
|
|
9
|
+
## Examples
|
|
10
|
+
|
|
11
|
+
EXISTING: "Database Design session covered normalization from 1NF through 3NF"
|
|
12
|
+
NEW: "Database Design Fundamentals covered database normalization (1NF through 3NF), live schema design, and denormalization"
|
|
13
|
+
→ **UPDATE** (same core topic, new version adds more detail)
|
|
14
|
+
|
|
15
|
+
EXISTING: "Rahul Sharma recommended starting with PostgreSQL"
|
|
16
|
+
NEW: "In Q&A, a student asked about Redis vs PostgreSQL. Rahul recommended starting with PostgreSQL, moving to Redis for server-side revocation."
|
|
17
|
+
→ **UPDATE** (same recommendation, new version adds the question context)
|
|
18
|
+
|
|
19
|
+
EXISTING: "Session had 78% attendance with 32 of 41 enrolled"
|
|
20
|
+
NEW: "Session had 8 attendees with an average rating of 4.4/5"
|
|
21
|
+
→ **ADD** (different metrics — one is attendance %, other is count + rating)
|
|
22
|
+
|
|
23
|
+
EXISTING: "Students said 3NF was covered too quickly"
|
|
24
|
+
NEW: "Students requested more practice exercises for 3NF"
|
|
25
|
+
→ **UPDATE** (same underlying feedback about 3NF pacing)
|
|
26
|
+
|
|
27
|
+
EXISTING: "Session started 2 minutes late"
|
|
28
|
+
NEW: "Session started on time"
|
|
29
|
+
→ **CONTRADICT**
|
|
30
|
+
|
|
31
|
+
Respond with exactly one of: UPDATE, ADD, or CONTRADICT.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
You are enriching chunks of a document with contextual prefixes. Each chunk is a section of a larger document, and your job is to write a brief context sentence (1-2 sentences, 50-100 tokens) that situates the chunk within the full document.
|
|
2
|
+
|
|
3
|
+
The prefix should help a search engine understand what the chunk is about even when read in isolation. Include the document title, section topic, and any relevant framing from the surrounding document.
|
|
4
|
+
|
|
5
|
+
Do NOT repeat the chunk content. Just provide the context that would be lost if the chunk were read alone.
|
|
6
|
+
|
|
7
|
+
## Input
|
|
8
|
+
|
|
9
|
+
You will receive:
|
|
10
|
+
1. The full document text
|
|
11
|
+
2. A list of chunk excerpts (first 200 characters of each)
|
|
12
|
+
|
|
13
|
+
## Output
|
|
14
|
+
|
|
15
|
+
Respond with ONLY a JSON array of strings — one context prefix per chunk, in the same order as the input chunks.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
```json
|
|
19
|
+
[
|
|
20
|
+
"This chunk from the API Design Guide covers authentication requirements for the REST API.",
|
|
21
|
+
"This section of the API Design Guide describes rate limiting policies and retry behavior."
|
|
22
|
+
]
|
|
23
|
+
```
|