nexusmem 0.10.4 → 0.11.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/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli/index.ts
4
4
  import { Command, Option } from "commander";
5
- import pc22 from "picocolors";
5
+ import pc24 from "picocolors";
6
6
 
7
7
  // src/config/workspace.ts
8
8
  import { existsSync } from "fs";
@@ -10,6 +10,17 @@ import { mkdir, readFile, writeFile } from "fs/promises";
10
10
  import { join } from "path";
11
11
  import { z } from "zod";
12
12
 
13
+ // src/core/text.ts
14
+ function truncate(s, max) {
15
+ return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}\u2026`;
16
+ }
17
+ function stripBom(text) {
18
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
19
+ }
20
+ function approxTokens(text) {
21
+ return Math.ceil(text.length / 4);
22
+ }
23
+
13
24
  // src/slm/provider.ts
14
25
  var DEFAULT_BASE_URL = "http://127.0.0.1:11434";
15
26
  var DEFAULT_SLM_MODEL = "qwen2.5:3b";
@@ -92,6 +103,17 @@ var ConfigSchema = z.object({
92
103
  /** Lines kept from scrape-based (no-hook) history files each sync. */
93
104
  tailLines: z.number().int().positive().default(300)
94
105
  }).default({ enabled: true, tailLines: 300 }),
106
+ /**
107
+ * Commands a coding agent ran and files it edited, captured by the
108
+ * agent hook (`nexusmem agent install`).
109
+ *
110
+ * On by default, like git/shell, because it reads only what NexusMem's
111
+ * own hook wrote: with no hook installed there is nothing to read.
112
+ * Installing that hook is the deliberate step.
113
+ */
114
+ agent: z.object({
115
+ enabled: z.boolean().default(true)
116
+ }).default({ enabled: true }),
95
117
  /**
96
118
  * Opt-in, unlike git/shell: conversation transcripts are the source
97
119
  * most likely to contain something sensitive (a pasted credential,
@@ -178,6 +200,7 @@ var ConfigSchema = z.object({
178
200
  }).default({
179
201
  git: { enabled: true, since: null, includeMerges: true },
180
202
  shell: { enabled: true, tailLines: 300 },
203
+ agent: { enabled: true },
181
204
  conversation: { enabled: false },
182
205
  session: {
183
206
  enabled: false,
@@ -227,7 +250,7 @@ async function readConfig(ws) {
227
250
  }
228
251
  let parsed;
229
252
  try {
230
- parsed = JSON.parse(raw);
253
+ parsed = JSON.parse(stripBom(raw));
231
254
  } catch (err) {
232
255
  throw new ConfigError(`${ws.configPath} is not valid JSON: ${err.message}`);
233
256
  }
@@ -514,16 +537,27 @@ function resolveZshProfilePath() {
514
537
  return join3(homedir2(), ".zshrc");
515
538
  }
516
539
 
540
+ // src/hooks/recorder-command.ts
541
+ import { fileURLToPath as fileURLToPath2 } from "url";
542
+ function defaultRecorderCommand() {
543
+ return { node: process.execPath, script: fileURLToPath2(new URL("../../dist/cli/recorder.js", import.meta.url)) };
544
+ }
545
+ function forPosixShell(path) {
546
+ return /^[A-Za-z]:\\/.test(path) ? path.replace(/\\/g, "/") : path;
547
+ }
548
+
517
549
  // src/hooks/bash.ts
518
550
  var MARK_START = "# >>> nexusmem shell hook >>>";
519
551
  var MARK_END = "# <<< nexusmem shell hook <<<";
520
552
  function toBashLiteral(s) {
521
553
  return `'${s.replace(/'/g, `'\\''`)}'`;
522
554
  }
523
- function renderHookSnippet(logPath) {
555
+ function renderHookSnippet(logPath, recorder = defaultRecorderCommand()) {
524
556
  return [
525
557
  MARK_START,
526
558
  `__nxm_log_path=${toBashLiteral(logPath)}`,
559
+ `__nxm_node=${toBashLiteral(forPosixShell(recorder.node))}`,
560
+ `__nxm_recorder=${toBashLiteral(forPosixShell(recorder.script))}`,
527
561
  '__nxm_cmd_start=""',
528
562
  '__nxm_last_cmd=""',
529
563
  '__nxm_debug_trap_owned=""',
@@ -534,6 +568,7 @@ function renderHookSnippet(logPath) {
534
568
  ' s=${s//\\"/\\\\\\"}',
535
569
  " s=${s//$'\\n'/\\\\n}",
536
570
  " s=${s//$'\\t'/\\\\t}",
571
+ " s=${s//$'\\r'/\\\\r}",
537
572
  ` printf '%s' "$s"`,
538
573
  "}",
539
574
  "",
@@ -569,12 +604,12 @@ function renderHookSnippet(logPath) {
569
604
  " __nxm_dur=$(( __nxm_ms - __nxm_cmd_start ))",
570
605
  " fi",
571
606
  "",
572
- " local __nxm_dir",
573
- ' __nxm_dir=$(dirname -- "$__nxm_log_path" 2>/dev/null)',
574
- ' [ -d "$__nxm_dir" ] || mkdir -p "$__nxm_dir" 2>/dev/null',
575
- ` printf '{"ts":"%s","cwd":"%s","exitCode":%s,"durationMs":%s,"command":"%s","shell":"bash-hook"}\\n' \\`,
607
+ " local __nxm_event",
608
+ ` __nxm_event=$(printf '{"ts":"%s","cwd":"%s","exitCode":%s,"durationMs":%s,"command":"%s"}' \\`,
576
609
  ' "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" "$(__nxm_json_escape "$PWD")" "$__nxm_exit" "$__nxm_dur" \\',
577
- ' "$(__nxm_json_escape "$__nxm_last_cmd")" >> "$__nxm_log_path" 2>/dev/null',
610
+ ' "$(__nxm_json_escape "$__nxm_last_cmd")")',
611
+ // Only the recorder's stdin (a pipe, never a file) sees the raw command; it hashes, redacts, then appends.
612
+ ` ( printf '%s' "$__nxm_event" | "$__nxm_node" "$__nxm_recorder" --log "$__nxm_log_path" --shell bash-hook >/dev/null 2>&1 & )`,
578
613
  "",
579
614
  ' __nxm_cmd_start=""',
580
615
  ' __nxm_last_cmd=""',
@@ -612,12 +647,12 @@ function stripHookSnippet(profileContent) {
612
647
  const afterBlock = profileContent.slice(endIdx + MARK_END.length).replace(/^\r?\n/, "");
613
648
  return profileContent.slice(0, startIdx) + afterBlock;
614
649
  }
615
- function upsertHookSnippet(profileContent, logPath) {
650
+ function upsertHookSnippet(profileContent, logPath, recorder) {
616
651
  const stripped = stripHookSnippet(profileContent).replace(/\s+$/, "");
617
652
  const prefix = stripped.length > 0 ? `${stripped}
618
653
 
619
654
  ` : "";
620
- return `${prefix}${renderHookSnippet(logPath)}`;
655
+ return `${prefix}${renderHookSnippet(logPath, recorder)}`;
621
656
  }
622
657
 
623
658
  // src/hooks/powershell.ts
@@ -626,12 +661,14 @@ var MARK_END2 = "# <<< nexusmem shell hook <<<";
626
661
  function toPowerShellLiteral(s) {
627
662
  return `'${s.replace(/'/g, "''")}'`;
628
663
  }
629
- function renderHookSnippet2(logPath) {
664
+ function renderHookSnippet2(logPath, recorder = defaultRecorderCommand()) {
630
665
  return [
631
666
  MARK_START2,
632
667
  "if (Test-Path Function:\\prompt) { $function:__ssd_original_prompt = $function:prompt }",
633
668
  "$global:__ssd_last_history_id = -1",
634
669
  `$global:__ssd_log_path = ${toPowerShellLiteral(logPath)}`,
670
+ `$global:__ssd_node = ${toPowerShellLiteral(recorder.node)}`,
671
+ `$global:__ssd_recorder = ${toPowerShellLiteral(recorder.script)}`,
635
672
  "function global:prompt {",
636
673
  // Must be first: Get-History (or anything else) below would overwrite $?.
637
674
  " $__ssd_ok = $?",
@@ -648,9 +685,19 @@ function renderHookSnippet2(logPath) {
648
685
  " durationMs = [int](($__ssd_h.EndExecutionTime - $__ssd_h.StartExecutionTime).TotalMilliseconds)",
649
686
  " command = $__ssd_h.CommandLine",
650
687
  " }",
651
- " $__ssd_dir = Split-Path -Parent $global:__ssd_log_path",
652
- " if (-not (Test-Path $__ssd_dir)) { New-Item -ItemType Directory -Force -Path $__ssd_dir | Out-Null }",
653
- " Add-Content -LiteralPath $global:__ssd_log_path -Value ($__ssd_entry | ConvertTo-Json -Compress) -Encoding utf8",
688
+ // The raw command only ever reaches the recorder's stdin (a pipe, never a file); it hashes and redacts before writing.
689
+ " $__ssd_psi = New-Object System.Diagnostics.ProcessStartInfo",
690
+ " $__ssd_psi.FileName = $global:__ssd_node",
691
+ ` $__ssd_psi.Arguments = '"{0}" --log "{1}" --shell pwsh-hook' -f $global:__ssd_recorder, $global:__ssd_log_path`,
692
+ " $__ssd_psi.UseShellExecute = $false",
693
+ " $__ssd_psi.CreateNoWindow = $true",
694
+ " $__ssd_psi.RedirectStandardInput = $true",
695
+ " $__ssd_p = [System.Diagnostics.Process]::Start($__ssd_psi)",
696
+ // Bytes, not StandardInput.Write: Windows PowerShell would encode text in the console code page.
697
+ " $__ssd_bytes = [System.Text.Encoding]::UTF8.GetBytes(($__ssd_entry | ConvertTo-Json -Compress))",
698
+ " $__ssd_p.StandardInput.BaseStream.Write($__ssd_bytes, 0, $__ssd_bytes.Length)",
699
+ " $__ssd_p.StandardInput.Close()",
700
+ " $__ssd_p.Dispose()",
654
701
  " } catch {}",
655
702
  " }",
656
703
  " if (Test-Path Function:\\__ssd_original_prompt) { & $function:__ssd_original_prompt }",
@@ -670,12 +717,12 @@ function stripHookSnippet2(profileContent) {
670
717
  const afterBlock = profileContent.slice(endIdx + MARK_END2.length).replace(/^\r?\n/, "");
671
718
  return profileContent.slice(0, startIdx) + afterBlock;
672
719
  }
673
- function upsertHookSnippet2(profileContent, logPath) {
720
+ function upsertHookSnippet2(profileContent, logPath, recorder) {
674
721
  const stripped = stripHookSnippet2(profileContent).replace(/\s+$/, "");
675
722
  const prefix = stripped.length > 0 ? `${stripped}
676
723
 
677
724
  ` : "";
678
- return `${prefix}${renderHookSnippet2(logPath)}`;
725
+ return `${prefix}${renderHookSnippet2(logPath, recorder)}`;
679
726
  }
680
727
 
681
728
  // src/hooks/zsh.ts
@@ -684,10 +731,12 @@ var MARK_END3 = "# <<< nexusmem shell hook <<<";
684
731
  function toZshLiteral(s) {
685
732
  return `'${s.replace(/'/g, `'\\''`)}'`;
686
733
  }
687
- function renderHookSnippet3(logPath) {
734
+ function renderHookSnippet3(logPath, recorder = defaultRecorderCommand()) {
688
735
  return [
689
736
  MARK_START3,
690
737
  `__nxm_log_path=${toZshLiteral(logPath)}`,
738
+ `__nxm_node=${toZshLiteral(forPosixShell(recorder.node))}`,
739
+ `__nxm_recorder=${toZshLiteral(forPosixShell(recorder.script))}`,
691
740
  '__nxm_cmd_start=""',
692
741
  '__nxm_last_cmd=""',
693
742
  "zmodload zsh/datetime 2>/dev/null",
@@ -698,6 +747,7 @@ function renderHookSnippet3(logPath) {
698
747
  ' s=${s//\\"/\\\\\\"}',
699
748
  " s=${s//$'\\n'/\\\\n}",
700
749
  " s=${s//$'\\t'/\\\\t}",
750
+ " s=${s//$'\\r'/\\\\r}",
701
751
  ` printf '%s' "$s"`,
702
752
  "}",
703
753
  "",
@@ -726,12 +776,12 @@ function renderHookSnippet3(logPath) {
726
776
  " __nxm_dur=$(( __nxm_now - __nxm_cmd_start ))",
727
777
  " fi",
728
778
  "",
729
- " local __nxm_dir",
730
- ' __nxm_dir=$(dirname -- "$__nxm_log_path" 2>/dev/null)',
731
- ' [ -d "$__nxm_dir" ] || mkdir -p "$__nxm_dir" 2>/dev/null',
732
- ` printf '{"ts":"%s","cwd":"%s","exitCode":%s,"durationMs":%s,"command":"%s","shell":"zsh-hook"}\\n' \\`,
779
+ " local __nxm_event",
780
+ ` __nxm_event=$(printf '{"ts":"%s","cwd":"%s","exitCode":%s,"durationMs":%s,"command":"%s"}' \\`,
733
781
  ' "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" "$(__nxm_json_escape "$PWD")" "$__nxm_exit" "$__nxm_dur" \\',
734
- ' "$(__nxm_json_escape "$__nxm_last_cmd")" >> "$__nxm_log_path" 2>/dev/null',
782
+ ' "$(__nxm_json_escape "$__nxm_last_cmd")")',
783
+ // Only the recorder's stdin (a pipe, never a file) sees the raw command; it hashes, redacts, then appends.
784
+ ` ( printf '%s' "$__nxm_event" | "$__nxm_node" "$__nxm_recorder" --log "$__nxm_log_path" --shell zsh-hook >/dev/null 2>&1 & )`,
735
785
  "",
736
786
  ' __nxm_cmd_start=""',
737
787
  ' __nxm_last_cmd=""',
@@ -755,29 +805,32 @@ function stripHookSnippet3(profileContent) {
755
805
  const afterBlock = profileContent.slice(endIdx + MARK_END3.length).replace(/^\r?\n/, "");
756
806
  return profileContent.slice(0, startIdx) + afterBlock;
757
807
  }
758
- function upsertHookSnippet3(profileContent, logPath) {
808
+ function upsertHookSnippet3(profileContent, logPath, recorder) {
759
809
  const stripped = stripHookSnippet3(profileContent).replace(/\s+$/, "");
760
810
  const prefix = stripped.length > 0 ? `${stripped}
761
811
 
762
812
  ` : "";
763
- return `${prefix}${renderHookSnippet3(logPath)}`;
813
+ return `${prefix}${renderHookSnippet3(logPath, recorder)}`;
764
814
  }
765
815
 
766
816
  // src/hooks/install.ts
767
817
  var SHELL_MODULES = {
768
818
  pwsh: {
819
+ renderHookSnippet: (logPath) => renderHookSnippet2(logPath),
769
820
  isHookInstalled: isHookInstalled2,
770
821
  stripHookSnippet: stripHookSnippet2,
771
822
  upsertHookSnippet: upsertHookSnippet2,
772
823
  resolveProfilePath: async (override) => override ?? await resolvePowerShellProfilePath()
773
824
  },
774
825
  bash: {
826
+ renderHookSnippet: (logPath) => renderHookSnippet(logPath),
775
827
  isHookInstalled,
776
828
  stripHookSnippet,
777
829
  upsertHookSnippet,
778
830
  resolveProfilePath: async (override) => override ?? resolveBashProfilePath()
779
831
  },
780
832
  zsh: {
833
+ renderHookSnippet: (logPath) => renderHookSnippet3(logPath),
781
834
  isHookInstalled: isHookInstalled3,
782
835
  stripHookSnippet: stripHookSnippet3,
783
836
  upsertHookSnippet: upsertHookSnippet3,
@@ -830,8 +883,14 @@ async function removeHook(target) {
830
883
  }
831
884
  async function hookStatus(target) {
832
885
  const mod = SHELL_MODULES[target.shell];
833
- const current = await readProfile(target.profilePath);
834
- return { installed: mod.isHookInstalled(current) };
886
+ const current = (await readProfile(target.profilePath)).replace(/\r\n/g, "\n");
887
+ const installed = mod.isHookInstalled(current);
888
+ const expected = mod.renderHookSnippet(target.logPath).trimEnd();
889
+ const endMarker = expected.slice(expected.lastIndexOf("\n") + 1);
890
+ const start = current.indexOf(expected.slice(0, expected.indexOf("\n")));
891
+ const end = current.indexOf(endMarker, start);
892
+ const upToDate = installed && start !== -1 && end !== -1 && current.slice(start, end + endMarker.length) === expected;
893
+ return { installed, upToDate };
835
894
  }
836
895
 
837
896
  // src/hooks/install-git-precommit.ts
@@ -1904,8 +1963,8 @@ function replaceFileEdges(db, projectId, edges) {
1904
1963
  }
1905
1964
  function fileEdgeStats(db, projectId) {
1906
1965
  const edges = db.prepare("SELECT COUNT(*) AS c FROM file_edges WHERE project_id = ?").get(projectId).c;
1907
- const files = db.prepare("SELECT COUNT(DISTINCT from_path) AS c FROM file_edges WHERE project_id = ?").get(projectId).c;
1908
- return { edges, files };
1966
+ const files2 = db.prepare("SELECT COUNT(DISTINCT from_path) AS c FROM file_edges WHERE project_id = ?").get(projectId).c;
1967
+ return { edges, files: files2 };
1909
1968
  }
1910
1969
 
1911
1970
  // src/store/embeddings.ts
@@ -1990,7 +2049,7 @@ function search(db, projectId, query, limit = 20, opts = {}) {
1990
2049
  function stats(db, projectId) {
1991
2050
  const kinds = db.prepare("SELECT kind, COUNT(*) AS n FROM nodes WHERE project_id = ? GROUP BY kind").all(projectId);
1992
2051
  const range = db.prepare("SELECT MIN(ts) AS oldest, MAX(ts) AS newest FROM nodes WHERE project_id = ?").get(projectId);
1993
- const files = db.prepare(
2052
+ const files2 = db.prepare(
1994
2053
  `SELECT COUNT(DISTINCT f.path) AS n
1995
2054
  FROM node_files f JOIN nodes n ON n.id = f.node_id
1996
2055
  WHERE n.project_id = ?`
@@ -2000,7 +2059,7 @@ function stats(db, projectId) {
2000
2059
  byKind: Object.fromEntries(kinds.map((k) => [k.kind, k.n])),
2001
2060
  oldest: range.oldest,
2002
2061
  newest: range.newest,
2003
- distinctFiles: files.n
2062
+ distinctFiles: files2.n
2004
2063
  };
2005
2064
  }
2006
2065
 
@@ -2095,7 +2154,6 @@ var MemoryStore = class _MemoryStore {
2095
2154
  listOtherProjectIds(currentProjectId) {
2096
2155
  return listOtherProjectIds(this.db, currentProjectId);
2097
2156
  }
2098
- /** Total nodes held under the given project identities. */
2099
2157
  countProjectNodes(projectIds) {
2100
2158
  return countProjectNodes(this.db, projectIds);
2101
2159
  }
@@ -2142,12 +2200,7 @@ var MemoryStore = class _MemoryStore {
2142
2200
  * Hydrate full content for a set of node ids, e.g. to pack a linked
2143
2201
  * resolution alongside the failure node that points at it. Order is not
2144
2202
  * guaranteed to match `ids`; ids with no matching row are silently omitted
2145
- * rather than erroring. `node_links` has `ON DELETE CASCADE` on both
2146
- * columns, so an individual node delete (e.g. `reconcile.ts` migrating a
2147
- * node to a freshly-computed id) removes any link pointing at the old id
2148
- * along with it -- correct as a safety default, though note that reconcile
2149
- * does not currently re-create the link under the migrated node's new id;
2150
- * that gap is not addressed here.
2203
+ * rather than erroring.
2151
2204
  */
2152
2205
  getNodesByIds(ids) {
2153
2206
  return getNodesByIds(this.db, ids);
@@ -2626,7 +2679,7 @@ async function runHookStatus(opts) {
2626
2679
  `${pc3.dim("shell ")} ${target.shell}`,
2627
2680
  `${pc3.dim("profile")} ${target.profilePath}`,
2628
2681
  `${pc3.dim("log ")} ${target.logPath}`,
2629
- `${pc3.dim("status ")} ${result.installed ? pc3.green("installed") : pc3.yellow("not installed")}`,
2682
+ `${pc3.dim("status ")} ${!result.installed ? pc3.yellow("not installed") : result.upToDate ? pc3.green("installed") : pc3.yellow("installed, OUTDATED -- run `nexusmem hook install` again (hooks before 0.10.5 write commands to disk unredacted)")}`,
2630
2683
  ""
2631
2684
  ].join("\n")
2632
2685
  );
@@ -2869,14 +2922,6 @@ import { z as z4 } from "zod";
2869
2922
  // src/mcp/tools.ts
2870
2923
  import { basename as basename3 } from "path";
2871
2924
 
2872
- // src/core/text.ts
2873
- function truncate(s, max) {
2874
- return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}\u2026`;
2875
- }
2876
- function approxTokens(text) {
2877
- return Math.ceil(text.length / 4);
2878
- }
2879
-
2880
2925
  // src/retrieval/pack.ts
2881
2926
  var DEFAULT_SUMMARY_CHARS = 320;
2882
2927
  var NODE_OVERHEAD_TOKENS = 8;
@@ -3061,11 +3106,167 @@ function renderContextBlock(query, result) {
3061
3106
  return lines.join("\n");
3062
3107
  }
3063
3108
 
3109
+ // src/conversation/redact.ts
3110
+ var REDACTION_MARK = "[redacted]";
3111
+ var MARK = REDACTION_MARK;
3112
+ var NOT_MARK = String.raw`(?!\[redacted\])`;
3113
+ var keepPrefix = (groups) => `${groups[0]}${MARK}`;
3114
+ var SECRET_KEYWORD = String.raw`(?:(?:pass(?:word|wd|phrase)|secret|credential|api[_-]?key|(?:access|private|secret|client|signing|encryption|master)[_-]?key)s?|token)`;
3115
+ var SECRET_KEY = String.raw`(?:[A-Za-z0-9_.-]{0,100}?${SECRET_KEYWORD}|(?:-{1,2}|(?:[A-Za-z0-9]{1,40}[_.-]){1,8})pass)(?:[_.-][A-Za-z0-9]{1,40}){0,8}`;
3116
+ var TYPE_WORD = String.raw`(?:string|number|boolean|bool|int|str|null|undefined|none|nil|true|false|any|unknown|object)(?=[\s;,)|\]}>]|$)`;
3117
+ var SECRET_VALUE = String.raw`(?:"(?:\\.|[^"\\\r\n]){1,500}"|'(?:\\.|[^'\\\r\n]){1,500}'|\x60(?:\\.|[^\x60\\\r\n]){1,500}\x60|"(?!")[^\r\n]+|'(?!')[^\r\n]+|\x60(?!\x60)[^\r\n]+|[^\s'"\x60]+)`;
3118
+ var SAME_COMMAND = String.raw`[^\n|;&]{0,500}?`;
3119
+ var NOT_FLAG_OR_REDIRECT = String.raw`(?![-<>|&;])`;
3120
+ var AUTH_SCHEME = String.raw`(?:bearer|basic|token|digest|negotiate|ntlm)`;
3121
+ var CURL_USER = String.raw`\bcurl(?=[ \t])${SAME_COMMAND}[ \t](?:-u|--user)(?:[ \t]+|=)?`;
3122
+ var RULES = [
3123
+ {
3124
+ name: "private-key-block",
3125
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
3126
+ highConfidence: true
3127
+ },
3128
+ { name: "aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/g, highConfidence: true },
3129
+ { name: "github-token", pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, highConfidence: true },
3130
+ { name: "slack-token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, highConfidence: true },
3131
+ {
3132
+ name: "jwt",
3133
+ pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
3134
+ highConfidence: true
3135
+ },
3136
+ // scheme://user:password@host -- the password may itself contain `@`, so the host starts after the last one.
3137
+ {
3138
+ name: "uri-credentials",
3139
+ pattern: new RegExp(
3140
+ String.raw`(\b[a-z][a-z0-9+.-]{1,30}://[^\s/?#'"@:]*:)${NOT_MARK}[^\s/?#'"]+(?=@[^\s/?#'"@]*(?:[/?#\s'"]|$))`,
3141
+ "gi"
3142
+ ),
3143
+ highConfidence: true,
3144
+ render: keepPrefix
3145
+ },
3146
+ // An option whose name ends in `bearer` (`--oauth2-bearer x`) always takes a credential, whatever
3147
+ // the token looks like. The prose-shaped rule below cannot cover it: it needs a digit, so an
3148
+ // all-alphabetic token would survive.
3149
+ {
3150
+ name: "bearer-option-arg",
3151
+ pattern: new RegExp(
3152
+ String.raw`(?<=^|[\s'"(])(-{1,2}[A-Za-z0-9_.-]{0,40}bearer[ \t]+)${NOT_FLAG_OR_REDIRECT}${NOT_MARK}${SECRET_VALUE}`,
3153
+ "gi"
3154
+ ),
3155
+ highConfidence: true,
3156
+ render: keepPrefix
3157
+ },
3158
+ // A bearer token outside a header (`Bearer x`); it must contain a digit so "bearer authentication" is left alone.
3159
+ {
3160
+ name: "bearer-token",
3161
+ pattern: new RegExp(String.raw`(\bbearer[ \t]+)${NOT_MARK}(?=[A-Za-z0-9._~+/-]*\d)[A-Za-z0-9._~+/-]{16,}=*`, "gi"),
3162
+ highConfidence: true,
3163
+ render: keepPrefix
3164
+ },
3165
+ // Tool-anchored password arguments: `-p` means port or parents almost everywhere else. Case-sensitive (`-P` is mysql's port).
3166
+ // mysql takes only the compact `-psecret` form; `-p secret` means "prompt, then use database secret".
3167
+ {
3168
+ name: "mysql-password-arg",
3169
+ pattern: new RegExp(
3170
+ String.raw`(\b(?:mysql|mysqldump|mysqladmin|mysqlimport|mysqlsh|mariadb|mariadb-dump)(?=[ \t])${SAME_COMMAND}[ \t]-p)${NOT_MARK}(?=\S)${SECRET_VALUE}`,
3171
+ "g"
3172
+ ),
3173
+ highConfidence: true,
3174
+ render: keepPrefix
3175
+ },
3176
+ // Only sshpass's own options may precede -p, so a wrapped `ssh -p 22` is never read as the password.
3177
+ {
3178
+ name: "sshpass-password-arg",
3179
+ pattern: new RegExp(String.raw`(\bsshpass(?:[ \t]+-[^p\s]\S*)*[ \t]+-p[ \t]*)${NOT_MARK}(?=\S)${SECRET_VALUE}`, "g"),
3180
+ highConfidence: true,
3181
+ render: keepPrefix
3182
+ },
3183
+ {
3184
+ name: "mongo-password-arg",
3185
+ pattern: new RegExp(
3186
+ String.raw`(\bmongo(?:sh|dump|restore|export|import|stat|top|files)?(?=[ \t])${SAME_COMMAND}[ \t]-p[ \t]+)${NOT_FLAG_OR_REDIRECT}${NOT_MARK}${SECRET_VALUE}`,
3187
+ "g"
3188
+ ),
3189
+ highConfidence: true,
3190
+ render: keepPrefix
3191
+ },
3192
+ {
3193
+ name: "redis-cli-password-arg",
3194
+ pattern: new RegExp(
3195
+ String.raw`(\bredis-cli(?=[ \t])${SAME_COMMAND}[ \t]-a[ \t]+)${NOT_FLAG_OR_REDIRECT}${NOT_MARK}${SECRET_VALUE}`,
3196
+ "g"
3197
+ ),
3198
+ highConfidence: true,
3199
+ render: keepPrefix
3200
+ },
3201
+ // Quoted credentials get their own branches: `curl -u "user:pass word"` holds a space, which
3202
+ // the unquoted branch would stop at, leaving the rest of the password in the text. Like
3203
+ // SECRET_VALUE, they step over escaped characters so `"alice:pa\"ss"` is not cut at `\"`. Inside
3204
+ // quotes the user name may hold a space too (`"alice smith:pw"`); only the quote ends it.
3205
+ {
3206
+ name: "curl-user-password",
3207
+ pattern: new RegExp(
3208
+ String.raw`(${CURL_USER}"(?:\\.|[^:"\\\r\n])*:)${NOT_MARK}(?:\\.|[^"\\\r\n])*|(${CURL_USER}'(?:\\.|[^:'\\\r\n])*:)${NOT_MARK}(?:\\.|[^'\\\r\n])*|(${CURL_USER}[^\s:'"]*:)${NOT_MARK}[^\s'"]+`,
3209
+ "g"
3210
+ ),
3211
+ highConfidence: true,
3212
+ render: (groups) => `${groups.find((g) => g.length > 0) ?? ""}${MARK}`
3213
+ },
3214
+ // Authorization: Bearer x / Basic x / token x. The scheme word is kept; it must not be mistaken for the value.
3215
+ {
3216
+ name: "authorization-header",
3217
+ pattern: new RegExp(
3218
+ String.raw`(\b(?:proxy-)?authorization["']?[ \t]*[:=][ \t]*["'\x60]?(?:${AUTH_SCHEME}[ \t]+)?)(?!${AUTH_SCHEME}[ \t])${NOT_MARK}[^\s'"\x60]+`,
3219
+ "gi"
3220
+ ),
3221
+ highConfidence: false,
3222
+ render: keepPrefix
3223
+ },
3224
+ // `--password secret`: a secret-named option whose value is the next argument. The flag must start a
3225
+ // command-line word, so prose like "the `id`-token heuristic" is not read as a `-token` option.
3226
+ {
3227
+ name: "secret-option-arg",
3228
+ pattern: new RegExp(
3229
+ String.raw`(?<=^|[\s'"(])(-{1,2}${SECRET_KEY}[ \t]+)${NOT_FLAG_OR_REDIRECT}${NOT_MARK}${SECRET_VALUE}`,
3230
+ "gi"
3231
+ ),
3232
+ highConfidence: false,
3233
+ render: keepPrefix
3234
+ },
3235
+ // key/token/secret/password = "value" or : value, in code, JSON, env-file, shell or prose form.
3236
+ // The key start is an explicit non-identifier lookbehind, not `\b`: `_` is a word character, so
3237
+ // `\b` never fired inside DB_PASSWORD and the value leaked. Found live via `scan-shell`.
3238
+ {
3239
+ name: "key-value-secret",
3240
+ pattern: new RegExp(
3241
+ String.raw`(?<![A-Za-z0-9_.-])(${SECRET_KEY})["']?[ \t]*[:=](?![=>])[ \t]*(?!${TYPE_WORD})${NOT_MARK}${SECRET_VALUE}`,
3242
+ "gi"
3243
+ ),
3244
+ highConfidence: false,
3245
+ // Keep the key name so the redaction is legible ("apiKey: [redacted]").
3246
+ render: (groups) => `${groups[0]}: ${MARK}`
3247
+ }
3248
+ ];
3249
+ function redact(text, profile = "all") {
3250
+ let redactedCount = 0;
3251
+ let out = text;
3252
+ for (const rule of RULES) {
3253
+ if (profile === "high-confidence" && !rule.highConfidence) continue;
3254
+ out = out.replace(rule.pattern, (...args) => {
3255
+ redactedCount += 1;
3256
+ const end = args.findIndex((arg, i) => i > 0 && typeof arg === "number");
3257
+ const groups = args.slice(1, end).map((g) => typeof g === "string" ? g : "");
3258
+ return rule.render ? rule.render(groups) : MARK;
3259
+ });
3260
+ }
3261
+ return { text: out, redactedCount };
3262
+ }
3263
+
3064
3264
  // src/correlate/failure-fix.ts
3065
3265
  var DEFAULT_RETRY_WINDOW_MS = 24 * 60 * 60 * 1e3;
3066
3266
  var DEFAULT_DISCUSSION_WINDOW_MS = 24 * 60 * 60 * 1e3;
3067
3267
  var RESOLVED_BY_RETRY = "resolved_by:retry";
3068
3268
  var RESOLVED_BY_DISCUSSION = "resolved_by:discussion";
3269
+ var isAgentSource = (source) => source?.startsWith("agent:") === true;
3069
3270
  function normalizeCommand(command) {
3070
3271
  return command.trim().replace(/\s+/g, " ").toLowerCase();
3071
3272
  }
@@ -3091,7 +3292,9 @@ function correlateFailures(store, projectId, opts = {}) {
3091
3292
  const discussionWindowMs = opts.discussionWindowMs ?? DEFAULT_DISCUSSION_WINDOW_MS;
3092
3293
  const db = store.raw;
3093
3294
  const failures = db.prepare(
3094
- `SELECT id, ts_epoch, json_extract(meta, '$.command') AS command, json_extract(meta, '$.cwd') AS cwd
3295
+ `SELECT id, ts_epoch, source, json_extract(meta, '$.command') AS command,
3296
+ json_extract(meta, '$.commandHash') AS command_hash, json_extract(meta, '$.execHash') AS exec_hash,
3297
+ json_extract(meta, '$.cwd') AS cwd
3095
3298
  FROM nodes
3096
3299
  WHERE project_id = ? AND kind = 'shell_command'
3097
3300
  AND source_ts IS NOT NULL
@@ -3100,15 +3303,22 @@ function correlateFailures(store, projectId, opts = {}) {
3100
3303
  AND json_extract(meta, '$.cwd') IS NOT NULL`
3101
3304
  ).all(projectId);
3102
3305
  const findRetry = db.prepare(
3103
- `SELECT id FROM nodes
3104
- WHERE project_id = ? AND kind = 'shell_command'
3105
- AND source_ts IS NOT NULL
3106
- AND json_extract(meta, '$.exitCode') = 0
3107
- AND json_extract(meta, '$.cwd') IS NOT NULL
3108
- AND ts_epoch > ? AND ts_epoch <= ?
3109
- AND lower(trim(json_extract(meta, '$.command'))) = ?
3110
- AND (json_extract(meta, '$.cwd') IS ? OR json_extract(meta, '$.cwd') = ?)
3111
- ORDER BY ts_epoch ASC LIMIT 1`
3306
+ `SELECT n.id, n.source,
3307
+ (SELECT COUNT(*) FROM node_files f WHERE f.node_id = n.id) AS file_count
3308
+ FROM nodes n
3309
+ WHERE n.project_id = ? AND n.kind = 'shell_command'
3310
+ AND n.source_ts IS NOT NULL
3311
+ AND json_extract(n.meta, '$.exitCode') = 0
3312
+ AND json_extract(n.meta, '$.cwd') IS NOT NULL
3313
+ AND n.ts_epoch > ? AND n.ts_epoch <= ?
3314
+ AND (json_extract(n.meta, '$.cwd') IS ? OR json_extract(n.meta, '$.cwd') = ?)
3315
+ AND CASE
3316
+ WHEN ? IS NOT NULL AND json_extract(n.meta, '$.execHash') IS NOT NULL
3317
+ THEN json_extract(n.meta, '$.execHash') = ?
3318
+ ELSE lower(trim(json_extract(n.meta, '$.command'))) = ?
3319
+ AND (? IS NULL OR json_extract(n.meta, '$.commandHash') = ?)
3320
+ END
3321
+ ORDER BY n.ts_epoch ASC LIMIT 1`
3112
3322
  );
3113
3323
  const findDiscussion = db.prepare(
3114
3324
  `SELECT n.id FROM nodes_fts
@@ -3120,19 +3330,31 @@ function correlateFailures(store, projectId, opts = {}) {
3120
3330
  );
3121
3331
  let linkedByRetry = 0;
3122
3332
  let linkedByDiscussion = 0;
3333
+ let unexplainedRetries = 0;
3123
3334
  for (const failure of failures) {
3124
3335
  if (!failure.command) continue;
3125
- const retry = findRetry.get(
3336
+ const redacted = failure.command.includes(REDACTION_MARK);
3337
+ const requiredHash = redacted ? failure.command_hash ?? "" : null;
3338
+ const retry = redacted && !failure.command_hash && !failure.exec_hash ? void 0 : findRetry.get(
3126
3339
  projectId,
3127
3340
  failure.ts_epoch,
3128
3341
  failure.ts_epoch + retryWindowMs,
3129
- normalizeCommand(failure.command),
3130
3342
  failure.cwd,
3131
- failure.cwd
3343
+ failure.cwd,
3344
+ failure.exec_hash,
3345
+ failure.exec_hash,
3346
+ normalizeCommand(failure.command),
3347
+ requiredHash,
3348
+ requiredHash
3132
3349
  );
3133
3350
  if (retry) {
3134
- store.linkNodes(failure.id, retry.id, RESOLVED_BY_RETRY);
3135
- linkedByRetry += 1;
3351
+ const bothAgentRecorded = isAgentSource(failure.source) && isAgentSource(retry.source);
3352
+ if (bothAgentRecorded && retry.file_count === 0) {
3353
+ unexplainedRetries += 1;
3354
+ } else {
3355
+ store.linkNodes(failure.id, retry.id, RESOLVED_BY_RETRY);
3356
+ linkedByRetry += 1;
3357
+ }
3136
3358
  }
3137
3359
  const tokens = filterBoilerplateTokens(db, projectId, significantTokens(failure.command));
3138
3360
  const match = tokens.length > 0 ? tokens.map((t) => `"${t}"*`).join(" AND ") : null;
@@ -3144,7 +3366,7 @@ function correlateFailures(store, projectId, opts = {}) {
3144
3366
  }
3145
3367
  }
3146
3368
  }
3147
- return { failuresExamined: failures.length, linkedByRetry, linkedByDiscussion };
3369
+ return { failuresExamined: failures.length, linkedByRetry, linkedByDiscussion, unexplainedRetries };
3148
3370
  }
3149
3371
  function getChainStats(store, projectId) {
3150
3372
  const db = store.raw;
@@ -3467,6 +3689,7 @@ var OllamaEmbeddingProvider = class {
3467
3689
  };
3468
3690
 
3469
3691
  // src/cli/commands/sync.ts
3692
+ import { existsSync as existsSync5 } from "fs";
3470
3693
  import pc7 from "picocolors";
3471
3694
 
3472
3695
  // src/conversation/chunk.ts
@@ -3505,42 +3728,6 @@ function chunkAssistantText(text, maxChars) {
3505
3728
  return chunks;
3506
3729
  }
3507
3730
 
3508
- // src/conversation/redact.ts
3509
- var RULES = [
3510
- {
3511
- name: "private-key-block",
3512
- pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
3513
- highConfidence: true
3514
- },
3515
- { name: "aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/g, highConfidence: true },
3516
- { name: "github-token", pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, highConfidence: true },
3517
- { name: "slack-token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, highConfidence: true },
3518
- {
3519
- name: "jwt",
3520
- pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
3521
- highConfidence: true
3522
- },
3523
- // key/token/secret/password = "value" or : value, in code, JSON, env-file or prose form.
3524
- {
3525
- name: "key-value-secret",
3526
- pattern: /\b((?:api[_-]?key|secret|password|passwd|token|access[_-]?key)s?)\s*[:=]\s*['"]?[A-Za-z0-9_\-./+=]{8,}['"]?/gi,
3527
- highConfidence: false
3528
- }
3529
- ];
3530
- function redact(text, profile = "all") {
3531
- let redactedCount = 0;
3532
- let out = text;
3533
- for (const rule of RULES) {
3534
- if (profile === "high-confidence" && !rule.highConfidence) continue;
3535
- out = out.replace(rule.pattern, (_match, ...rest) => {
3536
- redactedCount += 1;
3537
- const key = typeof rest[0] === "string" ? rest[0] : null;
3538
- return key ? `${key}: [redacted]` : "[redacted]";
3539
- });
3540
- }
3541
- return { text: out, redactedCount };
3542
- }
3543
-
3544
3731
  // src/collectors/conversation.ts
3545
3732
  var DEFAULT_MAX_BODY_CHARS = 2500;
3546
3733
  var DEFAULT_MAX_CHUNK_CHARS = 900;
@@ -3562,15 +3749,15 @@ ${replyText}`;
3562
3749
  var FILE_PATH = /\b(?:[\w.-]+\/)*[\w-]+\.(?:ts|tsx|js|jsx|json|md|py|rs|go|java|c|cpp|h|hpp|css|html|ya?ml|toml|sql|sh|ps1)\b/g;
3563
3750
  function extractMentionedFiles(text) {
3564
3751
  const seen = /* @__PURE__ */ new Set();
3565
- const files = [];
3752
+ const files2 = [];
3566
3753
  for (const match of text.matchAll(FILE_PATH)) {
3567
3754
  const path = match[0];
3568
3755
  if (seen.has(path)) continue;
3569
3756
  seen.add(path);
3570
- files.push({ path, insertions: null, deletions: null, binary: false });
3571
- if (files.length >= MAX_FILES_PER_NODE) break;
3757
+ files2.push({ path, insertions: null, deletions: null, binary: false });
3758
+ if (files2.length >= MAX_FILES_PER_NODE) break;
3572
3759
  }
3573
- return files;
3760
+ return files2;
3574
3761
  }
3575
3762
  function withSuffix(base, suffix) {
3576
3763
  const budget = Math.max(20, MAX_TITLE_CHARS - suffix.length);
@@ -3667,7 +3854,7 @@ function stripSubject(message, subject) {
3667
3854
  }
3668
3855
  var NUMSTAT_LINE = /^(\d+|-)\t(\d+|-)\t(.*)$/;
3669
3856
  function parseNumstat(block) {
3670
- const files = [];
3857
+ const files2 = [];
3671
3858
  for (const line of block.split("\n")) {
3672
3859
  const m = NUMSTAT_LINE.exec(line.trimEnd());
3673
3860
  if (!m) continue;
@@ -3681,9 +3868,9 @@ function parseNumstat(block) {
3681
3868
  binary
3682
3869
  };
3683
3870
  if (previousPath) touch.previousPath = previousPath;
3684
- files.push(touch);
3871
+ files2.push(touch);
3685
3872
  }
3686
- return files;
3873
+ return files2;
3687
3874
  }
3688
3875
  function resolveRenamePath(raw) {
3689
3876
  const braced = /^(.*)\{(.*?) => (.*?)\}(.*)$/.exec(raw);
@@ -3803,12 +3990,12 @@ function parseCommitDiffRecord(record) {
3803
3990
  var FILE_HEADER = "diff --git ";
3804
3991
  var HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/;
3805
3992
  function parseFileDiffs(block) {
3806
- const files = [];
3993
+ const files2 = [];
3807
3994
  let section = null;
3808
3995
  const flush = () => {
3809
3996
  if (!section) return;
3810
3997
  const parsed = parseFileSection(section);
3811
- if (parsed) files.push(parsed);
3998
+ if (parsed) files2.push(parsed);
3812
3999
  section = null;
3813
4000
  };
3814
4001
  for (const raw of block.split("\n")) {
@@ -3821,7 +4008,7 @@ function parseFileDiffs(block) {
3821
4008
  }
3822
4009
  }
3823
4010
  flush();
3824
- return files;
4011
+ return files2;
3825
4012
  }
3826
4013
  function parseFileSection(lines) {
3827
4014
  let status = "modified";
@@ -4070,8 +4257,8 @@ function scoreFileDiff(subject, file) {
4070
4257
  function byChurnDesc2(a, b) {
4071
4258
  return b.insertions + b.deletions - (a.insertions + a.deletions);
4072
4259
  }
4073
- function indexableFiles(files) {
4074
- return files.filter((f) => !f.binary && f.hunkCount > 0 && !isGeneratedPath(f.path));
4260
+ function indexableFiles(files2) {
4261
+ return files2.filter((f) => !f.binary && f.hunkCount > 0 && !isGeneratedPath(f.path));
4075
4262
  }
4076
4263
  var STATUS_LABEL = {
4077
4264
  added: "added",
@@ -4189,8 +4376,8 @@ function toMemoryNodes3(file, projectId, opts = {}) {
4189
4376
  };
4190
4377
  });
4191
4378
  }
4192
- function collectDocFiles(files, projectId, opts = {}) {
4193
- return files.flatMap((file) => toMemoryNodes3(file, projectId, opts));
4379
+ function collectDocFiles(files2, projectId, opts = {}) {
4380
+ return files2.flatMap((file) => toMemoryNodes3(file, projectId, opts));
4194
4381
  }
4195
4382
 
4196
4383
  // src/collectors/github.ts
@@ -4512,7 +4699,8 @@ function toMemoryNode3(entry, projectId, opts = {}) {
4512
4699
  signal: scoreShellCommand(entry),
4513
4700
  provenance: "observed",
4514
4701
  meta: {
4515
- command: entry.command,
4702
+ command: redactedCommand,
4703
+ commandHash: entry.commandHash ?? sha256Hex(entry.command).slice(0, 12),
4516
4704
  cwd: entry.cwd,
4517
4705
  exitCode: entry.exitCode,
4518
4706
  durationMs: entry.durationMs,
@@ -4627,9 +4815,9 @@ function parseClaudeCodeTranscript(raw, opts = {}) {
4627
4815
  return turns;
4628
4816
  }
4629
4817
  async function collectClaudeCodeTranscripts(repoRoot) {
4630
- const files = await listTranscriptFiles(repoRoot);
4818
+ const files2 = await listTranscriptFiles(repoRoot);
4631
4819
  const turns = [];
4632
- for (const file of files) {
4820
+ for (const file of files2) {
4633
4821
  const raw = await readFile6(file, "utf8");
4634
4822
  turns.push(...parseClaudeCodeTranscript(raw, { sessionId: basename2(file, ".jsonl") }));
4635
4823
  }
@@ -4647,7 +4835,7 @@ async function listDocFiles(repoRoot, opts = {}) {
4647
4835
  }
4648
4836
  async function readDocFiles(repoRoot, opts = {}) {
4649
4837
  const paths = await listDocFiles(repoRoot, opts);
4650
- const files = [];
4838
+ const files2 = [];
4651
4839
  const unreadable = [];
4652
4840
  for (const relPath of paths) {
4653
4841
  const path = relPath.replace(/\\/g, "/");
@@ -4660,9 +4848,9 @@ async function readDocFiles(repoRoot, opts = {}) {
4660
4848
  unreadable.push(path);
4661
4849
  continue;
4662
4850
  }
4663
- files.push({ path, content, ts: mtime.toISOString() });
4851
+ files2.push({ path, content, ts: mtime.toISOString() });
4664
4852
  }
4665
- return { files, unreadable };
4853
+ return { files: files2, unreadable };
4666
4854
  }
4667
4855
 
4668
4856
  // src/github/read.ts
@@ -4864,10 +5052,11 @@ ${full.body}`);
4864
5052
 
4865
5053
  // src/shell/detect.ts
4866
5054
  import { existsSync as existsSync4 } from "fs";
4867
- import { readFile as readFile9, stat as stat2 } from "fs/promises";
5055
+ import { readFile as readFile9, stat as stat3 } from "fs/promises";
5056
+ import { posix, win32 } from "path";
4868
5057
 
4869
5058
  // src/shell/hook-log.ts
4870
- import { appendFile, mkdir as mkdir5, readFile as readFile8 } from "fs/promises";
5059
+ import { appendFile, mkdir as mkdir5, open, readFile as readFile8, rename as rename2, stat as stat2, unlink as unlink2, writeFile as writeFile6 } from "fs/promises";
4871
5060
  import { dirname as dirname4 } from "path";
4872
5061
  var HOOK_SHELL_KINDS = /* @__PURE__ */ new Set(["pwsh-hook", "bash-hook", "zsh-hook"]);
4873
5062
  function parseHookLogLine(line) {
@@ -4882,7 +5071,9 @@ function parseHookLogLine(line) {
4882
5071
  if (typeof obj !== "object" || obj === null) return null;
4883
5072
  const o = obj;
4884
5073
  if (typeof o.ts !== "string" || typeof o.cwd !== "string" || typeof o.command !== "string") return null;
5074
+ const commandHash = typeof o.commandHash === "string" && /^[0-9a-f]{12}$/.test(o.commandHash) ? o.commandHash : void 0;
4885
5075
  return {
5076
+ ...commandHash ? { commandHash } : {},
4886
5077
  ts: o.ts,
4887
5078
  cwd: o.cwd,
4888
5079
  exitCode: typeof o.exitCode === "number" ? o.exitCode : null,
@@ -4906,6 +5097,87 @@ async function readHookLog(path, fromLine) {
4906
5097
  const entries = allParsed.slice(sliceStart).filter((e) => e !== null);
4907
5098
  return { entries, totalLines: lines.length, shellsSeen };
4908
5099
  }
5100
+ function sanitizeLine(line) {
5101
+ if (line.trim().length === 0) return { line, legacy: false };
5102
+ let obj;
5103
+ try {
5104
+ obj = JSON.parse(line);
5105
+ } catch {
5106
+ return { line: redact(line).text, legacy: false };
5107
+ }
5108
+ const o = obj;
5109
+ if (typeof o !== "object" || o === null || typeof o.command !== "string") return { line: redact(line).text, legacy: false };
5110
+ const legacy = typeof o.commandHash !== "string";
5111
+ const raw = o.command;
5112
+ const { text } = redact(raw);
5113
+ if (!legacy && text === raw) return { line, legacy };
5114
+ const commandHash = legacy ? sha256Hex(raw).slice(0, 12) : o.commandHash;
5115
+ return { line: JSON.stringify({ ...o, command: text, commandHash }), legacy };
5116
+ }
5117
+ function sanitizeText(raw) {
5118
+ const parts = raw.split(/(\r?\n)/);
5119
+ let changed = 0;
5120
+ let legacy = 0;
5121
+ for (let i = 0; i < parts.length; i += 2) {
5122
+ const next = sanitizeLine(parts[i]);
5123
+ if (next.legacy) legacy += 1;
5124
+ if (next.line !== parts[i]) {
5125
+ parts[i] = next.line;
5126
+ changed += 1;
5127
+ }
5128
+ }
5129
+ return { text: parts.join(""), changed, legacy };
5130
+ }
5131
+ async function readRange(path, start, end) {
5132
+ const fh = await open(path, "r");
5133
+ try {
5134
+ const buf = Buffer.alloc(end - start);
5135
+ await fh.read(buf, 0, buf.length, start);
5136
+ return buf;
5137
+ } finally {
5138
+ await fh.close();
5139
+ }
5140
+ }
5141
+ var RENAME_BUSY = /* @__PURE__ */ new Set(["EPERM", "EACCES", "EBUSY"]);
5142
+ async function sanitizeHookLog(path, opts = {}) {
5143
+ let raw;
5144
+ try {
5145
+ raw = await readFile8(path);
5146
+ } catch {
5147
+ return { linesChanged: 0, legacyLines: 0 };
5148
+ }
5149
+ const first = sanitizeText(raw.toString("utf8"));
5150
+ if (first.changed === 0 || opts.dryRun) return { linesChanged: first.changed, legacyLines: first.legacy };
5151
+ const tmp = `${path}.${process.pid}.scrub.tmp`;
5152
+ let linesChanged = first.changed;
5153
+ let legacyLines = first.legacy;
5154
+ try {
5155
+ await writeFile6(tmp, first.text, { encoding: "utf8", mode: 384 });
5156
+ let consumed = raw.length;
5157
+ for (let attempt = 0; ; attempt += 1) {
5158
+ const size = (await stat2(path)).size;
5159
+ if (size > consumed) {
5160
+ const extra = sanitizeText((await readRange(path, consumed, size)).toString("utf8"));
5161
+ await appendFile(tmp, extra.text, "utf8");
5162
+ linesChanged += extra.changed;
5163
+ legacyLines += extra.legacy;
5164
+ consumed = size;
5165
+ }
5166
+ try {
5167
+ await rename2(tmp, path);
5168
+ break;
5169
+ } catch (err) {
5170
+ if (attempt >= 20 || !RENAME_BUSY.has(err.code ?? "")) throw err;
5171
+ await new Promise((r) => setTimeout(r, 50));
5172
+ }
5173
+ }
5174
+ } catch (err) {
5175
+ await unlink2(tmp).catch(() => {
5176
+ });
5177
+ throw err;
5178
+ }
5179
+ return { linesChanged, legacyLines };
5180
+ }
4909
5181
 
4910
5182
  // src/shell/parse-bash.ts
4911
5183
  var EPOCH_COMMENT = /^#(\d{9,10})$/;
@@ -5008,16 +5280,25 @@ ${rawLines[i]}`;
5008
5280
  }
5009
5281
 
5010
5282
  // src/shell/detect.ts
5011
- function isUnderRoot(cwd, root) {
5012
- const norm = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
5013
- const c = norm(cwd);
5014
- const r = norm(root);
5015
- return c === r || c.startsWith(`${r}/`);
5283
+ function forAdmission(path, platform) {
5284
+ const impl = platform === "win32" ? win32 : posix;
5285
+ let text = impl.normalize(path);
5286
+ const root = impl.parse(text).root;
5287
+ if (text.length > root.length) text = text.replace(platform === "win32" ? /[\\/]+$/ : /\/+$/, "");
5288
+ return { text: platform === "win32" ? text.toLowerCase() : text, sep: impl.sep };
5289
+ }
5290
+ function isUnderRoot(cwd, root, platform = process.platform) {
5291
+ const c = forAdmission(cwd, platform);
5292
+ const r = forAdmission(root, platform);
5293
+ if (c.text === r.text) return true;
5294
+ return c.text.startsWith(r.text.endsWith(r.sep) ? r.text : r.text + r.sep);
5016
5295
  }
5017
5296
  function hookEntryToRaw(e) {
5018
5297
  const shell = e.shell ?? "pwsh-hook";
5298
+ const commandHash = e.commandHash ?? sha256Hex(e.command).slice(0, 12);
5019
5299
  return {
5020
- naturalKey: `${shell}:${e.ts}:${sha256Hex(e.command).slice(0, 12)}`,
5300
+ naturalKey: `${shell}:${e.ts}:${commandHash}`,
5301
+ commandHash,
5021
5302
  command: e.command,
5022
5303
  ts: e.ts,
5023
5304
  tsApprox: false,
@@ -5029,7 +5310,7 @@ function hookEntryToRaw(e) {
5029
5310
  }
5030
5311
  async function tryReadScrapeSource(path, parse, tailLines) {
5031
5312
  if (!existsSync4(path)) return null;
5032
- const [raw, stats2] = await Promise.all([readFile9(path, "utf8"), stat2(path)]);
5313
+ const [raw, stats2] = await Promise.all([readFile9(path, "utf8"), stat3(path)]);
5033
5314
  return parse(raw, stats2.mtimeMs, { tailLines });
5034
5315
  }
5035
5316
  async function collectAvailableShellHistory(opts = {}) {
@@ -5043,7 +5324,7 @@ async function collectAvailableShellHistory(opts = {}) {
5043
5324
  const fromLine = Number(opts.hookCursor ?? "0") || 0;
5044
5325
  const { entries, totalLines, shellsSeen: seen } = await readHookLog(hookPath, fromLine);
5045
5326
  shellsSeen = seen;
5046
- const scoped = opts.repoRoot ? entries.filter((e) => isUnderRoot(e.cwd, opts.repoRoot)) : entries;
5327
+ const scoped = opts.repoRoot ? entries.filter((e) => isUnderRoot(e.cwd, opts.repoRoot, opts.platform)) : entries;
5047
5328
  results.push({ name: "pwsh-hook", entries: scoped.map(hookEntryToRaw), cursorAfter: String(totalLines) });
5048
5329
  }
5049
5330
  const skipPwshScrape = preferHook && shellsSeen.has("pwsh-hook");
@@ -5065,7 +5346,7 @@ async function collectAvailableShellHistory(opts = {}) {
5065
5346
  }
5066
5347
 
5067
5348
  // src/store/reconcile.ts
5068
- function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, computeNaturalKey, denyEntries) {
5349
+ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, computeNaturalKey, denyEntries, movedIds) {
5069
5350
  const rows = source ? db.prepare("SELECT * FROM nodes WHERE project_id = ? AND kind = ? AND source = ?").all(oldProjectId, kind, source) : db.prepare("SELECT * FROM nodes WHERE project_id = ? AND kind = ?").all(oldProjectId, kind);
5070
5351
  const nodeExists = db.prepare("SELECT 1 FROM nodes WHERE id = ?");
5071
5352
  const insertNode = db.prepare(
@@ -5103,6 +5384,7 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
5103
5384
  continue;
5104
5385
  }
5105
5386
  const newId = makeNodeId(newProjectId, kind, naturalKey);
5387
+ movedIds.set(row.id, newId);
5106
5388
  if (nodeExists.get(newId)) {
5107
5389
  deduped += 1;
5108
5390
  } else {
@@ -5138,69 +5420,101 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
5138
5420
  }
5139
5421
  return { migrated, deduped, skipped, denied };
5140
5422
  }
5141
- function reconcileProjectId(db, oldProjectId, newProjectId) {
5142
- return db.transaction(() => {
5143
- const denyEntries = listDenyListEntries(db, newProjectId);
5144
- const sessions = recomputeByNaturalKey(
5423
+ function snapshotLinks(db, projectIds) {
5424
+ const scope = "SELECT id FROM nodes WHERE project_id IN (SELECT value FROM json_each(?))";
5425
+ return db.prepare(
5426
+ `SELECT from_node_id, to_node_id, relation, created_at FROM node_links
5427
+ WHERE from_node_id IN (${scope}) OR to_node_id IN (${scope})`
5428
+ ).all(JSON.stringify(projectIds), JSON.stringify(projectIds));
5429
+ }
5430
+ function restoreLinks(db, links, movedIds, newProjectId) {
5431
+ const inNewProject = db.prepare("SELECT 1 FROM nodes WHERE id = ? AND project_id = ?");
5432
+ const insertLink = db.prepare(
5433
+ "INSERT OR IGNORE INTO node_links (from_node_id, to_node_id, relation, created_at) VALUES (?, ?, ?, ?)"
5434
+ );
5435
+ for (const link of links) {
5436
+ const from = movedIds.get(link.from_node_id) ?? link.from_node_id;
5437
+ const to = movedIds.get(link.to_node_id) ?? link.to_node_id;
5438
+ if (inNewProject.get(from, newProjectId) && inNewProject.get(to, newProjectId)) {
5439
+ insertLink.run(from, to, link.relation, link.created_at);
5440
+ }
5441
+ }
5442
+ }
5443
+ function reconcileOne(db, oldProjectId, newProjectId, movedIds) {
5444
+ const denyEntries = listDenyListEntries(db, newProjectId);
5445
+ const sessions = recomputeByNaturalKey(
5446
+ db,
5447
+ oldProjectId,
5448
+ newProjectId,
5449
+ "session_summary",
5450
+ null,
5451
+ (_row, meta) => typeof meta.sessionKey === "string" ? meta.sessionKey : null,
5452
+ denyEntries,
5453
+ movedIds
5454
+ );
5455
+ const HOOK_SHELL_SOURCES = [
5456
+ { source: "shell:pwsh-hook", prefix: "pwsh-hook" },
5457
+ { source: "shell:bash-hook", prefix: "bash-hook" },
5458
+ { source: "shell:zsh-hook", prefix: "zsh-hook" }
5459
+ ];
5460
+ const hookShell = { migrated: 0, deduped: 0, skipped: 0, denied: 0 };
5461
+ for (const { source, prefix } of HOOK_SHELL_SOURCES) {
5462
+ const r = recomputeByNaturalKey(
5145
5463
  db,
5146
5464
  oldProjectId,
5147
5465
  newProjectId,
5148
- "session_summary",
5149
- null,
5150
- (_row, meta) => typeof meta.sessionKey === "string" ? meta.sessionKey : null,
5151
- denyEntries
5466
+ "shell_command",
5467
+ source,
5468
+ (row, meta) => {
5469
+ const hash = typeof meta.commandHash === "string" ? meta.commandHash : typeof meta.command === "string" ? sha256Hex(meta.command).slice(0, 12) : null;
5470
+ return hash ? `${prefix}:${row.ts}:${hash}` : null;
5471
+ },
5472
+ denyEntries,
5473
+ movedIds
5152
5474
  );
5153
- const HOOK_SHELL_SOURCES = [
5154
- { source: "shell:pwsh-hook", prefix: "pwsh-hook" },
5155
- { source: "shell:bash-hook", prefix: "bash-hook" },
5156
- { source: "shell:zsh-hook", prefix: "zsh-hook" }
5157
- ];
5158
- const hookShell = { migrated: 0, deduped: 0, skipped: 0, denied: 0 };
5159
- for (const { source, prefix } of HOOK_SHELL_SOURCES) {
5160
- const r = recomputeByNaturalKey(
5161
- db,
5162
- oldProjectId,
5163
- newProjectId,
5164
- "shell_command",
5165
- source,
5166
- (row, meta) => typeof meta.command === "string" ? `${prefix}:${row.ts}:${sha256Hex(meta.command).slice(0, 12)}` : null,
5167
- denyEntries
5168
- );
5169
- hookShell.migrated += r.migrated;
5170
- hookShell.deduped += r.deduped;
5171
- hookShell.skipped += r.skipped;
5172
- hookShell.denied += r.denied;
5173
- }
5174
- let deniedConversationTurns = 0;
5175
- if (denyEntries.length > 0) {
5176
- const conversationTurns = db.prepare(`SELECT id, title, body, meta FROM nodes WHERE project_id = ? AND kind = 'conversation_turn'`).all(oldProjectId);
5177
- if (conversationTurns.length > 0) {
5178
- const dropEmbedding = db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
5179
- const deleteNode = db.prepare("DELETE FROM nodes WHERE id = ?");
5180
- for (const row of conversationTurns) {
5181
- let meta;
5182
- try {
5183
- meta = JSON.parse(row.meta);
5184
- } catch {
5185
- meta = {};
5186
- }
5187
- if (firstMatchingEntry(denyEntries, { title: row.title, body: row.body, meta })) {
5188
- dropEmbedding.run(row.id);
5189
- deleteNode.run(row.id);
5190
- deniedConversationTurns += 1;
5191
- }
5475
+ hookShell.migrated += r.migrated;
5476
+ hookShell.deduped += r.deduped;
5477
+ hookShell.skipped += r.skipped;
5478
+ hookShell.denied += r.denied;
5479
+ }
5480
+ let deniedConversationTurns = 0;
5481
+ if (denyEntries.length > 0) {
5482
+ const conversationTurns = db.prepare(`SELECT id, title, body, meta FROM nodes WHERE project_id = ? AND kind = 'conversation_turn'`).all(oldProjectId);
5483
+ if (conversationTurns.length > 0) {
5484
+ const dropEmbedding = db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
5485
+ const deleteNode = db.prepare("DELETE FROM nodes WHERE id = ?");
5486
+ for (const row of conversationTurns) {
5487
+ let meta;
5488
+ try {
5489
+ meta = JSON.parse(row.meta);
5490
+ } catch {
5491
+ meta = {};
5492
+ }
5493
+ if (firstMatchingEntry(denyEntries, { title: row.title, body: row.body, meta })) {
5494
+ dropEmbedding.run(row.id);
5495
+ deleteNode.run(row.id);
5496
+ deniedConversationTurns += 1;
5192
5497
  }
5193
5498
  }
5194
5499
  }
5195
- const reassigned = db.prepare(`UPDATE nodes SET project_id = ? WHERE project_id = ? AND kind = 'conversation_turn'`).run(newProjectId, oldProjectId).changes;
5196
- return {
5197
- oldProjectId,
5198
- migrated: sessions.migrated + hookShell.migrated,
5199
- reassigned,
5200
- deduped: sessions.deduped + hookShell.deduped,
5201
- skipped: sessions.skipped + hookShell.skipped,
5202
- denied: sessions.denied + hookShell.denied + deniedConversationTurns
5203
- };
5500
+ }
5501
+ const reassigned = db.prepare(`UPDATE nodes SET project_id = ? WHERE project_id = ? AND kind = 'conversation_turn'`).run(newProjectId, oldProjectId).changes;
5502
+ return {
5503
+ oldProjectId,
5504
+ migrated: sessions.migrated + hookShell.migrated,
5505
+ reassigned,
5506
+ deduped: sessions.deduped + hookShell.deduped,
5507
+ skipped: sessions.skipped + hookShell.skipped,
5508
+ denied: sessions.denied + hookShell.denied + deniedConversationTurns
5509
+ };
5510
+ }
5511
+ function reconcileProjectIds(db, oldProjectIds, newProjectId) {
5512
+ return db.transaction(() => {
5513
+ const links = snapshotLinks(db, oldProjectIds);
5514
+ const movedIds = /* @__PURE__ */ new Map();
5515
+ const results = oldProjectIds.map((oldProjectId) => reconcileOne(db, oldProjectId, newProjectId, movedIds));
5516
+ restoreLinks(db, links, movedIds, newProjectId);
5517
+ return results;
5204
5518
  })();
5205
5519
  }
5206
5520
 
@@ -5329,7 +5643,7 @@ function extractRustModSpecifiers(source) {
5329
5643
  }
5330
5644
 
5331
5645
  // src/structure/resolve.ts
5332
- import { posix } from "path";
5646
+ import { posix as posix2 } from "path";
5333
5647
  var REWRITE_EXTENSIONS = {
5334
5648
  ".js": [".ts", ".tsx", ".js"],
5335
5649
  ".jsx": [".tsx", ".jsx"],
@@ -5339,10 +5653,10 @@ var REWRITE_EXTENSIONS = {
5339
5653
  var APPEND_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".d.ts", ".json"];
5340
5654
  var INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.jsx"];
5341
5655
  function resolveSpecifier(fromPath, specifier, trackedPaths) {
5342
- const fromDir = posix.dirname(fromPath);
5343
- const joined = posix.normalize(posix.join(fromDir, specifier));
5656
+ const fromDir = posix2.dirname(fromPath);
5657
+ const joined = posix2.normalize(posix2.join(fromDir, specifier));
5344
5658
  if (trackedPaths.has(joined)) return joined;
5345
- const ext = posix.extname(joined);
5659
+ const ext = posix2.extname(joined);
5346
5660
  const rewrites = REWRITE_EXTENSIONS[ext];
5347
5661
  if (rewrites) {
5348
5662
  const base = joined.slice(0, -ext.length);
@@ -5358,14 +5672,14 @@ function resolveSpecifier(fromPath, specifier, trackedPaths) {
5358
5672
  if (trackedPaths.has(candidate)) return candidate;
5359
5673
  }
5360
5674
  for (const indexFile of INDEX_FILES) {
5361
- const candidate = posix.join(joined, indexFile);
5675
+ const candidate = posix2.join(joined, indexFile);
5362
5676
  if (trackedPaths.has(candidate)) return candidate;
5363
5677
  }
5364
5678
  return null;
5365
5679
  }
5366
5680
 
5367
5681
  // src/structure/resolve-go.ts
5368
- import { posix as posix2 } from "path";
5682
+ import { posix as posix3 } from "path";
5369
5683
  function parseGoModulePath(goModContent) {
5370
5684
  const match = goModContent.match(/^module\s+(\S+)/m);
5371
5685
  return match ? match[1] : null;
@@ -5379,11 +5693,11 @@ function resolveGoSpecifier(modulePath, importPath, trackedPaths) {
5379
5693
  } else {
5380
5694
  return [];
5381
5695
  }
5382
- return [...trackedPaths].filter((p) => p.endsWith(".go") && !p.endsWith("_test.go") && posix2.dirname(p) === relDir).sort();
5696
+ return [...trackedPaths].filter((p) => p.endsWith(".go") && !p.endsWith("_test.go") && posix3.dirname(p) === relDir).sort();
5383
5697
  }
5384
5698
 
5385
5699
  // src/structure/resolve-java.ts
5386
- import { posix as posix3 } from "path";
5700
+ import { posix as posix4 } from "path";
5387
5701
  function endsAtSegmentBoundary(path, suffix) {
5388
5702
  return path === suffix || path.endsWith(`/${suffix}`);
5389
5703
  }
@@ -5393,8 +5707,8 @@ function resolveJavaSpecifier(specifier, trackedPaths) {
5393
5707
  if (segments.length === 0) return [];
5394
5708
  if (isWildcard) {
5395
5709
  const dirSuffix = segments.join("/");
5396
- const matches2 = [...trackedPaths].filter((p) => p.endsWith(".java") && endsAtSegmentBoundary(posix3.dirname(p), dirSuffix));
5397
- const dirs = new Set(matches2.map((p) => posix3.dirname(p)));
5710
+ const matches2 = [...trackedPaths].filter((p) => p.endsWith(".java") && endsAtSegmentBoundary(posix4.dirname(p), dirSuffix));
5711
+ const dirs = new Set(matches2.map((p) => posix4.dirname(p)));
5398
5712
  return dirs.size === 1 ? matches2.sort() : [];
5399
5713
  }
5400
5714
  const fileSuffix = `${segments.join("/")}.java`;
@@ -5403,11 +5717,11 @@ function resolveJavaSpecifier(specifier, trackedPaths) {
5403
5717
  }
5404
5718
 
5405
5719
  // src/structure/resolve-php.ts
5406
- import { posix as posix4 } from "path";
5720
+ import { posix as posix5 } from "path";
5407
5721
  function resolvePhpSpecifier(fromPath, specifier, trackedPaths) {
5408
- const fromDir = posix4.dirname(fromPath);
5409
- const relative2 = specifier.startsWith("/") ? specifier.slice(1) : specifier;
5410
- const joined = posix4.normalize(posix4.join(fromDir, relative2));
5722
+ const fromDir = posix5.dirname(fromPath);
5723
+ const relative3 = specifier.startsWith("/") ? specifier.slice(1) : specifier;
5724
+ const joined = posix5.normalize(posix5.join(fromDir, relative3));
5411
5725
  if (trackedPaths.has(joined)) return joined;
5412
5726
  const withExt = joined.endsWith(".php") ? joined : `${joined}.php`;
5413
5727
  if (trackedPaths.has(withExt)) return withExt;
@@ -5415,7 +5729,7 @@ function resolvePhpSpecifier(fromPath, specifier, trackedPaths) {
5415
5729
  }
5416
5730
 
5417
5731
  // src/structure/resolve-python.ts
5418
- import { posix as posix5 } from "path";
5732
+ import { posix as posix6 } from "path";
5419
5733
  var STDLIB_MODULES = /* @__PURE__ */ new Set([
5420
5734
  "__future__",
5421
5735
  "abc",
@@ -5593,27 +5907,27 @@ function resolvePythonSpecifier(fromPath, specifier, trackedPaths) {
5593
5907
  if (level === 0 && STDLIB_MODULES.has(specifier)) return null;
5594
5908
  const segments = (level === 0 ? specifier : specifier.slice(level)).split(".").filter(Boolean);
5595
5909
  if (segments.length === 0) return null;
5596
- let dir = posix5.dirname(fromPath);
5910
+ let dir = posix6.dirname(fromPath);
5597
5911
  for (let i = 1; i < level; i++) {
5598
- dir = posix5.dirname(dir);
5912
+ dir = posix6.dirname(dir);
5599
5913
  }
5600
- const joined = posix5.join(dir, ...segments);
5914
+ const joined = posix6.join(dir, ...segments);
5601
5915
  const moduleFile = `${joined}.py`;
5602
5916
  if (trackedPaths.has(moduleFile)) return moduleFile;
5603
- const packageInit = posix5.join(joined, "__init__.py");
5917
+ const packageInit = posix6.join(joined, "__init__.py");
5604
5918
  if (trackedPaths.has(packageInit)) return packageInit;
5605
5919
  return null;
5606
5920
  }
5607
5921
 
5608
5922
  // src/structure/resolve-rust.ts
5609
- import { posix as posix6 } from "path";
5923
+ import { posix as posix7 } from "path";
5610
5924
  function resolveRustModSpecifier(fromPath, name, trackedPaths) {
5611
- const dir = posix6.dirname(fromPath);
5612
- const base = posix6.basename(fromPath, ".rs");
5613
- const moduleDir = base === "mod" || base === "lib" || base === "main" ? dir : posix6.join(dir, base);
5614
- const sibling = posix6.join(moduleDir, `${name}.rs`);
5925
+ const dir = posix7.dirname(fromPath);
5926
+ const base = posix7.basename(fromPath, ".rs");
5927
+ const moduleDir = base === "mod" || base === "lib" || base === "main" ? dir : posix7.join(dir, base);
5928
+ const sibling = posix7.join(moduleDir, `${name}.rs`);
5615
5929
  if (trackedPaths.has(sibling)) return sibling;
5616
- const nested = posix6.join(moduleDir, name, "mod.rs");
5930
+ const nested = posix7.join(moduleDir, name, "mod.rs");
5617
5931
  if (trackedPaths.has(nested)) return nested;
5618
5932
  return null;
5619
5933
  }
@@ -5744,11 +6058,231 @@ ${node.body}`)
5744
6058
  };
5745
6059
  }
5746
6060
 
6061
+ // src/agent/paths.ts
6062
+ import { join as join11 } from "path";
6063
+ function agentEventLogPath() {
6064
+ return join11(globalWorkspaceDir(), "agent-events.jsonl");
6065
+ }
6066
+
6067
+ // src/agent/record.ts
6068
+ import { appendFile as appendFile2, mkdir as mkdir6, readFile as readFile11 } from "fs/promises";
6069
+ import { dirname as dirname5 } from "path";
6070
+ var KINDS = /* @__PURE__ */ new Set(["command", "edit"]);
6071
+ var OUTCOMES = /* @__PURE__ */ new Set(["ok", "fail", "interrupted", "unknown"]);
6072
+ function parseAgentEventLine(line) {
6073
+ const trimmed = line.trim();
6074
+ if (!trimmed) return null;
6075
+ let parsed;
6076
+ try {
6077
+ parsed = JSON.parse(trimmed);
6078
+ } catch {
6079
+ return null;
6080
+ }
6081
+ if (typeof parsed !== "object" || parsed === null) return null;
6082
+ const e = parsed;
6083
+ if (typeof e.agent !== "string" || typeof e.sessionId !== "string" || typeof e.eventId !== "string") return null;
6084
+ if (typeof e.ts !== "string" || typeof e.kind !== "string" || !KINDS.has(e.kind)) return null;
6085
+ if (typeof e.outcome !== "string" || !OUTCOMES.has(e.outcome)) return null;
6086
+ return parsed;
6087
+ }
6088
+ async function readAgentEvents(logPath, fromLine) {
6089
+ let raw;
6090
+ try {
6091
+ raw = await readFile11(logPath, "utf8");
6092
+ } catch {
6093
+ return { events: [], totalLines: fromLine };
6094
+ }
6095
+ const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
6096
+ const start = fromLine > 0 && fromLine <= lines.length ? fromLine : 0;
6097
+ const events = lines.slice(start).map(parseAgentEventLine).filter((e) => e !== null);
6098
+ return { events, totalLines: lines.length };
6099
+ }
6100
+
6101
+ // src/collectors/agent-events.ts
6102
+ import { relative as relative2 } from "path";
6103
+
6104
+ // src/agent/event.ts
6105
+ var OBSERVATION_COMMANDS = /* @__PURE__ */ new Set(["ls", "pwd", "echo", "cat"]);
6106
+ var OBSERVATION_GIT_SUBCOMMANDS = /* @__PURE__ */ new Set(["status", "log", "diff", "show"]);
6107
+ var UNSAFE_SEGMENT = /[|&<>`(){}]|\$\(/;
6108
+ function splitSegments(command) {
6109
+ const segments = [];
6110
+ let current = "";
6111
+ let quote2 = null;
6112
+ for (let i = 0; i < command.length; i += 1) {
6113
+ const ch = command[i];
6114
+ if (quote2) {
6115
+ current += ch;
6116
+ if (ch === quote2) quote2 = null;
6117
+ continue;
6118
+ }
6119
+ if (ch === '"' || ch === "'") {
6120
+ quote2 = ch;
6121
+ current += ch;
6122
+ continue;
6123
+ }
6124
+ if (ch === ";") {
6125
+ segments.push(current);
6126
+ current = "";
6127
+ continue;
6128
+ }
6129
+ if (ch === "&" && command[i + 1] === "&") {
6130
+ segments.push(current);
6131
+ current = "";
6132
+ i += 1;
6133
+ continue;
6134
+ }
6135
+ current += ch;
6136
+ }
6137
+ if (quote2) return null;
6138
+ segments.push(current);
6139
+ return segments.map((s) => s.trim()).filter((s) => s.length > 0);
6140
+ }
6141
+ var CD_SEGMENT = /^cd\s+(?:"([^"]*)"|'([^']*)'|(\S+))$/;
6142
+ var DRIVE_SPELLING = /^\/(?:mnt\/)?([a-zA-Z])(?:\/|$)/;
6143
+ var WINDOWS_DRIVE = /^[a-zA-Z]:(?:\/|$)/;
6144
+ var WINDOWS_UNC = /^\/\/[^/]+\/[^/]+/;
6145
+ function directoryForCompare(path, platform) {
6146
+ if (platform !== "win32") return { text: path.replace(/\/+$/, ""), foldsCase: false };
6147
+ const slashed = path.replace(/\\/g, "/");
6148
+ const drive = DRIVE_SPELLING.exec(slashed);
6149
+ const native = drive ? `${drive[1]}:/${slashed.slice(drive[0].length)}` : slashed;
6150
+ return { text: native.replace(/\/+$/, ""), foldsCase: WINDOWS_DRIVE.test(native) || WINDOWS_UNC.test(native) };
6151
+ }
6152
+ function sameDirectory(a, b, platform = process.platform) {
6153
+ const x = directoryForCompare(a, platform);
6154
+ const y = directoryForCompare(b, platform);
6155
+ return x.foldsCase && y.foldsCase ? x.text.toLowerCase() === y.text.toLowerCase() : x.text === y.text;
6156
+ }
6157
+ function classify(segment, cwd, platform) {
6158
+ if (UNSAFE_SEGMENT.test(segment)) return "target";
6159
+ const cd = CD_SEGMENT.exec(segment);
6160
+ if (cd) {
6161
+ if (!cwd) return "target";
6162
+ const to = cd[1] ?? cd[2] ?? cd[3] ?? "";
6163
+ if (to === ".") return "navigation";
6164
+ return sameDirectory(to, cwd, platform) ? "navigation" : "target";
6165
+ }
6166
+ const [head, next] = segment.split(/\s+/);
6167
+ if (!head) return "target";
6168
+ if (OBSERVATION_COMMANDS.has(head)) return "observation";
6169
+ if (head === "git" && next !== void 0 && OBSERVATION_GIT_SUBCOMMANDS.has(next)) return "observation";
6170
+ return "target";
6171
+ }
6172
+ function canonicalizeCommand(command, cwd, platform = process.platform) {
6173
+ const segments = splitSegments(command);
6174
+ if (!segments) return command;
6175
+ const targets = segments.filter((s) => classify(s, cwd, platform) === "target");
6176
+ return targets.length === 1 ? targets[0] : command;
6177
+ }
6178
+ var MAX_ERROR_SIGNATURE_CHARS = 200;
6179
+ function agentEventNaturalKey(event) {
6180
+ return `agent:${event.agent}:${event.sessionId}:${event.eventId}`;
6181
+ }
6182
+ function redactAgentEvent(raw) {
6183
+ const { command, errorSignature, ...rest } = raw;
6184
+ return {
6185
+ ...rest,
6186
+ ...command === void 0 ? {} : {
6187
+ command: redact(command).text,
6188
+ commandHash: sha256Hex(command).slice(0, 12),
6189
+ execHash: sha256Hex(canonicalizeCommand(command, raw.cwd)).slice(0, 12)
6190
+ },
6191
+ ...errorSignature === void 0 ? {} : { errorSignature: redact(errorSignature).text.slice(0, MAX_ERROR_SIGNATURE_CHARS) }
6192
+ };
6193
+ }
6194
+
6195
+ // src/collectors/agent-events.ts
6196
+ var MAX_TITLE_CHARS8 = 200;
6197
+ var DEFAULT_MAX_BODY_CHARS6 = 1e3;
6198
+ function toFileTouch(path, repoRoot) {
6199
+ const rel = relative2(repoRoot, path).replace(/\\/g, "/");
6200
+ return { path: rel, insertions: null, deletions: null, binary: false };
6201
+ }
6202
+ function renderBody3(event, files2, maxChars) {
6203
+ const parts = [`$ ${event.command ?? ""}`];
6204
+ const meta = [];
6205
+ if (event.cwd) meta.push(`cwd: ${event.cwd}`);
6206
+ if (event.exitCode !== null) meta.push(`exit: ${event.exitCode}`);
6207
+ else if (event.outcome === "interrupted") meta.push("interrupted");
6208
+ if (event.durationMs !== null) meta.push(`duration: ${event.durationMs}ms`);
6209
+ meta.push(`agent: ${event.agent}`);
6210
+ parts.push("", meta.join(" "));
6211
+ if (event.errorSignature) parts.push(`error: ${event.errorSignature}`);
6212
+ if (files2.length > 0) parts.push(`changed before this ran: ${files2.map((f) => f.path).join(", ")}`);
6213
+ return truncate(parts.join("\n"), maxChars);
6214
+ }
6215
+ function toAgentMemoryNode(event, files2, projectId, opts) {
6216
+ const command = event.command ?? "";
6217
+ return {
6218
+ id: makeNodeId(projectId, "shell_command", agentEventNaturalKey(event)),
6219
+ kind: "shell_command",
6220
+ projectId,
6221
+ ts: event.ts,
6222
+ sourceTs: event.ts,
6223
+ source: `agent:${event.agent}`,
6224
+ title: truncate(command.split(/\r?\n/)[0] ?? command, MAX_TITLE_CHARS8),
6225
+ body: renderBody3(event, files2, opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS6),
6226
+ files: [...files2],
6227
+ // The command text is already redacted; scoring only reads its shape and the exit code.
6228
+ signal: scoreShellCommand({
6229
+ naturalKey: agentEventNaturalKey(event),
6230
+ command,
6231
+ ts: event.ts,
6232
+ tsApprox: false,
6233
+ exitCode: event.exitCode,
6234
+ cwd: event.cwd,
6235
+ durationMs: event.durationMs,
6236
+ shell: event.agent
6237
+ }),
6238
+ provenance: "observed",
6239
+ meta: {
6240
+ // These keys match the shell collector's, so failure-fix and precheck read agent nodes unchanged.
6241
+ command,
6242
+ commandHash: event.commandHash,
6243
+ // Agent-only: the shell collector has no `cd`-prefix habit to normalize.
6244
+ execHash: event.execHash,
6245
+ cwd: event.cwd,
6246
+ exitCode: event.exitCode,
6247
+ durationMs: event.durationMs,
6248
+ tsApprox: false,
6249
+ sourceTimestamp: event.ts,
6250
+ shell: event.agent,
6251
+ agent: event.agent,
6252
+ agentSessionId: event.sessionId,
6253
+ toolUseId: event.eventId,
6254
+ outcome: event.outcome,
6255
+ captureVia: "hook",
6256
+ ...event.errorSignature ? { errorSignature: event.errorSignature } : {},
6257
+ ...event.agentId ? { agentId: event.agentId } : {}
6258
+ }
6259
+ };
6260
+ }
6261
+ function collectAgentEvents(events, projectId, opts) {
6262
+ const pendingEdits = /* @__PURE__ */ new Map();
6263
+ const nodes = [];
6264
+ for (const event of events) {
6265
+ if (event.kind === "edit") {
6266
+ if (!event.filePath || !isUnderRoot(event.filePath, opts.repoRoot, opts.platform)) continue;
6267
+ const pending = pendingEdits.get(event.sessionId) ?? [];
6268
+ const touch = toFileTouch(event.filePath, opts.repoRoot);
6269
+ if (!pending.some((f) => f.path === touch.path)) pending.push(touch);
6270
+ pendingEdits.set(event.sessionId, pending);
6271
+ continue;
6272
+ }
6273
+ if (!event.command || !event.cwd || !isUnderRoot(event.cwd, opts.repoRoot, opts.platform)) continue;
6274
+ const files2 = pendingEdits.get(event.sessionId) ?? [];
6275
+ nodes.push(toAgentMemoryNode(event, files2, projectId, opts));
6276
+ pendingEdits.delete(event.sessionId);
6277
+ }
6278
+ return nodes;
6279
+ }
6280
+
5747
6281
  // src/cli/sync-lock.ts
5748
6282
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync } from "fs";
5749
- import { join as join11 } from "path";
6283
+ import { join as join12 } from "path";
5750
6284
  function lockPath(wsDir) {
5751
- return join11(wsDir, "sync.lock");
6285
+ return join12(wsDir, "sync.lock");
5752
6286
  }
5753
6287
  function readOwner(path) {
5754
6288
  try {
@@ -5900,6 +6434,19 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
5900
6434
  repoRoot,
5901
6435
  hookCursor: store.getSyncCursor(projectId, "shell:pwsh-hook")
5902
6436
  });
6437
+ if (existsSync5(hookLogPath())) {
6438
+ try {
6439
+ const { linesChanged, legacyLines } = await sanitizeHookLog(hookLogPath());
6440
+ if (linesChanged > 0) log(` ${pc7.dim(`shell hook log: redacted ${linesChanged} line(s)`)}`);
6441
+ if (legacyLines > 0) {
6442
+ log(
6443
+ `${pc7.yellow("shell")} ${legacyLines} hook-log line(s) were written by an outdated NexusMem shell hook that stores commands unredacted until a sync -- run \`nexusmem hook install\` again in each shell`
6444
+ );
6445
+ }
6446
+ } catch (err) {
6447
+ log(`${pc7.yellow("shell")} could not redact the hook log (${err.message}) -- run \`nexusmem scrub-secrets --yes\``);
6448
+ }
6449
+ }
5903
6450
  if (results.length === 0) {
5904
6451
  log(`${pc7.dim("shell")} no history source found on this machine`);
5905
6452
  return { totals, seen: 0 };
@@ -5917,6 +6464,25 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
5917
6464
  }
5918
6465
  return { totals, seen };
5919
6466
  }
6467
+ var AGENT_SOURCE = "agent";
6468
+ async function syncAgent(store, projectId, repoRoot, config, log) {
6469
+ const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
6470
+ if (!config.sources.agent.enabled) {
6471
+ log(`${pc7.dim("agent")} disabled in config`);
6472
+ return { totals, seen: 0 };
6473
+ }
6474
+ const logPath = agentEventLogPath();
6475
+ if (!existsSync5(logPath)) return { totals, seen: 0 };
6476
+ const fromLine = Number(store.getSyncCursor(projectId, AGENT_SOURCE) ?? "0") || 0;
6477
+ const { events, totalLines } = await readAgentEvents(logPath, fromLine);
6478
+ const nodes = collectAgentEvents(events, projectId, { repoRoot, maxBodyChars: config.limits.maxBodyChars });
6479
+ if (nodes.length > 0) {
6480
+ addStats(totals, store.upsertNodes(nodes));
6481
+ log(` ${pc7.dim(`${AGENT_SOURCE}: ${nodes.length} agent action(s) read`)}`);
6482
+ }
6483
+ store.setSyncCursor(projectId, AGENT_SOURCE, String(totalLines));
6484
+ return { totals, seen: nodes.length };
6485
+ }
5920
6486
  var CONVERSATION_SOURCE = "conversation:claude-code";
5921
6487
  function syncConversation(store, projectId, turns, config, log, forceEnabled) {
5922
6488
  const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
@@ -5977,8 +6543,8 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
5977
6543
  log(`${pc7.dim("docs")} disabled in config`);
5978
6544
  return { totals, seen: 0 };
5979
6545
  }
5980
- const { files, unreadable } = await readDocFiles(repoRoot, { include: config.sources.docs.include });
5981
- const nodes = collectDocFiles(files, projectId, { maxBodyChars: config.limits.maxBodyChars });
6546
+ const { files: files2, unreadable } = await readDocFiles(repoRoot, { include: config.sources.docs.include });
6547
+ const nodes = collectDocFiles(files2, projectId, { maxBodyChars: config.limits.maxBodyChars });
5982
6548
  if (nodes.length > 0) addStats(totals, store.upsertNodes(nodes));
5983
6549
  const pruned = store.pruneSourceNodes(
5984
6550
  projectId,
@@ -5987,12 +6553,12 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
5987
6553
  { keepPaths: unreadable }
5988
6554
  );
5989
6555
  store.setSyncCursor(projectId, DOCS_SOURCE, `scanned:${nodes.length}`);
5990
- if (files.length === 0 && unreadable.length === 0) {
6556
+ if (files2.length === 0 && unreadable.length === 0) {
5991
6557
  log(`${pc7.dim("docs")} no tracked .md files found`);
5992
6558
  } else {
5993
6559
  const prunedPart = pruned > 0 ? `, ${pc7.yellow(`${pruned} stale removed`)}` : "";
5994
6560
  const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (kept)` : "";
5995
- log(` ${pc7.dim(`${DOCS_SOURCE}: ${nodes.length} section(s) from ${files.length} file(s)`)}${prunedPart}${pc7.dim(skippedPart)}`);
6561
+ log(` ${pc7.dim(`${DOCS_SOURCE}: ${nodes.length} section(s) from ${files2.length} file(s)`)}${prunedPart}${pc7.dim(skippedPart)}`);
5996
6562
  }
5997
6563
  return { totals, seen: nodes.length };
5998
6564
  }
@@ -6093,10 +6659,10 @@ async function runAutoContradictionCheck(store, config, projectId, providers) {
6093
6659
  maxJudgments: config.contradictions.maxPerSync,
6094
6660
  model: config.contradictions.model
6095
6661
  });
6096
- const open = store.countContradictionSuggestions(projectId);
6097
- if (fresh.length === 0 && open === 0) return "";
6662
+ const open3 = store.countContradictionSuggestions(projectId);
6663
+ if (fresh.length === 0 && open3 === 0) return "";
6098
6664
  const freshPart = fresh.length > 0 ? pc7.yellow(`${fresh.length} new`) : `${fresh.length} new`;
6099
- return ` ${pc7.dim("contradictions:")} ${freshPart}${pc7.dim(`, ${open} open suggestion(s) -- run`)} ${pc7.bold("nexusmem stale")} ${pc7.dim("for detail")}
6665
+ return ` ${pc7.dim("contradictions:")} ${freshPart}${pc7.dim(`, ${open3} open suggestion(s) -- run`)} ${pc7.bold("nexusmem stale")} ${pc7.dim("for detail")}
6100
6666
  `;
6101
6667
  }
6102
6668
  async function runSync(opts) {
@@ -6124,8 +6690,7 @@ async function runSync(opts) {
6124
6690
  log(`${pc7.dim("rebuild")} dropped ${removed} existing node(s)`);
6125
6691
  }
6126
6692
  const staleProjectIds = store.listOtherProjectIds(projectId);
6127
- for (const staleId of staleProjectIds) {
6128
- const result = reconcileProjectId(store.raw, staleId, projectId);
6693
+ for (const result of reconcileProjectIds(store.raw, staleProjectIds, projectId)) {
6129
6694
  const parts = [
6130
6695
  result.migrated > 0 ? `${result.migrated} migrated` : null,
6131
6696
  result.reassigned > 0 ? `${result.reassigned} reassigned` : null,
@@ -6135,7 +6700,7 @@ async function runSync(opts) {
6135
6700
  ].filter((part) => part !== null);
6136
6701
  if (parts.length > 0) {
6137
6702
  log(
6138
- `${pc7.yellow("reconciled")} previous project identity ${pc7.dim(staleId)} (remote URL likely changed): ${parts.join(", ")}`
6703
+ `${pc7.yellow("reconciled")} previous project identity ${pc7.dim(result.oldProjectId)} (remote URL likely changed): ${parts.join(", ")}`
6139
6704
  );
6140
6705
  }
6141
6706
  }
@@ -6154,6 +6719,7 @@ async function runSync(opts) {
6154
6719
  const git2 = await syncGit(store, projectId, opts, repo, config, log);
6155
6720
  const diffs = await syncDiffs(store, projectId, opts, repo, config, log);
6156
6721
  const shell = await syncShell(store, projectId, opts, repo.root, config, log);
6722
+ const agent = await syncAgent(store, projectId, repo.root, config, log);
6157
6723
  const conversationEnabled = opts.conversationOverride ?? config.sources.conversation.enabled;
6158
6724
  const turns = conversationEnabled || config.sources.session.enabled ? await collectClaudeCodeTranscripts(repo.root) : [];
6159
6725
  const conversation = syncConversation(store, projectId, turns, config, log, opts.conversationOverride);
@@ -6189,9 +6755,10 @@ async function runSync(opts) {
6189
6755
  slm: new OllamaChatProvider({ model: config.contradictions.model })
6190
6756
  }) : "";
6191
6757
  let linkLine = "";
6192
- if (opts.linkFailures) {
6758
+ if (opts.linkFailures || agent.seen > 0) {
6193
6759
  const linkStats = correlateFailures(store, projectId);
6194
- linkLine = ` ${pc7.dim(`chains: ${linkStats.failuresExamined} failure(s) examined, ${linkStats.linkedByRetry} linked by retry, ${linkStats.linkedByDiscussion} by discussion`)}
6760
+ const unexplained = linkStats.unexplainedRetries > 0 ? `, ${linkStats.unexplainedRetries} passed again with nothing edited (not linked)` : "";
6761
+ linkLine = ` ${pc7.dim(`chains: ${linkStats.failuresExamined} failure(s) examined, ${linkStats.linkedByRetry} linked by retry, ${linkStats.linkedByDiscussion} by discussion${unexplained}`)}
6195
6762
  `;
6196
6763
  }
6197
6764
  store.markSynced(projectId);
@@ -6199,6 +6766,7 @@ async function runSync(opts) {
6199
6766
  addStats(totals, git2.totals);
6200
6767
  addStats(totals, diffs.totals);
6201
6768
  addStats(totals, shell.totals);
6769
+ addStats(totals, agent.totals);
6202
6770
  addStats(totals, conversation.totals);
6203
6771
  addStats(totals, sessions.totals);
6204
6772
  addStats(totals, docs.totals);
@@ -6212,10 +6780,11 @@ async function runSync(opts) {
6212
6780
  const structurePart = config.sources.structure.enabled ? `, ${structure.edges} import edge(s)` : "";
6213
6781
  const githubEnabled = opts.githubOverride ?? config.sources.github.enabled;
6214
6782
  const githubPart = githubEnabled ? `, ${github.seen} github thread(s)` : "";
6783
+ const agentPart = agent.seen > 0 ? `, ${agent.seen} agent action(s)` : "";
6215
6784
  const deniedPart = totals.denied > 0 ? ` ${pc7.red(`-${totals.denied} denied`)}` : "";
6216
6785
  out(
6217
6786
  [
6218
- `${pc7.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${sessionPart}${docsPart}${githubPart}${structurePart} in ${elapsed}s`,
6787
+ `${pc7.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${agentPart}${conversationPart}${sessionPart}${docsPart}${githubPart}${structurePart} in ${elapsed}s`,
6219
6788
  ` ${pc7.green(`+${totals.inserted} new`)} ${pc7.yellow(`~${totals.updated} updated`)} ${pc7.dim(`=${totals.unchanged} unchanged`)}${deniedPart}`,
6220
6789
  ` ${pc7.dim(`${stats2.total} node(s) total across ${stats2.distinctFiles} file path(s)`)}`,
6221
6790
  ""
@@ -6786,10 +7355,10 @@ ${hottest.join("\n")}` : ""
6786
7355
  async function runScanConversation(opts) {
6787
7356
  const repo = await readRepoInfo(opts.cwd);
6788
7357
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
6789
- const files = await listTranscriptFiles(repo.root);
7358
+ const files2 = await listTranscriptFiles(repo.root);
6790
7359
  if (!opts.json) {
6791
7360
  process.stderr.write(
6792
- files.length ? `${pc12.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
7361
+ files2.length ? `${pc12.dim("transcripts")} ${files2.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
6793
7362
 
6794
7363
  ` : `${pc12.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
6795
7364
  `
@@ -6869,10 +7438,10 @@ import pc14 from "picocolors";
6869
7438
  async function runScanDocs(opts) {
6870
7439
  const repo = await readRepoInfo(opts.cwd);
6871
7440
  const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
6872
- const { files, unreadable } = await readDocFiles(repo.root);
7441
+ const { files: files2, unreadable } = await readDocFiles(repo.root);
6873
7442
  if (!opts.json) {
6874
7443
  process.stderr.write(
6875
- files.length ? `${pc14.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
7444
+ files2.length ? `${pc14.dim("tracked .md files")} ${files2.map((f) => f.path).join(", ")}
6876
7445
 
6877
7446
  ` : `${pc14.yellow("no tracked .md files found")}
6878
7447
  `
@@ -6883,7 +7452,7 @@ async function runScanDocs(opts) {
6883
7452
  `);
6884
7453
  }
6885
7454
  }
6886
- const nodes = collectDocFiles(files, projectId).filter((n) => n.signal >= opts.minSignal);
7455
+ const nodes = collectDocFiles(files2, projectId).filter((n) => n.signal >= opts.minSignal);
6887
7456
  if (opts.json) {
6888
7457
  process.stdout.write(`${JSON.stringify(nodes, null, 2)}
6889
7458
  `);
@@ -6894,7 +7463,7 @@ async function runScanDocs(opts) {
6894
7463
  const approxTotal = approxTotalTokens(nodes);
6895
7464
  process.stderr.write(
6896
7465
  `
6897
- ${pc14.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc14.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
7466
+ ${pc14.bold(String(nodes.length))} section(s) from ${files2.length} file(s) above threshold ${pc14.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
6898
7467
  `
6899
7468
  );
6900
7469
  return 0;
@@ -6944,14 +7513,14 @@ ${summarize2(nodes)}
6944
7513
  function formatNode4(node) {
6945
7514
  const sha = String(node.meta.shortSha ?? "").padEnd(9);
6946
7515
  const date = node.ts.slice(0, 10);
6947
- const files = Number(node.meta.filesChanged ?? 0);
7516
+ const files2 = Number(node.meta.filesChanged ?? 0);
6948
7517
  const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
6949
7518
  return [
6950
7519
  formatSignal(node.signal, GIT_SIGNAL_BANDS),
6951
7520
  pc15.dim(date),
6952
7521
  pc15.magenta(sha),
6953
7522
  node.title,
6954
- pc15.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
7523
+ pc15.dim(`(${files2} file${files2 === 1 ? "" : "s"}, ${churn})`)
6955
7524
  ].join(" ");
6956
7525
  }
6957
7526
 
@@ -7154,30 +7723,1050 @@ ${pc19.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKE
7154
7723
  return 0;
7155
7724
  }
7156
7725
 
7157
- // src/cli/commands/stale.ts
7726
+ // src/cli/commands/agent.ts
7727
+ import { spawn as spawn2 } from "child_process";
7728
+ import { existsSync as existsSync6 } from "fs";
7729
+ import { mkdir as mkdir7, readFile as readFile12, writeFile as writeFile7 } from "fs/promises";
7730
+ import { homedir as homedir4 } from "os";
7731
+ import { dirname as dirname7, join as join15 } from "path";
7158
7732
  import pc20 from "picocolors";
7159
- var STALE_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
7160
- async function runStale(opts) {
7161
- const { projectId, ws } = await loadContext(opts.cwd);
7162
- const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
7163
- const store = MemoryStore.open(ws.dbPath);
7733
+
7734
+ // src/agent/capture-health.ts
7735
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
7736
+ import { dirname as dirname6, join as join13 } from "path";
7737
+ var DROP_REASONS = [
7738
+ "unparsable-json",
7739
+ "unsupported-event",
7740
+ "unsupported-tool",
7741
+ "missing-fields",
7742
+ "write-failed",
7743
+ "payload-too-large"
7744
+ ];
7745
+ var DROP_FAMILIES = ["post-tool-use", "post-tool-use-failure", "session-start", "other"];
7746
+ var HEALTHY_WINDOW_MS = 24 * 60 * 60 * 1e3;
7747
+ function captureDropStatePath() {
7748
+ return join13(globalWorkspaceDir(), "agent-capture-drops.json");
7749
+ }
7750
+ var isDropReason = (value) => DROP_REASONS.includes(value);
7751
+ var isDropFamily = (value) => DROP_FAMILIES.includes(value);
7752
+ function readDropState(path) {
7164
7753
  try {
7165
- if (opts.dismiss) {
7166
- const dismissed = store.dismissContradictionSuggestion(projectId, opts.dismiss);
7167
- out(
7168
- dismissed > 0 ? `${pc20.green("dismissed")} the contradiction suggestion for ${opts.dismiss} -- it will not resurface
7169
- ` : `${pc20.dim("no open contradiction suggestion")} for ${opts.dismiss} -- nothing to dismiss
7170
- `
7171
- );
7172
- return 0;
7173
- }
7174
- const candidates = store.listStaleCandidates(projectId, { minAgeDays: opts.minAgeDays, limit: opts.limit });
7175
- if (candidates.length === 0) {
7176
- out(`${pc20.dim("no stale candidates")} -- no unconfirmed node older than the threshold lacks a successor
7177
- `);
7178
- return 0;
7179
- }
7180
- let suggestions = [];
7754
+ const parsed = JSON.parse(readFileSync3(path, "utf8"));
7755
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
7756
+ } catch {
7757
+ return {};
7758
+ }
7759
+ }
7760
+ function lastEvent(logPath) {
7761
+ let raw;
7762
+ try {
7763
+ raw = readFileSync3(logPath, "utf8");
7764
+ } catch (err) {
7765
+ return err.code === "ENOENT" ? null : "unreadable";
7766
+ }
7767
+ const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
7768
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
7769
+ const event = parseAgentEventLine(lines[i]);
7770
+ if (event) return { at: event.ts, kind: event.kind, outcome: event.outcome };
7771
+ }
7772
+ return lines.length > 0 ? "unreadable" : null;
7773
+ }
7774
+ function readCaptureStatus(opts = {}) {
7775
+ const now = opts.now ?? /* @__PURE__ */ new Date();
7776
+ const event = lastEvent(opts.logPath ?? agentEventLogPath());
7777
+ const drop = readDropState(opts.dropStatePath ?? captureDropStatePath());
7778
+ const lastDropAt = typeof drop.lastDropAt === "string" ? drop.lastDropAt : null;
7779
+ const lastDropReason = isDropReason(drop.lastDropReason) ? drop.lastDropReason : null;
7780
+ const lastDropFamily = isDropFamily(drop.lastDropFamily) ? drop.lastDropFamily : null;
7781
+ const drops = typeof drop.drops === "number" && Number.isFinite(drop.drops) ? drop.drops : 0;
7782
+ const base = { lastDropAt, lastDropReason, lastDropFamily, drops };
7783
+ if (event === "unreadable") {
7784
+ return { ...base, health: "unknown", lastEventAt: null, lastEventKind: null, lastEventOutcome: null };
7785
+ }
7786
+ const droppedRecently = lastDropAt !== null && now.getTime() - Date.parse(lastDropAt) <= HEALTHY_WINDOW_MS;
7787
+ if (event === null) {
7788
+ return {
7789
+ ...base,
7790
+ health: droppedRecently ? "degraded" : "never-observed",
7791
+ lastEventAt: null,
7792
+ lastEventKind: null,
7793
+ lastEventOutcome: null
7794
+ };
7795
+ }
7796
+ const captured = { lastEventAt: event.at, lastEventKind: event.kind, lastEventOutcome: event.outcome };
7797
+ const eventAt = Date.parse(event.at);
7798
+ if (Number.isNaN(eventAt)) return { ...base, ...captured, health: "unknown" };
7799
+ if (droppedRecently && lastDropAt !== null && Date.parse(lastDropAt) > eventAt) {
7800
+ return { ...base, ...captured, health: "degraded" };
7801
+ }
7802
+ return { ...base, ...captured, health: now.getTime() - eventAt <= HEALTHY_WINDOW_MS ? "healthy" : "stale" };
7803
+ }
7804
+
7805
+ // src/agent/hook-command.ts
7806
+ import { fileURLToPath as fileURLToPath3 } from "url";
7807
+ var WINDOWS_PATH = /^(?:[A-Za-z]:[\\/]|\\\\)/;
7808
+ var quote = (path) => {
7809
+ const p = WINDOWS_PATH.test(path) ? path.replace(/\\/g, "/") : path;
7810
+ return `"${p.replace(/(["$`\\])/g, "\\$1")}"`;
7811
+ };
7812
+ function hookCommandPaths(command) {
7813
+ return [...command.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => (m[1] ?? "").replace(/\\(.)/g, "$1"));
7814
+ }
7815
+ function agentHookCommands(node = process.execPath, captureScript = fileURLToPath3(new URL("../../dist/cli/agent-hook.js", import.meta.url)), cliScript = fileURLToPath3(new URL("../../dist/cli/index.js", import.meta.url))) {
7816
+ return {
7817
+ capture: `${quote(node)} ${quote(captureScript)}`,
7818
+ recall: `${quote(node)} ${quote(cliScript)} agent recall --trigger failure`,
7819
+ sessionStart: `${quote(node)} ${quote(cliScript)} agent session-start`
7820
+ };
7821
+ }
7822
+
7823
+ // src/agent/recall.ts
7824
+ var MAX_RECALL_CHARS = 1200;
7825
+ var MAX_PAST_FAILURES = 3;
7826
+ var SELECT_BY_HASH = `
7827
+ SELECT n.id, n.ts, n.meta,
7828
+ (SELECT group_concat(f.path) FROM node_files f WHERE f.node_id = n.id) AS paths
7829
+ FROM nodes n
7830
+ WHERE n.project_id = ?
7831
+ AND n.kind = 'shell_command'
7832
+ AND json_extract(n.meta, '$.execHash') = ?
7833
+ AND json_extract(n.meta, '$.exitCode') IS NOT NULL
7834
+ AND json_extract(n.meta, '$.exitCode') != 0
7835
+ ORDER BY n.ts DESC
7836
+ LIMIT ?`;
7837
+ var SELECT_RECENT_FAILURES = `
7838
+ SELECT n.id, n.ts, n.meta, NULL AS paths
7839
+ FROM nodes n
7840
+ WHERE n.project_id = ?
7841
+ AND n.kind = 'shell_command'
7842
+ AND json_extract(n.meta, '$.exitCode') IS NOT NULL
7843
+ AND json_extract(n.meta, '$.exitCode') != 0
7844
+ AND n.ts >= ?
7845
+ ORDER BY n.ts DESC
7846
+ LIMIT 40`;
7847
+ var SELECT_BY_ID = `
7848
+ SELECT n.id, n.ts, n.meta,
7849
+ (SELECT group_concat(f.path) FROM node_files f WHERE f.node_id = n.id) AS paths
7850
+ FROM nodes n WHERE n.id = ?`;
7851
+ var lookupNode = (store, id) => store.raw.prepare(SELECT_BY_ID).get(id);
7852
+ var REVERT_SUBJECT = /^revert(\([^)]*\))?[:!]|^revert\s+"/i;
7853
+ var SELECT_REVERT_OF = `
7854
+ SELECT n.ts, n.title
7855
+ FROM nodes n
7856
+ WHERE n.project_id = ?
7857
+ AND n.kind = 'git_commit'
7858
+ AND n.title LIKE 'revert%'
7859
+ AND n.ts_epoch > (SELECT ts_epoch FROM nodes WHERE id = ?)
7860
+ AND EXISTS (
7861
+ SELECT 1 FROM node_files a
7862
+ JOIN node_files b ON b.path = a.path
7863
+ WHERE a.node_id = n.id AND b.node_id = ?
7864
+ )
7865
+ ORDER BY n.ts_epoch ASC
7866
+ LIMIT 3`;
7867
+ function revertOfFix(store, projectId, fixId) {
7868
+ const rows = store.raw.prepare(SELECT_REVERT_OF).all(projectId, fixId, fixId);
7869
+ return rows.find((row) => REVERT_SUBJECT.test(row.title)) ?? null;
7870
+ }
7871
+ var day = (ts) => ts.slice(0, 10);
7872
+ var files = (row) => row.paths ? row.paths.split(",").join(", ") : "";
7873
+ var fixFilesOf = (fix) => fix ? files(fix) : "";
7874
+ function describeAttempt(row) {
7875
+ const changed = files(row);
7876
+ return changed ? `${day(row.ts)}: failed after editing ${changed}` : `${day(row.ts)}: failed`;
7877
+ }
7878
+ function recallFailure(store, projectId, execHash) {
7879
+ const db = store.raw;
7880
+ const past = db.prepare(SELECT_BY_HASH).all(projectId, execHash, MAX_PAST_FAILURES);
7881
+ if (past.length === 0) return null;
7882
+ const lines = [];
7883
+ let resolved = false;
7884
+ let superseded = false;
7885
+ let stale = false;
7886
+ for (const row of past) {
7887
+ lines.push(`- ${describeAttempt(row)}`);
7888
+ }
7889
+ const newestFailureTs = past[0].ts;
7890
+ for (const row of past) {
7891
+ const [fixId] = store.getLinkedNodeIds(row.id, RESOLVED_BY_RETRY);
7892
+ if (!fixId) continue;
7893
+ const fix = lookupNode(store, fixId);
7894
+ if (!fix) continue;
7895
+ const changed = files(fix);
7896
+ const what = changed ? `fixed on ${day(fix.ts)} after editing ${changed}` : `fixed on ${day(fix.ts)}`;
7897
+ const revert = revertOfFix(store, projectId, fixId);
7898
+ const failedAgain = !revert && newestFailureTs > fix.ts;
7899
+ if (revert) lines.push(`- ${what}, but that fix was reverted on ${day(revert.ts)} -- it no longer holds`);
7900
+ else if (failedAgain) lines.push(`- ${what}, but it failed again on ${day(newestFailureTs)} -- that fix no longer holds`);
7901
+ else lines.push(`- ${what}`);
7902
+ superseded = Boolean(revert);
7903
+ stale = failedAgain;
7904
+ resolved = !revert && !failedAgain;
7905
+ break;
7906
+ }
7907
+ if (!resolved && !superseded && !stale) lines.push("- no fix for it was ever recorded here");
7908
+ const header = `NexusMem: this exact command has failed in this repository before (${past.length} time(s)).`;
7909
+ let footer = "Previous attempts did not resolve it, so a different approach is likely needed.";
7910
+ if (resolved) footer = "Check what changed in that fix before retrying the same approach.";
7911
+ if (stale) footer = "That fix did not hold, so repeating it is unlikely to be enough -- check what changed since.";
7912
+ if (superseded) footer = "That fix was reverted, so repeating it is unlikely to work -- check why it was backed out.";
7913
+ return {
7914
+ text: [header, ...lines, footer].join("\n").slice(0, MAX_RECALL_CHARS),
7915
+ matched: past.length,
7916
+ resolved,
7917
+ superseded,
7918
+ stale
7919
+ };
7920
+ }
7921
+ var SELECT_UNCERTAIN_BY_HASH = `
7922
+ SELECT n.id, n.ts, n.meta, NULL AS paths
7923
+ FROM nodes n
7924
+ WHERE n.project_id = ?
7925
+ AND n.kind = 'shell_command'
7926
+ AND json_extract(n.meta, '$.execHash') = ?
7927
+ AND json_extract(n.meta, '$.outcome') = 'unknown'
7928
+ ORDER BY n.ts DESC
7929
+ LIMIT ?`;
7930
+ function recallUncertain(store, projectId, execHash) {
7931
+ const past = store.raw.prepare(SELECT_UNCERTAIN_BY_HASH).all(projectId, execHash, MAX_PAST_FAILURES);
7932
+ if (past.length === 0) return null;
7933
+ const lines = past.map((row) => `- ${day(row.ts)}: ran here, but its exit status was hidden behind the filter it was piped into`);
7934
+ const header = `NexusMem: this exact command has an undetermined outcome here before (${past.length} time(s)) -- it is piped into a filter (head/tail/tee) whose own exit status hides whether the command it fed actually passed.`;
7935
+ const footer = "Capture the real status directly (e.g. read $? or PIPESTATUS before piping) if this run needs to be trusted.";
7936
+ return {
7937
+ text: [header, ...lines, footer].join("\n").slice(0, MAX_RECALL_CHARS),
7938
+ matched: past.length
7939
+ };
7940
+ }
7941
+ var displayCommand = (row) => JSON.parse(row.meta).command?.split(/\r?\n/)[0]?.trim() || void 0;
7942
+ function executionKey(row) {
7943
+ const meta = JSON.parse(row.meta);
7944
+ const command = displayCommand(row);
7945
+ if (!command) return null;
7946
+ if (typeof meta.execHash === "string") return `exec:${meta.execHash}`;
7947
+ if (typeof meta.commandHash === "string") return `exec:${meta.commandHash}`;
7948
+ if (meta.command.includes(REDACTION_MARK)) return `row:${row.id}`;
7949
+ return `text:${command}`;
7950
+ }
7951
+ var MAX_DIGEST_CHARS = 600;
7952
+ var DIGEST_WINDOW_DAYS = 14;
7953
+ var MAX_DIGEST_COMMANDS = 3;
7954
+ var STATE_PRIORITY = { resolved: 0, stale: 1, superseded: 2, uncertain: 3, unresolved: 4 };
7955
+ function recallSessionStart(store, projectId, now = /* @__PURE__ */ new Date()) {
7956
+ const since = new Date(now.getTime() - DIGEST_WINDOW_DAYS * 864e5).toISOString();
7957
+ const rows = store.raw.prepare(SELECT_RECENT_FAILURES).all(projectId, since);
7958
+ const byExecution = /* @__PURE__ */ new Map();
7959
+ for (const row of rows) {
7960
+ const key = executionKey(row);
7961
+ if (!key) continue;
7962
+ const command = displayCommand(row);
7963
+ const group = byExecution.get(key);
7964
+ if (!group) byExecution.set(key, { command, rows: [row] });
7965
+ else {
7966
+ group.rows.push(row);
7967
+ if (command.length < group.command.length) group.command = command;
7968
+ }
7969
+ }
7970
+ if (byExecution.size === 0) return null;
7971
+ const summaries = [];
7972
+ for (const { command, rows: [newest, ...older] } of byExecution.values()) {
7973
+ const [newestFixId] = store.getLinkedNodeIds(newest.id, RESOLVED_BY_RETRY);
7974
+ if (newestFixId) {
7975
+ const fix = lookupNode(store, newestFixId);
7976
+ const revert = revertOfFix(store, projectId, newestFixId);
7977
+ const fixFiles = fixFilesOf(fix);
7978
+ summaries.push(
7979
+ revert ? { command, newestTs: newest.ts, state: "superseded", fixTs: fix?.ts, fixFiles, revertTs: revert.ts } : { command, newestTs: newest.ts, state: "resolved", fixTs: fix?.ts, fixFiles }
7980
+ );
7981
+ continue;
7982
+ }
7983
+ const staleFixId = older.map((row) => store.getLinkedNodeIds(row.id, RESOLVED_BY_RETRY)[0]).find((id) => id !== void 0);
7984
+ if (staleFixId) {
7985
+ const fix = lookupNode(store, staleFixId);
7986
+ summaries.push({ command, newestTs: newest.ts, state: "stale", fixTs: fix?.ts, fixFiles: fixFilesOf(fix) });
7987
+ continue;
7988
+ }
7989
+ const [discussionId] = store.getLinkedNodeIds(newest.id, RESOLVED_BY_DISCUSSION);
7990
+ if (discussionId) {
7991
+ const discussion = lookupNode(store, discussionId);
7992
+ summaries.push({ command, newestTs: newest.ts, state: "uncertain", fixTs: discussion?.ts });
7993
+ } else {
7994
+ summaries.push({ command, newestTs: newest.ts, state: "unresolved" });
7995
+ }
7996
+ }
7997
+ summaries.sort((a, b) => STATE_PRIORITY[a.state] - STATE_PRIORITY[b.state] || (b.newestTs < a.newestTs ? -1 : 1));
7998
+ const listed = summaries.slice(0, MAX_DIGEST_COMMANDS);
7999
+ const lines = listed.map((s) => {
8000
+ const editSuffix = s.fixFiles ? ` after editing ${s.fixFiles}` : "";
8001
+ if (s.state === "resolved") return `- ${s.command} (failed here before, fixed ${day(s.fixTs)}${editSuffix})`;
8002
+ if (s.state === "stale") {
8003
+ return `- ${s.command} (fixed ${day(s.fixTs)}${editSuffix}, but failed again ${day(s.newestTs)} -- that fix no longer holds)`;
8004
+ }
8005
+ if (s.state === "superseded") {
8006
+ return `- ${s.command} (fixed ${day(s.fixTs)}${editSuffix}, but that fix was reverted ${day(s.revertTs)} -- it no longer holds)`;
8007
+ }
8008
+ if (s.state === "uncertain") {
8009
+ return `- ${s.command} failed ${day(s.newestTs)} -- possibly discussed around ${day(s.fixTs)}, not confirmed as a fix`;
8010
+ }
8011
+ return `- ${s.command} failed ${day(s.newestTs)} with no recorded fix`;
8012
+ });
8013
+ const more = summaries.length > listed.length ? ` and ${summaries.length - listed.length} other(s)` : "";
8014
+ const counts = { resolved: 0, stale: 0, superseded: 0, uncertain: 0, unresolved: 0 };
8015
+ for (const s of summaries) counts[s.state] += 1;
8016
+ return {
8017
+ text: [
8018
+ `NexusMem: ${summaries.length} relevant command(s) from the last ${DIGEST_WINDOW_DAYS} days${more}:`,
8019
+ ...lines,
8020
+ "This history is searchable with the nexusmem MCP tools if one of them comes up."
8021
+ ].join("\n").slice(0, MAX_DIGEST_CHARS),
8022
+ ...counts
8023
+ };
8024
+ }
8025
+
8026
+ // src/agent/recall-state.ts
8027
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmdirSync, statSync, writeFileSync as writeFileSync3 } from "fs";
8028
+ import { join as join14 } from "path";
8029
+ var MAX_INJECTIONS_PER_SESSION = 5;
8030
+ var MAX_SESSIONS_KEPT = 20;
8031
+ function recallStatePath() {
8032
+ return join14(globalWorkspaceDir(), "agent-recall-state.json");
8033
+ }
8034
+ function read(path) {
8035
+ try {
8036
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
8037
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
8038
+ } catch {
8039
+ return {};
8040
+ }
8041
+ }
8042
+ function shouldInject(sessionId, key, path = recallStatePath()) {
8043
+ const session = read(path)[sessionId];
8044
+ if (!session) return true;
8045
+ return session.count < MAX_INJECTIONS_PER_SESSION && !session.keys.includes(key);
8046
+ }
8047
+ var LOCK_STALE_MS = 2e3;
8048
+ function acquireLock(path) {
8049
+ const lockPath2 = `${path}.lock`;
8050
+ try {
8051
+ mkdirSync4(lockPath2);
8052
+ return true;
8053
+ } catch (err) {
8054
+ if (err.code !== "EEXIST") return false;
8055
+ try {
8056
+ if (Date.now() - statSync(lockPath2).mtimeMs > LOCK_STALE_MS) {
8057
+ rmdirSync(lockPath2);
8058
+ mkdirSync4(lockPath2);
8059
+ return true;
8060
+ }
8061
+ } catch {
8062
+ }
8063
+ return false;
8064
+ }
8065
+ }
8066
+ function releaseLock(path) {
8067
+ try {
8068
+ rmdirSync(`${path}.lock`);
8069
+ } catch {
8070
+ }
8071
+ }
8072
+ function claimInjection(sessionId, key, path = recallStatePath(), now = Date.now()) {
8073
+ if (!acquireLock(path)) return false;
8074
+ try {
8075
+ const state = read(path);
8076
+ const session = state[sessionId];
8077
+ if (session && (session.count >= MAX_INJECTIONS_PER_SESSION || session.keys.includes(key))) return false;
8078
+ const next = session ?? { count: 0, keys: [], at: now };
8079
+ next.count += 1;
8080
+ next.at = now;
8081
+ if (!next.keys.includes(key)) next.keys.push(key);
8082
+ state[sessionId] = next;
8083
+ const pruned = Object.entries(state).sort(([, a], [, b]) => b.at - a.at).slice(0, MAX_SESSIONS_KEPT);
8084
+ try {
8085
+ writeFileSync3(path, JSON.stringify(Object.fromEntries(pruned)), { encoding: "utf8", mode: 384 });
8086
+ return true;
8087
+ } catch {
8088
+ return false;
8089
+ }
8090
+ } finally {
8091
+ releaseLock(path);
8092
+ }
8093
+ }
8094
+
8095
+ // src/adapters/claude-code/install.ts
8096
+ var CAPTURE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
8097
+ var RECALL_MATCHER = "Bash";
8098
+ var RECALL_TIMEOUT_SECONDS = 5;
8099
+ function isNexusMemHook(command) {
8100
+ const c = command.toLowerCase();
8101
+ return c.includes("agent-hook.js") || /agent\s+(recall|session-start)/.test(c);
8102
+ }
8103
+ var HOOK_EVENTS = ["SessionStart", "PostToolUse", "PostToolUseFailure"];
8104
+ function matcherFor(commands, event) {
8105
+ if (event === "SessionStart") {
8106
+ return [{ hooks: [{ type: "command", command: commands.sessionStart, timeout: RECALL_TIMEOUT_SECONDS }] }];
8107
+ }
8108
+ const capture = {
8109
+ matcher: CAPTURE_MATCHER,
8110
+ hooks: [{ type: "command", command: commands.capture }]
8111
+ };
8112
+ return [
8113
+ capture,
8114
+ { matcher: RECALL_MATCHER, hooks: [{ type: "command", command: commands.recall, timeout: RECALL_TIMEOUT_SECONDS }] }
8115
+ ];
8116
+ }
8117
+ function withoutOurs(entries) {
8118
+ return entries.map((entry) => ({ ...entry, hooks: (entry.hooks ?? []).filter((h) => !isNexusMemHook(h.command ?? "")) })).filter((entry) => (entry.hooks?.length ?? 0) > 0);
8119
+ }
8120
+ function upsertAgentHooks(settings, commands) {
8121
+ const hooks = { ...settings.hooks ?? {} };
8122
+ for (const event of HOOK_EVENTS) {
8123
+ hooks[event] = [...withoutOurs(hooks[event] ?? []), ...matcherFor(commands, event)];
8124
+ }
8125
+ return { ...settings, hooks };
8126
+ }
8127
+ function removeAgentHooks(settings) {
8128
+ const hooks = { ...settings.hooks ?? {} };
8129
+ let removed = 0;
8130
+ for (const [event, entries] of Object.entries(hooks)) {
8131
+ const before = entries.flatMap((e) => e.hooks ?? []).filter((h) => isNexusMemHook(h.command ?? "")).length;
8132
+ removed += before;
8133
+ const kept = withoutOurs(entries);
8134
+ if (kept.length > 0) hooks[event] = kept;
8135
+ else delete hooks[event];
8136
+ }
8137
+ const next = { ...settings, hooks };
8138
+ if (Object.keys(hooks).length === 0) delete next.hooks;
8139
+ return { settings: next, removed };
8140
+ }
8141
+ function agentHookStatus(settings, commands) {
8142
+ const present = Object.values(settings.hooks ?? {}).flatMap((entries) => entries.flatMap((e) => e.hooks ?? [])).map((h) => h.command ?? "").filter(isNexusMemHook);
8143
+ if (present.length === 0) return { installed: false, upToDate: false, commands: [] };
8144
+ const expected = [commands.capture, commands.recall, commands.sessionStart];
8145
+ return { installed: true, upToDate: expected.every((cmd) => present.includes(cmd)), commands: [...new Set(present)] };
8146
+ }
8147
+
8148
+ // src/adapters/claude-code/payload.ts
8149
+ var AGENT = "claude-code";
8150
+ var EDIT_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
8151
+ var EXIT_CODE = /^Exit code (\d+)/;
8152
+ var str = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
8153
+ var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
8154
+ var EXIT_ECHO = /^exit\s*[:=]\s*(\d+)\s*$/i;
8155
+ var EXIT_WRAPPER = /^([\s\S]*?);\s*echo\s+(?:"exit\s*[:=]\s*\$\?"|exit\s*[:=]\s*\$\?)\s*$/i;
8156
+ function echoesOwnExitStatus(command) {
8157
+ const body = EXIT_WRAPPER.exec(command.trim())?.[1];
8158
+ return body !== void 0 && body.trim().length > 0 && !/[|;\r\n]/.test(body);
8159
+ }
8160
+ var OPAQUE_FILTER_TAIL = /\|\s*(?:head|tail|tee)(?![A-Za-z0-9._-])[^|]*$/i;
8161
+ function pipesIntoOpaqueFilter(command) {
8162
+ return OPAQUE_FILTER_TAIL.test(command.trim());
8163
+ }
8164
+ function classifyBashResult(hookOutcome, failed, error, recovered, command) {
8165
+ if (failed) return { outcome: hookOutcome, exitCode: error?.exitCode ?? null };
8166
+ if (recovered !== null) return { outcome: recovered === 0 ? "ok" : "fail", exitCode: recovered };
8167
+ if (pipesIntoOpaqueFilter(command)) return { outcome: "unknown", exitCode: null };
8168
+ return { outcome: hookOutcome, exitCode: 0 };
8169
+ }
8170
+ function recoverExitStatusFromOutput(command, stdout) {
8171
+ if (!stdout || !echoesOwnExitStatus(command)) return null;
8172
+ const lines = stdout.split(/\r?\n/).map((l) => l.trim());
8173
+ let last = "";
8174
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
8175
+ if (lines[i]) {
8176
+ last = lines[i];
8177
+ break;
8178
+ }
8179
+ }
8180
+ const match = EXIT_ECHO.exec(last);
8181
+ return match ? Number(match[1]) : null;
8182
+ }
8183
+ function parseError(error) {
8184
+ const [first = "", ...rest] = error.split(/\r?\n/);
8185
+ const match = EXIT_CODE.exec(first);
8186
+ if (!match) return { exitCode: null, signature: error.trim() };
8187
+ return { exitCode: Number(match[1]), signature: rest.join(" ").trim() };
8188
+ }
8189
+ function parseSessionStart(rawJson) {
8190
+ let payload;
8191
+ try {
8192
+ payload = JSON.parse(rawJson.trim());
8193
+ } catch {
8194
+ return null;
8195
+ }
8196
+ if (typeof payload !== "object" || payload === null) return null;
8197
+ const p = payload;
8198
+ if (str(p.hook_event_name) !== "SessionStart") return null;
8199
+ const sessionId = str(p.session_id);
8200
+ const cwd = str(p.cwd);
8201
+ if (!sessionId || !cwd) return null;
8202
+ return { sessionId, cwd, source: str(p.source) ?? null };
8203
+ }
8204
+ var FAMILY_BY_EVENT = {
8205
+ PostToolUse: "post-tool-use",
8206
+ PostToolUseFailure: "post-tool-use-failure",
8207
+ SessionStart: "session-start"
8208
+ };
8209
+ var familyOf = (event) => event ? FAMILY_BY_EVENT[event] ?? "other" : "other";
8210
+ function parseHookPayloadDetailed(rawJson, now) {
8211
+ let payload;
8212
+ try {
8213
+ payload = JSON.parse(rawJson.trim());
8214
+ } catch {
8215
+ return { ok: false, reason: "unparsable-json", family: "other" };
8216
+ }
8217
+ if (typeof payload !== "object" || payload === null) return { ok: false, reason: "unparsable-json", family: "other" };
8218
+ const p = payload;
8219
+ const event = str(p.hook_event_name);
8220
+ const family = familyOf(event);
8221
+ const sessionId = str(p.session_id);
8222
+ const eventId = str(p.tool_use_id);
8223
+ const toolName = str(p.tool_name);
8224
+ if (event !== "PostToolUse" && event !== "PostToolUseFailure") return { ok: false, reason: "unsupported-event", family };
8225
+ if (!sessionId || !eventId || !toolName) return { ok: false, reason: "missing-fields", family };
8226
+ const failed = event === "PostToolUseFailure";
8227
+ const interrupted = failed && p.is_interrupt === true;
8228
+ const outcome = interrupted ? "interrupted" : failed ? "fail" : "ok";
8229
+ const base = {
8230
+ agent: AGENT,
8231
+ sessionId,
8232
+ eventId,
8233
+ ts: now,
8234
+ cwd: str(p.cwd) ?? null,
8235
+ outcome,
8236
+ durationMs: num(p.duration_ms),
8237
+ ...str(p.agent_id) ? { agentId: str(p.agent_id) } : {}
8238
+ };
8239
+ if (toolName === "Bash") {
8240
+ const command = str(p.tool_input?.command);
8241
+ if (!command) return { ok: false, reason: "missing-fields", family };
8242
+ const error = failed ? parseError(str(p.error) ?? "") : null;
8243
+ const recovered = failed ? null : recoverExitStatusFromOutput(command, str(p.tool_response?.stdout));
8244
+ const draft = {
8245
+ ...base,
8246
+ kind: "command",
8247
+ command,
8248
+ ...classifyBashResult(outcome, failed, error, recovered, command),
8249
+ ...error?.signature ? { errorSignature: error.signature } : {}
8250
+ };
8251
+ return { ok: true, event: redactAgentEvent(draft), family };
8252
+ }
8253
+ if (EDIT_TOOLS.has(toolName)) {
8254
+ const filePath = str(p.tool_input?.file_path) ?? str(p.tool_input?.notebook_path);
8255
+ if (!filePath) return { ok: false, reason: "missing-fields", family };
8256
+ return { ok: true, event: redactAgentEvent({ ...base, kind: "edit", filePath, exitCode: null }), family };
8257
+ }
8258
+ return { ok: false, reason: "unsupported-tool", family };
8259
+ }
8260
+
8261
+ // src/cli/commands/agent.ts
8262
+ function settingsPathFor(scope, cwd) {
8263
+ return scope === "project" ? join15(cwd, ".claude", "settings.local.json") : join15(homedir4(), ".claude", "settings.json");
8264
+ }
8265
+ async function readSettings(path) {
8266
+ let raw;
8267
+ try {
8268
+ raw = await readFile12(path, "utf8");
8269
+ } catch {
8270
+ return existsSync6(path) ? null : {};
8271
+ }
8272
+ try {
8273
+ const parsed = JSON.parse(stripBom(raw));
8274
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
8275
+ } catch {
8276
+ return null;
8277
+ }
8278
+ }
8279
+ async function writeSettings(path, settings) {
8280
+ await mkdir7(dirname7(path), { recursive: true });
8281
+ await writeFile7(path, `${JSON.stringify(settings, null, 2)}
8282
+ `, "utf8");
8283
+ }
8284
+ async function runAgentInstall(opts) {
8285
+ const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
8286
+ const scope = opts.scope ?? "user";
8287
+ const path = settingsPathFor(scope, opts.cwd);
8288
+ const settings = await readSettings(path);
8289
+ if (settings === null) {
8290
+ out(`${pc20.red("refused")} ${path} exists but could not be parsed as JSON -- fix or move it first
8291
+ `);
8292
+ return 1;
8293
+ }
8294
+ const commands = opts.commands ?? agentHookCommands();
8295
+ await writeSettings(path, upsertAgentHooks(settings, commands));
8296
+ out(
8297
+ [
8298
+ `${pc20.green("installed")} NexusMem agent hooks in ${path}`,
8299
+ ` ${pc20.dim("captures")} commands and edits Claude Code makes, redacted, into this machine's agent log`,
8300
+ ` ${pc20.dim("recalls")} past failures of the same command, at the moment one fails again`,
8301
+ ` ${pc20.dim("restart Claude Code for the hooks to take effect")}`,
8302
+ // The README leads with `npx nexusmem`, and an install run that way
8303
+ // writes the cache directory into the hook command. It works until npm
8304
+ // clears the cache, after which the hook captures nothing and says
8305
+ // nothing. Verified by clearing it.
8306
+ ...isNpxCacheInstall(commands.capture) ? [
8307
+ ` ${pc20.yellow("warning")} this copy of NexusMem is npx's temporary cache, and the hooks now point into it`,
8308
+ ` ${pc20.dim(" ")} they stop working when npm clears that cache -- install NexusMem first (npm i -g nexusmem), then run this again`
8309
+ ] : [],
8310
+ ""
8311
+ ].join("\n")
8312
+ );
8313
+ return 0;
8314
+ }
8315
+ var printablePath = (path) => path.replace(/[\x00-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
8316
+ function isNpxCacheInstall(command) {
8317
+ return hookCommandPaths(command).some((p) => /[\\/]_npx[\\/]/.test(p));
8318
+ }
8319
+ async function runAgentRemove(opts) {
8320
+ const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
8321
+ const path = settingsPathFor(opts.scope ?? "user", opts.cwd);
8322
+ const { settings, removed } = removeAgentHooks(await readSettings(path) ?? {});
8323
+ if (removed === 0) {
8324
+ out(`${pc20.dim("nothing to remove")} no NexusMem agent hooks in ${path}
8325
+ `);
8326
+ return 0;
8327
+ }
8328
+ await writeSettings(path, settings);
8329
+ out(`${pc20.green("removed")} ${removed} NexusMem hook(s) from ${path}
8330
+ `);
8331
+ return 0;
8332
+ }
8333
+ async function runAgentStatus(opts) {
8334
+ const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
8335
+ const scope = opts.scope ?? "user";
8336
+ const path = settingsPathFor(scope, opts.cwd);
8337
+ const status = agentHookStatus(await readSettings(path) ?? {}, opts.commands ?? agentHookCommands());
8338
+ const missing = [...new Set(status.commands.flatMap(hookCommandPaths))].filter((p) => !existsSync6(p));
8339
+ const capture = readCaptureStatus();
8340
+ const CAPTURE_LABEL = {
8341
+ healthy: pc20.green("healthy") + pc20.dim(" -- an event was captured in the last 24h"),
8342
+ stale: pc20.yellow("stale") + pc20.dim(" -- nothing captured in the last 24h, which is expected if no agent ran"),
8343
+ degraded: pc20.yellow("degraded") + pc20.dim(" -- recent events were dropped, so some agent activity may be missing"),
8344
+ "never-observed": pc20.yellow("never observed") + pc20.dim(" -- no event has been captured yet"),
8345
+ unknown: pc20.yellow("unknown") + pc20.dim(" -- the event log could not be read")
8346
+ };
8347
+ out(
8348
+ [
8349
+ `${pc20.dim("settings ")} ${path}`,
8350
+ `${pc20.dim("installed ")} ${!status.installed ? pc20.yellow("no") : status.upToDate ? pc20.green("yes") : pc20.yellow("yes, but pointing at a different NexusMem -- run `nexusmem agent install` again")}`,
8351
+ // Only when a path is genuinely absent: this must not fire on a healthy install.
8352
+ ...missing.length > 0 ? [
8353
+ `${pc20.dim("paths ")} ${pc20.yellow(`${missing.length} path(s) in the installed hook do not exist here`)}`,
8354
+ ...missing.map((p) => `${pc20.dim(" ")} ${printablePath(p)}`),
8355
+ `${pc20.dim(" ")} ${pc20.dim("reinstall from the environment Claude Code runs in")}`
8356
+ ] : [],
8357
+ `${pc20.dim("capture ")} ${CAPTURE_LABEL[capture.health]}`,
8358
+ ...capture.lastEventAt ? [`${pc20.dim("last event")} ${capture.lastEventAt} ${pc20.dim(`(${capture.lastEventKind}, ${capture.lastEventOutcome})`)}`] : [],
8359
+ // Only when it happened: a healthy install should print nothing about drops.
8360
+ ...capture.drops > 0 ? [
8361
+ `${pc20.dim("drops ")} ${capture.drops} ${pc20.dim(
8362
+ `(last: ${capture.lastDropReason ?? "unrecognised"} from ${capture.lastDropFamily ?? "unrecognised"} at ${capture.lastDropAt})`
8363
+ )}`
8364
+ ] : [],
8365
+ ""
8366
+ ].join("\n")
8367
+ );
8368
+ return 0;
8369
+ }
8370
+ function spawnBackgroundSync(repoRoot) {
8371
+ const cli = process.argv[1];
8372
+ if (!cli) return;
8373
+ spawn2(process.execPath, [cli, "sync", "--auto", "--quiet", "-C", repoRoot], {
8374
+ detached: true,
8375
+ stdio: "ignore"
8376
+ }).unref();
8377
+ }
8378
+ async function runAgentSessionStart(opts = {}) {
8379
+ const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
8380
+ try {
8381
+ const payload = parseSessionStart(opts.input ?? await readStdin());
8382
+ if (!payload) return 0;
8383
+ const repo = await readRepoInfo(payload.cwd);
8384
+ const ws = resolveWorkspace(repo.root);
8385
+ if (!existsSync6(ws.dbPath) || !existsSync6(ws.configPath)) return 0;
8386
+ (opts.startSync ?? spawnBackgroundSync)(repo.root);
8387
+ const { projectId } = await readConfig(ws);
8388
+ const store = MemoryStore.open(ws.dbPath);
8389
+ let digest;
8390
+ try {
8391
+ digest = recallSessionStart(store, projectId);
8392
+ } finally {
8393
+ store.close();
8394
+ }
8395
+ if (!digest) return 0;
8396
+ out(`${digest.text}
8397
+ `);
8398
+ return 0;
8399
+ } catch {
8400
+ return 0;
8401
+ }
8402
+ }
8403
+ async function readStdin() {
8404
+ const chunks = [];
8405
+ for await (const chunk2 of process.stdin) chunks.push(chunk2);
8406
+ return Buffer.concat(chunks).toString("utf8");
8407
+ }
8408
+ var HOOK_EVENT_BY_FAMILY = {
8409
+ "post-tool-use": "PostToolUse",
8410
+ "post-tool-use-failure": "PostToolUseFailure"
8411
+ };
8412
+ async function runAgentRecall(opts = {}) {
8413
+ const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
8414
+ try {
8415
+ const raw = opts.input ?? await readStdin();
8416
+ const parsed = parseHookPayloadDetailed(raw, (/* @__PURE__ */ new Date()).toISOString());
8417
+ if (!parsed.ok) return 0;
8418
+ const event = parsed.event;
8419
+ if (event.kind !== "command" || event.outcome !== "fail" && event.outcome !== "unknown" || !event.execHash || !event.cwd) return 0;
8420
+ const hookEventName = HOOK_EVENT_BY_FAMILY[parsed.family];
8421
+ if (!hookEventName) return 0;
8422
+ if (!shouldInject(event.sessionId, event.execHash)) return 0;
8423
+ const repo = await readRepoInfo(event.cwd);
8424
+ const ws = resolveWorkspace(repo.root);
8425
+ if (!existsSync6(ws.dbPath) || !existsSync6(ws.configPath)) return 0;
8426
+ const { projectId } = await readConfig(ws);
8427
+ const store = MemoryStore.open(ws.dbPath);
8428
+ let recall;
8429
+ try {
8430
+ recall = event.outcome === "fail" ? recallFailure(store, projectId, event.execHash) : recallUncertain(store, projectId, event.execHash);
8431
+ } finally {
8432
+ store.close();
8433
+ }
8434
+ if (!recall) return 0;
8435
+ if (!claimInjection(event.sessionId, event.execHash)) return 0;
8436
+ out(
8437
+ `${JSON.stringify({
8438
+ hookSpecificOutput: { hookEventName, additionalContext: recall.text }
8439
+ })}
8440
+ `
8441
+ );
8442
+ return 0;
8443
+ } catch {
8444
+ return 0;
8445
+ }
8446
+ }
8447
+
8448
+ // src/cli/commands/scrub-secrets.ts
8449
+ import { existsSync as existsSync7 } from "fs";
8450
+ import { dirname as dirname9 } from "path";
8451
+ import pc21 from "picocolors";
8452
+
8453
+ // src/store/scrub.ts
8454
+ import { chmod as chmod2, open as open2, readdir as readdir2, rm } from "fs/promises";
8455
+ import { basename as basename4, dirname as dirname8, join as join16 } from "path";
8456
+ import Database2 from "better-sqlite3";
8457
+ var KIND_PROFILES = {
8458
+ shell_command: "all",
8459
+ conversation_turn: "all",
8460
+ session_summary: "all",
8461
+ code_diff: "high-confidence"
8462
+ };
8463
+ var SCRUB_KINDS = Object.keys(KIND_PROFILES);
8464
+ var ScrubRaceError = class extends Error {
8465
+ };
8466
+ var ScrubBackupError = class extends Error {
8467
+ };
8468
+ function scrubMeta(kind, raw) {
8469
+ let meta;
8470
+ try {
8471
+ meta = JSON.parse(raw);
8472
+ } catch {
8473
+ return redact(raw, KIND_PROFILES[kind]).text;
8474
+ }
8475
+ if (typeof meta !== "object" || meta === null) return raw;
8476
+ const m = meta;
8477
+ let changed = false;
8478
+ if (kind === "shell_command" && typeof m.command === "string") {
8479
+ const command = redact(m.command).text;
8480
+ if (command !== m.command) {
8481
+ if (typeof m.commandHash !== "string") m.commandHash = sha256Hex(m.command).slice(0, 12);
8482
+ m.command = command;
8483
+ changed = true;
8484
+ }
8485
+ }
8486
+ if (kind === "conversation_turn" && typeof m.heading === "string") {
8487
+ const heading = redact(m.heading).text;
8488
+ if (heading !== m.heading) {
8489
+ m.heading = heading;
8490
+ changed = true;
8491
+ }
8492
+ }
8493
+ return changed ? JSON.stringify(m) : raw;
8494
+ }
8495
+ function hasTable(db, name) {
8496
+ return db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name) !== void 0;
8497
+ }
8498
+ function plan(db) {
8499
+ const byKind = Object.fromEntries(SCRUB_KINDS.map((k) => [k, { scanned: 0, changed: 0 }]));
8500
+ const rows = [];
8501
+ const select = db.prepare(
8502
+ `SELECT rowid, kind, title, body, meta FROM nodes WHERE kind IN (${SCRUB_KINDS.map(() => "?").join(", ")})`
8503
+ );
8504
+ for (const row of select.iterate(...SCRUB_KINDS)) {
8505
+ const profile = KIND_PROFILES[row.kind];
8506
+ const title = redact(row.title, profile).text;
8507
+ const body = redact(row.body, profile).text;
8508
+ const meta = scrubMeta(row.kind, row.meta);
8509
+ byKind[row.kind].scanned += 1;
8510
+ if (title === row.title && body === row.body && meta === row.meta) continue;
8511
+ byKind[row.kind].changed += 1;
8512
+ rows.push({ rowid: row.rowid, title, body, meta, textChanged: title !== row.title || body !== row.body });
8513
+ }
8514
+ const reasons = [];
8515
+ if (hasTable(db, "contradiction_checks")) {
8516
+ const select2 = db.prepare("SELECT rowid, reason FROM contradiction_checks WHERE reason IS NOT NULL");
8517
+ for (const r of select2.iterate()) {
8518
+ const reason = redact(r.reason).text;
8519
+ if (reason !== r.reason) reasons.push({ rowid: r.rowid, reason });
8520
+ }
8521
+ }
8522
+ return { byKind, rows, reasons };
8523
+ }
8524
+ async function listBackups(dbPath) {
8525
+ const prefix = `${basename4(dbPath)}.backup-`;
8526
+ try {
8527
+ return (await readdir2(dirname8(dbPath))).filter((name) => name.startsWith(prefix)).sort().map((name) => join16(dirname8(dbPath), name));
8528
+ } catch {
8529
+ return [];
8530
+ }
8531
+ }
8532
+ function purgeRemnants(db) {
8533
+ db.exec(`INSERT INTO nodes_fts (nodes_fts) VALUES ('rebuild')`);
8534
+ try {
8535
+ db.exec("VACUUM");
8536
+ } catch (err) {
8537
+ if (String(err.code ?? "").startsWith("SQLITE_BUSY")) return false;
8538
+ throw err;
8539
+ }
8540
+ const [checkpoint] = db.pragma("wal_checkpoint(TRUNCATE)");
8541
+ return checkpoint?.busy === 0;
8542
+ }
8543
+ async function scrubDatabase(dbPath, opts) {
8544
+ const existingBackups = await listBackups(dbPath);
8545
+ const base = { dbPath, existingBackups, embeddingsDropped: 0, reembedded: 0, embeddingsPending: 0 };
8546
+ if (!opts.apply) {
8547
+ const db2 = new Database2(dbPath, { readonly: true, fileMustExist: true });
8548
+ try {
8549
+ const p = plan(db2);
8550
+ return {
8551
+ ...base,
8552
+ applied: false,
8553
+ byKind: p.byKind,
8554
+ rowsChanged: p.rows.length,
8555
+ contradictionReasonsChanged: p.reasons.length,
8556
+ backupPath: null,
8557
+ remnantsPurged: null
8558
+ };
8559
+ } finally {
8560
+ db2.close();
8561
+ }
8562
+ }
8563
+ const store = MemoryStore.open(dbPath);
8564
+ const db = store.raw;
8565
+ try {
8566
+ let backupPath = null;
8567
+ const first = plan(db);
8568
+ if (first.rows.length + first.reasons.length > 0) {
8569
+ const stamp = (opts.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
8570
+ backupPath = `${dbPath}.backup-${stamp}-pre-scrub-secrets`;
8571
+ await (await open2(backupPath, "wx", 384)).close();
8572
+ await db.backup(backupPath);
8573
+ try {
8574
+ await (opts.protectBackup ?? ((path) => chmod2(path, 384)))(backupPath);
8575
+ } catch (err) {
8576
+ const protectMessage = err.message;
8577
+ try {
8578
+ await (opts.removeBackup ?? ((path) => rm(path, { force: true })))(backupPath);
8579
+ } catch (cleanupErr) {
8580
+ throw new ScrubBackupError(
8581
+ `could not restrict permissions on the backup (${protectMessage}) nor remove it (${cleanupErr.message}) -- nothing was scrubbed, but ${backupPath} still holds the unredacted data; delete it`
8582
+ );
8583
+ }
8584
+ throw new ScrubBackupError(`could not restrict permissions on the backup (${protectMessage}) -- nothing was scrubbed`);
8585
+ }
8586
+ }
8587
+ const updateNode = db.prepare("UPDATE nodes SET title = ?, body = ?, meta = ? WHERE rowid = ?");
8588
+ const dropVector = db.prepare("DELETE FROM nodes_vec WHERE rowid = ?");
8589
+ const updateReason = db.prepare("UPDATE contradiction_checks SET reason = ? WHERE rowid = ?");
8590
+ const { applied, dropped } = db.transaction(() => {
8591
+ const p = plan(db);
8592
+ if (!backupPath && p.rows.length + p.reasons.length > 0) {
8593
+ throw new ScrubRaceError("the database changed while it was being scrubbed -- nothing was modified; re-run");
8594
+ }
8595
+ let dropped2 = 0;
8596
+ for (const c of p.rows) {
8597
+ updateNode.run(c.title, c.body, c.meta, c.rowid);
8598
+ if (c.textChanged) dropped2 += dropVector.run(BigInt(c.rowid)).changes;
8599
+ }
8600
+ for (const r of p.reasons) updateReason.run(r.reason, r.rowid);
8601
+ return { applied: p, dropped: dropped2 };
8602
+ })();
8603
+ let reembedded = 0;
8604
+ let pending = dropped;
8605
+ if (opts.embeddingProvider && dropped > 0) {
8606
+ const changedRowids = applied.rows.filter((c) => c.textChanged).map((c) => c.rowid);
8607
+ const projects = db.prepare(`SELECT DISTINCT project_id AS p FROM nodes WHERE rowid IN (${changedRowids.map(() => "?").join(", ")})`).all(...changedRowids);
8608
+ pending = 0;
8609
+ try {
8610
+ for (const { p } of projects) {
8611
+ const r = await embedPendingNodes(store, opts.embeddingProvider, p);
8612
+ reembedded += r.embedded;
8613
+ pending += r.remaining;
8614
+ }
8615
+ } catch {
8616
+ pending = projects.reduce((sum, { p }) => sum + store.countNodesNeedingEmbedding(p), 0);
8617
+ }
8618
+ }
8619
+ const remnantsPurged = purgeRemnants(db);
8620
+ return {
8621
+ ...base,
8622
+ applied: true,
8623
+ byKind: applied.byKind,
8624
+ rowsChanged: applied.rows.length,
8625
+ contradictionReasonsChanged: applied.reasons.length,
8626
+ embeddingsDropped: dropped,
8627
+ reembedded,
8628
+ // Reported as measured: clamping this to the rows scrub changed made a project with
8629
+ // an embedding backlog look finished when it was not.
8630
+ embeddingsPending: pending,
8631
+ backupPath,
8632
+ remnantsPurged
8633
+ };
8634
+ } finally {
8635
+ store.close();
8636
+ }
8637
+ }
8638
+
8639
+ // src/cli/commands/scrub-secrets.ts
8640
+ async function runScrubSecrets(opts) {
8641
+ const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
8642
+ let incomplete = false;
8643
+ const logPath = hookLogPath();
8644
+ out(`${pc21.bold("shell hook log")} ${logPath}
8645
+ `);
8646
+ if (!existsSync7(logPath)) {
8647
+ out(` ${pc21.dim("not present on this machine")}
8648
+ `);
8649
+ } else {
8650
+ try {
8651
+ const { linesChanged, legacyLines } = await sanitizeHookLog(logPath, { dryRun: !opts.yes });
8652
+ out(` ${linesChanged} line(s) ${opts.yes ? "redacted" : "to redact"}
8653
+ `);
8654
+ if (legacyLines > 0) {
8655
+ out(
8656
+ ` ${pc21.yellow(`${legacyLines} line(s) came from an outdated shell hook`)} that writes commands unredacted -- run \`nexusmem hook install\` again in each shell (see \`nexusmem hook status\`)
8657
+ `
8658
+ );
8659
+ }
8660
+ } catch (err) {
8661
+ incomplete = true;
8662
+ out(` ${pc21.red("could not redact the hook log")}: ${err.message}
8663
+ `);
8664
+ }
8665
+ }
8666
+ const dbPaths = opts.allProjects ? [...new Set((await readLiveRegistry()).entries.map((e) => e.dbPath))] : [resolveWorkspace((await readRepoInfo(opts.cwd)).root).dbPath];
8667
+ for (const dbPath of dbPaths) {
8668
+ out(`
8669
+ ${pc21.bold(dbPath)}
8670
+ `);
8671
+ if (!existsSync7(dbPath)) {
8672
+ out(` ${pc21.dim("no database -- nothing to scrub")}
8673
+ `);
8674
+ continue;
8675
+ }
8676
+ const lock = opts.yes ? acquireSyncLock(dirname9(dbPath)) : null;
8677
+ if (opts.yes && !lock) {
8678
+ incomplete = true;
8679
+ out(` ${pc21.yellow("a background sync is using this database -- re-run when it finishes")}
8680
+ `);
8681
+ continue;
8682
+ }
8683
+ try {
8684
+ const report = await scrubDatabase(dbPath, {
8685
+ apply: opts.yes,
8686
+ embeddingProvider: opts.yes && opts.embed ? new OllamaEmbeddingProvider() : null
8687
+ });
8688
+ printReport(report, out);
8689
+ if (report.remnantsPurged === false) incomplete = true;
8690
+ } catch (err) {
8691
+ incomplete = true;
8692
+ out(` ${pc21.red("scrub failed")}: ${err.message}
8693
+ `);
8694
+ } finally {
8695
+ lock?.release();
8696
+ }
8697
+ }
8698
+ if (!opts.yes) {
8699
+ out(
8700
+ `
8701
+ ${pc21.bold("Dry run")}: nothing was changed. Re-run with --yes to back up each database, redact it in place, drop and re-embed stale vectors, rebuild the FTS index, VACUUM, and truncate the WAL.
8702
+ `
8703
+ );
8704
+ }
8705
+ return incomplete ? 1 : 0;
8706
+ }
8707
+ function printReport(r, out) {
8708
+ for (const kind of SCRUB_KINDS) {
8709
+ const { scanned, changed } = r.byKind[kind];
8710
+ out(` ${kind.padEnd(22)} ${String(scanned).padStart(6)} scanned ${changed} ${r.applied ? "redacted" : "to redact"}
8711
+ `);
8712
+ }
8713
+ out(` ${"contradiction reasons".padEnd(22)} ${" ".repeat(14)}${r.contradictionReasonsChanged} ${r.applied ? "redacted" : "to redact"}
8714
+ `);
8715
+ if (r.applied) {
8716
+ if (r.backupPath) {
8717
+ out(` ${pc21.bold("backup")} ${r.backupPath}
8718
+ `);
8719
+ out(
8720
+ ` ${pc21.yellow("WARNING")} that backup was taken BEFORE redaction and still contains every secret removed here. Verify this database, then delete the backup yourself -- NexusMem never deletes backups.
8721
+ `
8722
+ );
8723
+ } else {
8724
+ out(` ${pc21.dim("no row needed redaction -- no backup taken")}
8725
+ `);
8726
+ }
8727
+ if (r.embeddingsDropped > 0) {
8728
+ out(
8729
+ ` dropped ${r.embeddingsDropped} stale embedding(s), re-embedded ${r.reembedded}` + (r.embeddingsPending > 0 ? `; ${r.embeddingsPending} still pending (the next \`nexusmem sync\` embeds them)` : "") + "\n"
8730
+ );
8731
+ }
8732
+ out(
8733
+ r.remnantsPurged ? ` FTS index rebuilt, database VACUUMed, WAL truncated
8734
+ ` : ` ${pc21.yellow("on-disk remnants NOT purged")}: the rows are redacted, but another process (MCP server, VS Code extension, a running sync) has this database open, which blocks VACUUM / WAL truncation. Close it and re-run \`nexusmem scrub-secrets --yes\`.
8735
+ `
8736
+ );
8737
+ }
8738
+ if (r.existingBackups.length > 0) {
8739
+ out(` ${pc21.yellow("older backups")} (may hold unredacted secrets; NexusMem never deletes them):
8740
+ `);
8741
+ for (const path of r.existingBackups) out(` ${path}
8742
+ `);
8743
+ }
8744
+ }
8745
+
8746
+ // src/cli/commands/stale.ts
8747
+ import pc22 from "picocolors";
8748
+ var STALE_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
8749
+ async function runStale(opts) {
8750
+ const { projectId, ws } = await loadContext(opts.cwd);
8751
+ const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
8752
+ const store = MemoryStore.open(ws.dbPath);
8753
+ try {
8754
+ if (opts.dismiss) {
8755
+ const dismissed = store.dismissContradictionSuggestion(projectId, opts.dismiss);
8756
+ out(
8757
+ dismissed > 0 ? `${pc22.green("dismissed")} the contradiction suggestion for ${opts.dismiss} -- it will not resurface
8758
+ ` : `${pc22.dim("no open contradiction suggestion")} for ${opts.dismiss} -- nothing to dismiss
8759
+ `
8760
+ );
8761
+ return 0;
8762
+ }
8763
+ const candidates = store.listStaleCandidates(projectId, { minAgeDays: opts.minAgeDays, limit: opts.limit });
8764
+ if (candidates.length === 0) {
8765
+ out(`${pc22.dim("no stale candidates")} -- no unconfirmed node older than the threshold lacks a successor
8766
+ `);
8767
+ return 0;
8768
+ }
8769
+ let suggestions = [];
7181
8770
  if (opts.checkContradictions) {
7182
8771
  suggestions = await checkContradictions(
7183
8772
  store,
@@ -7195,15 +8784,15 @@ async function runStale(opts) {
7195
8784
  for (const s of suggestions) byCandidateId.set(s.candidateId, s);
7196
8785
  out(
7197
8786
  [
7198
- `${pc20.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
8787
+ `${pc22.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
7199
8788
  ...candidates.map((c) => {
7200
- const line = ` ${pc20.dim(c.id)} ${pc20.yellow(`${c.ageDays}d old`)} [${c.kind}] ${c.title}`;
8789
+ const line = ` ${pc22.dim(c.id)} ${pc22.yellow(`${c.ageDays}d old`)} [${c.kind}] ${c.title}`;
7201
8790
  const hit = byCandidateId.get(c.id);
7202
8791
  return hit ? `${line}
7203
- ${pc20.red("likely superseded by")} ${pc20.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
8792
+ ${pc22.red("likely superseded by")} ${pc22.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
7204
8793
  }),
7205
8794
  "",
7206
- `run ${pc20.bold("nexusmem mark-stale <id> --supersedes <newId>")} on any that are actually wrong`
8795
+ `run ${pc22.bold("nexusmem mark-stale <id> --supersedes <newId>")} on any that are actually wrong`
7207
8796
  ].join("\n").concat("\n")
7208
8797
  );
7209
8798
  return 0;
@@ -7213,10 +8802,10 @@ async function runStale(opts) {
7213
8802
  }
7214
8803
 
7215
8804
  // src/cli/commands/status.ts
7216
- import { basename as basename4, join as join12 } from "path";
7217
- import { statSync } from "fs";
7218
- import { readFile as readFile11 } from "fs/promises";
7219
- import pc21 from "picocolors";
8805
+ import { basename as basename5, join as join17 } from "path";
8806
+ import { statSync as statSync2 } from "fs";
8807
+ import { readFile as readFile13 } from "fs/promises";
8808
+ import pc23 from "picocolors";
7220
8809
  function daySpan(oldest, newest) {
7221
8810
  const oldestDay = Date.parse(oldest.slice(0, 10));
7222
8811
  const newestDay = Date.parse(newest.slice(0, 10));
@@ -7234,7 +8823,7 @@ function buildShareText(repo, stats2, chains) {
7234
8823
  (p) => !p.startsWith("0 ")
7235
8824
  );
7236
8825
  const lines = [
7237
- `NexusMem has been watching ${basename4(repo.root)} for ${days} day(s):`,
8826
+ `NexusMem has been watching ${basename5(repo.root)} for ${days} day(s):`,
7238
8827
  ` ${stats2.total} memories${parts.length ? ` (${parts.join(", ")})` : ""}`
7239
8828
  ];
7240
8829
  if (chains.failuresTotal) {
@@ -7250,7 +8839,7 @@ function humanBytes(bytes) {
7250
8839
  }
7251
8840
  function fileSize(path) {
7252
8841
  try {
7253
- return statSync(path).size;
8842
+ return statSync2(path).size;
7254
8843
  } catch {
7255
8844
  return 0;
7256
8845
  }
@@ -7275,7 +8864,7 @@ async function getShellHookStatus(injected) {
7275
8864
  }
7276
8865
  async function readLastAutoSync(repoRoot) {
7277
8866
  try {
7278
- const raw = await readFile11(join12(repoRoot, STATE_PATH), "utf8");
8867
+ const raw = await readFile13(join17(repoRoot, STATE_PATH), "utf8");
7279
8868
  return parsePostCommitSyncState(raw);
7280
8869
  } catch {
7281
8870
  return null;
@@ -7311,46 +8900,46 @@ async function runStatus(opts) {
7311
8900
  postCommitGitHookStatus(postCommitTarget)
7312
8901
  ]);
7313
8902
  const now = (opts.now ?? Date.now)();
7314
- const hooksPathNote2 = (target) => target.hooksPathConfig ? pc21.dim(` (core.hooksPath=${target.hooksPathConfig})`) : "";
7315
- const installedLabel = (installed) => installed ? pc21.green("installed") : pc21.yellow("not installed");
7316
- const lastAutoSyncLabel = lastAutoSync ? `${lastAutoSync.ok ? pc21.green("ok") : pc21.red(`FAILED (exit ${lastAutoSync.exitCode})`)} ${pc21.dim(relativeTime(Date.parse(lastAutoSync.ts), now))}` : postCommitStatus.installed ? pc21.dim("never (or hook predates this field -- reinstall with `nexusmem hook git-post install`)") : pc21.dim("n/a -- hook not installed");
8903
+ const hooksPathNote2 = (target) => target.hooksPathConfig ? pc23.dim(` (core.hooksPath=${target.hooksPathConfig})`) : "";
8904
+ const installedLabel = (installed) => installed ? pc23.green("installed") : pc23.yellow("not installed");
8905
+ const lastAutoSyncLabel = lastAutoSync ? `${lastAutoSync.ok ? pc23.green("ok") : pc23.red(`FAILED (exit ${lastAutoSync.exitCode})`)} ${pc23.dim(relativeTime(Date.parse(lastAutoSync.ts), now))}` : postCommitStatus.installed ? pc23.dim("never (or hook predates this field -- reinstall with `nexusmem hook git-post install`)") : pc23.dim("n/a -- hook not installed");
7317
8906
  const hooksLines = [
7318
- pc21.dim("hooks"),
7319
- ` ${pc21.dim(`shell (${shellHook?.shell ?? "?"})`.padEnd(23))} ${shellHook ? installedLabel(shellHook.installed) : pc21.dim("unknown -- could not detect this machine\u2019s shell profile")}`,
7320
- ` ${pc21.dim("git pre-commit".padEnd(23))} ${installedLabel(preCommitStatus.installed)}${hooksPathNote2(preCommitTarget)}`,
7321
- ` ${pc21.dim("git post-commit".padEnd(23))} ${installedLabel(postCommitStatus.installed)}${hooksPathNote2(postCommitTarget)}`,
7322
- ` ${pc21.dim("last auto-sync".padEnd(23))} ${lastAutoSyncLabel}`
8907
+ pc23.dim("hooks"),
8908
+ ` ${pc23.dim(`shell (${shellHook?.shell ?? "?"})`.padEnd(23))} ${shellHook ? installedLabel(shellHook.installed) : pc23.dim("unknown -- could not detect this machine\u2019s shell profile")}`,
8909
+ ` ${pc23.dim("git pre-commit".padEnd(23))} ${installedLabel(preCommitStatus.installed)}${hooksPathNote2(preCommitTarget)}`,
8910
+ ` ${pc23.dim("git post-commit".padEnd(23))} ${installedLabel(postCommitStatus.installed)}${hooksPathNote2(postCommitTarget)}`,
8911
+ ` ${pc23.dim("last auto-sync".padEnd(23))} ${lastAutoSyncLabel}`
7323
8912
  ];
7324
8913
  const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
7325
8914
  const kinds = Object.entries(stats2.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
7326
- const staleProjectWarning = otherProjectIds.length ? `${pc21.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc21.bold(
8915
+ const staleProjectWarning = otherProjectIds.length ? `${pc23.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc23.bold(
7327
8916
  "nexusmem sync --prune-source <name>"
7328
8917
  )} to remove stale source data` : "";
7329
8918
  out(
7330
8919
  [
7331
- `${pc21.dim("repo ")} ${repo.root}`,
7332
- `${pc21.dim("branch ")} ${repo.branch ?? pc21.yellow("(detached)")}`,
7333
- `${pc21.dim("project ")} ${pc21.cyan(projectId)}`,
7334
- `${pc21.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc21.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
7335
- `${pc21.dim("database")} ${ws.dbPath} ${pc21.dim(`(${humanBytes(dbBytes)})`)}`,
8920
+ `${pc23.dim("repo ")} ${repo.root}`,
8921
+ `${pc23.dim("branch ")} ${repo.branch ?? pc23.yellow("(detached)")}`,
8922
+ `${pc23.dim("project ")} ${pc23.cyan(projectId)}`,
8923
+ `${pc23.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc23.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
8924
+ `${pc23.dim("database")} ${ws.dbPath} ${pc23.dim(`(${humanBytes(dbBytes)})`)}`,
7336
8925
  staleProjectWarning,
7337
8926
  "",
7338
- `${pc21.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc21.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
8927
+ `${pc23.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc23.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
7339
8928
  ...kinds,
7340
- stats2.total ? ` ${pc21.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
8929
+ stats2.total ? ` ${pc23.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
7341
8930
  "",
7342
- sources.length ? pc21.dim("sources") : pc21.yellow("no sources synced yet"),
8931
+ sources.length ? pc23.dim("sources") : pc23.yellow("no sources synced yet"),
7343
8932
  ...sources.map((s) => {
7344
8933
  const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
7345
8934
  const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
7346
- return ` ${s.source.padEnd(14)} ${pc21.dim(`last run ${when}`)} ${pc21.dim(`cursor ${cursorLabel}`)}`;
8935
+ return ` ${s.source.padEnd(14)} ${pc23.dim(`last run ${when}`)} ${pc23.dim(`cursor ${cursorLabel}`)}`;
7347
8936
  }),
7348
8937
  "",
7349
- gitCursor && gitCursor !== repo.head ? `${pc21.yellow("git behind HEAD")} \u2014 run ${pc21.bold("nexusmem sync")}` : "",
7350
- chains.failuresTotal ? `${pc21.dim("chains ")} ${pc21.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc21.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc21.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
7351
- structure.edges ? `${pc21.dim("structure")} ${pc21.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
7352
- staleCount ? `${pc21.dim("aging ")} ${pc21.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc21.bold("nexusmem stale")}` : "",
7353
- flaggedCount ? `${pc21.dim("flagged ")} ${pc21.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc21.bold("nexusmem stale")} for detail` : "",
8938
+ gitCursor && gitCursor !== repo.head ? `${pc23.yellow("git behind HEAD")} \u2014 run ${pc23.bold("nexusmem sync")}` : "",
8939
+ chains.failuresTotal ? `${pc23.dim("chains ")} ${pc23.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc23.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc23.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
8940
+ structure.edges ? `${pc23.dim("structure")} ${pc23.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
8941
+ staleCount ? `${pc23.dim("aging ")} ${pc23.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc23.bold("nexusmem stale")}` : "",
8942
+ flaggedCount ? `${pc23.dim("flagged ")} ${pc23.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc23.bold("nexusmem stale")} for detail` : "",
7354
8943
  "",
7355
8944
  ...hooksLines
7356
8945
  ].filter((line) => line !== "").join("\n").concat("\n")
@@ -7375,7 +8964,7 @@ function guard(run) {
7375
8964
  process.exitCode = await run();
7376
8965
  } catch (err) {
7377
8966
  if (isExpected(err)) {
7378
- process.stderr.write(`${pc22.red("error")} ${err.message}
8967
+ process.stderr.write(`${pc24.red("error")} ${err.message}
7379
8968
  `);
7380
8969
  process.exitCode = 1;
7381
8970
  return;
@@ -7488,6 +9077,30 @@ program.command("forget").description(
7488
9077
  })
7489
9078
  )()
7490
9079
  );
9080
+ program.command("agent").description("Manage the opt-in Claude Code hooks that record what the agent tried, and recall past failures").addCommand(
9081
+ new Command("install").description("Install (or update) NexusMem's hooks in Claude Code settings").option("-C, --cwd <path>", "repository path", process.cwd()).option("--project", "write to this repository's .claude/settings.local.json instead of the user settings", false).action(
9082
+ (options) => guard(() => runAgentInstall({ cwd: options.cwd, scope: options.project ? "project" : "user" }))()
9083
+ )
9084
+ ).addCommand(
9085
+ new Command("remove").description("Remove NexusMem's hooks from Claude Code settings, leaving any other tool's hooks alone").option("-C, --cwd <path>", "repository path", process.cwd()).option("--project", "use this repository's .claude/settings.local.json", false).action(
9086
+ (options) => guard(() => runAgentRemove({ cwd: options.cwd, scope: options.project ? "project" : "user" }))()
9087
+ )
9088
+ ).addCommand(
9089
+ new Command("status").description("Show whether the Claude Code hooks are installed and current").option("-C, --cwd <path>", "repository path", process.cwd()).option("--project", "use this repository's .claude/settings.local.json", false).action(
9090
+ (options) => guard(() => runAgentStatus({ cwd: options.cwd, scope: options.project ? "project" : "user" }))()
9091
+ )
9092
+ ).addCommand(
9093
+ new Command("recall").description("Read a hook payload on stdin and print matching failure history -- run by the hook, not by hand").option("--trigger <kind>", "which hook fired", "failure").action(() => guard(() => runAgentRecall())())
9094
+ ).addCommand(
9095
+ new Command("session-start").description("Start a background sync and print any unresolved-failure digest -- run by the hook, not by hand").action(() => guard(() => runAgentSessionStart())())
9096
+ );
9097
+ program.command("scrub-secrets").description(
9098
+ "Redact secrets that older versions already stored (database rows, FTS index, embeddings, shell hook log) -- dry-run unless --yes"
9099
+ ).option("-C, --cwd <path>", "repository path", process.cwd()).option("-a, --all-projects", "every repository in the NexusMem registry, not just this one", false).option("--no-embed", "leave re-embedding the redacted nodes to the next sync").option("--yes", "back up each database, then redact it in place", false).action(
9100
+ (options) => guard(
9101
+ () => runScrubSecrets({ cwd: options.cwd, allProjects: options.allProjects, yes: options.yes, embed: options.embed })
9102
+ )()
9103
+ );
7491
9104
  program.command("mark-stale").description(
7492
9105
  "Mark a node as superseded by another -- the ranker down-weights it (never deletes it) so its replacement usually outranks it"
7493
9106
  ).argument("<nodeId>", "id of the node to mark stale").requiredOption("--supersedes <newNodeId>", "id of the node that supersedes it").option("-C, --cwd <path>", "repository path", process.cwd()).action(
@@ -7577,7 +9190,7 @@ program.command("scan-structure").description("Preview the JS/TS/Python/Go/Rust/
7577
9190
  program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
7578
9191
  program.parseAsync(process.argv).catch((err) => {
7579
9192
  const message = err instanceof Error ? err.message : String(err);
7580
- process.stderr.write(`${pc22.red("error")} ${message}
9193
+ process.stderr.write(`${pc24.red("error")} ${message}
7581
9194
  `);
7582
9195
  process.exitCode = 1;
7583
9196
  });