gnosys 5.13.1 → 5.15.1

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
@@ -1097,6 +1097,12 @@ program
1097
1097
  console.error(`\nCould not write upgrade marker: ${err instanceof Error ? err.message : err}`);
1098
1098
  console.error(`Running MCP servers will need to be restarted manually.`);
1099
1099
  }
1100
+ // v5.15: a Node upgrade moves the node path hardcoded in the dream
1101
+ // LaunchAgent plist, silently killing the scheduler. Repair it here.
1102
+ const { repairDreamLaunchAgentAfterUpgrade } = await import("./lib/dreamLaunchd.js");
1103
+ const dreamRepairLine = await repairDreamLaunchAgentAfterUpgrade();
1104
+ if (dreamRepairLine)
1105
+ console.log(`\n${dreamRepairLine}`);
1100
1106
  if (opts.sync === false || opts.yes) {
1101
1107
  console.log(`\nDone. Run 'gnosys setup sync-projects' when you're ready to refresh registered projects.`);
1102
1108
  return;
@@ -1307,6 +1313,15 @@ program
1307
1313
  const { runRecallCommand } = await import("./lib/recallCommand.js");
1308
1314
  await runRecallCommand(query, opts);
1309
1315
  });
1316
+ // ─── gnosys recall-hook ──────────────────────────────────────────────────
1317
+ program
1318
+ .command("recall-hook")
1319
+ .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.")
1320
+ .option("--limit <n>", "Max memories to inject (default from config)")
1321
+ .action(async (opts) => {
1322
+ const { runRecallHookCommand } = await import("./lib/recallHookCommand.js");
1323
+ await runRecallHookCommand(opts);
1324
+ });
1310
1325
  // ─── gnosys audit ────────────────────────────────────────────────────────
1311
1326
  program
1312
1327
  .command("audit")
@@ -1855,10 +1870,13 @@ if (!isTestEnv()) {
1855
1870
  // console.log during boot corrupts the protocol and the host (Grok, Codex,
1856
1871
  // etc.) sees the server as [unavailable]. Suppress the nag in serve mode.
1857
1872
  const isServeCmd = process.argv.slice(2).some(a => a === "serve");
1873
+ // v5.14.0: recall-hook runs on every Claude Code prompt — keep its
1874
+ // stderr quiet too so hook error logs stay clean.
1875
+ const isHookCmd = process.argv.slice(2).some(a => a === "recall-hook");
1858
1876
  // v5.9.3 Phase H: fire on any mismatch (upgrade OR downgrade).
1859
1877
  const mismatch = lastVersion !== null && lastVersion !== undefined &&
1860
1878
  compareSemver(currentVersion, lastVersion) !== 0;
1861
- if (mismatch && !isUpgradeCmd && !isSetupSyncCmd && !isServeCmd) {
1879
+ if (mismatch && !isUpgradeCmd && !isSetupSyncCmd && !isServeCmd && !isHookCmd) {
1862
1880
  // v5.9.3 Phase H: emit on STDERR (was stdout). Safer invariant per
1863
1881
  // deci-045 — stdout is reserved for command output.
1864
1882
  const isMajorOrMinor = (() => {
package/dist/index.js CHANGED
@@ -1703,7 +1703,8 @@ regTool("gnosys_import", "Bulk import structured data (CSV, JSON, JSONL) into Gn
1703
1703
  skipExisting,
1704
1704
  limit,
1705
1705
  offset,
1706
- concurrency,
1706
+ // v5.15: explicit param wins; otherwise config importConcurrency.
1707
+ concurrency: concurrency ?? ctx.config?.importConcurrency,
1707
1708
  batchCommit: true,
1708
1709
  });
1709
1710
  // Reindex after import
@@ -2246,7 +2247,7 @@ async function reindexAllStores() {
2246
2247
  //
2247
2248
  // Priority 1 + audience: assistant = hosts inject this before every message.
2248
2249
  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).",
2250
+ 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
2251
  mimeType: "text/markdown",
2251
2252
  annotations: {
2252
2253
  audience: ["assistant"],
@@ -2294,7 +2295,7 @@ regResource("gnosys_recall", "gnosys://recall", {
2294
2295
  // ─── Tool: gnosys_recall (query-specific fallback) ──────────────────────
2295
2296
  // For hosts that don't support MCP Resources, or when the agent wants to
2296
2297
  // 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).", {
2298
+ 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
2299
  query: z
2299
2300
  .string()
2300
2301
  .describe("What the agent is currently working on. Use keywords. Example: 'auth JWT middleware' or 'database migration schema'"),
package/dist/lib/ask.js CHANGED
@@ -289,7 +289,11 @@ export class GnosysAsk {
289
289
  }
290
290
  return usedArchiveIds;
291
291
  }
292
- catch {
292
+ catch (err) {
293
+ // Degrade gracefully, but leave a trace on stderr — a hard dearchive
294
+ // failure previously looked identical to "no archive hits"
295
+ // (sprint 2026-07-02).
296
+ console.error(`gnosys: dearchive failed: ${err instanceof Error ? err.message : String(err)}`);
293
297
  return [];
294
298
  }
295
299
  }
@@ -132,7 +132,10 @@ export async function runAskCommand(getResolver, question, opts) {
132
132
  const writeTarget = resolver.getWriteTarget();
133
133
  if (writeTarget) {
134
134
  const { GnosysMaintenanceEngine } = await import("./maintenance.js");
135
- await GnosysMaintenanceEngine.reinforceBatch(writeTarget.store, result.sources.map((s) => s.relativePath)).catch(() => { });
135
+ await GnosysMaintenanceEngine.reinforceBatch(writeTarget.store, result.sources.map((s) => s.relativePath)).catch((err) => {
136
+ // Best-effort, but don't be fully silent (sprint 2026-07-02).
137
+ console.error(`gnosys: reinforcement skipped: ${err instanceof Error ? err.message : String(err)}`);
138
+ });
136
139
  }
137
140
  }
138
141
  }
@@ -42,6 +42,15 @@ export interface LLMTurnResult {
42
42
  result: string;
43
43
  }>;
44
44
  }
45
+ /**
46
+ * Build the system prompt for a chat turn.
47
+ *
48
+ * v5.15: wires two previously-unread config knobs —
49
+ * `chat.systemPromptPrefix` (prepended before the base prompt) and
50
+ * `chat.toolsEnabled` (false omits the gnosys-tool addendum entirely).
51
+ * Exported for tests.
52
+ */
53
+ export declare function composeSystemPrompt(config: GnosysConfig, recalled: RecalledMemory[] | undefined): string;
45
54
  /**
46
55
  * Run a single turn against the LLM. Loops if the assistant emits
47
56
  * gnosys-tool fences: each tool is executed in-process, the result is
@@ -12,9 +12,23 @@ import { CHOOSE_SYSTEM_PROMPT_ADDENDUM } from "./choose.js";
12
12
  import { buildToolsSystemPrompt, findTool } from "./tools.js";
13
13
  import { extractToolFences } from "./toolFence.js";
14
14
  const BASE_SYSTEM_PROMPT = `You are an assistant inside the Gnosys terminal chat — a memory-aware REPL. The user has persistent memory across sessions; relevant memories are injected as <memory id="..."> blocks before their question. Cite memory IDs in square brackets like [deci-037] when you use them. Be concise and direct. Markdown renders.${CHOOSE_SYSTEM_PROMPT_ADDENDUM}`;
15
- function composeSystemPrompt(recalled) {
16
- const toolsAddendum = buildToolsSystemPrompt();
17
- const base = `${BASE_SYSTEM_PROMPT}\n${toolsAddendum}`;
15
+ /**
16
+ * Build the system prompt for a chat turn.
17
+ *
18
+ * v5.15: wires two previously-unread config knobs —
19
+ * `chat.systemPromptPrefix` (prepended before the base prompt) and
20
+ * `chat.toolsEnabled` (false omits the gnosys-tool addendum entirely).
21
+ * Exported for tests.
22
+ */
23
+ export function composeSystemPrompt(config, recalled) {
24
+ const toolsEnabled = config.chat?.toolsEnabled !== false;
25
+ let base = toolsEnabled
26
+ ? `${BASE_SYSTEM_PROMPT}\n${buildToolsSystemPrompt()}`
27
+ : BASE_SYSTEM_PROMPT;
28
+ const prefix = config.chat?.systemPromptPrefix;
29
+ if (typeof prefix === "string" && prefix.trim().length > 0) {
30
+ base = `${prefix.trim()}\n\n${base}`;
31
+ }
18
32
  if (!recalled || recalled.length === 0)
19
33
  return base;
20
34
  return `${base}\n\n${formatRecallForPrompt(recalled)}`;
@@ -47,8 +61,11 @@ export async function runTurn(config, opts) {
47
61
  // falls back to defaultProvider when no chat override is set, so existing
48
62
  // installs keep working without a chat-specific config.
49
63
  const provider = getLLMProvider(config, "chat");
50
- const system = composeSystemPrompt(opts.recalled);
51
- const maxIterations = opts.maxToolIterations ?? 4;
64
+ const system = composeSystemPrompt(config, opts.recalled);
65
+ // v5.15: when chat.toolsEnabled === false the first response is final —
66
+ // no tool-fence execution loop.
67
+ const toolsEnabled = config.chat?.toolsEnabled !== false;
68
+ const maxIterations = toolsEnabled ? (opts.maxToolIterations ?? 4) : 1;
52
69
  const toolCalls = [];
53
70
  let toolPreamble;
54
71
  let combinedText = "";
@@ -64,7 +81,7 @@ export async function runTurn(config, opts) {
64
81
  combinedText += (combinedText ? "\n\n" : "") + chunk;
65
82
  // Look for tool fences; if none, this iteration's chunk IS the answer.
66
83
  const extraction = extractToolFences(chunk);
67
- if (!extraction || extraction.calls.length === 0) {
84
+ if (!toolsEnabled || !extraction || extraction.calls.length === 0) {
68
85
  break;
69
86
  }
70
87
  // Run each tool, accumulate results into the next prompt as a system block
@@ -183,7 +183,6 @@ export declare const GnosysConfigSchema: z.ZodObject<{
183
183
  openai: "openai";
184
184
  }>>;
185
185
  defaultModel: z.ZodOptional<z.ZodString>;
186
- bulkIngestionBatchSize: z.ZodDefault<z.ZodNumber>;
187
186
  importConcurrency: z.ZodDefault<z.ZodNumber>;
188
187
  autoCommit: z.ZodDefault<z.ZodBoolean>;
189
188
  llmRetryAttempts: z.ZodDefault<z.ZodNumber>;
@@ -241,7 +240,6 @@ export declare const GnosysConfigSchema: z.ZodObject<{
241
240
  }, z.core.$strip>>;
242
241
  chat: z.ZodDefault<z.ZodObject<{
243
242
  toolsEnabled: z.ZodDefault<z.ZodBoolean>;
244
- autoSummarizeAfterTurns: z.ZodDefault<z.ZodNumber>;
245
243
  systemPromptPrefix: z.ZodDefault<z.ZodString>;
246
244
  }, z.core.$strip>>;
247
245
  multimodal: z.ZodDefault<z.ZodObject<{
@@ -137,9 +137,6 @@ const RecallConfigSchema = z.object({
137
137
  const ChatConfigSchema = z.object({
138
138
  /** Allow LLM to call gnosys tools via gnosys-tool fences (default: true). */
139
139
  toolsEnabled: z.boolean().default(true),
140
- /** Suggest /save-turn after N consecutive turns where no memory was written.
141
- * 0 disables the nudge. Heuristic only — never writes without consent. */
142
- autoSummarizeAfterTurns: z.number().int().min(0).default(0),
143
140
  /** Prepended to the chat system prompt. Use for persona / style / domain hints. */
144
141
  systemPromptPrefix: z.string().default(""),
145
142
  });
@@ -210,8 +207,6 @@ export const GnosysConfigSchema = z.object({
210
207
  .optional(),
211
208
  /** @deprecated Use llm.anthropic.model or llm.ollama.model instead */
212
209
  defaultModel: z.string().optional(),
213
- /** Max records per batch commit during bulk import */
214
- bulkIngestionBatchSize: z.number().int().min(1).max(10000).default(500),
215
210
  /** Parallel LLM calls during import */
216
211
  importConcurrency: z.number().int().min(1).max(20).default(5),
217
212
  /** Enable auto-commit on every write (disable for manual git) */
@@ -259,7 +254,6 @@ export const GnosysConfigSchema = z.object({
259
254
  /** Chat TUI — interactive chat configuration (v5.8.0) */
260
255
  chat: ChatConfigSchema.default({
261
256
  toolsEnabled: true,
262
- autoSummarizeAfterTurns: 0,
263
257
  systemPromptPrefix: "",
264
258
  }),
265
259
  /** Multimodal ingestion — PDF, audio, image, video processing */
@@ -483,6 +477,30 @@ function deepMergeConfig(base, override) {
483
477
  }
484
478
  return result;
485
479
  }
480
+ // ─── Removed-knob deprecation warnings (v5.15) ──────────────────────────
481
+ //
482
+ // These knobs were documented but never read by any code ("settings that
483
+ // lie"), so v5.15 removed them from the schema. Zod strips unknown keys on
484
+ // parse, so old config files keep loading — but warn once per process (on
485
+ // stderr only; MCP serve stdout must stay clean for JSON-RPC).
486
+ const warnedRemovedKeys = new Set();
487
+ function warnRemovedConfigKeys(raw) {
488
+ const found = [];
489
+ if ("bulkIngestionBatchSize" in raw)
490
+ found.push("bulkIngestionBatchSize");
491
+ const chat = raw.chat;
492
+ if (typeof chat === "object" &&
493
+ chat !== null &&
494
+ "autoSummarizeAfterTurns" in chat) {
495
+ found.push("chat.autoSummarizeAfterTurns");
496
+ }
497
+ for (const key of found) {
498
+ if (warnedRemovedKeys.has(key))
499
+ continue;
500
+ warnedRemovedKeys.add(key);
501
+ console.error(`gnosys: config option "${key}" was removed in v5.15 and is ignored`);
502
+ }
503
+ }
486
504
  export async function loadConfig(storePath) {
487
505
  const configPath = path.join(storePath, "gnosys.json");
488
506
  const globalPath = path.join(getGnosysHome(), "gnosys.json");
@@ -510,6 +528,7 @@ export async function loadConfig(storePath) {
510
528
  if (raw === null) {
511
529
  return DEFAULT_CONFIG;
512
530
  }
531
+ warnRemovedConfigKeys(raw);
513
532
  const migrated = migrateConfig(raw);
514
533
  const parsed = GnosysConfigSchema.parse(migrated);
515
534
  return applyDreamProviderInheritance(parsed, migrated);
@@ -579,7 +598,6 @@ export function generateConfigTemplate() {
579
598
  },
580
599
  },
581
600
  taskModels: {},
582
- bulkIngestionBatchSize: 500,
583
601
  importConcurrency: 5,
584
602
  autoCommit: true,
585
603
  llmRetryAttempts: 3,
@@ -608,7 +626,6 @@ export function generateConfigTemplate() {
608
626
  },
609
627
  chat: {
610
628
  toolsEnabled: true,
611
- autoSummarizeAfterTurns: 0,
612
629
  systemPromptPrefix: "",
613
630
  },
614
631
  }, null, 2);
@@ -1,2 +1,67 @@
1
+ export interface LaunchctlResult {
2
+ ok: boolean;
3
+ message: string;
4
+ }
5
+ /** Pure helper: argv for loading a LaunchAgent plist. Exported for tests. */
6
+ export declare function launchctlLoadArgs(plistFile: string): string[];
7
+ /** Pure helper: argv for unloading a LaunchAgent plist. Exported for tests. */
8
+ export declare function launchctlUnloadArgs(plistFile: string): string[];
9
+ /**
10
+ * Pure helper: interpret a launchctl load failure. "Already loaded" is a
11
+ * success for our purposes (the agent is active). Exported for tests.
12
+ */
13
+ export declare function interpretLaunchctlLoadError(stderr: string): LaunchctlResult;
14
+ /**
15
+ * Best-effort `launchctl load` of the dream LaunchAgent so scheduled runs
16
+ * start immediately instead of waiting for the next login. (v5.14.x sprint,
17
+ * pre-approved.) Never throws.
18
+ */
19
+ export declare function loadDreamLaunchAgent(plistFile: string): LaunchctlResult;
20
+ /**
21
+ * Best-effort `launchctl unload` before removing the plist, so disabling
22
+ * Dream Mode deactivates the schedule immediately. Never throws.
23
+ */
24
+ export declare function unloadDreamLaunchAgent(plistFile: string): LaunchctlResult;
1
25
  export declare function installDreamLaunchAgent(): string | null;
26
+ /**
27
+ * Pure helper: extract the node and cli paths from a dream LaunchAgent
28
+ * plist body. Template shape (installDreamLaunchAgent): the first
29
+ * `<string>` is the Label, the second is the node binary path, the third
30
+ * is the cli path. Exported for tests.
31
+ */
32
+ export declare function parseDreamPlistPaths(body: string): {
33
+ nodePath?: string;
34
+ cliPath?: string;
35
+ };
36
+ export interface DreamLaunchAgentHealth {
37
+ installed: boolean;
38
+ loaded: boolean;
39
+ nodeExists: boolean;
40
+ cliExists: boolean;
41
+ healthy: boolean;
42
+ problems: string[];
43
+ plistFile: string | null;
44
+ }
45
+ /**
46
+ * Health-check the dream LaunchAgent. The plist hardcodes absolute node +
47
+ * cli paths (e.g. ~/.nvm/versions/node/vX.Y.Z/bin/node), so a Node upgrade
48
+ * silently kills the scheduler — this detects that. Never throws.
49
+ */
50
+ export declare function checkDreamLaunchAgent(): DreamLaunchAgentHealth;
51
+ /**
52
+ * Repair the dream LaunchAgent: rewrite the plist with the current
53
+ * process's node + cli paths, then reload it. Best-effort — never throws.
54
+ */
55
+ export declare function repairDreamLaunchAgent(): {
56
+ ok: boolean;
57
+ message: string;
58
+ };
59
+ /**
60
+ * Post-upgrade hook: when Dream Mode is enabled and the LaunchAgent is
61
+ * installed but unhealthy (typically because a Node upgrade moved the
62
+ * hardcoded node path), repair it. Returns a printable status line, or
63
+ * null when nothing applies (non-darwin, dream disabled, agent healthy
64
+ * or not installed). Never throws.
65
+ */
66
+ export declare function repairDreamLaunchAgentAfterUpgrade(): Promise<string | null>;
2
67
  export declare function uninstallDreamLaunchAgent(): string | null;
@@ -1,7 +1,73 @@
1
+ import { execFileSync } from "child_process";
1
2
  import fs from "fs";
2
3
  import os from "os";
3
4
  import path from "path";
4
5
  const LABEL = "com.gnosys.dream";
6
+ /** Pure helper: argv for loading a LaunchAgent plist. Exported for tests. */
7
+ export function launchctlLoadArgs(plistFile) {
8
+ return ["load", "-w", plistFile];
9
+ }
10
+ /** Pure helper: argv for unloading a LaunchAgent plist. Exported for tests. */
11
+ export function launchctlUnloadArgs(plistFile) {
12
+ return ["unload", plistFile];
13
+ }
14
+ /**
15
+ * Pure helper: interpret a launchctl load failure. "Already loaded" is a
16
+ * success for our purposes (the agent is active). Exported for tests.
17
+ */
18
+ export function interpretLaunchctlLoadError(stderr) {
19
+ const text = stderr.trim();
20
+ if (/already loaded|service already loaded|Load failed: 5/i.test(text)) {
21
+ return { ok: true, message: "launchd agent already loaded" };
22
+ }
23
+ return {
24
+ ok: false,
25
+ message: `launchctl load failed${text ? `: ${text}` : ""} — the agent will load at next login`,
26
+ };
27
+ }
28
+ /**
29
+ * Best-effort `launchctl load` of the dream LaunchAgent so scheduled runs
30
+ * start immediately instead of waiting for the next login. (v5.14.x sprint,
31
+ * pre-approved.) Never throws.
32
+ */
33
+ export function loadDreamLaunchAgent(plistFile) {
34
+ if (process.platform !== "darwin")
35
+ return { ok: false, message: "launchctl unavailable (not macOS)" };
36
+ try {
37
+ execFileSync("launchctl", launchctlLoadArgs(plistFile), {
38
+ stdio: ["ignore", "pipe", "pipe"],
39
+ timeout: 10_000,
40
+ });
41
+ return { ok: true, message: "launchd agent loaded — scheduled dream runs are active now" };
42
+ }
43
+ catch (err) {
44
+ const stderr = err && typeof err === "object" && "stderr" in err && err.stderr
45
+ ? String(err.stderr)
46
+ : err instanceof Error
47
+ ? err.message
48
+ : String(err);
49
+ return interpretLaunchctlLoadError(stderr);
50
+ }
51
+ }
52
+ /**
53
+ * Best-effort `launchctl unload` before removing the plist, so disabling
54
+ * Dream Mode deactivates the schedule immediately. Never throws.
55
+ */
56
+ export function unloadDreamLaunchAgent(plistFile) {
57
+ if (process.platform !== "darwin")
58
+ return { ok: false, message: "launchctl unavailable (not macOS)" };
59
+ try {
60
+ execFileSync("launchctl", launchctlUnloadArgs(plistFile), {
61
+ stdio: ["ignore", "pipe", "pipe"],
62
+ timeout: 10_000,
63
+ });
64
+ return { ok: true, message: "launchd agent unloaded" };
65
+ }
66
+ catch {
67
+ // Not loaded (or launchctl unavailable) — nothing to deactivate.
68
+ return { ok: true, message: "launchd agent was not loaded" };
69
+ }
70
+ }
5
71
  function plistPath() {
6
72
  return path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
7
73
  }
@@ -58,10 +124,151 @@ export function installDreamLaunchAgent() {
58
124
  fs.writeFileSync(file, body, "utf8");
59
125
  return file;
60
126
  }
127
+ function xmlUnescape(value) {
128
+ return value
129
+ .replace(/&quot;/g, '"')
130
+ .replace(/&gt;/g, ">")
131
+ .replace(/&lt;/g, "<")
132
+ .replace(/&amp;/g, "&");
133
+ }
134
+ /**
135
+ * Pure helper: extract the node and cli paths from a dream LaunchAgent
136
+ * plist body. Template shape (installDreamLaunchAgent): the first
137
+ * `<string>` is the Label, the second is the node binary path, the third
138
+ * is the cli path. Exported for tests.
139
+ */
140
+ export function parseDreamPlistPaths(body) {
141
+ const strings = [...body.matchAll(/<string>([^<]*)<\/string>/g)].map((m) => xmlUnescape(m[1]));
142
+ return {
143
+ nodePath: strings.length > 1 ? strings[1] : undefined,
144
+ cliPath: strings.length > 2 ? strings[2] : undefined,
145
+ };
146
+ }
147
+ /**
148
+ * Health-check the dream LaunchAgent. The plist hardcodes absolute node +
149
+ * cli paths (e.g. ~/.nvm/versions/node/vX.Y.Z/bin/node), so a Node upgrade
150
+ * silently kills the scheduler — this detects that. Never throws.
151
+ */
152
+ export function checkDreamLaunchAgent() {
153
+ if (process.platform !== "darwin") {
154
+ return {
155
+ installed: false,
156
+ loaded: false,
157
+ nodeExists: false,
158
+ cliExists: false,
159
+ healthy: false,
160
+ problems: ["launchd unavailable (not macOS)"],
161
+ plistFile: null,
162
+ };
163
+ }
164
+ const file = plistPath();
165
+ if (!fs.existsSync(file)) {
166
+ return {
167
+ installed: false,
168
+ loaded: false,
169
+ nodeExists: false,
170
+ cliExists: false,
171
+ healthy: false,
172
+ problems: ["launchd agent not installed"],
173
+ plistFile: file,
174
+ };
175
+ }
176
+ const problems = [];
177
+ let nodeExists = false;
178
+ let cliExists = false;
179
+ try {
180
+ const body = fs.readFileSync(file, "utf8");
181
+ const { nodePath, cliPath } = parseDreamPlistPaths(body);
182
+ nodeExists = nodePath !== undefined && fs.existsSync(nodePath);
183
+ if (!nodeExists) {
184
+ problems.push(`node binary missing at ${nodePath ?? "(unknown)"} — a Node upgrade likely moved it; run repair to rewrite the agent`);
185
+ }
186
+ // "gnosys" (bare command) counts as existing — resolved via PATH.
187
+ cliExists =
188
+ cliPath !== undefined && (cliPath === "gnosys" || fs.existsSync(cliPath));
189
+ if (!cliExists) {
190
+ problems.push(`gnosys cli missing at ${cliPath ?? "(unknown)"}`);
191
+ }
192
+ }
193
+ catch (err) {
194
+ problems.push(`could not read plist: ${err instanceof Error ? err.message : String(err)}`);
195
+ }
196
+ let loaded = false;
197
+ try {
198
+ execFileSync("launchctl", ["list", LABEL], {
199
+ stdio: ["ignore", "pipe", "pipe"],
200
+ timeout: 10_000,
201
+ });
202
+ loaded = true;
203
+ }
204
+ catch {
205
+ problems.push("launchd agent not loaded");
206
+ }
207
+ return {
208
+ installed: true,
209
+ loaded,
210
+ nodeExists,
211
+ cliExists,
212
+ healthy: loaded && nodeExists && cliExists,
213
+ problems,
214
+ plistFile: file,
215
+ };
216
+ }
217
+ /**
218
+ * Repair the dream LaunchAgent: rewrite the plist with the current
219
+ * process's node + cli paths, then reload it. Best-effort — never throws.
220
+ */
221
+ export function repairDreamLaunchAgent() {
222
+ const file = installDreamLaunchAgent();
223
+ if (!file) {
224
+ return { ok: false, message: "launchd unavailable (not macOS)" };
225
+ }
226
+ unloadDreamLaunchAgent(file);
227
+ const load = loadDreamLaunchAgent(file);
228
+ if (!load.ok) {
229
+ return {
230
+ ok: false,
231
+ message: `agent reinstalled but reload failed: ${load.message}`,
232
+ };
233
+ }
234
+ return { ok: true, message: "dream launchd agent repaired and reloaded" };
235
+ }
236
+ /**
237
+ * Post-upgrade hook: when Dream Mode is enabled and the LaunchAgent is
238
+ * installed but unhealthy (typically because a Node upgrade moved the
239
+ * hardcoded node path), repair it. Returns a printable status line, or
240
+ * null when nothing applies (non-darwin, dream disabled, agent healthy
241
+ * or not installed). Never throws.
242
+ */
243
+ export async function repairDreamLaunchAgentAfterUpgrade() {
244
+ if (process.platform !== "darwin")
245
+ return null;
246
+ try {
247
+ const { loadConfig } = await import("./config.js");
248
+ const { getGnosysHome } = await import("./paths.js");
249
+ const config = await loadConfig(getGnosysHome());
250
+ if (!config.dream?.enabled)
251
+ return null;
252
+ const health = checkDreamLaunchAgent();
253
+ if (!health.installed || health.healthy)
254
+ return null;
255
+ const repair = repairDreamLaunchAgent();
256
+ return repair.ok
257
+ ? "✓ dream launchd agent repaired (node path had changed)"
258
+ : `⚠ dream launchd agent unhealthy and repair failed: ${repair.message}`;
259
+ }
260
+ catch {
261
+ return null;
262
+ }
263
+ }
61
264
  export function uninstallDreamLaunchAgent() {
62
265
  if (process.platform !== "darwin")
63
266
  return null;
64
267
  const file = plistPath();
268
+ // Unload before unlinking — launchctl needs the plist on disk to resolve
269
+ // the label, and this deactivates the schedule immediately. Best-effort.
270
+ if (fs.existsSync(file))
271
+ unloadDreamLaunchAgent(file);
65
272
  try {
66
273
  fs.unlinkSync(file);
67
274
  }
@@ -92,7 +92,10 @@ export async function runHybridSearchCommand(getResolver, query, opts) {
92
92
  const writeTarget = resolver.getWriteTarget();
93
93
  if (writeTarget) {
94
94
  const { GnosysMaintenanceEngine } = await import("./maintenance.js");
95
- await GnosysMaintenanceEngine.reinforceBatch(writeTarget.store, results.map((r) => r.relativePath)).catch(() => { });
95
+ await GnosysMaintenanceEngine.reinforceBatch(writeTarget.store, results.map((r) => r.relativePath)).catch((err) => {
96
+ // Best-effort, but don't be fully silent (sprint 2026-07-02).
97
+ console.error(`gnosys: reinforcement skipped: ${err instanceof Error ? err.message : String(err)}`);
98
+ });
96
99
  }
97
100
  }
98
101
  search.close();
@@ -33,7 +33,11 @@ export async function runImportCommand(getResolver, fileOrUrl, opts) {
33
33
  const ingestion = new GnosysIngestion(writeTarget.store, tagRegistry);
34
34
  const format = opts.format;
35
35
  const mode = opts.mode;
36
- const concurrency = opts.concurrency || 5;
36
+ // v5.15: --concurrency CLI flag wins; otherwise the config's
37
+ // importConcurrency (schema default 5) applies.
38
+ const { loadConfig } = await import("./config.js");
39
+ const config = await loadConfig(writeTarget.store.getStorePath());
40
+ const concurrency = opts.concurrency || config.importConcurrency;
37
41
  // Show estimate for LLM mode
38
42
  if (mode === "llm") {
39
43
  console.error(`Mode: LLM (concurrency: ${concurrency})`);
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Guard for interactive prompt flows (v5.14.x sprint, pre-approved).
3
+ *
4
+ * Interactive setup flows crash with ERR_USE_AFTER_CLOSE when stdin is
5
+ * closed (e.g. `gnosys setup dream` run non-interactively from a script
6
+ * or CI). Instead of a stack trace, print a friendly one-line error and
7
+ * exit 1.
8
+ */
9
+ /** Returns true when stdin can host an interactive prompt session. */
10
+ export declare function stdinIsInteractive(): boolean;
11
+ /** Human-readable message for a blocked interactive flow. */
12
+ export declare function nonInteractiveMessage(flow: string): string;
13
+ /**
14
+ * Exit with a friendly one-liner when stdin is not a TTY.
15
+ * Call at the entry of interactive prompt flows, before any readline use.
16
+ */
17
+ export declare function guardInteractiveStdin(flow: string): void;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Guard for interactive prompt flows (v5.14.x sprint, pre-approved).
3
+ *
4
+ * Interactive setup flows crash with ERR_USE_AFTER_CLOSE when stdin is
5
+ * closed (e.g. `gnosys setup dream` run non-interactively from a script
6
+ * or CI). Instead of a stack trace, print a friendly one-line error and
7
+ * exit 1.
8
+ */
9
+ /** Returns true when stdin can host an interactive prompt session. */
10
+ export function stdinIsInteractive() {
11
+ return Boolean(process.stdin.isTTY);
12
+ }
13
+ /** Human-readable message for a blocked interactive flow. */
14
+ export function nonInteractiveMessage(flow) {
15
+ return `gnosys ${flow} is interactive and requires a terminal (stdin is not a TTY). Re-run from an interactive shell.`;
16
+ }
17
+ /**
18
+ * Exit with a friendly one-liner when stdin is not a TTY.
19
+ * Call at the entry of interactive prompt flows, before any readline use.
20
+ */
21
+ export function guardInteractiveStdin(flow) {
22
+ if (stdinIsInteractive())
23
+ return;
24
+ console.error(nonInteractiveMessage(flow));
25
+ process.exit(1);
26
+ }
@@ -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/dist/lib/setup.js CHANGED
@@ -14,6 +14,7 @@ import fsSync from "fs";
14
14
  import path from "path";
15
15
  import os from "os";
16
16
  import { execSync } from "child_process";
17
+ import { guardInteractiveStdin } from "./interactiveGuard.js";
17
18
  import { loadConfig, updateConfig, resolveTaskModel, getProviderModel, } from "./config.js";
18
19
  import { isApiKeyValidationError, validateModel } from "./modelValidation.js";
19
20
  import { buildOpenRouterTiers, OPENROUTER_STATIC_TIERS, } from "./openrouterTiers.js";
@@ -1162,6 +1163,7 @@ export async function runSetup(opts) {
1162
1163
  };
1163
1164
  }
1164
1165
  // ─── Interactive mode ─────────────────────────────────────────────────
1166
+ guardInteractiveStdin("setup");
1165
1167
  const rl = createInterface({ input: stdin, output: stdout });
1166
1168
  let setupCompleted = false;
1167
1169
  // Handle Ctrl+C gracefully — only show "cancelled" if setup didn't finish
@@ -2130,6 +2132,8 @@ export function buildTaskModelsPatchFromAccepted(accepted, currentByTask, select
2130
2132
  export async function runModelsSetup(opts = {}) {
2131
2133
  const projectDir = opts.directory ? path.resolve(opts.directory) : process.cwd();
2132
2134
  const ownsRl = !opts.rl;
2135
+ if (ownsRl)
2136
+ guardInteractiveStdin("setup models");
2133
2137
  const rl = opts.rl ?? createInterface({ input: stdin, output: stdout });
2134
2138
  try {
2135
2139
  const { Header } = await import("./setup/ui/header.js");
@@ -2519,6 +2523,8 @@ async function runModelsTaskRoutingSetup(ctx) {
2519
2523
  export async function runDreamSetup(opts = {}) {
2520
2524
  const projectDir = opts.directory ? path.resolve(opts.directory) : process.cwd();
2521
2525
  const ownsRl = !opts.rl;
2526
+ if (ownsRl)
2527
+ guardInteractiveStdin("setup dream");
2522
2528
  const rl = opts.rl ?? createInterface({ input: stdin, output: stdout });
2523
2529
  try {
2524
2530
  // v5.9.3 Screen 7 — three grouped sub-screens (7.0 enable, 7.1
@@ -2757,8 +2763,11 @@ export async function runDreamSetup(opts = {}) {
2757
2763
  // Reset consecutive failure counter on a fresh setup so Layer 4
2758
2764
  // doesn't fire immediately based on stale history.
2759
2765
  localDb.resetDreamConsecutiveFailures();
2760
- const { installDreamLaunchAgent } = await import("./dreamLaunchd.js");
2766
+ const { installDreamLaunchAgent, loadDreamLaunchAgent } = await import("./dreamLaunchd.js");
2761
2767
  const launchdPath = installDreamLaunchAgent();
2768
+ // Best-effort immediate activation so the schedule doesn't wait for the
2769
+ // next login (v5.14.x sprint, pre-approved).
2770
+ const launchdLoad = launchdPath ? loadDreamLaunchAgent(launchdPath) : null;
2762
2771
  localDb.close();
2763
2772
  remoteDb?.close();
2764
2773
  // Final Diff block per the design — provider/machine + the two
@@ -2789,6 +2798,8 @@ export async function runDreamSetup(opts = {}) {
2789
2798
  printStatus("progress", `scheduled dream checks run nightly (${scheduleStartHour}:00-${scheduleEndHour}:00) on ${dreamerName}`);
2790
2799
  if (launchdPath)
2791
2800
  printStatus("ok", "launchd agent installed", launchdPath);
2801
+ if (launchdLoad)
2802
+ printStatus(launchdLoad.ok ? "ok" : "warn", launchdLoad.message);
2792
2803
  printStatus("progress", "check status anytime with `gnosys status --system`");
2793
2804
  }
2794
2805
  finally {
@@ -1,5 +1,6 @@
1
1
  import { createInterface } from "readline/promises";
2
2
  import { stdin, stdout } from "process";
3
+ import { guardInteractiveStdin } from "./interactiveGuard.js";
3
4
  import fs from "fs/promises";
4
5
  import fsSync from "fs";
5
6
  import os from "os";
@@ -522,6 +523,8 @@ export function renderProviderTable(providers) {
522
523
  }
523
524
  export async function runKeysSetup(opts) {
524
525
  const ownReadline = !opts?.rl;
526
+ if (ownReadline)
527
+ guardInteractiveStdin("setup keys");
525
528
  const rl = opts?.rl ?? createInterface({ input: stdin, output: stdout });
526
529
  try {
527
530
  while (true) {
@@ -86,6 +86,22 @@ export async function runStatusCommand(opts, deps) {
86
86
  }
87
87
  const data = await collectDashboardData(resolver, cfg, deps.pkgVersion, dashDb ?? undefined);
88
88
  console.log(opts.json ? formatDashboardJSON(data) : formatDashboard(data));
89
+ // v5.15: dream launchd agent health (read-only — repair happens in
90
+ // `gnosys upgrade`). macOS + dream enabled only; skip in JSON mode
91
+ // to keep the JSON payload shape stable.
92
+ if (!opts.json && process.platform === "darwin" && cfg.dream?.enabled) {
93
+ const { checkDreamLaunchAgent } = await import("./dreamLaunchd.js");
94
+ const health = checkDreamLaunchAgent();
95
+ console.log("\nDream launchd agent:");
96
+ if (health.healthy) {
97
+ console.log(" ✓ healthy (installed, loaded, node + cli paths valid)");
98
+ }
99
+ else {
100
+ for (const problem of health.problems) {
101
+ console.log(` ⚠ ${problem}`);
102
+ }
103
+ }
104
+ }
89
105
  }
90
106
  catch (err) {
91
107
  console.error(`Error: ${err instanceof Error ? err.message : err}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnosys",
3
- "version": "5.13.1",
3
+ "version": "5.15.1",
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",