gnosys 5.13.0 → 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
@@ -1173,9 +1173,16 @@ dreamCmd
1173
1173
  .option("--force", "Run even if this machine is not the designated dream node")
1174
1174
  .option("--scheduled", "Run as the machine-level scheduler (applies night/idle/cooldown gates)")
1175
1175
  .option("--json", "Output raw JSON report")
1176
- .action(async (opts) => {
1176
+ .action(async function (opts) {
1177
+ // v5.13.1: the bare `gnosys dream` parent declares the same flags, and
1178
+ // commander resolves parent-declared options onto the PARENT during
1179
+ // `dream run --scheduled` — so run's opts arrived empty and every
1180
+ // scheduled invocation (the launchd agent's exact command line) ran as
1181
+ // a MANUAL dream, bypassing the night/idle/dreamworthiness gates.
1182
+ // Merge parent opts (same pattern as `dream log` below).
1183
+ const merged = { ...(this.parent?.opts() ?? {}), ...opts };
1177
1184
  const { runDreamCommand } = await import("./lib/dreamCommand.js");
1178
- await runDreamCommand(opts);
1185
+ await runDreamCommand(merged);
1179
1186
  });
1180
1187
  // `gnosys dream log` — view recent dream runs from audit_log
1181
1188
  dreamCmd
@@ -1300,6 +1307,15 @@ program
1300
1307
  const { runRecallCommand } = await import("./lib/recallCommand.js");
1301
1308
  await runRecallCommand(query, opts);
1302
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
+ });
1303
1319
  // ─── gnosys audit ────────────────────────────────────────────────────────
1304
1320
  program
1305
1321
  .command("audit")
@@ -1848,10 +1864,13 @@ if (!isTestEnv()) {
1848
1864
  // console.log during boot corrupts the protocol and the host (Grok, Codex,
1849
1865
  // etc.) sees the server as [unavailable]. Suppress the nag in serve mode.
1850
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");
1851
1870
  // v5.9.3 Phase H: fire on any mismatch (upgrade OR downgrade).
1852
1871
  const mismatch = lastVersion !== null && lastVersion !== undefined &&
1853
1872
  compareSemver(currentVersion, lastVersion) !== 0;
1854
- if (mismatch && !isUpgradeCmd && !isSetupSyncCmd && !isServeCmd) {
1873
+ if (mismatch && !isUpgradeCmd && !isSetupSyncCmd && !isServeCmd && !isHookCmd) {
1855
1874
  // v5.9.3 Phase H: emit on STDERR (was stdout). Safer invariant per
1856
1875
  // deci-045 — stdout is reserved for command output.
1857
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'"),
package/dist/lib/db.d.ts CHANGED
@@ -330,6 +330,12 @@ export declare class GnosysDB {
330
330
  tags: string;
331
331
  content: string;
332
332
  }>;
333
+ /**
334
+ * v5.13.1: top active memories for wildcard recall. The gnosys://recall
335
+ * resource asks for "the best memories, no query" — rank by how often a
336
+ * memory proved useful (reinforcement), then confidence, then recency.
337
+ */
338
+ getTopActiveMemories(limit?: number): DbMemory[];
333
339
  /** v5.13.0: memories whose embedding column is NULL (embedding-health check). */
334
340
  countMemoriesMissingEmbedding(): number;
335
341
  updateEmbedding(id: string, embedding: Buffer): void;
package/dist/lib/db.js CHANGED
@@ -1264,6 +1264,15 @@ export class GnosysDB {
1264
1264
  const lim = limit && limit > 0 ? ` LIMIT ${Math.floor(limit)}` : "";
1265
1265
  return this.withRecovery(() => this.prep(`SELECT id, title, relevance, tags, content FROM memories ${where} ORDER BY modified DESC${lim}`).all());
1266
1266
  }
1267
+ /**
1268
+ * v5.13.1: top active memories for wildcard recall. The gnosys://recall
1269
+ * resource asks for "the best memories, no query" — rank by how often a
1270
+ * memory proved useful (reinforcement), then confidence, then recency.
1271
+ */
1272
+ getTopActiveMemories(limit = 15) {
1273
+ return this.withRecovery(() => this.prep(`SELECT ${LEAN_MEMORY_PROJECTION} FROM memories WHERE tier = 'active' AND status = 'active'
1274
+ ORDER BY reinforcement_count DESC, confidence DESC, modified DESC LIMIT ?`).all(limit));
1275
+ }
1267
1276
  /** v5.13.0: memories whose embedding column is NULL (embedding-health check). */
1268
1277
  countMemoriesMissingEmbedding() {
1269
1278
  return this.withRecovery(() => {
@@ -1,7 +1,7 @@
1
1
  import { GnosysResolver } from "./resolver.js";
2
2
  import { GnosysDB } from "./db.js";
3
3
  import { loadConfig } from "./config.js";
4
- import { acquireDreamLock, appendDreamRun, countChangedMemoriesSince, createSkipRunRecord, getSystemIdleMinutes, isInsideNightWindow, readDreamState, } from "./dreamRunLog.js";
4
+ import { acquireDreamLock, appendDreamRun, countDreamworthyChanges, createSkipRunRecord, getSystemIdleMinutes, isInsideNightWindow, readDreamState, } from "./dreamRunLog.js";
5
5
  export async function runDreamCommand(opts) {
6
6
  const resolver = new GnosysResolver();
7
7
  await resolver.resolve();
@@ -15,7 +15,14 @@ export async function runDreamCommand(opts) {
15
15
  const { getMachineId } = await import("./remote.js");
16
16
  const storePath = stores[0].path;
17
17
  const cfg = await loadConfig(storePath);
18
- const db = new DbClass(storePath);
18
+ // v5.13.1: Dream operates on the brain — the central DB — not the
19
+ // project store the command happened to be run from. Opening
20
+ // stores[0].path meant `gnosys dream run` failed with "run gnosys
21
+ // migrate" from any project directory whose local .gnosys has no
22
+ // migrated DB, even though the central DB was fine (and the designation
23
+ // check below already used openCentral). DbClass alias keeps the lazy
24
+ // import pattern.
25
+ const db = DbClass.openCentral();
19
26
  if (!db.isAvailable() || !db.isMigrated()) {
20
27
  console.error("Dream Mode requires gnosys.db (v2.0). Run 'gnosys migrate' first.");
21
28
  process.exit(1);
@@ -114,7 +121,9 @@ export async function runDreamCommand(opts) {
114
121
  gates.push({ name: "system-idle", passed: true, details: { idleMinutes } });
115
122
  const state = readDreamState();
116
123
  const memories = db.getActiveMemories();
117
- const changed = countChangedMemoriesSince(memories, state.lastMemoryMaxModified);
124
+ // v5.13.1: max(date-watermark changes, count delta) — self-heals
125
+ // stale/polluted state files and catches bulk imports/deletions.
126
+ const changed = countDreamworthyChanges(memories, state);
118
127
  const lastRunMs = state.lastSuccessfulRunAt ? Date.parse(state.lastSuccessfulRunAt) : 0;
119
128
  const hoursSince = lastRunMs ? (Date.now() - lastRunMs) / 3_600_000 : Infinity;
120
129
  const enoughMemories = changed >= cfg.dream.minNewMemoriesToDream;
@@ -107,6 +107,16 @@ export declare function memoryWatermark(memories: DbMemory[]): {
107
107
  maxModified: string | undefined;
108
108
  };
109
109
  export declare function countChangedMemoriesSince(memories: DbMemory[], sinceIso?: string): number;
110
+ /**
111
+ * v5.13.1: total change signal for the dreamworthiness gate. The date-only
112
+ * watermark misses bulk imports, deletions, and stale/polluted state files
113
+ * (a test-suite bug wrote lastMemoryCount=5 against 1,200+ memory brains,
114
+ * suppressing scheduled dreaming indefinitely — fixed in v5.13.0, but
115
+ * already-polluted machines need the gate itself to self-heal). A mismatch
116
+ * between the stored memory count and reality IS change — take the max of
117
+ * both signals.
118
+ */
119
+ export declare function countDreamworthyChanges(memories: DbMemory[], state: DreamState): number;
110
120
  export declare function isInsideNightWindow(now: Date, schedule: DreamConfig["schedule"]): boolean;
111
121
  export declare function getSystemIdleMinutes(): number | null;
112
122
  export declare function createSkipRunRecord(input: {
@@ -175,6 +175,20 @@ export function countChangedMemoriesSince(memories, sinceIso) {
175
175
  return memories.length;
176
176
  return memories.filter((m) => Date.parse(m.modified || m.created) > sinceMs).length;
177
177
  }
178
+ /**
179
+ * v5.13.1: total change signal for the dreamworthiness gate. The date-only
180
+ * watermark misses bulk imports, deletions, and stale/polluted state files
181
+ * (a test-suite bug wrote lastMemoryCount=5 against 1,200+ memory brains,
182
+ * suppressing scheduled dreaming indefinitely — fixed in v5.13.0, but
183
+ * already-polluted machines need the gate itself to self-heal). A mismatch
184
+ * between the stored memory count and reality IS change — take the max of
185
+ * both signals.
186
+ */
187
+ export function countDreamworthyChanges(memories, state) {
188
+ const byDate = countChangedMemoriesSince(memories, state.lastMemoryMaxModified);
189
+ const countDelta = state.lastMemoryCount == null ? 0 : Math.abs(memories.length - state.lastMemoryCount);
190
+ return Math.max(byDate, countDelta);
191
+ }
178
192
  export function isInsideNightWindow(now, schedule) {
179
193
  const start = schedule.startHour;
180
194
  const end = schedule.endHour;
@@ -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 */
@@ -23,6 +23,7 @@
23
23
  import { GnosysArchive } from "./archive.js";
24
24
  import { mergeOverlayDiscoverResults, pendingAddToDbMemory, } from "./clientReadOverlay.js";
25
25
  import { auditLog } from "./audit.js";
26
+ import { ftsTerms } from "./ftsQuery.js";
26
27
  /** Default recall config */
27
28
  const DEFAULT_RECALL_CONFIG = {
28
29
  aggressive: true,
@@ -71,6 +72,17 @@ export async function recall(query, options) {
71
72
  return recallFromDb(query, options.gnosysDb, limit, cfg, options.traceId, options.pendingOverlay);
72
73
  }
73
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
+ }
74
86
  const memories = [];
75
87
  // Step 1: Fast keyword search on active memories (FTS5 — sub-10ms)
76
88
  const fetchLimit = Math.max(limit * 2, 15);
@@ -95,7 +107,7 @@ export async function recall(query, options) {
95
107
  }
96
108
  // Step 2: Archive fallback if active results are thin
97
109
  let totalArchived = 0;
98
- if (memories.length < limit) {
110
+ if (memories.length < limit && options.storePath) {
99
111
  try {
100
112
  const archive = new GnosysArchive(options.storePath);
101
113
  if (archive.isAvailable()) {
@@ -150,6 +162,48 @@ function recallFromDb(query, db, limit, cfg, traceId, pendingOverlay) {
150
162
  const memories = [];
151
163
  // Step 1: FTS5 discover on gnosys.db
152
164
  const fetchLimit = Math.max(limit * 2, 15);
165
+ // v5.13.1: wildcard recall. The gnosys://recall resource passes "*"
166
+ // meaning "inject the top memories, no specific query" — but "*" was
167
+ // never a valid FTS5 match-all (it's a syntax error), so the resource
168
+ // returned zero memories since it shipped in v4.0.0. Any query with no
169
+ // searchable terms now serves top active memories ranked by
170
+ // reinforcement, confidence, and recency.
171
+ if (ftsTerms(query).length === 0) {
172
+ const top = db.getTopActiveMemories(fetchLimit);
173
+ for (let i = 0; i < top.length; i++) {
174
+ const mem = top[i];
175
+ memories.push({
176
+ id: mem.id,
177
+ title: mem.title,
178
+ category: mem.category,
179
+ relevance: mem.relevance || "",
180
+ confidence: mem.confidence,
181
+ path: mem.id,
182
+ fromArchive: false,
183
+ snippet: mem.content.substring(0, 300),
184
+ // Rank-derived score, floored above minRelevance (0.4 default) so
185
+ // non-aggressive mode keeps top memories too.
186
+ relevanceScore: Math.max(0.9 - i * 0.02, 0.5),
187
+ });
188
+ }
189
+ const wildcardCounts = db.getMemoryCount();
190
+ const result = applyRecallFiltering(memories, top.length, wildcardCounts.archived, limit, cfg, start);
191
+ db.logAudit({
192
+ timestamp: new Date().toISOString(),
193
+ operation: "recall",
194
+ memory_id: null,
195
+ details: JSON.stringify({
196
+ query,
197
+ wildcard: true,
198
+ aggressive: cfg.aggressive,
199
+ totalCandidates: memories.length,
200
+ filtered: result.memories.length,
201
+ }),
202
+ duration_ms: Math.round(result.recallTimeMs),
203
+ trace_id: traceId || null,
204
+ });
205
+ return result;
206
+ }
153
207
  let dbResults = db.discoverFts(query, fetchLimit);
154
208
  if (pendingOverlay?.length) {
155
209
  dbResults = mergeOverlayDiscoverResults(dbResults, pendingOverlay, query, fetchLimit, (p) => ({
@@ -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.0",
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",