gnosys 5.13.1 → 5.14.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.
package/dist/cli.js CHANGED
@@ -1307,6 +1307,15 @@ program
1307
1307
  const { runRecallCommand } = await import("./lib/recallCommand.js");
1308
1308
  await runRecallCommand(query, opts);
1309
1309
  });
1310
+ // ─── gnosys recall-hook ──────────────────────────────────────────────────
1311
+ program
1312
+ .command("recall-hook")
1313
+ .description("Claude Code hook entry — reads the hook event JSON from stdin and prints a <gnosys-recall> context block. Wired automatically by gnosys init into UserPromptSubmit + SessionStart.")
1314
+ .option("--limit <n>", "Max memories to inject (default from config)")
1315
+ .action(async (opts) => {
1316
+ const { runRecallHookCommand } = await import("./lib/recallHookCommand.js");
1317
+ await runRecallHookCommand(opts);
1318
+ });
1310
1319
  // ─── gnosys audit ────────────────────────────────────────────────────────
1311
1320
  program
1312
1321
  .command("audit")
@@ -1855,10 +1864,13 @@ if (!isTestEnv()) {
1855
1864
  // console.log during boot corrupts the protocol and the host (Grok, Codex,
1856
1865
  // etc.) sees the server as [unavailable]. Suppress the nag in serve mode.
1857
1866
  const isServeCmd = process.argv.slice(2).some(a => a === "serve");
1867
+ // v5.14.0: recall-hook runs on every Claude Code prompt — keep its
1868
+ // stderr quiet too so hook error logs stay clean.
1869
+ const isHookCmd = process.argv.slice(2).some(a => a === "recall-hook");
1858
1870
  // v5.9.3 Phase H: fire on any mismatch (upgrade OR downgrade).
1859
1871
  const mismatch = lastVersion !== null && lastVersion !== undefined &&
1860
1872
  compareSemver(currentVersion, lastVersion) !== 0;
1861
- if (mismatch && !isUpgradeCmd && !isSetupSyncCmd && !isServeCmd) {
1873
+ if (mismatch && !isUpgradeCmd && !isSetupSyncCmd && !isServeCmd && !isHookCmd) {
1862
1874
  // v5.9.3 Phase H: emit on STDERR (was stdout). Safer invariant per
1863
1875
  // deci-045 — stdout is reserved for command output.
1864
1876
  const isMajorOrMinor = (() => {
package/dist/index.js CHANGED
@@ -2246,7 +2246,7 @@ async function reindexAllStores() {
2246
2246
  //
2247
2247
  // Priority 1 + audience: assistant = hosts inject this before every message.
2248
2248
  regResource("gnosys_recall", "gnosys://recall", {
2249
- description: "Automatic memory injection. Hosts read this resource on every turn to inject the most relevant memories as context. Returns a <gnosys-recall> block with [[wikilinks]] and relevance scores. Priority 1 (highest) — designed for always-on context injection without any tool call. Configure aggressiveness in gnosys.json: recall.aggressive (default: true).",
2249
+ description: "Top-memory injection block for hosts that read MCP resources. Returns a <gnosys-recall> block with [[wikilinks]] and relevance scores. NOTE: most hosts (Claude Code, Cursor) do NOT auto-read MCP resources per turn — for true automatic injection use the hooks `gnosys init` installs (UserPromptSubmit/SessionStart → `gnosys recall-hook`). Configure aggressiveness in gnosys.json: recall.aggressive (default: true).",
2250
2250
  mimeType: "text/markdown",
2251
2251
  annotations: {
2252
2252
  audience: ["assistant"],
@@ -2294,7 +2294,7 @@ regResource("gnosys_recall", "gnosys://recall", {
2294
2294
  // ─── Tool: gnosys_recall (query-specific fallback) ──────────────────────
2295
2295
  // For hosts that don't support MCP Resources, or when the agent wants to
2296
2296
  // recall memories for a specific query. The resource above is preferred.
2297
- regTool("gnosys_recall", "Fast memory recall — inject relevant memories as context. Returns <gnosys-recall> block. In aggressive mode (default), always returns top memories even at medium relevance. Prefer the gnosys://recall MCP Resource for automatic injection (no tool call needed).", {
2297
+ regTool("gnosys_recall", "Fast memory recall — inject relevant memories as context. Returns <gnosys-recall> block. In aggressive mode (default), always returns top memories even at medium relevance. A wildcard query ('*') returns the top memories by reinforcement/confidence/recency. Hosts with gnosys hooks installed (via gnosys init) already get this automatically per prompt.", {
2298
2298
  query: z
2299
2299
  .string()
2300
2300
  .describe("What the agent is currently working on. Use keywords. Example: 'auth JWT middleware' or 'database migration schema'"),
@@ -202,29 +202,61 @@ export async function configureClaudeCode(projectDir) {
202
202
  catch {
203
203
  // No settings yet
204
204
  }
205
- // Build the Gnosys SessionStart hook
205
+ // v5.14.0: the hook command is `gnosys recall-hook` — it reads the hook
206
+ // event JSON from stdin (per the Claude Code hooks contract) and prints
207
+ // a <gnosys-recall> block, which Claude Code adds to the model context.
208
+ // Pre-5.14 installs wrote `gnosys recall --query ... --projectRoot ...`,
209
+ // options that never existed — the hook failed silently on every
210
+ // session start. Detect and heal that shape below.
211
+ const hookCommand = "gnosys recall-hook 2>/dev/null || true";
206
212
  const gnosysHook = {
207
213
  type: "command",
208
- command: "gnosys recall --query \"session start\" --projectRoot \"$CLAUDE_PROJECT_DIR\" 2>/dev/null || true",
214
+ command: hookCommand,
209
215
  timeout: 10,
210
216
  };
211
- // Merge into existing hooks without clobbering other SessionStart hooks
217
+ const isGnosysHookCmd = (h) => typeof h.command === "string" &&
218
+ (h.command.includes("gnosys recall-hook") ||
219
+ // the exact broken pre-5.14 shape only — a user's own `gnosys recall
220
+ // <topic>` hook must not count as "already configured"
221
+ h.command.includes("gnosys recall --query"));
222
+ const isBrokenLegacyCmd = (h) => typeof h.command === "string" && h.command.includes("gnosys recall --query");
223
+ // Merge into existing hooks without clobbering other entries
212
224
  const hooks = (settings.hooks || {});
225
+ let changed = false;
226
+ // Heal the broken legacy command wherever it appears
227
+ for (const eventName of Object.keys(hooks)) {
228
+ for (const entry of (hooks[eventName] || [])) {
229
+ const entryHooks = (entry.hooks || []);
230
+ for (const h of entryHooks) {
231
+ if (isBrokenLegacyCmd(h)) {
232
+ h.command = hookCommand;
233
+ changed = true;
234
+ }
235
+ }
236
+ }
237
+ }
238
+ // SessionStart: top memories at startup/resume/compact
213
239
  const sessionStartEntries = (hooks.SessionStart || []);
214
- // Check if a Gnosys hook already exists
215
- const hasGnosysHook = sessionStartEntries.some((entry) => {
216
- const entryHooks = (entry.hooks || []);
217
- return entryHooks.some((h) => typeof h.command === "string" && h.command.includes("gnosys recall"));
218
- });
219
- if (!hasGnosysHook) {
220
- sessionStartEntries.push({
221
- matcher: "startup|resume|compact",
222
- hooks: [gnosysHook],
223
- });
240
+ const hasSessionHook = sessionStartEntries.some((entry) => (entry.hooks || []).some(isGnosysHookCmd));
241
+ if (!hasSessionHook) {
242
+ sessionStartEntries.push({ matcher: "startup|resume|compact", hooks: [gnosysHook] });
224
243
  hooks.SessionStart = sessionStartEntries;
244
+ changed = true;
245
+ }
246
+ // UserPromptSubmit: per-prompt recall with the prompt text as the query —
247
+ // this is the "automatic memory injection" path (no matcher: always fires)
248
+ const promptEntries = (hooks.UserPromptSubmit || []);
249
+ const hasPromptHook = promptEntries.some((entry) => (entry.hooks || []).some(isGnosysHookCmd));
250
+ if (!hasPromptHook) {
251
+ promptEntries.push({ hooks: [gnosysHook] });
252
+ hooks.UserPromptSubmit = promptEntries;
253
+ changed = true;
254
+ }
255
+ if (changed) {
225
256
  settings.hooks = hooks;
226
257
  await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
227
258
  }
259
+ const hasGnosysHook = !changed;
228
260
  // v5.8.4: also register the MCP server itself (not just the recall hook),
229
261
  // so `gnosys init` is a one-stop shop. Previously the user had to ALSO
230
262
  // run `gnosys setup` for agents to actually call gnosys MCP tools.
@@ -234,7 +266,9 @@ export async function configureClaudeCode(projectDir) {
234
266
  configured: true,
235
267
  filePath: settingsPath,
236
268
  details: [
237
- hasGnosysHook ? "SessionStart hook already configured" : "Added SessionStart hook",
269
+ hasGnosysHook
270
+ ? "Recall hooks already configured"
271
+ : "Recall hooks configured (SessionStart + UserPromptSubmit → gnosys recall-hook)",
238
272
  mcpResult.success ? mcpResult.message : `MCP register skipped: ${mcpResult.message}`,
239
273
  ].join("; "),
240
274
  };
@@ -62,9 +62,10 @@ interface RecallMemory {
62
62
  */
63
63
  export declare function recall(query: string, options: {
64
64
  limit?: number;
65
- search: GnosysSearch;
66
- resolver: GnosysResolver;
67
- storePath: string;
65
+ /** Optional in v5.14: callers on the DB fast path (hooks) skip the file-store deps. */
66
+ search?: GnosysSearch;
67
+ resolver?: GnosysResolver;
68
+ storePath?: string;
68
69
  traceId?: string;
69
70
  recallConfig?: RecallConfig;
70
71
  /** v2.0: When provided, recall uses SQLite directly — no filesystem reads */
@@ -72,6 +72,17 @@ export async function recall(query, options) {
72
72
  return recallFromDb(query, options.gnosysDb, limit, cfg, options.traceId, options.pendingOverlay);
73
73
  }
74
74
  // ─── v1.x legacy path (filesystem + search.db) ────────────────────
75
+ // v5.14: DB-only callers (recall-hook) pass no file-store deps; without
76
+ // them and without a central DB there is nothing to search.
77
+ if (!options.search || !options.resolver) {
78
+ return {
79
+ memories: [],
80
+ totalActive: 0,
81
+ totalArchived: 0,
82
+ recallTimeMs: Math.round((performance.now() - start) * 100) / 100,
83
+ aggressive: cfg.aggressive,
84
+ };
85
+ }
75
86
  const memories = [];
76
87
  // Step 1: Fast keyword search on active memories (FTS5 — sub-10ms)
77
88
  const fetchLimit = Math.max(limit * 2, 15);
@@ -96,7 +107,7 @@ export async function recall(query, options) {
96
107
  }
97
108
  // Step 2: Archive fallback if active results are thin
98
109
  let totalArchived = 0;
99
- if (memories.length < limit) {
110
+ if (memories.length < limit && options.storePath) {
100
111
  try {
101
112
  const archive = new GnosysArchive(options.storePath);
102
113
  if (archive.isAvailable()) {
@@ -70,7 +70,7 @@ export async function runRecallCommand(query, opts) {
70
70
  }
71
71
  return;
72
72
  }
73
- // Legacy file-based recall
73
+ // Default recall path
74
74
  const resolver = new GnosysResolver();
75
75
  await resolver.resolve();
76
76
  const stores = resolver.getStores();
@@ -91,14 +91,29 @@ export async function runRecallCommand(query, opts) {
91
91
  // Build search index
92
92
  const search = new GnosysSearch(storePath);
93
93
  await search.addStoreMemories(stores[0].store);
94
- const result = await recall(query, {
95
- limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
96
- search,
97
- resolver,
98
- storePath,
99
- traceId: opts.traceId,
100
- recallConfig,
101
- });
94
+ // v5.14.0: recall from the central DB (the brain) like the MCP tool
95
+ // does the CLI previously only searched the project file store, so
96
+ // `gnosys recall` (and the SessionStart hook built on it) returned
97
+ // nothing on DB-only installs. Snapshot-aware via clientReadResolve;
98
+ // falls back to the file-store path when no central DB exists.
99
+ const { resolveClientRead } = await import("./clientReadResolve.js");
100
+ const clientRead = resolveClientRead();
101
+ let result;
102
+ try {
103
+ result = await recall(query, {
104
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
105
+ search,
106
+ resolver,
107
+ storePath,
108
+ traceId: opts.traceId,
109
+ recallConfig,
110
+ gnosysDb: clientRead?.db,
111
+ pendingOverlay: clientRead?.pendingOverlay,
112
+ });
113
+ }
114
+ finally {
115
+ clientRead?.release();
116
+ }
102
117
  if (opts.json) {
103
118
  console.log(JSON.stringify(result, null, 2));
104
119
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `gnosys recall-hook` — Claude Code hook entry point (v5.14.0).
3
+ *
4
+ * This is what makes "automatic memory injection" real: Claude Code never
5
+ * auto-reads MCP resources, but its hooks contract
6
+ * (code.claude.com/docs/en/hooks.md) adds a hook's plain stdout to the
7
+ * model context on exit 0. `gnosys init` wires this command into
8
+ * UserPromptSubmit (every prompt, query = the prompt text) and
9
+ * SessionStart (startup/resume/compact, wildcard = top memories).
10
+ *
11
+ * Contract obligations:
12
+ * - FAST: runs on every prompt. DB fast path only — no file stores, no
13
+ * embeddings, no model loads. (~150ms cold including node startup.)
14
+ * - NEVER breaks the prompt: always exit 0; every failure is silent.
15
+ * - Empty stdout when there is nothing to inject (no noise strings).
16
+ * - Output stays well under the 10,000-char hook cap.
17
+ */
18
+ export type HookEvent = {
19
+ hook_event_name?: string;
20
+ prompt_text?: string;
21
+ /** Older docs name the field `prompt`; accept both. */
22
+ prompt?: string;
23
+ source?: string;
24
+ cwd?: string;
25
+ };
26
+ /** Parse the hook event JSON into a recall query ("*" = top memories). */
27
+ export declare function hookQueryFromStdin(raw: string): string;
28
+ export declare function runRecallHookCommand(opts?: {
29
+ limit?: string;
30
+ }): Promise<void>;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `gnosys recall-hook` — Claude Code hook entry point (v5.14.0).
3
+ *
4
+ * This is what makes "automatic memory injection" real: Claude Code never
5
+ * auto-reads MCP resources, but its hooks contract
6
+ * (code.claude.com/docs/en/hooks.md) adds a hook's plain stdout to the
7
+ * model context on exit 0. `gnosys init` wires this command into
8
+ * UserPromptSubmit (every prompt, query = the prompt text) and
9
+ * SessionStart (startup/resume/compact, wildcard = top memories).
10
+ *
11
+ * Contract obligations:
12
+ * - FAST: runs on every prompt. DB fast path only — no file stores, no
13
+ * embeddings, no model loads. (~150ms cold including node startup.)
14
+ * - NEVER breaks the prompt: always exit 0; every failure is silent.
15
+ * - Empty stdout when there is nothing to inject (no noise strings).
16
+ * - Output stays well under the 10,000-char hook cap.
17
+ */
18
+ /** Max chars of the user prompt used as the recall query. */
19
+ const MAX_QUERY_CHARS = 400;
20
+ /** Keep comfortably under Claude Code's 10k hook-output cap. */
21
+ const MAX_OUTPUT_CHARS = 8_000;
22
+ /** Parse the hook event JSON into a recall query ("*" = top memories). */
23
+ export function hookQueryFromStdin(raw) {
24
+ try {
25
+ const evt = JSON.parse(raw);
26
+ const prompt = evt.prompt_text ?? evt.prompt;
27
+ if (typeof prompt === "string" && prompt.trim().length > 0) {
28
+ return prompt.trim().slice(0, MAX_QUERY_CHARS);
29
+ }
30
+ }
31
+ catch {
32
+ // Not JSON (manual invocation / future contract change) — wildcard.
33
+ }
34
+ return "*";
35
+ }
36
+ async function readStdin(timeoutMs) {
37
+ if (process.stdin.isTTY)
38
+ return "";
39
+ return new Promise((resolve) => {
40
+ let data = "";
41
+ const timer = setTimeout(() => resolve(data), timeoutMs);
42
+ timer.unref?.();
43
+ process.stdin.setEncoding("utf8");
44
+ process.stdin.on("data", (chunk) => {
45
+ data += chunk;
46
+ });
47
+ process.stdin.on("end", () => {
48
+ clearTimeout(timer);
49
+ resolve(data);
50
+ });
51
+ process.stdin.on("error", () => {
52
+ clearTimeout(timer);
53
+ resolve(data);
54
+ });
55
+ });
56
+ }
57
+ export async function runRecallHookCommand(opts = {}) {
58
+ try {
59
+ const raw = await readStdin(1_000);
60
+ const query = hookQueryFromStdin(raw);
61
+ const { resolveClientRead } = await import("./clientReadResolve.js");
62
+ const clientRead = resolveClientRead();
63
+ if (!clientRead)
64
+ return; // no central DB — inject nothing, exit 0
65
+ try {
66
+ const { recall, formatRecall } = await import("./recall.js");
67
+ const result = await recall(query, {
68
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
69
+ gnosysDb: clientRead.db,
70
+ pendingOverlay: clientRead.pendingOverlay,
71
+ traceId: "claude-code-hook",
72
+ });
73
+ if (result.memories.length === 0)
74
+ return; // empty stdout = no injection
75
+ const block = formatRecall(result);
76
+ process.stdout.write(block.length > MAX_OUTPUT_CHARS ? `${block.slice(0, MAX_OUTPUT_CHARS)}\n</gnosys-recall>\n` : `${block}\n`);
77
+ }
78
+ finally {
79
+ clientRead.release();
80
+ }
81
+ }
82
+ catch {
83
+ // Never fail the user's prompt over memory recall.
84
+ }
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnosys",
3
- "version": "5.13.1",
3
+ "version": "5.14.0",
4
4
  "description": "Gnosys — Persistent Memory for AI Agents. Sandbox-first runtime, central SQLite brain, federated search, Dream Mode, Web Knowledge Base, Obsidian export.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",