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/CHANGELOG.md +768 -651
- package/LICENSE +21 -21
- package/README.md +605 -562
- package/dist/cli/agent-hook.js +450 -0
- package/dist/cli/agent-hook.js.map +1 -0
- package/dist/cli/index.js +1904 -291
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/recorder.js +225 -0
- package/dist/cli/recorder.js.map +1 -0
- package/package.json +71 -70
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
|
|
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
|
|
573
|
-
|
|
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")"
|
|
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
|
-
|
|
652
|
-
"
|
|
653
|
-
"
|
|
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
|
|
730
|
-
|
|
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")"
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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.
|
|
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("
|
|
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,
|
|
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
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
AND
|
|
3108
|
-
AND
|
|
3109
|
-
AND
|
|
3110
|
-
AND
|
|
3111
|
-
|
|
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
|
|
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
|
-
|
|
3135
|
-
|
|
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
|
|
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
|
-
|
|
3571
|
-
if (
|
|
3757
|
+
files2.push({ path, insertions: null, deletions: null, binary: false });
|
|
3758
|
+
if (files2.length >= MAX_FILES_PER_NODE) break;
|
|
3572
3759
|
}
|
|
3573
|
-
return
|
|
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
|
|
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
|
-
|
|
3871
|
+
files2.push(touch);
|
|
3685
3872
|
}
|
|
3686
|
-
return
|
|
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
|
|
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)
|
|
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
|
|
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(
|
|
4074
|
-
return
|
|
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(
|
|
4193
|
-
return
|
|
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:
|
|
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
|
|
4818
|
+
const files2 = await listTranscriptFiles(repoRoot);
|
|
4631
4819
|
const turns = [];
|
|
4632
|
-
for (const file of
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
5012
|
-
const
|
|
5013
|
-
|
|
5014
|
-
const
|
|
5015
|
-
|
|
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}:${
|
|
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"),
|
|
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
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
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
|
-
"
|
|
5149
|
-
|
|
5150
|
-
(
|
|
5151
|
-
|
|
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
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
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
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
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 =
|
|
5343
|
-
const joined =
|
|
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 =
|
|
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 =
|
|
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
|
|
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") &&
|
|
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
|
|
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(
|
|
5397
|
-
const dirs = new Set(matches2.map((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
|
|
5720
|
+
import { posix as posix5 } from "path";
|
|
5407
5721
|
function resolvePhpSpecifier(fromPath, specifier, trackedPaths) {
|
|
5408
|
-
const fromDir =
|
|
5409
|
-
const
|
|
5410
|
-
const joined =
|
|
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
|
|
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 =
|
|
5910
|
+
let dir = posix6.dirname(fromPath);
|
|
5597
5911
|
for (let i = 1; i < level; i++) {
|
|
5598
|
-
dir =
|
|
5912
|
+
dir = posix6.dirname(dir);
|
|
5599
5913
|
}
|
|
5600
|
-
const joined =
|
|
5914
|
+
const joined = posix6.join(dir, ...segments);
|
|
5601
5915
|
const moduleFile = `${joined}.py`;
|
|
5602
5916
|
if (trackedPaths.has(moduleFile)) return moduleFile;
|
|
5603
|
-
const packageInit =
|
|
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
|
|
5923
|
+
import { posix as posix7 } from "path";
|
|
5610
5924
|
function resolveRustModSpecifier(fromPath, name, trackedPaths) {
|
|
5611
|
-
const dir =
|
|
5612
|
-
const base =
|
|
5613
|
-
const moduleDir = base === "mod" || base === "lib" || base === "main" ? dir :
|
|
5614
|
-
const sibling =
|
|
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 =
|
|
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
|
|
6283
|
+
import { join as join12 } from "path";
|
|
5750
6284
|
function lockPath(wsDir) {
|
|
5751
|
-
return
|
|
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(
|
|
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 (
|
|
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 ${
|
|
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
|
|
6097
|
-
if (fresh.length === 0 &&
|
|
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(`, ${
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
7358
|
+
const files2 = await listTranscriptFiles(repo.root);
|
|
6790
7359
|
if (!opts.json) {
|
|
6791
7360
|
process.stderr.write(
|
|
6792
|
-
|
|
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
|
-
|
|
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(
|
|
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 ${
|
|
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
|
|
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(`(${
|
|
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/
|
|
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
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
7163
|
-
|
|
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
|
-
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
7172
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
7175
|
-
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
7180
|
-
|
|
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
|
-
`${
|
|
8787
|
+
`${pc22.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
|
|
7199
8788
|
...candidates.map((c) => {
|
|
7200
|
-
const line = ` ${
|
|
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
|
-
${
|
|
8792
|
+
${pc22.red("likely superseded by")} ${pc22.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
|
|
7204
8793
|
}),
|
|
7205
8794
|
"",
|
|
7206
|
-
`run ${
|
|
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
|
|
7217
|
-
import { statSync } from "fs";
|
|
7218
|
-
import { readFile as
|
|
7219
|
-
import
|
|
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 ${
|
|
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
|
|
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
|
|
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 ?
|
|
7315
|
-
const installedLabel = (installed) => installed ?
|
|
7316
|
-
const lastAutoSyncLabel = lastAutoSync ? `${lastAutoSync.ok ?
|
|
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
|
-
|
|
7319
|
-
` ${
|
|
7320
|
-
` ${
|
|
7321
|
-
` ${
|
|
7322
|
-
` ${
|
|
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 ? `${
|
|
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
|
-
`${
|
|
7332
|
-
`${
|
|
7333
|
-
`${
|
|
7334
|
-
`${
|
|
7335
|
-
`${
|
|
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
|
-
`${
|
|
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 ? ` ${
|
|
8929
|
+
stats2.total ? ` ${pc23.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
|
|
7341
8930
|
"",
|
|
7342
|
-
sources.length ?
|
|
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)} ${
|
|
8935
|
+
return ` ${s.source.padEnd(14)} ${pc23.dim(`last run ${when}`)} ${pc23.dim(`cursor ${cursorLabel}`)}`;
|
|
7347
8936
|
}),
|
|
7348
8937
|
"",
|
|
7349
|
-
gitCursor && gitCursor !== repo.head ? `${
|
|
7350
|
-
chains.failuresTotal ? `${
|
|
7351
|
-
structure.edges ? `${
|
|
7352
|
-
staleCount ? `${
|
|
7353
|
-
flaggedCount ? `${
|
|
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(`${
|
|
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(`${
|
|
9193
|
+
process.stderr.write(`${pc24.red("error")} ${message}
|
|
7581
9194
|
`);
|
|
7582
9195
|
process.exitCode = 1;
|
|
7583
9196
|
});
|