nexusmem 0.1.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/LICENSE +21 -0
- package/README.md +337 -0
- package/dist/cli/index.js +2686 -0
- package/dist/cli/index.js.map +1 -0
- package/package.json +63 -0
|
@@ -0,0 +1,2686 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import pc11 from "picocolors";
|
|
6
|
+
|
|
7
|
+
// src/config/workspace.ts
|
|
8
|
+
import { existsSync } from "fs";
|
|
9
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
var WORKSPACE_DIR = ".nexusmem";
|
|
13
|
+
function resolveWorkspace(repoRoot) {
|
|
14
|
+
const dir = join(repoRoot, WORKSPACE_DIR);
|
|
15
|
+
return {
|
|
16
|
+
root: repoRoot,
|
|
17
|
+
dir,
|
|
18
|
+
dbPath: join(dir, "memory.db"),
|
|
19
|
+
configPath: join(dir, "config.json")
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function isInitialized(ws) {
|
|
23
|
+
return existsSync(ws.configPath);
|
|
24
|
+
}
|
|
25
|
+
var ConfigSchema = z.object({
|
|
26
|
+
version: z.literal(1),
|
|
27
|
+
projectId: z.string().min(1),
|
|
28
|
+
sources: z.object({
|
|
29
|
+
git: z.object({
|
|
30
|
+
enabled: z.boolean().default(true),
|
|
31
|
+
/** Git date expression bounding how far back to ingest; null = all history. */
|
|
32
|
+
since: z.string().nullable().default(null),
|
|
33
|
+
includeMerges: z.boolean().default(true)
|
|
34
|
+
}).default({ enabled: true, since: null, includeMerges: true }),
|
|
35
|
+
shell: z.object({
|
|
36
|
+
enabled: z.boolean().default(true),
|
|
37
|
+
/** Lines kept from scrape-based (no-hook) history files each sync. */
|
|
38
|
+
tailLines: z.number().int().positive().default(300)
|
|
39
|
+
}).default({ enabled: true, tailLines: 300 }),
|
|
40
|
+
/**
|
|
41
|
+
* Opt-in, unlike git/shell: conversation transcripts are the source
|
|
42
|
+
* most likely to contain something sensitive (a pasted credential,
|
|
43
|
+
* confidential discussion), so this must be a deliberate choice, not
|
|
44
|
+
* an automatic default. See docs/phase-2-spec.md.
|
|
45
|
+
*/
|
|
46
|
+
conversation: z.object({
|
|
47
|
+
enabled: z.boolean().default(false)
|
|
48
|
+
}).default({ enabled: false }),
|
|
49
|
+
/** Tracked `.md` files -- README, architecture docs. On by default like git/shell: no secrets risk, just project prose. */
|
|
50
|
+
docs: z.object({
|
|
51
|
+
enabled: z.boolean().default(true),
|
|
52
|
+
/** git pathspecs passed to `git ls-files`. */
|
|
53
|
+
include: z.array(z.string()).default(["*.md"])
|
|
54
|
+
}).default({ enabled: true, include: ["*.md"] })
|
|
55
|
+
}).default({
|
|
56
|
+
git: { enabled: true, since: null, includeMerges: true },
|
|
57
|
+
shell: { enabled: true, tailLines: 300 },
|
|
58
|
+
conversation: { enabled: false },
|
|
59
|
+
docs: { enabled: true, include: ["*.md"] }
|
|
60
|
+
}),
|
|
61
|
+
limits: z.object({
|
|
62
|
+
maxFilesPerNode: z.number().int().positive().default(40),
|
|
63
|
+
maxBodyChars: z.number().int().positive().default(4e3)
|
|
64
|
+
}).default({ maxFilesPerNode: 40, maxBodyChars: 4e3 })
|
|
65
|
+
});
|
|
66
|
+
function defaultConfig(projectId) {
|
|
67
|
+
return ConfigSchema.parse({ version: 1, projectId });
|
|
68
|
+
}
|
|
69
|
+
var ConfigError = class extends Error {
|
|
70
|
+
constructor(message) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = "ConfigError";
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
async function readConfig(ws) {
|
|
76
|
+
let raw;
|
|
77
|
+
try {
|
|
78
|
+
raw = await readFile(ws.configPath, "utf8");
|
|
79
|
+
} catch {
|
|
80
|
+
throw new ConfigError(`Not initialized: ${ws.configPath} not found. Run \`nexusmem init\` first.`);
|
|
81
|
+
}
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
throw new ConfigError(`${ws.configPath} is not valid JSON: ${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
const result = ConfigSchema.safeParse(parsed);
|
|
89
|
+
if (!result.success) {
|
|
90
|
+
const issues = result.error.issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
91
|
+
throw new ConfigError(`${ws.configPath} is invalid:
|
|
92
|
+
${issues}`);
|
|
93
|
+
}
|
|
94
|
+
return result.data;
|
|
95
|
+
}
|
|
96
|
+
async function writeConfig(ws, config) {
|
|
97
|
+
await mkdir(ws.dir, { recursive: true });
|
|
98
|
+
await writeFile(ws.configPath, `${JSON.stringify(config, null, 2)}
|
|
99
|
+
`, "utf8");
|
|
100
|
+
}
|
|
101
|
+
async function writeWorkspaceGitignore(ws) {
|
|
102
|
+
await mkdir(ws.dir, { recursive: true });
|
|
103
|
+
await writeFile(join(ws.dir, ".gitignore"), "# Machine-local derived data.\n*\n", "utf8");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/git/exec.ts
|
|
107
|
+
import { spawn } from "child_process";
|
|
108
|
+
var GitError = class extends Error {
|
|
109
|
+
constructor(message, args, exitCode, stderr) {
|
|
110
|
+
super(message);
|
|
111
|
+
this.args = args;
|
|
112
|
+
this.exitCode = exitCode;
|
|
113
|
+
this.stderr = stderr;
|
|
114
|
+
this.name = "GitError";
|
|
115
|
+
}
|
|
116
|
+
args;
|
|
117
|
+
exitCode;
|
|
118
|
+
stderr;
|
|
119
|
+
};
|
|
120
|
+
var GitSpawnError = class extends Error {
|
|
121
|
+
constructor(message, args, code, transient, cause) {
|
|
122
|
+
super(message);
|
|
123
|
+
this.args = args;
|
|
124
|
+
this.code = code;
|
|
125
|
+
this.transient = transient;
|
|
126
|
+
this.cause = cause;
|
|
127
|
+
this.name = "GitSpawnError";
|
|
128
|
+
}
|
|
129
|
+
args;
|
|
130
|
+
code;
|
|
131
|
+
transient;
|
|
132
|
+
cause;
|
|
133
|
+
};
|
|
134
|
+
var GitCrashError = class extends Error {
|
|
135
|
+
constructor(message, args, status, exitCode, signal) {
|
|
136
|
+
super(message);
|
|
137
|
+
this.args = args;
|
|
138
|
+
this.status = status;
|
|
139
|
+
this.exitCode = exitCode;
|
|
140
|
+
this.signal = signal;
|
|
141
|
+
this.name = "GitCrashError";
|
|
142
|
+
}
|
|
143
|
+
args;
|
|
144
|
+
status;
|
|
145
|
+
exitCode;
|
|
146
|
+
signal;
|
|
147
|
+
};
|
|
148
|
+
var NTSTATUS_FAILURE_BASE = 3221225472;
|
|
149
|
+
var STATUS_CONTROL_C_EXIT = 3221225786;
|
|
150
|
+
var FATAL_SIGNALS = /* @__PURE__ */ new Set(["SIGSEGV", "SIGBUS", "SIGABRT", "SIGILL", "SIGFPE"]);
|
|
151
|
+
function crashStatus(code, signal) {
|
|
152
|
+
if (signal) return FATAL_SIGNALS.has(signal) ? signal : null;
|
|
153
|
+
if (code >= NTSTATUS_FAILURE_BASE && code !== STATUS_CONTROL_C_EXIT) {
|
|
154
|
+
return `0x${code.toString(16).toUpperCase()}`;
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
var TRANSIENT_SPAWN_CODES = /* @__PURE__ */ new Set(["EAGAIN", "EPERM", "EACCES", "EMFILE", "ENFILE", "ENOMEM", "EBUSY", "ETXTBSY"]);
|
|
159
|
+
function toSpawnError(err, cwd, args) {
|
|
160
|
+
const code = err?.code;
|
|
161
|
+
if (code === "ENOENT") {
|
|
162
|
+
return new GitSpawnError(
|
|
163
|
+
`Could not run git: either git is not on PATH, or the directory does not exist: ${cwd}`,
|
|
164
|
+
args,
|
|
165
|
+
code,
|
|
166
|
+
false,
|
|
167
|
+
err
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const transient = code !== void 0 && TRANSIENT_SPAWN_CODES.has(code);
|
|
171
|
+
const suffix = transient ? " This is usually transient on Windows -- retrying the same command often succeeds." : "";
|
|
172
|
+
return new GitSpawnError(
|
|
173
|
+
`Could not start git (${code ?? "unknown spawn failure"}) in ${cwd}.${suffix}`,
|
|
174
|
+
args,
|
|
175
|
+
code,
|
|
176
|
+
transient,
|
|
177
|
+
err
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
var BASE_ARGS = ["-c", "core.quotePath=false", "-c", "core.pager=", "--no-pager"];
|
|
181
|
+
var RETRY_DELAYS_MS = [50, 150, 400];
|
|
182
|
+
var realSleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
183
|
+
async function* gitStream(cwd, args, opts = {}) {
|
|
184
|
+
const sleep = opts.sleep ?? realSleep;
|
|
185
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
186
|
+
let produced = false;
|
|
187
|
+
try {
|
|
188
|
+
for await (const chunk of runGitOnce(cwd, args, opts)) {
|
|
189
|
+
produced = true;
|
|
190
|
+
yield chunk;
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
} catch (err) {
|
|
194
|
+
const retryable = err instanceof GitSpawnError && err.transient || err instanceof GitCrashError;
|
|
195
|
+
if (produced || !retryable || attempt >= RETRY_DELAYS_MS.length) throw err;
|
|
196
|
+
await sleep(RETRY_DELAYS_MS[attempt]);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function* runGitOnce(cwd, args, opts) {
|
|
201
|
+
const fullArgs = [...BASE_ARGS, ...args];
|
|
202
|
+
const child = (opts.spawn ?? spawn)("git", fullArgs, { cwd, windowsHide: true });
|
|
203
|
+
child.stdout.setEncoding("utf8");
|
|
204
|
+
child.stderr.setEncoding("utf8");
|
|
205
|
+
let stderr = "";
|
|
206
|
+
child.stderr.on("data", (chunk) => {
|
|
207
|
+
if (stderr.length < 64 * 1024) stderr += chunk;
|
|
208
|
+
});
|
|
209
|
+
const exited = new Promise((resolve2, reject) => {
|
|
210
|
+
child.once("error", (err) => reject(toSpawnError(err, cwd, fullArgs)));
|
|
211
|
+
child.once("close", (code2, signal2) => resolve2({ code: code2 ?? 0, signal: signal2 ?? null }));
|
|
212
|
+
});
|
|
213
|
+
exited.catch(() => {
|
|
214
|
+
});
|
|
215
|
+
try {
|
|
216
|
+
for await (const chunk of child.stdout) {
|
|
217
|
+
yield chunk;
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
if (child.exitCode === null) child.kill();
|
|
221
|
+
}
|
|
222
|
+
const { code, signal } = await exited;
|
|
223
|
+
const crash = crashStatus(code, signal);
|
|
224
|
+
if (crash) {
|
|
225
|
+
throw new GitCrashError(
|
|
226
|
+
`git ${args.join(" ")} was killed before it could answer (${crash}) in ${cwd}. This is an environment fault, not a problem with the repository.`,
|
|
227
|
+
fullArgs,
|
|
228
|
+
crash,
|
|
229
|
+
code,
|
|
230
|
+
signal
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (code !== 0) {
|
|
234
|
+
const trimmed = stderr.trim();
|
|
235
|
+
const detail = trimmed.split("\n")[0];
|
|
236
|
+
throw new GitError(
|
|
237
|
+
`git ${args.join(" ")} exited with code ${code}${detail ? `: ${detail}` : ""}`,
|
|
238
|
+
fullArgs,
|
|
239
|
+
code,
|
|
240
|
+
trimmed
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async function git(cwd, args, opts = {}) {
|
|
245
|
+
let out = "";
|
|
246
|
+
for await (const chunk of gitStream(cwd, args, opts)) out += chunk;
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
async function gitOrNull(cwd, args, opts = {}) {
|
|
250
|
+
try {
|
|
251
|
+
return await git(cwd, args, opts);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if (err instanceof GitError) return null;
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/git/repo.ts
|
|
259
|
+
import { resolve } from "path";
|
|
260
|
+
var NotAGitRepositoryError = class extends Error {
|
|
261
|
+
constructor(cwd) {
|
|
262
|
+
super(`Not a git repository: ${cwd}`);
|
|
263
|
+
this.cwd = cwd;
|
|
264
|
+
this.name = "NotAGitRepositoryError";
|
|
265
|
+
}
|
|
266
|
+
cwd;
|
|
267
|
+
};
|
|
268
|
+
var NOT_A_REPO = /not a git repository/i;
|
|
269
|
+
async function isAncestor(cwd, ancestor, descendant) {
|
|
270
|
+
try {
|
|
271
|
+
await git(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]);
|
|
272
|
+
return true;
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (err instanceof GitError) return false;
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async function readRepoInfo(cwd) {
|
|
279
|
+
let rootRaw;
|
|
280
|
+
try {
|
|
281
|
+
rootRaw = await git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
if (err instanceof GitError && NOT_A_REPO.test(err.stderr)) {
|
|
284
|
+
throw new NotAGitRepositoryError(cwd);
|
|
285
|
+
}
|
|
286
|
+
throw err;
|
|
287
|
+
}
|
|
288
|
+
const root = resolve(rootRaw.trim());
|
|
289
|
+
const [branchRaw, headRaw, originRaw] = await Promise.all([
|
|
290
|
+
gitOrNull(root, ["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
291
|
+
gitOrNull(root, ["rev-parse", "HEAD"]),
|
|
292
|
+
gitOrNull(root, ["remote", "get-url", "origin"])
|
|
293
|
+
]);
|
|
294
|
+
const branch = branchRaw?.trim() ?? null;
|
|
295
|
+
return {
|
|
296
|
+
root,
|
|
297
|
+
branch: branch && branch !== "HEAD" ? branch : null,
|
|
298
|
+
head: headRaw?.trim() || null,
|
|
299
|
+
originUrl: originRaw?.trim() || null
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// src/hooks/install.ts
|
|
304
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
305
|
+
import { dirname } from "path";
|
|
306
|
+
|
|
307
|
+
// src/shell/paths.ts
|
|
308
|
+
import { execFile } from "child_process";
|
|
309
|
+
import { homedir } from "os";
|
|
310
|
+
import { join as join2 } from "path";
|
|
311
|
+
import { promisify } from "util";
|
|
312
|
+
var execFileAsync = promisify(execFile);
|
|
313
|
+
function psReadLineHistoryPath() {
|
|
314
|
+
const appData = process.env.APPDATA ?? join2(homedir(), "AppData", "Roaming");
|
|
315
|
+
return join2(appData, "Microsoft", "Windows", "PowerShell", "PSReadLine", "ConsoleHost_history.txt");
|
|
316
|
+
}
|
|
317
|
+
function bashHistoryPath() {
|
|
318
|
+
return process.env.HISTFILE_BASH ?? join2(homedir(), ".bash_history");
|
|
319
|
+
}
|
|
320
|
+
function zshHistoryPath() {
|
|
321
|
+
return process.env.HISTFILE ?? join2(homedir(), ".zsh_history");
|
|
322
|
+
}
|
|
323
|
+
function globalWorkspaceDir() {
|
|
324
|
+
return join2(homedir(), ".nexusmem");
|
|
325
|
+
}
|
|
326
|
+
function hookLogPath() {
|
|
327
|
+
return join2(globalWorkspaceDir(), "shell-history.jsonl");
|
|
328
|
+
}
|
|
329
|
+
async function resolvePowerShellProfilePath(exe = "powershell") {
|
|
330
|
+
try {
|
|
331
|
+
const { stdout } = await execFileAsync(exe, ["-NoLogo", "-NoProfile", "-Command", "$PROFILE"], {
|
|
332
|
+
windowsHide: true
|
|
333
|
+
});
|
|
334
|
+
const path = stdout.trim();
|
|
335
|
+
return path.length > 0 ? path : null;
|
|
336
|
+
} catch {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/hooks/powershell.ts
|
|
342
|
+
var MARK_START = "# >>> nexusmem shell hook >>>";
|
|
343
|
+
var MARK_END = "# <<< nexusmem shell hook <<<";
|
|
344
|
+
function toPowerShellLiteral(s) {
|
|
345
|
+
return `'${s.replace(/'/g, "''")}'`;
|
|
346
|
+
}
|
|
347
|
+
function renderHookSnippet(logPath) {
|
|
348
|
+
return [
|
|
349
|
+
MARK_START,
|
|
350
|
+
"if (Test-Path Function:\\prompt) { $function:__ssd_original_prompt = $function:prompt }",
|
|
351
|
+
"$global:__ssd_last_history_id = -1",
|
|
352
|
+
`$global:__ssd_log_path = ${toPowerShellLiteral(logPath)}`,
|
|
353
|
+
"function global:prompt {",
|
|
354
|
+
" $__ssd_h = Get-History -Count 1 -ErrorAction SilentlyContinue",
|
|
355
|
+
" if ($__ssd_h -and $__ssd_h.Id -ne $global:__ssd_last_history_id) {",
|
|
356
|
+
" $global:__ssd_last_history_id = $__ssd_h.Id",
|
|
357
|
+
" try {",
|
|
358
|
+
" $__ssd_entry = [ordered]@{",
|
|
359
|
+
' ts = (Get-Date).ToString("o")',
|
|
360
|
+
" cwd = (Get-Location).Path",
|
|
361
|
+
" exitCode = $LASTEXITCODE",
|
|
362
|
+
" durationMs = [int](($__ssd_h.EndExecutionTime - $__ssd_h.StartExecutionTime).TotalMilliseconds)",
|
|
363
|
+
" command = $__ssd_h.CommandLine",
|
|
364
|
+
" }",
|
|
365
|
+
" $__ssd_dir = Split-Path -Parent $global:__ssd_log_path",
|
|
366
|
+
" if (-not (Test-Path $__ssd_dir)) { New-Item -ItemType Directory -Force -Path $__ssd_dir | Out-Null }",
|
|
367
|
+
" Add-Content -LiteralPath $global:__ssd_log_path -Value ($__ssd_entry | ConvertTo-Json -Compress) -Encoding utf8",
|
|
368
|
+
" } catch {}",
|
|
369
|
+
" }",
|
|
370
|
+
" if (Test-Path Function:\\__ssd_original_prompt) { & $function:__ssd_original_prompt }",
|
|
371
|
+
` else { "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) " }`,
|
|
372
|
+
"}",
|
|
373
|
+
MARK_END,
|
|
374
|
+
""
|
|
375
|
+
].join("\n");
|
|
376
|
+
}
|
|
377
|
+
function isHookInstalled(profileContent) {
|
|
378
|
+
return profileContent.includes(MARK_START);
|
|
379
|
+
}
|
|
380
|
+
function stripHookSnippet(profileContent) {
|
|
381
|
+
const startIdx = profileContent.indexOf(MARK_START);
|
|
382
|
+
const endIdx = profileContent.indexOf(MARK_END);
|
|
383
|
+
if (startIdx === -1 || endIdx === -1) return profileContent;
|
|
384
|
+
const afterBlock = profileContent.slice(endIdx + MARK_END.length).replace(/^\r?\n/, "");
|
|
385
|
+
return profileContent.slice(0, startIdx) + afterBlock;
|
|
386
|
+
}
|
|
387
|
+
function upsertHookSnippet(profileContent, logPath) {
|
|
388
|
+
const stripped = stripHookSnippet(profileContent).replace(/\s+$/, "");
|
|
389
|
+
const prefix = stripped.length > 0 ? `${stripped}
|
|
390
|
+
|
|
391
|
+
` : "";
|
|
392
|
+
return `${prefix}${renderHookSnippet(logPath)}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/hooks/install.ts
|
|
396
|
+
var ProfileNotFoundError = class extends Error {
|
|
397
|
+
constructor() {
|
|
398
|
+
super("Could not resolve a PowerShell profile path (tried `powershell -Command $PROFILE`). Pass --profile explicitly.");
|
|
399
|
+
this.name = "ProfileNotFoundError";
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
async function resolveHookTarget(profileOverride, logPathOverride) {
|
|
403
|
+
const profilePath = profileOverride ?? await resolvePowerShellProfilePath();
|
|
404
|
+
if (!profilePath) throw new ProfileNotFoundError();
|
|
405
|
+
return { profilePath, logPath: logPathOverride ?? hookLogPath() };
|
|
406
|
+
}
|
|
407
|
+
async function readProfile(path) {
|
|
408
|
+
try {
|
|
409
|
+
return await readFile2(path, "utf8");
|
|
410
|
+
} catch {
|
|
411
|
+
return "";
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
async function installHook(target) {
|
|
415
|
+
const current = await readProfile(target.profilePath);
|
|
416
|
+
const alreadyInstalled = isHookInstalled(current);
|
|
417
|
+
const next = upsertHookSnippet(current, target.logPath);
|
|
418
|
+
if (next === current) return { changed: false, alreadyInstalled };
|
|
419
|
+
await mkdir2(dirname(target.profilePath), { recursive: true });
|
|
420
|
+
await writeFile2(target.profilePath, next, "utf8");
|
|
421
|
+
return { changed: true, alreadyInstalled };
|
|
422
|
+
}
|
|
423
|
+
async function removeHook(target) {
|
|
424
|
+
const current = await readProfile(target.profilePath);
|
|
425
|
+
if (!isHookInstalled(current)) return { changed: false };
|
|
426
|
+
await writeFile2(target.profilePath, stripHookSnippet(current), "utf8");
|
|
427
|
+
return { changed: true };
|
|
428
|
+
}
|
|
429
|
+
async function hookStatus(target) {
|
|
430
|
+
const current = await readProfile(target.profilePath);
|
|
431
|
+
return { installed: isHookInstalled(current) };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// src/cli/commands/hook.ts
|
|
435
|
+
import pc from "picocolors";
|
|
436
|
+
async function runHookInstall(opts) {
|
|
437
|
+
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
438
|
+
const result = await installHook(target);
|
|
439
|
+
process.stdout.write(
|
|
440
|
+
[
|
|
441
|
+
result.changed ? `${pc.green(result.alreadyInstalled ? "updated" : "installed")} shell hook` : `${pc.dim("already up to date")}`,
|
|
442
|
+
` profile ${target.profilePath}`,
|
|
443
|
+
` log ${target.logPath}`,
|
|
444
|
+
"",
|
|
445
|
+
`New commands in any PowerShell session using this profile will now log their timestamp, cwd and exit code.`,
|
|
446
|
+
`Open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect.`,
|
|
447
|
+
`Run ${pc.bold("nexusmem hook remove")} to undo this.`,
|
|
448
|
+
""
|
|
449
|
+
].join("\n")
|
|
450
|
+
);
|
|
451
|
+
return 0;
|
|
452
|
+
}
|
|
453
|
+
async function runHookRemove(opts) {
|
|
454
|
+
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
455
|
+
const result = await removeHook(target);
|
|
456
|
+
process.stdout.write(
|
|
457
|
+
result.changed ? `${pc.green("removed")} shell hook from ${target.profilePath}
|
|
458
|
+
` : `${pc.dim("nothing to remove")} \u2014 no hook block found in ${target.profilePath}
|
|
459
|
+
`
|
|
460
|
+
);
|
|
461
|
+
return 0;
|
|
462
|
+
}
|
|
463
|
+
async function runHookStatus(opts) {
|
|
464
|
+
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
465
|
+
const result = await hookStatus(target);
|
|
466
|
+
process.stdout.write(
|
|
467
|
+
[
|
|
468
|
+
`${pc.dim("profile")} ${target.profilePath}`,
|
|
469
|
+
`${pc.dim("log ")} ${target.logPath}`,
|
|
470
|
+
`${pc.dim("status ")} ${result.installed ? pc.green("installed") : pc.yellow("not installed")}`,
|
|
471
|
+
""
|
|
472
|
+
].join("\n")
|
|
473
|
+
);
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/cli/commands/init.ts
|
|
478
|
+
import { relative } from "path";
|
|
479
|
+
import pc2 from "picocolors";
|
|
480
|
+
|
|
481
|
+
// src/core/ids.ts
|
|
482
|
+
import { createHash } from "crypto";
|
|
483
|
+
var KEY_SEP = "\0";
|
|
484
|
+
function sha256Hex(input) {
|
|
485
|
+
return createHash("sha256").update(input, "utf8").digest("hex");
|
|
486
|
+
}
|
|
487
|
+
function makeNodeId(projectId, kind, naturalKey) {
|
|
488
|
+
return sha256Hex([projectId, kind, naturalKey].join(KEY_SEP)).slice(0, 24);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// src/core/project.ts
|
|
492
|
+
function normalizeGitUrl(url) {
|
|
493
|
+
let s = url.trim();
|
|
494
|
+
const scp = /^(?:[^@/]+@)?([^/:]+):(.+)$/.exec(s);
|
|
495
|
+
if (scp && !s.includes("://")) {
|
|
496
|
+
s = `${scp[1]}/${scp[2]}`;
|
|
497
|
+
} else {
|
|
498
|
+
s = s.replace(/^[a-z+]+:\/\//i, "").replace(/^[^@/]+@/, "");
|
|
499
|
+
}
|
|
500
|
+
return s.replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "").replace(/\/{2,}/g, "/").toLowerCase();
|
|
501
|
+
}
|
|
502
|
+
function makeProjectId({ root, originUrl }) {
|
|
503
|
+
const basis = originUrl ? `remote:${normalizeGitUrl(originUrl)}` : `path:${root.replace(/\\/g, "/").toLowerCase()}`;
|
|
504
|
+
return sha256Hex(basis).slice(0, 16);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// src/store/store.ts
|
|
508
|
+
import Database from "better-sqlite3";
|
|
509
|
+
import { mkdirSync } from "fs";
|
|
510
|
+
import { dirname as dirname2 } from "path";
|
|
511
|
+
import * as sqliteVec from "sqlite-vec";
|
|
512
|
+
|
|
513
|
+
// src/store/fts.ts
|
|
514
|
+
var FTS_SYNTAX = /["'()*:^{}[\]-]/g;
|
|
515
|
+
function toMatchQuery(input) {
|
|
516
|
+
const tokens = input.replace(FTS_SYNTAX, " ").split(/\s+/).map((t) => t.trim()).filter((t) => t.length > 0);
|
|
517
|
+
if (tokens.length === 0) return null;
|
|
518
|
+
return tokens.map((t) => `"${t}"*`).join(" OR ");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// src/store/schema.ts
|
|
522
|
+
var V1 = `
|
|
523
|
+
CREATE TABLE meta (
|
|
524
|
+
key TEXT PRIMARY KEY,
|
|
525
|
+
value TEXT NOT NULL
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
CREATE TABLE projects (
|
|
529
|
+
id TEXT PRIMARY KEY,
|
|
530
|
+
root TEXT NOT NULL,
|
|
531
|
+
origin_url TEXT,
|
|
532
|
+
created_at INTEGER NOT NULL,
|
|
533
|
+
last_synced_at INTEGER
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
CREATE TABLE nodes (
|
|
537
|
+
id TEXT PRIMARY KEY,
|
|
538
|
+
kind TEXT NOT NULL,
|
|
539
|
+
project_id TEXT NOT NULL,
|
|
540
|
+
-- Human-readable ISO-8601 with offset, kept verbatim from the source event.
|
|
541
|
+
ts TEXT NOT NULL,
|
|
542
|
+
-- Same instant as epoch ms, so range scans and ordering never parse strings.
|
|
543
|
+
ts_epoch INTEGER NOT NULL,
|
|
544
|
+
source TEXT NOT NULL,
|
|
545
|
+
title TEXT NOT NULL,
|
|
546
|
+
body TEXT NOT NULL,
|
|
547
|
+
signal REAL NOT NULL,
|
|
548
|
+
meta TEXT NOT NULL DEFAULT '{}',
|
|
549
|
+
created_at INTEGER NOT NULL
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
CREATE INDEX idx_nodes_project_ts ON nodes (project_id, ts_epoch DESC);
|
|
553
|
+
CREATE INDEX idx_nodes_project_kind ON nodes (project_id, kind, ts_epoch DESC);
|
|
554
|
+
|
|
555
|
+
CREATE TABLE node_files (
|
|
556
|
+
node_id TEXT NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
|
|
557
|
+
path TEXT NOT NULL,
|
|
558
|
+
previous_path TEXT,
|
|
559
|
+
insertions INTEGER,
|
|
560
|
+
deletions INTEGER,
|
|
561
|
+
is_binary INTEGER NOT NULL DEFAULT 0,
|
|
562
|
+
PRIMARY KEY (node_id, path)
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
-- Path-scoped recall ("what happened to src/store/db.ts?") is a first-class
|
|
566
|
+
-- query, so it gets its own index rather than a scan over node_files.
|
|
567
|
+
CREATE INDEX idx_node_files_path ON node_files (path);
|
|
568
|
+
|
|
569
|
+
-- External-content FTS: the index stores no copy of the text, it points back
|
|
570
|
+
-- at nodes.rowid. Halves the on-disk footprint of the searchable corpus.
|
|
571
|
+
CREATE VIRTUAL TABLE nodes_fts USING fts5 (
|
|
572
|
+
title,
|
|
573
|
+
body,
|
|
574
|
+
content = 'nodes',
|
|
575
|
+
content_rowid = 'rowid',
|
|
576
|
+
tokenize = 'unicode61 remove_diacritics 2'
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
CREATE TRIGGER nodes_fts_ai AFTER INSERT ON nodes BEGIN
|
|
580
|
+
INSERT INTO nodes_fts (rowid, title, body) VALUES (new.rowid, new.title, new.body);
|
|
581
|
+
END;
|
|
582
|
+
|
|
583
|
+
CREATE TRIGGER nodes_fts_ad AFTER DELETE ON nodes BEGIN
|
|
584
|
+
INSERT INTO nodes_fts (nodes_fts, rowid, title, body) VALUES ('delete', old.rowid, old.title, old.body);
|
|
585
|
+
END;
|
|
586
|
+
|
|
587
|
+
CREATE TRIGGER nodes_fts_au AFTER UPDATE ON nodes BEGIN
|
|
588
|
+
INSERT INTO nodes_fts (nodes_fts, rowid, title, body) VALUES ('delete', old.rowid, old.title, old.body);
|
|
589
|
+
INSERT INTO nodes_fts (rowid, title, body) VALUES (new.rowid, new.title, new.body);
|
|
590
|
+
END;
|
|
591
|
+
|
|
592
|
+
CREATE TABLE sync_state (
|
|
593
|
+
project_id TEXT NOT NULL,
|
|
594
|
+
-- Collector identity, e.g. 'git' or 'shell:pwsh'.
|
|
595
|
+
source TEXT NOT NULL,
|
|
596
|
+
-- Opaque to the store; for git this is the HEAD sha at last successful sync.
|
|
597
|
+
cursor TEXT,
|
|
598
|
+
last_run_at INTEGER,
|
|
599
|
+
PRIMARY KEY (project_id, source)
|
|
600
|
+
);
|
|
601
|
+
`;
|
|
602
|
+
var EMBEDDING_DIM = 768;
|
|
603
|
+
var V2 = `
|
|
604
|
+
-- Unlike nodes_fts, this is NOT trigger-populated: computing an embedding
|
|
605
|
+
-- means an async call to an external model, which a synchronous SQL trigger
|
|
606
|
+
-- cannot make. Rows are written explicitly by the embedding pass in
|
|
607
|
+
-- vector/embed.ts, keyed by the same rowid nodes_fts already uses.
|
|
608
|
+
CREATE VIRTUAL TABLE nodes_vec USING vec0 (
|
|
609
|
+
embedding float[${EMBEDDING_DIM}]
|
|
610
|
+
);
|
|
611
|
+
`;
|
|
612
|
+
var MIGRATIONS = [
|
|
613
|
+
{ version: 1, up: (db) => db.exec(V1) },
|
|
614
|
+
{ version: 2, up: (db) => db.exec(V2) }
|
|
615
|
+
];
|
|
616
|
+
var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
|
|
617
|
+
function currentSchemaVersion(db) {
|
|
618
|
+
return Number(db.pragma("user_version", { simple: true }) ?? 0);
|
|
619
|
+
}
|
|
620
|
+
function migrate(db) {
|
|
621
|
+
const from = currentSchemaVersion(db);
|
|
622
|
+
for (const migration of MIGRATIONS) {
|
|
623
|
+
if (migration.version <= from) continue;
|
|
624
|
+
db.transaction(() => {
|
|
625
|
+
migration.up(db);
|
|
626
|
+
db.pragma(`user_version = ${migration.version}`);
|
|
627
|
+
})();
|
|
628
|
+
}
|
|
629
|
+
return { from, to: currentSchemaVersion(db) };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// src/store/store.ts
|
|
633
|
+
function epochOf(ts) {
|
|
634
|
+
const parsed = Date.parse(ts);
|
|
635
|
+
return Number.isNaN(parsed) ? Date.now() : parsed;
|
|
636
|
+
}
|
|
637
|
+
var MemoryStore = class _MemoryStore {
|
|
638
|
+
constructor(db) {
|
|
639
|
+
this.db = db;
|
|
640
|
+
}
|
|
641
|
+
db;
|
|
642
|
+
static open(dbPath) {
|
|
643
|
+
mkdirSync(dirname2(dbPath), { recursive: true });
|
|
644
|
+
const db = new Database(dbPath);
|
|
645
|
+
db.pragma("journal_mode = WAL");
|
|
646
|
+
db.pragma("synchronous = NORMAL");
|
|
647
|
+
db.pragma("foreign_keys = ON");
|
|
648
|
+
sqliteVec.load(db);
|
|
649
|
+
migrate(db);
|
|
650
|
+
return new _MemoryStore(db);
|
|
651
|
+
}
|
|
652
|
+
close() {
|
|
653
|
+
this.db.close();
|
|
654
|
+
}
|
|
655
|
+
upsertProject(project) {
|
|
656
|
+
this.db.prepare(
|
|
657
|
+
`INSERT INTO projects (id, root, origin_url, created_at)
|
|
658
|
+
VALUES (@id, @root, @originUrl, @now)
|
|
659
|
+
ON CONFLICT(id) DO UPDATE SET root = excluded.root, origin_url = excluded.origin_url`
|
|
660
|
+
).run({ ...project, now: Date.now() });
|
|
661
|
+
}
|
|
662
|
+
markSynced(projectId) {
|
|
663
|
+
this.db.prepare("UPDATE projects SET last_synced_at = ? WHERE id = ?").run(Date.now(), projectId);
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Write a batch of nodes in one transaction.
|
|
667
|
+
*
|
|
668
|
+
* Ids are content-addressed, so re-ingesting the same event is a no-op --
|
|
669
|
+
* a node is only rewritten when the derived content actually changed (which
|
|
670
|
+
* happens when scoring or body composition is improved between releases).
|
|
671
|
+
*/
|
|
672
|
+
upsertNodes(nodes) {
|
|
673
|
+
const exists = this.db.prepare("SELECT body, signal, title FROM nodes WHERE id = ?");
|
|
674
|
+
const dropStaleEmbedding = this.db.prepare(
|
|
675
|
+
"DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)"
|
|
676
|
+
);
|
|
677
|
+
const insertNode = this.db.prepare(
|
|
678
|
+
`INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, created_at)
|
|
679
|
+
VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @now)
|
|
680
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
681
|
+
ts = excluded.ts, ts_epoch = excluded.ts_epoch, source = excluded.source,
|
|
682
|
+
title = excluded.title, body = excluded.body, signal = excluded.signal, meta = excluded.meta`
|
|
683
|
+
);
|
|
684
|
+
const clearFiles = this.db.prepare("DELETE FROM node_files WHERE node_id = ?");
|
|
685
|
+
const insertFile = this.db.prepare(
|
|
686
|
+
`INSERT INTO node_files (node_id, path, previous_path, insertions, deletions, is_binary)
|
|
687
|
+
VALUES (@nodeId, @path, @previousPath, @insertions, @deletions, @isBinary)
|
|
688
|
+
ON CONFLICT(node_id, path) DO UPDATE SET
|
|
689
|
+
previous_path = excluded.previous_path, insertions = excluded.insertions,
|
|
690
|
+
deletions = excluded.deletions, is_binary = excluded.is_binary`
|
|
691
|
+
);
|
|
692
|
+
const stats = { inserted: 0, updated: 0, unchanged: 0 };
|
|
693
|
+
const run = this.db.transaction((batch) => {
|
|
694
|
+
const now = Date.now();
|
|
695
|
+
for (const node of batch) {
|
|
696
|
+
const prior = exists.get(node.id);
|
|
697
|
+
if (prior) {
|
|
698
|
+
if (prior.body === node.body && prior.signal === node.signal && prior.title === node.title) {
|
|
699
|
+
stats.unchanged += 1;
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
stats.updated += 1;
|
|
703
|
+
dropStaleEmbedding.run(node.id);
|
|
704
|
+
} else {
|
|
705
|
+
stats.inserted += 1;
|
|
706
|
+
}
|
|
707
|
+
insertNode.run({
|
|
708
|
+
id: node.id,
|
|
709
|
+
kind: node.kind,
|
|
710
|
+
projectId: node.projectId,
|
|
711
|
+
ts: node.ts,
|
|
712
|
+
tsEpoch: epochOf(node.ts),
|
|
713
|
+
source: node.source,
|
|
714
|
+
title: node.title,
|
|
715
|
+
body: node.body,
|
|
716
|
+
signal: node.signal,
|
|
717
|
+
meta: JSON.stringify(node.meta),
|
|
718
|
+
now
|
|
719
|
+
});
|
|
720
|
+
clearFiles.run(node.id);
|
|
721
|
+
for (const file of node.files) {
|
|
722
|
+
insertFile.run({
|
|
723
|
+
nodeId: node.id,
|
|
724
|
+
path: file.path,
|
|
725
|
+
previousPath: file.previousPath ?? null,
|
|
726
|
+
insertions: file.insertions,
|
|
727
|
+
deletions: file.deletions,
|
|
728
|
+
isBinary: file.binary ? 1 : 0
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
run(nodes);
|
|
734
|
+
return stats;
|
|
735
|
+
}
|
|
736
|
+
getSyncCursor(projectId, source) {
|
|
737
|
+
const row = this.db.prepare("SELECT cursor FROM sync_state WHERE project_id = ? AND source = ?").get(projectId, source);
|
|
738
|
+
return row?.cursor ?? null;
|
|
739
|
+
}
|
|
740
|
+
setSyncCursor(projectId, source, cursor) {
|
|
741
|
+
this.db.prepare(
|
|
742
|
+
`INSERT INTO sync_state (project_id, source, cursor, last_run_at)
|
|
743
|
+
VALUES (?, ?, ?, ?)
|
|
744
|
+
ON CONFLICT(project_id, source) DO UPDATE SET cursor = excluded.cursor, last_run_at = excluded.last_run_at`
|
|
745
|
+
).run(projectId, source, cursor, Date.now());
|
|
746
|
+
}
|
|
747
|
+
/** Every source that has ever synced for this project, most recently run first. */
|
|
748
|
+
listSyncState(projectId) {
|
|
749
|
+
return this.db.prepare("SELECT source, cursor, last_run_at AS lastRunAt FROM sync_state WHERE project_id = ? ORDER BY last_run_at DESC").all(projectId);
|
|
750
|
+
}
|
|
751
|
+
/** Drop every node for a project. Used by `sync --rebuild`. */
|
|
752
|
+
clearProject(projectId) {
|
|
753
|
+
this.db.prepare("DELETE FROM nodes_vec WHERE rowid IN (SELECT rowid FROM nodes WHERE project_id = ?)").run(projectId);
|
|
754
|
+
const info = this.db.prepare("DELETE FROM nodes WHERE project_id = ?").run(projectId);
|
|
755
|
+
this.db.prepare("DELETE FROM sync_state WHERE project_id = ?").run(projectId);
|
|
756
|
+
return info.changes;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Delete the nodes of one source that its latest full scan did not produce.
|
|
760
|
+
*
|
|
761
|
+
* Needed by any source whose node ids are derived from content that can be
|
|
762
|
+
* *edited in place* rather than only appended to. A `doc_section` id comes
|
|
763
|
+
* from `path + heading slug`, so renaming a markdown heading mints a new node
|
|
764
|
+
* and strands the old one: `sync` reports `+1 new`, and the corpus then holds
|
|
765
|
+
* two contradictory versions of the same section, both of which come back for
|
|
766
|
+
* the same query. Git and shell nodes describe events that already happened
|
|
767
|
+
* and are never restated, so they have nothing to prune.
|
|
768
|
+
*
|
|
769
|
+
* Scoping is the whole safety story here, and it is deliberately narrow:
|
|
770
|
+
*
|
|
771
|
+
* - `project_id` -- never reaches another repository's memory.
|
|
772
|
+
* - `source` -- an exact match on the collector's own key, so pruning `docs`
|
|
773
|
+
* cannot touch `conversation:claude-code`, `shell:pwsh` or `git` nodes even
|
|
774
|
+
* though they share the table.
|
|
775
|
+
* - `keepIds` -- everything this scan produced.
|
|
776
|
+
* - `keepPaths` -- files the scan could not read. Their nodes are kept
|
|
777
|
+
* because an unreadable file is not evidence that its sections are gone.
|
|
778
|
+
*
|
|
779
|
+
* Callers must pass the ids from a *complete* scan of the source. A partial
|
|
780
|
+
* or filtered scan would read as "these nodes no longer exist" and delete
|
|
781
|
+
* real history.
|
|
782
|
+
*/
|
|
783
|
+
pruneSourceNodes(projectId, source, keepIds, opts = {}) {
|
|
784
|
+
const scope = `project_id = @projectId AND source = @source
|
|
785
|
+
AND id NOT IN (SELECT value FROM json_each(@keepIds))
|
|
786
|
+
AND id NOT IN (SELECT node_id FROM node_files WHERE path IN (SELECT value FROM json_each(@keepPaths)))`;
|
|
787
|
+
const params = {
|
|
788
|
+
projectId,
|
|
789
|
+
source,
|
|
790
|
+
keepIds: JSON.stringify(keepIds),
|
|
791
|
+
keepPaths: JSON.stringify(opts.keepPaths ?? [])
|
|
792
|
+
};
|
|
793
|
+
return this.db.transaction(() => {
|
|
794
|
+
this.db.prepare(`DELETE FROM nodes_vec WHERE rowid IN (SELECT rowid FROM nodes WHERE ${scope})`).run(params);
|
|
795
|
+
return this.db.prepare(`DELETE FROM nodes WHERE ${scope}`).run(params).changes;
|
|
796
|
+
})();
|
|
797
|
+
}
|
|
798
|
+
/** Nodes for this project that have no embedding yet (new, or invalidated by a content change). */
|
|
799
|
+
findNodesNeedingEmbedding(projectId, limit = 200) {
|
|
800
|
+
return this.db.prepare(
|
|
801
|
+
`SELECT n.rowid AS rowid, n.id AS id, n.title AS title, n.body AS body
|
|
802
|
+
FROM nodes n
|
|
803
|
+
LEFT JOIN nodes_vec v ON v.rowid = n.rowid
|
|
804
|
+
WHERE n.project_id = ? AND v.rowid IS NULL
|
|
805
|
+
LIMIT ?`
|
|
806
|
+
).all(projectId, limit);
|
|
807
|
+
}
|
|
808
|
+
upsertEmbedding(rowid, embedding) {
|
|
809
|
+
this.db.prepare("INSERT OR REPLACE INTO nodes_vec (rowid, embedding) VALUES (?, ?)").run(BigInt(rowid), embedding);
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Nearest-neighbour search over the corpus.
|
|
813
|
+
*
|
|
814
|
+
* `nodes_vec` has no `project_id` column of its own (embeddings are
|
|
815
|
+
* generic; project scoping lives on `nodes`), so this over-fetches `k`
|
|
816
|
+
* before joining and filtering, then caps to `limit`. Simple and correct;
|
|
817
|
+
* not the efficient way to do this at a scale this project isn't at yet.
|
|
818
|
+
*/
|
|
819
|
+
vectorSearch(projectId, embedding, limit = 20) {
|
|
820
|
+
const overfetch = Math.max(limit * 8, 50);
|
|
821
|
+
return this.db.prepare(
|
|
822
|
+
`SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, v.distance AS distance
|
|
823
|
+
FROM nodes_vec v
|
|
824
|
+
JOIN nodes n ON n.rowid = v.rowid
|
|
825
|
+
WHERE v.embedding MATCH ? AND k = ? AND n.project_id = ?
|
|
826
|
+
ORDER BY v.distance
|
|
827
|
+
LIMIT ?`
|
|
828
|
+
).all(embedding, overfetch, projectId, limit);
|
|
829
|
+
}
|
|
830
|
+
stats(projectId) {
|
|
831
|
+
const kinds = this.db.prepare("SELECT kind, COUNT(*) AS n FROM nodes WHERE project_id = ? GROUP BY kind").all(projectId);
|
|
832
|
+
const range = this.db.prepare("SELECT MIN(ts) AS oldest, MAX(ts) AS newest FROM nodes WHERE project_id = ?").get(projectId);
|
|
833
|
+
const files = this.db.prepare(
|
|
834
|
+
`SELECT COUNT(DISTINCT f.path) AS n
|
|
835
|
+
FROM node_files f JOIN nodes n ON n.id = f.node_id
|
|
836
|
+
WHERE n.project_id = ?`
|
|
837
|
+
).get(projectId);
|
|
838
|
+
return {
|
|
839
|
+
total: kinds.reduce((sum, k) => sum + k.n, 0),
|
|
840
|
+
byKind: Object.fromEntries(kinds.map((k) => [k.kind, k.n])),
|
|
841
|
+
oldest: range.oldest,
|
|
842
|
+
newest: range.newest,
|
|
843
|
+
distinctFiles: files.n
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Lexical search over the corpus.
|
|
848
|
+
*
|
|
849
|
+
* Title is weighted 10x body: a commit subject that names the thing you asked
|
|
850
|
+
* about is far stronger evidence than the same word buried in a file list.
|
|
851
|
+
* Ranking by `relevance x signal` happens a layer up, in retrieval.
|
|
852
|
+
*/
|
|
853
|
+
search(projectId, query, limit = 20) {
|
|
854
|
+
const match = toMatchQuery(query);
|
|
855
|
+
if (!match) return [];
|
|
856
|
+
const rows = this.db.prepare(
|
|
857
|
+
`SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal,
|
|
858
|
+
bm25(nodes_fts, 10.0, 1.0) AS rank
|
|
859
|
+
FROM nodes_fts
|
|
860
|
+
JOIN nodes n ON n.rowid = nodes_fts.rowid
|
|
861
|
+
WHERE nodes_fts MATCH ? AND n.project_id = ?
|
|
862
|
+
ORDER BY rank
|
|
863
|
+
LIMIT ?`
|
|
864
|
+
).all(match, projectId, limit);
|
|
865
|
+
return rows;
|
|
866
|
+
}
|
|
867
|
+
/** Escape hatch for tests and future modules. */
|
|
868
|
+
get raw() {
|
|
869
|
+
return this.db;
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
// src/cli/commands/init.ts
|
|
874
|
+
async function runInit(opts) {
|
|
875
|
+
const out = opts.out ?? ((chunk) => void process.stdout.write(chunk));
|
|
876
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
877
|
+
const ws = resolveWorkspace(repo.root);
|
|
878
|
+
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
879
|
+
const already = isInitialized(ws);
|
|
880
|
+
if (already && !opts.force) {
|
|
881
|
+
const existing = await readConfig(ws);
|
|
882
|
+
process.stderr.write(
|
|
883
|
+
`${pc2.yellow("already initialized")} ${relative(process.cwd(), ws.configPath) || ws.configPath}
|
|
884
|
+
project ${pc2.cyan(existing.projectId)}
|
|
885
|
+
use ${pc2.bold("--force")} to reset the config (the database is kept)
|
|
886
|
+
`
|
|
887
|
+
);
|
|
888
|
+
return 0;
|
|
889
|
+
}
|
|
890
|
+
await writeWorkspaceGitignore(ws);
|
|
891
|
+
const config = defaultConfig(projectId);
|
|
892
|
+
if (opts.enableConversation) config.sources.conversation.enabled = true;
|
|
893
|
+
await writeConfig(ws, config);
|
|
894
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
895
|
+
try {
|
|
896
|
+
store.upsertProject({ id: projectId, root: repo.root, originUrl: repo.originUrl });
|
|
897
|
+
} finally {
|
|
898
|
+
store.close();
|
|
899
|
+
}
|
|
900
|
+
const lines = [
|
|
901
|
+
`${pc2.green("initialized")} ${ws.dir}`,
|
|
902
|
+
` project ${pc2.cyan(projectId)}`,
|
|
903
|
+
` repo ${repo.root}`,
|
|
904
|
+
` branch ${repo.branch ?? pc2.yellow("(detached)")}`,
|
|
905
|
+
` schema v${LATEST_SCHEMA_VERSION}`
|
|
906
|
+
];
|
|
907
|
+
if (opts.enableConversation) {
|
|
908
|
+
lines.push(` ${pc2.yellow("conversation source enabled")} -- transcripts will be redacted-but-indexed on sync`);
|
|
909
|
+
}
|
|
910
|
+
if (opts.hook) {
|
|
911
|
+
try {
|
|
912
|
+
const target = await resolveHookTarget();
|
|
913
|
+
const result = await installHook(target);
|
|
914
|
+
lines.push(
|
|
915
|
+
"",
|
|
916
|
+
`${pc2.green(result.changed ? "installed" : "already installed")} shell hook`,
|
|
917
|
+
` profile ${target.profilePath}`,
|
|
918
|
+
` log ${target.logPath}`,
|
|
919
|
+
` open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect`
|
|
920
|
+
);
|
|
921
|
+
} catch (err) {
|
|
922
|
+
if (err instanceof ProfileNotFoundError) {
|
|
923
|
+
lines.push("", `${pc2.yellow("hook not installed")} ${err.message}`);
|
|
924
|
+
} else {
|
|
925
|
+
throw err;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
lines.push("", `Next: ${pc2.bold("nexusmem sync")}`, "");
|
|
930
|
+
out(lines.join("\n"));
|
|
931
|
+
return 0;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// src/mcp/server.ts
|
|
935
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
936
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
937
|
+
import { z as z2 } from "zod";
|
|
938
|
+
|
|
939
|
+
// src/core/text.ts
|
|
940
|
+
function truncate(s, max) {
|
|
941
|
+
return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}\u2026`;
|
|
942
|
+
}
|
|
943
|
+
function approxTokens(text) {
|
|
944
|
+
return Math.ceil(text.length / 4);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// src/retrieval/pack.ts
|
|
948
|
+
var DEFAULT_SUMMARY_CHARS = 320;
|
|
949
|
+
var NODE_OVERHEAD_TOKENS = 8;
|
|
950
|
+
var CONVERSATION_ANSWER_MARKER = "\n\nA: ";
|
|
951
|
+
function summarize(hit, maxChars) {
|
|
952
|
+
const answerIdx = hit.body.indexOf(CONVERSATION_ANSWER_MARKER);
|
|
953
|
+
if (answerIdx !== -1) {
|
|
954
|
+
const answer = hit.body.slice(answerIdx + CONVERSATION_ANSWER_MARKER.length).trim();
|
|
955
|
+
if (answer) return truncate(answer, maxChars);
|
|
956
|
+
}
|
|
957
|
+
const rest = hit.body.startsWith(hit.title) ? hit.body.slice(hit.title.length).trim() : hit.body;
|
|
958
|
+
return truncate(rest || hit.title, maxChars);
|
|
959
|
+
}
|
|
960
|
+
function packContext(ranked, tokensBudget, opts = {}) {
|
|
961
|
+
const summaryChars = opts.summaryChars ?? DEFAULT_SUMMARY_CHARS;
|
|
962
|
+
const nodes = [];
|
|
963
|
+
let tokensUsed = 0;
|
|
964
|
+
let droppedForBudget = 0;
|
|
965
|
+
for (const hit of ranked) {
|
|
966
|
+
const summary = summarize(hit, summaryChars);
|
|
967
|
+
const tokens = approxTokens(hit.title) + approxTokens(summary) + NODE_OVERHEAD_TOKENS;
|
|
968
|
+
if (tokensUsed + tokens > tokensBudget) {
|
|
969
|
+
droppedForBudget += 1;
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
nodes.push({
|
|
973
|
+
id: hit.id,
|
|
974
|
+
kind: hit.kind,
|
|
975
|
+
ts: hit.ts,
|
|
976
|
+
title: hit.title,
|
|
977
|
+
signal: hit.signal,
|
|
978
|
+
score: hit.score,
|
|
979
|
+
summary,
|
|
980
|
+
tokens
|
|
981
|
+
});
|
|
982
|
+
tokensUsed += tokens;
|
|
983
|
+
}
|
|
984
|
+
return { nodes, tokensUsed, tokensBudget, consideredNodes: ranked.length, droppedForBudget };
|
|
985
|
+
}
|
|
986
|
+
function renderContextBlock(query, result) {
|
|
987
|
+
if (result.nodes.length === 0) return `No remembered context matched "${query}".`;
|
|
988
|
+
const lines = [`Relevant history for: ${query}`, ""];
|
|
989
|
+
for (const node of result.nodes) {
|
|
990
|
+
lines.push(`- ${node.ts.slice(0, 10)} ${node.title}`);
|
|
991
|
+
if (node.summary && node.summary !== node.title) {
|
|
992
|
+
lines.push(` ${node.summary.replace(/\n+/g, " ")}`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return lines.join("\n");
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// src/retrieval/fuse.ts
|
|
999
|
+
var RRF_K = 60;
|
|
1000
|
+
function reciprocalRankFusion(lists) {
|
|
1001
|
+
const scores = /* @__PURE__ */ new Map();
|
|
1002
|
+
for (const list of lists) {
|
|
1003
|
+
list.forEach((item, index) => {
|
|
1004
|
+
const contribution = 1 / (RRF_K + index + 1);
|
|
1005
|
+
scores.set(item.id, (scores.get(item.id) ?? 0) + contribution);
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
return scores;
|
|
1009
|
+
}
|
|
1010
|
+
function mergeSearchAndVectorHits(bm25Hits, vectorHits) {
|
|
1011
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1012
|
+
for (const hit of bm25Hits) byId.set(hit.id, hit);
|
|
1013
|
+
for (const hit of vectorHits) {
|
|
1014
|
+
if (byId.has(hit.id)) continue;
|
|
1015
|
+
byId.set(hit.id, { id: hit.id, kind: hit.kind, ts: hit.ts, title: hit.title, body: hit.body, signal: hit.signal, rank: 0 });
|
|
1016
|
+
}
|
|
1017
|
+
return [...byId.values()];
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// src/retrieval/rank.ts
|
|
1021
|
+
var RELEVANCE_FLOOR = 0.15;
|
|
1022
|
+
var SIGNAL_FLOOR = 0.2;
|
|
1023
|
+
var RECENCY_FLOOR = 0.3;
|
|
1024
|
+
var DEFAULT_HALF_LIFE_DAYS = 30;
|
|
1025
|
+
var MS_PER_DAY = 864e5;
|
|
1026
|
+
var MAX_PRIOR_OVERTURN = 2;
|
|
1027
|
+
var SIGNAL_EXPONENT = Math.log(MAX_PRIOR_OVERTURN) / Math.log(1 / SIGNAL_FLOOR);
|
|
1028
|
+
var RECENCY_EXPONENT = Math.log(MAX_PRIOR_OVERTURN) / Math.log(1 / RECENCY_FLOOR);
|
|
1029
|
+
function normalizeRelevance(hits) {
|
|
1030
|
+
const costs = hits.map((h) => h.rank);
|
|
1031
|
+
const min = Math.min(...costs);
|
|
1032
|
+
const max = Math.max(...costs);
|
|
1033
|
+
if (min === max) return hits.map(() => 1);
|
|
1034
|
+
return costs.map((cost) => {
|
|
1035
|
+
const normalized = (max - cost) / (max - min);
|
|
1036
|
+
return RELEVANCE_FLOOR + (1 - RELEVANCE_FLOOR) * normalized;
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
function normalizeExternalRelevance(hits, scores) {
|
|
1040
|
+
const values = hits.map((h) => scores.get(h.id) ?? 0);
|
|
1041
|
+
const min = Math.min(...values);
|
|
1042
|
+
const max = Math.max(...values);
|
|
1043
|
+
if (min === max) return hits.map(() => 1);
|
|
1044
|
+
return values.map((v) => {
|
|
1045
|
+
const normalized = (v - min) / (max - min);
|
|
1046
|
+
return RELEVANCE_FLOOR + (1 - RELEVANCE_FLOOR) * normalized;
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
function ageDaysOf(ts, now) {
|
|
1050
|
+
const parsed = Date.parse(ts);
|
|
1051
|
+
if (Number.isNaN(parsed)) return 0;
|
|
1052
|
+
return Math.max(0, (now.getTime() - parsed) / MS_PER_DAY);
|
|
1053
|
+
}
|
|
1054
|
+
function rankHits(hits, opts = {}) {
|
|
1055
|
+
if (hits.length === 0) return [];
|
|
1056
|
+
const halfLife = opts.halfLifeDays ?? DEFAULT_HALF_LIFE_DAYS;
|
|
1057
|
+
const now = opts.now ?? /* @__PURE__ */ new Date();
|
|
1058
|
+
const relevances = opts.relevanceScores ? normalizeExternalRelevance(hits, opts.relevanceScores) : normalizeRelevance(hits);
|
|
1059
|
+
const ranked = hits.map((hit, i) => {
|
|
1060
|
+
const relevance = relevances[i] ?? RELEVANCE_FLOOR;
|
|
1061
|
+
const signalWeight = SIGNAL_FLOOR + (1 - SIGNAL_FLOOR) * hit.signal;
|
|
1062
|
+
const ageDays = ageDaysOf(hit.ts, now);
|
|
1063
|
+
const recencyFactor = RECENCY_FLOOR + (1 - RECENCY_FLOOR) * 2 ** (-ageDays / halfLife);
|
|
1064
|
+
const score = relevance * signalWeight ** SIGNAL_EXPONENT * recencyFactor ** RECENCY_EXPONENT;
|
|
1065
|
+
return { ...hit, relevance, signalWeight, ageDays, recencyFactor, score };
|
|
1066
|
+
});
|
|
1067
|
+
return ranked.sort((a, b) => b.score - a.score);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// src/retrieval/query-pipeline.ts
|
|
1071
|
+
async function runHybridQuery(store, projectId, query, opts) {
|
|
1072
|
+
const bm25Hits = store.search(projectId, query, opts.candidates);
|
|
1073
|
+
let vectorHits = [];
|
|
1074
|
+
if (opts.embeddingProvider) {
|
|
1075
|
+
const queryVector = await opts.embeddingProvider.embed(query);
|
|
1076
|
+
if (queryVector) vectorHits = store.vectorSearch(projectId, queryVector, opts.candidates);
|
|
1077
|
+
}
|
|
1078
|
+
const hits = vectorHits.length > 0 ? mergeSearchAndVectorHits(bm25Hits, vectorHits) : bm25Hits;
|
|
1079
|
+
const relevanceScores = vectorHits.length > 0 ? reciprocalRankFusion([bm25Hits, vectorHits]) : void 0;
|
|
1080
|
+
const ranked = rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores });
|
|
1081
|
+
const packed = packContext(ranked, opts.budget);
|
|
1082
|
+
return { bm25Count: bm25Hits.length, vectorCount: vectorHits.length, hits, packed };
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// src/vector/embed.ts
|
|
1086
|
+
var DEFAULT_BASE_URL = "http://127.0.0.1:11434";
|
|
1087
|
+
var DEFAULT_MODEL = "nomic-embed-text";
|
|
1088
|
+
var DEFAULT_DIMENSION = 768;
|
|
1089
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
1090
|
+
var OllamaEmbeddingProvider = class {
|
|
1091
|
+
dimension;
|
|
1092
|
+
baseUrl;
|
|
1093
|
+
model;
|
|
1094
|
+
timeoutMs;
|
|
1095
|
+
constructor(opts = {}) {
|
|
1096
|
+
this.baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL;
|
|
1097
|
+
this.model = opts.model ?? DEFAULT_MODEL;
|
|
1098
|
+
this.dimension = opts.dimension ?? DEFAULT_DIMENSION;
|
|
1099
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1100
|
+
}
|
|
1101
|
+
async embed(text) {
|
|
1102
|
+
const controller = new AbortController();
|
|
1103
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
1104
|
+
try {
|
|
1105
|
+
const res = await fetch(`${this.baseUrl}/api/embeddings`, {
|
|
1106
|
+
method: "POST",
|
|
1107
|
+
headers: { "content-type": "application/json" },
|
|
1108
|
+
body: JSON.stringify({ model: this.model, prompt: text }),
|
|
1109
|
+
signal: controller.signal
|
|
1110
|
+
});
|
|
1111
|
+
if (!res.ok) return null;
|
|
1112
|
+
const data = await res.json();
|
|
1113
|
+
if (!Array.isArray(data.embedding) || data.embedding.length !== this.dimension) return null;
|
|
1114
|
+
return new Float32Array(data.embedding);
|
|
1115
|
+
} catch {
|
|
1116
|
+
return null;
|
|
1117
|
+
} finally {
|
|
1118
|
+
clearTimeout(timeout);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
// src/cli/commands/sync.ts
|
|
1124
|
+
import pc3 from "picocolors";
|
|
1125
|
+
|
|
1126
|
+
// src/conversation/chunk.ts
|
|
1127
|
+
var HEADING_LINE = /^#{1,6}\s+(.+)$/;
|
|
1128
|
+
var BOLD_LEAD = /^\*\*([^*]+?)\*\*/;
|
|
1129
|
+
function sectionStart(paragraph) {
|
|
1130
|
+
const firstLine = paragraph.split("\n")[0] ?? "";
|
|
1131
|
+
const heading = HEADING_LINE.exec(firstLine);
|
|
1132
|
+
if (heading) return (heading[1] ?? "").trim();
|
|
1133
|
+
const bold = BOLD_LEAD.exec(paragraph);
|
|
1134
|
+
if (bold) return (bold[1] ?? "").trim();
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
function chunkAssistantText(text, maxChars) {
|
|
1138
|
+
const paragraphs = text.replace(/\r\n?/g, "\n").split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
|
|
1139
|
+
if (paragraphs.length === 0) return [];
|
|
1140
|
+
const chunks = [];
|
|
1141
|
+
let buffer = [];
|
|
1142
|
+
let bufferHeading = null;
|
|
1143
|
+
const bufferChars = () => buffer.reduce((n, p) => n + p.length, 0) + Math.max(0, buffer.length - 1) * 2;
|
|
1144
|
+
const flush = () => {
|
|
1145
|
+
if (buffer.length === 0) return;
|
|
1146
|
+
chunks.push({ heading: bufferHeading, text: truncate(buffer.join("\n\n"), maxChars) });
|
|
1147
|
+
buffer = [];
|
|
1148
|
+
bufferHeading = null;
|
|
1149
|
+
};
|
|
1150
|
+
for (const paragraph of paragraphs) {
|
|
1151
|
+
const heading = sectionStart(paragraph);
|
|
1152
|
+
const startsNewSection = heading !== null && buffer.length > 0;
|
|
1153
|
+
const wouldOverflow = buffer.length > 0 && bufferChars() + 2 + paragraph.length > maxChars;
|
|
1154
|
+
if (startsNewSection || wouldOverflow) flush();
|
|
1155
|
+
if (heading !== null) bufferHeading = heading;
|
|
1156
|
+
buffer.push(paragraph);
|
|
1157
|
+
}
|
|
1158
|
+
flush();
|
|
1159
|
+
return chunks;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// src/conversation/redact.ts
|
|
1163
|
+
var RULES = [
|
|
1164
|
+
{ name: "private-key-block", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
|
|
1165
|
+
{ name: "aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
1166
|
+
{ name: "github-token", pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
|
|
1167
|
+
{ name: "slack-token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
1168
|
+
{ name: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
|
|
1169
|
+
// key/token/secret/password = "value" or : value, in code, JSON, env-file or prose form.
|
|
1170
|
+
{
|
|
1171
|
+
name: "key-value-secret",
|
|
1172
|
+
pattern: /\b((?:api[_-]?key|secret|password|passwd|token|access[_-]?key)s?)\s*[:=]\s*['"]?[A-Za-z0-9_\-./+=]{8,}['"]?/gi
|
|
1173
|
+
}
|
|
1174
|
+
];
|
|
1175
|
+
function redact(text) {
|
|
1176
|
+
let redactedCount = 0;
|
|
1177
|
+
let out = text;
|
|
1178
|
+
for (const rule of RULES) {
|
|
1179
|
+
out = out.replace(rule.pattern, (_match, ...rest) => {
|
|
1180
|
+
redactedCount += 1;
|
|
1181
|
+
const key = typeof rest[0] === "string" ? rest[0] : null;
|
|
1182
|
+
return key ? `${key}: [redacted]` : "[redacted]";
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
return { text: out, redactedCount };
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// src/collectors/conversation.ts
|
|
1189
|
+
var DEFAULT_MAX_BODY_CHARS = 2500;
|
|
1190
|
+
var DEFAULT_MAX_CHUNK_CHARS = 900;
|
|
1191
|
+
var MAX_TITLE_CHARS = 200;
|
|
1192
|
+
var MAX_FILES_PER_NODE = 20;
|
|
1193
|
+
var EXPLANATION_MARKERS = /\b(because|the reason|design decision|trade-?off|instead of|rationale|so that)\b|เพราะ|ทำไม|เหตุผล/i;
|
|
1194
|
+
var TRIVIAL_ACK = /^(ok|okay|thanks?|ขอบคุณ|ครับ|ค่ะ|got it|sounds good|👍|done)\.?!?$/i;
|
|
1195
|
+
function scoreConversationTurn(userText, replyText) {
|
|
1196
|
+
const text = `${userText}
|
|
1197
|
+
${replyText}`;
|
|
1198
|
+
let score = 0.3;
|
|
1199
|
+
if (EXPLANATION_MARKERS.test(text)) score += 0.25;
|
|
1200
|
+
if (replyText.length > 400) score += 0.15;
|
|
1201
|
+
else if (replyText.length < 80) score -= 0.1;
|
|
1202
|
+
if (TRIVIAL_ACK.test(userText.trim())) score -= 0.2;
|
|
1203
|
+
if (userText.trim().endsWith("?")) score += 0.05;
|
|
1204
|
+
return Number(Math.min(1, Math.max(0.05, score)).toFixed(3));
|
|
1205
|
+
}
|
|
1206
|
+
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;
|
|
1207
|
+
function extractMentionedFiles(text) {
|
|
1208
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1209
|
+
const files = [];
|
|
1210
|
+
for (const match of text.matchAll(FILE_PATH)) {
|
|
1211
|
+
const path = match[0];
|
|
1212
|
+
if (seen.has(path)) continue;
|
|
1213
|
+
seen.add(path);
|
|
1214
|
+
files.push({ path, insertions: null, deletions: null, binary: false });
|
|
1215
|
+
if (files.length >= MAX_FILES_PER_NODE) break;
|
|
1216
|
+
}
|
|
1217
|
+
return files;
|
|
1218
|
+
}
|
|
1219
|
+
function withSuffix(base, suffix) {
|
|
1220
|
+
const budget = Math.max(20, MAX_TITLE_CHARS - suffix.length);
|
|
1221
|
+
return `${truncate(base, budget)}${suffix}`;
|
|
1222
|
+
}
|
|
1223
|
+
function chunkTitle(userFirstLine, heading, index, count) {
|
|
1224
|
+
if (heading) return withSuffix(userFirstLine, ` \u2014 ${heading}`);
|
|
1225
|
+
if (count > 1) return withSuffix(userFirstLine, ` (part ${index + 1}/${count})`);
|
|
1226
|
+
return truncate(userFirstLine, MAX_TITLE_CHARS);
|
|
1227
|
+
}
|
|
1228
|
+
function toMemoryNodes(turn, projectId, opts = {}) {
|
|
1229
|
+
const maxBody = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS;
|
|
1230
|
+
const maxChunk = opts.maxChunkChars ?? DEFAULT_MAX_CHUNK_CHARS;
|
|
1231
|
+
const userRedacted = redact(turn.userText);
|
|
1232
|
+
const userFirstLine = userRedacted.text.split(/\r?\n/)[0] ?? userRedacted.text;
|
|
1233
|
+
const assistantRedacted = redact(turn.assistantText);
|
|
1234
|
+
const chunks = chunkAssistantText(assistantRedacted.text, maxChunk);
|
|
1235
|
+
if (chunks.length === 0) return [];
|
|
1236
|
+
return chunks.map((chunk, index) => {
|
|
1237
|
+
const body = [`Q: ${userRedacted.text}`, "", `A: ${chunk.text}`].join("\n");
|
|
1238
|
+
return {
|
|
1239
|
+
id: makeNodeId(projectId, "conversation_turn", `${turn.naturalKey}:${index}`),
|
|
1240
|
+
kind: "conversation_turn",
|
|
1241
|
+
projectId,
|
|
1242
|
+
ts: turn.ts,
|
|
1243
|
+
source: `conversation:${turn.source}`,
|
|
1244
|
+
title: chunkTitle(userFirstLine, chunk.heading, index, chunks.length),
|
|
1245
|
+
body: truncate(body, maxBody),
|
|
1246
|
+
files: extractMentionedFiles(`${userRedacted.text}
|
|
1247
|
+
${chunk.text}`),
|
|
1248
|
+
signal: scoreConversationTurn(userRedacted.text, chunk.text),
|
|
1249
|
+
meta: {
|
|
1250
|
+
cwd: turn.cwd,
|
|
1251
|
+
source: turn.source,
|
|
1252
|
+
chunkIndex: index,
|
|
1253
|
+
chunkCount: chunks.length,
|
|
1254
|
+
heading: chunk.heading,
|
|
1255
|
+
// Redaction runs once over the whole reply before chunking (so a
|
|
1256
|
+
// secret can never straddle a chunk boundary and slip through) --
|
|
1257
|
+
// this is the turn's total, repeated on every chunk it produced,
|
|
1258
|
+
// not a per-chunk count.
|
|
1259
|
+
redactedCount: userRedacted.redactedCount + assistantRedacted.redactedCount
|
|
1260
|
+
}
|
|
1261
|
+
};
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
function collectConversationTurns(turns, projectId, opts = {}) {
|
|
1265
|
+
return turns.filter((t) => t.assistantText.length > 0).flatMap((turn) => toMemoryNodes(turn, projectId, opts));
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// src/collectors/docs.ts
|
|
1269
|
+
var DEFAULT_MAX_BODY_CHARS2 = 2e3;
|
|
1270
|
+
var DEFAULT_MAX_CHUNK_CHARS2 = 1200;
|
|
1271
|
+
var MAX_TITLE_CHARS2 = 200;
|
|
1272
|
+
var EXPLANATION_MARKERS2 = /\b(because|the reason|design decision|trade-?off|instead of|rationale|why)\b/i;
|
|
1273
|
+
function scoreDocSection(path, heading, text) {
|
|
1274
|
+
let score = 0.45;
|
|
1275
|
+
if (EXPLANATION_MARKERS2.test(text)) score += 0.25;
|
|
1276
|
+
if (/(^|\/)readme\.md$/i.test(path)) score += 0.1;
|
|
1277
|
+
if (heading === null) score -= 0.1;
|
|
1278
|
+
if (text.length < 80) score -= 0.15;
|
|
1279
|
+
return Number(Math.min(1, Math.max(0.05, score)).toFixed(3));
|
|
1280
|
+
}
|
|
1281
|
+
function slugify(heading, index) {
|
|
1282
|
+
if (heading === null) return `_preamble-${index}`;
|
|
1283
|
+
const slug = heading.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1284
|
+
return slug || `_section-${index}`;
|
|
1285
|
+
}
|
|
1286
|
+
function sectionTitle(path, heading, index, count) {
|
|
1287
|
+
if (heading) return truncate(`${path} \u2014 ${heading}`, MAX_TITLE_CHARS2);
|
|
1288
|
+
if (count > 1) return truncate(`${path} (part ${index + 1}/${count})`, MAX_TITLE_CHARS2);
|
|
1289
|
+
return truncate(path, MAX_TITLE_CHARS2);
|
|
1290
|
+
}
|
|
1291
|
+
function toMemoryNodes2(file, projectId, opts = {}) {
|
|
1292
|
+
const maxBody = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS2;
|
|
1293
|
+
const maxChunk = opts.maxChunkChars ?? DEFAULT_MAX_CHUNK_CHARS2;
|
|
1294
|
+
const chunks = chunkAssistantText(file.content, maxChunk);
|
|
1295
|
+
if (chunks.length === 0) return [];
|
|
1296
|
+
const seenSlugs = /* @__PURE__ */ new Map();
|
|
1297
|
+
return chunks.map((chunk, index) => {
|
|
1298
|
+
const baseSlug = slugify(chunk.heading, index);
|
|
1299
|
+
const occurrence = seenSlugs.get(baseSlug) ?? 0;
|
|
1300
|
+
seenSlugs.set(baseSlug, occurrence + 1);
|
|
1301
|
+
const naturalKey = occurrence === 0 ? `${file.path}#${baseSlug}` : `${file.path}#${baseSlug}:${occurrence}`;
|
|
1302
|
+
return {
|
|
1303
|
+
id: makeNodeId(projectId, "doc_section", naturalKey),
|
|
1304
|
+
kind: "doc_section",
|
|
1305
|
+
projectId,
|
|
1306
|
+
ts: file.ts,
|
|
1307
|
+
source: "docs",
|
|
1308
|
+
title: sectionTitle(file.path, chunk.heading, index, chunks.length),
|
|
1309
|
+
body: truncate(chunk.text, maxBody),
|
|
1310
|
+
files: [{ path: file.path, insertions: null, deletions: null, binary: false }],
|
|
1311
|
+
signal: scoreDocSection(file.path, chunk.heading, chunk.text),
|
|
1312
|
+
meta: {
|
|
1313
|
+
path: file.path,
|
|
1314
|
+
heading: chunk.heading,
|
|
1315
|
+
chunkIndex: index,
|
|
1316
|
+
chunkCount: chunks.length
|
|
1317
|
+
}
|
|
1318
|
+
};
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
function collectDocFiles(files, projectId, opts = {}) {
|
|
1322
|
+
return files.flatMap((file) => toMemoryNodes2(file, projectId, opts));
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// src/git/parse.ts
|
|
1326
|
+
var RECORD_SEP = "";
|
|
1327
|
+
var UNIT_SEP = "";
|
|
1328
|
+
var GIT_LOG_FORMAT = "%x1e%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%cI%x1f%s%x1f%B%x1f";
|
|
1329
|
+
var FIELD_COUNT = 10;
|
|
1330
|
+
function splitRecords(buffer, flush = false) {
|
|
1331
|
+
const parts = buffer.split(RECORD_SEP);
|
|
1332
|
+
const rest = flush ? "" : parts.pop() ?? "";
|
|
1333
|
+
const records = parts.filter((p) => p.trim().length > 0);
|
|
1334
|
+
if (flush && rest.trim().length > 0) records.push(rest);
|
|
1335
|
+
return { records, rest };
|
|
1336
|
+
}
|
|
1337
|
+
function parseCommitRecord(record) {
|
|
1338
|
+
let payload = record;
|
|
1339
|
+
while (payload.startsWith(RECORD_SEP)) payload = payload.slice(RECORD_SEP.length);
|
|
1340
|
+
const parts = payload.split(UNIT_SEP);
|
|
1341
|
+
if (parts.length < FIELD_COUNT) return null;
|
|
1342
|
+
const sha = parts[0] ?? "";
|
|
1343
|
+
if (!/^[0-9a-f]{7,64}$/i.test(sha)) return null;
|
|
1344
|
+
const numstatBlock = parts[parts.length - 1] ?? "";
|
|
1345
|
+
const message = parts.slice(8, parts.length - 1).join(UNIT_SEP).trim();
|
|
1346
|
+
const subject = (parts[7] ?? "").trim();
|
|
1347
|
+
const parents = (parts[2] ?? "").trim().split(/\s+/).filter(Boolean);
|
|
1348
|
+
return {
|
|
1349
|
+
sha,
|
|
1350
|
+
shortSha: parts[1] ?? "",
|
|
1351
|
+
parents,
|
|
1352
|
+
authorName: parts[3] ?? "",
|
|
1353
|
+
authorEmail: parts[4] ?? "",
|
|
1354
|
+
authoredAt: parts[5] ?? "",
|
|
1355
|
+
committedAt: parts[6] ?? "",
|
|
1356
|
+
subject,
|
|
1357
|
+
message,
|
|
1358
|
+
messageBody: stripSubject(message, subject),
|
|
1359
|
+
files: parseNumstat(numstatBlock),
|
|
1360
|
+
isMerge: parents.length > 1
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
function stripSubject(message, subject) {
|
|
1364
|
+
if (!subject || !message.startsWith(subject)) return message;
|
|
1365
|
+
return message.slice(subject.length).trim();
|
|
1366
|
+
}
|
|
1367
|
+
var NUMSTAT_LINE = /^(\d+|-)\t(\d+|-)\t(.*)$/;
|
|
1368
|
+
function parseNumstat(block) {
|
|
1369
|
+
const files = [];
|
|
1370
|
+
for (const line of block.split("\n")) {
|
|
1371
|
+
const m = NUMSTAT_LINE.exec(line.trimEnd());
|
|
1372
|
+
if (!m) continue;
|
|
1373
|
+
const [, addRaw = "", delRaw = "", pathRaw = ""] = m;
|
|
1374
|
+
const binary = addRaw === "-" || delRaw === "-";
|
|
1375
|
+
const { path, previousPath } = resolveRenamePath(unquoteGitPath(pathRaw));
|
|
1376
|
+
const touch = {
|
|
1377
|
+
path,
|
|
1378
|
+
insertions: binary ? null : Number(addRaw),
|
|
1379
|
+
deletions: binary ? null : Number(delRaw),
|
|
1380
|
+
binary
|
|
1381
|
+
};
|
|
1382
|
+
if (previousPath) touch.previousPath = previousPath;
|
|
1383
|
+
files.push(touch);
|
|
1384
|
+
}
|
|
1385
|
+
return files;
|
|
1386
|
+
}
|
|
1387
|
+
function resolveRenamePath(raw) {
|
|
1388
|
+
const braced = /^(.*)\{(.*?) => (.*?)\}(.*)$/.exec(raw);
|
|
1389
|
+
if (braced) {
|
|
1390
|
+
const [, prefix = "", from = "", to = "", suffix = ""] = braced;
|
|
1391
|
+
return {
|
|
1392
|
+
path: collapseSlashes(prefix + to + suffix),
|
|
1393
|
+
previousPath: collapseSlashes(prefix + from + suffix)
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
const plain = raw.split(" => ");
|
|
1397
|
+
if (plain.length === 2) {
|
|
1398
|
+
return { path: (plain[1] ?? "").trim(), previousPath: (plain[0] ?? "").trim() };
|
|
1399
|
+
}
|
|
1400
|
+
return { path: raw };
|
|
1401
|
+
}
|
|
1402
|
+
function collapseSlashes(p) {
|
|
1403
|
+
return p.replace(/\/{2,}/g, "/").replace(/^\//, "");
|
|
1404
|
+
}
|
|
1405
|
+
function unquoteGitPath(p) {
|
|
1406
|
+
if (p.length < 2 || !p.startsWith('"') || !p.endsWith('"')) return p;
|
|
1407
|
+
const inner = p.slice(1, -1);
|
|
1408
|
+
const bytes = [];
|
|
1409
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
1410
|
+
const ch = inner[i] ?? "";
|
|
1411
|
+
if (ch !== "\\") {
|
|
1412
|
+
for (const b of Buffer.from(ch, "utf8")) bytes.push(b);
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
const esc = inner[i + 1] ?? "";
|
|
1416
|
+
const simple = { n: 10, t: 9, r: 13, a: 7, b: 8, f: 12, v: 11 };
|
|
1417
|
+
if (esc in simple) {
|
|
1418
|
+
bytes.push(simple[esc]);
|
|
1419
|
+
i += 1;
|
|
1420
|
+
} else if (esc === '"' || esc === "\\") {
|
|
1421
|
+
bytes.push(esc.charCodeAt(0));
|
|
1422
|
+
i += 1;
|
|
1423
|
+
} else if (/[0-7]/.test(esc)) {
|
|
1424
|
+
const octal = inner.slice(i + 1, i + 4);
|
|
1425
|
+
bytes.push(parseInt(octal, 8) & 255);
|
|
1426
|
+
i += 3;
|
|
1427
|
+
} else {
|
|
1428
|
+
bytes.push(92);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
return Buffer.from(bytes).toString("utf8");
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// src/git/log.ts
|
|
1435
|
+
var EMPTY_HISTORY = /does not have any commits yet|unknown revision|bad revision|ambiguous argument/i;
|
|
1436
|
+
function buildLogArgs(opts = {}) {
|
|
1437
|
+
const { rev = "HEAD", afterCommit, since, maxCount, includeMerges = true, paths } = opts;
|
|
1438
|
+
const args = ["log", `--format=${GIT_LOG_FORMAT}`, "--numstat", "--no-color"];
|
|
1439
|
+
if (!includeMerges) args.push("--no-merges");
|
|
1440
|
+
if (maxCount && maxCount > 0) args.push(`--max-count=${maxCount}`);
|
|
1441
|
+
if (since) args.push(`--since=${since}`);
|
|
1442
|
+
args.push(afterCommit ? `${afterCommit}..${rev}` : rev);
|
|
1443
|
+
if (paths?.length) args.push("--", ...paths);
|
|
1444
|
+
return args;
|
|
1445
|
+
}
|
|
1446
|
+
async function* readCommits(cwd, opts = {}) {
|
|
1447
|
+
const args = buildLogArgs(opts);
|
|
1448
|
+
let buffer = "";
|
|
1449
|
+
try {
|
|
1450
|
+
for await (const chunk of gitStream(cwd, args)) {
|
|
1451
|
+
buffer += chunk;
|
|
1452
|
+
const { records, rest } = splitRecords(buffer);
|
|
1453
|
+
buffer = rest;
|
|
1454
|
+
for (const record of records) {
|
|
1455
|
+
const commit = parseCommitRecord(record);
|
|
1456
|
+
if (commit) yield commit;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
} catch (err) {
|
|
1460
|
+
if (err instanceof GitError && EMPTY_HISTORY.test(err.stderr)) return;
|
|
1461
|
+
throw err;
|
|
1462
|
+
}
|
|
1463
|
+
for (const record of splitRecords(buffer, true).records) {
|
|
1464
|
+
const commit = parseCommitRecord(record);
|
|
1465
|
+
if (commit) yield commit;
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// src/collectors/git-commits.ts
|
|
1470
|
+
var DEFAULTS = { maxFilesPerNode: 40, maxBodyChars: 4e3 };
|
|
1471
|
+
var MAX_TITLE_CHARS3 = 200;
|
|
1472
|
+
var CONVENTIONAL = /^([a-z]+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/i;
|
|
1473
|
+
function parseConventionalHeader(subject) {
|
|
1474
|
+
const m = CONVENTIONAL.exec(subject.trim());
|
|
1475
|
+
if (!m) return { type: null, scope: null, breaking: false, description: subject.trim() };
|
|
1476
|
+
return {
|
|
1477
|
+
type: (m[1] ?? "").toLowerCase(),
|
|
1478
|
+
scope: m[2] ?? null,
|
|
1479
|
+
breaking: Boolean(m[3]),
|
|
1480
|
+
description: (m[4] ?? "").trim()
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
var TYPE_WEIGHTS = {
|
|
1484
|
+
fix: 0.8,
|
|
1485
|
+
feat: 0.8,
|
|
1486
|
+
revert: 0.78,
|
|
1487
|
+
perf: 0.7,
|
|
1488
|
+
refactor: 0.68,
|
|
1489
|
+
security: 0.85,
|
|
1490
|
+
test: 0.45,
|
|
1491
|
+
docs: 0.35,
|
|
1492
|
+
build: 0.32,
|
|
1493
|
+
ci: 0.28,
|
|
1494
|
+
chore: 0.25,
|
|
1495
|
+
style: 0.2
|
|
1496
|
+
};
|
|
1497
|
+
var AUTOMATED = /^(merge (branch|pull request|remote)|bump |update dependenc|\[bot\]|revert "merge)/i;
|
|
1498
|
+
function scoreCommit(commit) {
|
|
1499
|
+
const header = parseConventionalHeader(commit.subject);
|
|
1500
|
+
let score = header.type ? TYPE_WEIGHTS[header.type] ?? 0.5 : 0.5;
|
|
1501
|
+
if (header.breaking) score += 0.12;
|
|
1502
|
+
if (commit.messageBody.length > 120) score += 0.1;
|
|
1503
|
+
if (commit.isMerge) score = Math.min(score, 0.3);
|
|
1504
|
+
if (AUTOMATED.test(commit.subject)) score -= 0.15;
|
|
1505
|
+
const churn = commit.files.reduce((n, f) => n + (f.insertions ?? 0) + (f.deletions ?? 0), 0);
|
|
1506
|
+
if (commit.files.length > 100 || churn > 5e3) score *= 0.75;
|
|
1507
|
+
if (commit.files.length <= 1 && churn <= 3) score -= 0.05;
|
|
1508
|
+
return Number(Math.min(1, Math.max(0.05, score)).toFixed(3));
|
|
1509
|
+
}
|
|
1510
|
+
function byChurnDesc(a, b) {
|
|
1511
|
+
const ca = (a.insertions ?? 0) + (a.deletions ?? 0);
|
|
1512
|
+
const cb = (b.insertions ?? 0) + (b.deletions ?? 0);
|
|
1513
|
+
return cb - ca;
|
|
1514
|
+
}
|
|
1515
|
+
function renderFileLine(f) {
|
|
1516
|
+
const churn = f.binary ? "binary" : `+${f.insertions ?? 0}/-${f.deletions ?? 0}`;
|
|
1517
|
+
return f.previousPath ? ` ${f.path} (${churn}, renamed from ${f.previousPath})` : ` ${f.path} (${churn})`;
|
|
1518
|
+
}
|
|
1519
|
+
function toMemoryNode(commit, projectId, opts = {}) {
|
|
1520
|
+
const maxFiles = opts.maxFilesPerNode ?? DEFAULTS.maxFilesPerNode;
|
|
1521
|
+
const maxBody = opts.maxBodyChars ?? DEFAULTS.maxBodyChars;
|
|
1522
|
+
const header = parseConventionalHeader(commit.subject);
|
|
1523
|
+
const keptFiles = [...commit.files].sort(byChurnDesc).slice(0, maxFiles);
|
|
1524
|
+
const insertions = commit.files.reduce((n, f) => n + (f.insertions ?? 0), 0);
|
|
1525
|
+
const deletions = commit.files.reduce((n, f) => n + (f.deletions ?? 0), 0);
|
|
1526
|
+
const bodyParts = [commit.subject];
|
|
1527
|
+
if (commit.messageBody) bodyParts.push("", commit.messageBody);
|
|
1528
|
+
if (keptFiles.length) {
|
|
1529
|
+
bodyParts.push("", "Files changed:", ...keptFiles.map(renderFileLine));
|
|
1530
|
+
if (commit.files.length > keptFiles.length) {
|
|
1531
|
+
bodyParts.push(` ...and ${commit.files.length - keptFiles.length} more file(s)`);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
return {
|
|
1535
|
+
id: makeNodeId(projectId, "git_commit", commit.sha),
|
|
1536
|
+
kind: "git_commit",
|
|
1537
|
+
projectId,
|
|
1538
|
+
ts: commit.authoredAt,
|
|
1539
|
+
source: "git",
|
|
1540
|
+
title: truncate(commit.subject || `(no subject) ${commit.shortSha}`, MAX_TITLE_CHARS3),
|
|
1541
|
+
body: truncate(bodyParts.join("\n"), maxBody),
|
|
1542
|
+
files: keptFiles,
|
|
1543
|
+
signal: scoreCommit(commit),
|
|
1544
|
+
meta: {
|
|
1545
|
+
sha: commit.sha,
|
|
1546
|
+
shortSha: commit.shortSha,
|
|
1547
|
+
parents: commit.parents,
|
|
1548
|
+
authorName: commit.authorName,
|
|
1549
|
+
authorEmail: commit.authorEmail,
|
|
1550
|
+
committedAt: commit.committedAt,
|
|
1551
|
+
isMerge: commit.isMerge,
|
|
1552
|
+
filesChanged: commit.files.length,
|
|
1553
|
+
insertions,
|
|
1554
|
+
deletions,
|
|
1555
|
+
conventionalType: header.type,
|
|
1556
|
+
conventionalScope: header.scope,
|
|
1557
|
+
breaking: header.breaking
|
|
1558
|
+
}
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
async function* collectGitCommits(cwd, projectId, opts = {}) {
|
|
1562
|
+
for await (const commit of readCommits(cwd, opts)) {
|
|
1563
|
+
yield toMemoryNode(commit, projectId, opts);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// src/collectors/shell-history.ts
|
|
1568
|
+
var DEFAULT_MAX_BODY_CHARS3 = 1e3;
|
|
1569
|
+
var MAX_TITLE_CHARS4 = 200;
|
|
1570
|
+
var NOISE = /^(cd|ls|dir|pwd|clear|cls|exit|history|whoami|date|type|cat|more|less|ll|la)\b/i;
|
|
1571
|
+
var BUILD_TEST = /^(npm|pnpm|yarn)\s+(run\s+)?(test|build|lint|typecheck|tsc)\b|^(pytest|go\s+test|cargo\s+(test|build)|mvn\s+test|gradle\s+test|dotnet\s+(test|build))\b/i;
|
|
1572
|
+
var INSTALL = /^(npm|pnpm|yarn)\s+(install|add|remove|uninstall|ci)\b|^pip\s+install\b|^(cargo\s+add|go\s+get|composer\s+require)\b/i;
|
|
1573
|
+
var GIT_CMD = /^git\s+/i;
|
|
1574
|
+
var GIT_DESTRUCTIVE = /^git\s+(push\s+.*--force|reset\s+--hard|clean\s+-[a-z]*f|branch\s+-d)\b/i;
|
|
1575
|
+
var RISKY = /(rm\s+-rf|remove-item\s+.*-recurse|del\s+\/s|drop\s+(table|database)|--force\b|truncate\s+table)/i;
|
|
1576
|
+
function scoreShellCommand(entry) {
|
|
1577
|
+
const cmd = entry.command.trim();
|
|
1578
|
+
let score;
|
|
1579
|
+
if (NOISE.test(cmd)) score = 0.1;
|
|
1580
|
+
else if (RISKY.test(cmd) || GIT_DESTRUCTIVE.test(cmd)) score = 0.75;
|
|
1581
|
+
else if (INSTALL.test(cmd)) score = 0.6;
|
|
1582
|
+
else if (BUILD_TEST.test(cmd)) score = 0.55;
|
|
1583
|
+
else if (GIT_CMD.test(cmd)) score = 0.2;
|
|
1584
|
+
else score = 0.35;
|
|
1585
|
+
if (entry.exitCode !== null) {
|
|
1586
|
+
score += entry.exitCode !== 0 ? 0.25 : 0.05;
|
|
1587
|
+
}
|
|
1588
|
+
if (cmd.length > 60) score += 0.05;
|
|
1589
|
+
else if (cmd.length <= 3) score -= 0.05;
|
|
1590
|
+
return Number(Math.min(1, Math.max(0.05, score)).toFixed(3));
|
|
1591
|
+
}
|
|
1592
|
+
function renderBody(entry, maxChars) {
|
|
1593
|
+
const parts = [`$ ${entry.command}`];
|
|
1594
|
+
const metaLine = [];
|
|
1595
|
+
if (entry.cwd) metaLine.push(`cwd: ${entry.cwd}`);
|
|
1596
|
+
if (entry.exitCode !== null) metaLine.push(`exit: ${entry.exitCode}`);
|
|
1597
|
+
if (entry.durationMs !== null) metaLine.push(`duration: ${entry.durationMs}ms`);
|
|
1598
|
+
if (metaLine.length) parts.push("", metaLine.join(" "));
|
|
1599
|
+
return truncate(parts.join("\n"), maxChars);
|
|
1600
|
+
}
|
|
1601
|
+
function toMemoryNode2(entry, projectId, opts = {}) {
|
|
1602
|
+
const maxBody = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS3;
|
|
1603
|
+
const titleLine = entry.command.split(/\r?\n/)[0] ?? entry.command;
|
|
1604
|
+
return {
|
|
1605
|
+
id: makeNodeId(projectId, "shell_command", entry.naturalKey),
|
|
1606
|
+
kind: "shell_command",
|
|
1607
|
+
projectId,
|
|
1608
|
+
ts: entry.ts,
|
|
1609
|
+
source: `shell:${entry.shell}`,
|
|
1610
|
+
title: truncate(titleLine, MAX_TITLE_CHARS4),
|
|
1611
|
+
body: renderBody(entry, maxBody),
|
|
1612
|
+
files: [],
|
|
1613
|
+
signal: scoreShellCommand(entry),
|
|
1614
|
+
meta: {
|
|
1615
|
+
command: entry.command,
|
|
1616
|
+
cwd: entry.cwd,
|
|
1617
|
+
exitCode: entry.exitCode,
|
|
1618
|
+
durationMs: entry.durationMs,
|
|
1619
|
+
tsApprox: entry.tsApprox,
|
|
1620
|
+
shell: entry.shell
|
|
1621
|
+
}
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
function collectShellHistory(entries, projectId, opts = {}) {
|
|
1625
|
+
return entries.map((entry) => toMemoryNode2(entry, projectId, opts));
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// src/conversation/claude-code-reader.ts
|
|
1629
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
1630
|
+
|
|
1631
|
+
// src/conversation/paths.ts
|
|
1632
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1633
|
+
import { readdir } from "fs/promises";
|
|
1634
|
+
import { homedir as homedir2 } from "os";
|
|
1635
|
+
import { join as join3 } from "path";
|
|
1636
|
+
function claudeProjectSlug(repoRoot) {
|
|
1637
|
+
return repoRoot.replace(/[\\/:]/g, "-");
|
|
1638
|
+
}
|
|
1639
|
+
function claudeProjectTranscriptDir(repoRoot) {
|
|
1640
|
+
return join3(homedir2(), ".claude", "projects", claudeProjectSlug(repoRoot));
|
|
1641
|
+
}
|
|
1642
|
+
async function listTranscriptFiles(repoRoot) {
|
|
1643
|
+
const dir = claudeProjectTranscriptDir(repoRoot);
|
|
1644
|
+
if (!existsSync2(dir)) return [];
|
|
1645
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1646
|
+
return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join3(dir, e.name));
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// src/conversation/claude-code-reader.ts
|
|
1650
|
+
function parseLine(raw) {
|
|
1651
|
+
const trimmed = raw.trim();
|
|
1652
|
+
if (!trimmed) return null;
|
|
1653
|
+
try {
|
|
1654
|
+
return JSON.parse(trimmed);
|
|
1655
|
+
} catch {
|
|
1656
|
+
return null;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
function extractUserText(line) {
|
|
1660
|
+
const content = line.message?.content;
|
|
1661
|
+
if (typeof content === "string") return content.trim() || null;
|
|
1662
|
+
if (Array.isArray(content)) {
|
|
1663
|
+
if (content.some((b) => b.type === "tool_result")) return null;
|
|
1664
|
+
const text = content.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n").trim();
|
|
1665
|
+
return text || null;
|
|
1666
|
+
}
|
|
1667
|
+
return null;
|
|
1668
|
+
}
|
|
1669
|
+
function extractAssistantText(line) {
|
|
1670
|
+
const content = line.message?.content;
|
|
1671
|
+
if (!Array.isArray(content)) return "";
|
|
1672
|
+
return content.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n").trim();
|
|
1673
|
+
}
|
|
1674
|
+
function parseClaudeCodeTranscript(raw, opts = {}) {
|
|
1675
|
+
const source = opts.source ?? "claude-code";
|
|
1676
|
+
const turns = [];
|
|
1677
|
+
let current = null;
|
|
1678
|
+
const flush = () => {
|
|
1679
|
+
if (!current) return;
|
|
1680
|
+
const assistantText = current.assistantParts.join("\n\n").trim();
|
|
1681
|
+
turns.push({
|
|
1682
|
+
naturalKey: `claude-code:${current.uuid}`,
|
|
1683
|
+
userText: current.userText,
|
|
1684
|
+
assistantText,
|
|
1685
|
+
ts: current.ts,
|
|
1686
|
+
cwd: current.cwd,
|
|
1687
|
+
source
|
|
1688
|
+
});
|
|
1689
|
+
current = null;
|
|
1690
|
+
};
|
|
1691
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
1692
|
+
const line = parseLine(rawLine);
|
|
1693
|
+
if (!line || line.isSidechain) continue;
|
|
1694
|
+
if (line.type === "user") {
|
|
1695
|
+
const userText = extractUserText(line);
|
|
1696
|
+
if (userText === null) continue;
|
|
1697
|
+
flush();
|
|
1698
|
+
current = {
|
|
1699
|
+
uuid: line.uuid ?? `noid-${turns.length}`,
|
|
1700
|
+
userText,
|
|
1701
|
+
ts: line.timestamp ?? (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
1702
|
+
cwd: line.cwd ?? null,
|
|
1703
|
+
assistantParts: []
|
|
1704
|
+
};
|
|
1705
|
+
continue;
|
|
1706
|
+
}
|
|
1707
|
+
if (line.type === "assistant" && current) {
|
|
1708
|
+
const text = extractAssistantText(line);
|
|
1709
|
+
if (text) current.assistantParts.push(text);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
flush();
|
|
1713
|
+
return turns;
|
|
1714
|
+
}
|
|
1715
|
+
async function collectClaudeCodeTranscripts(repoRoot) {
|
|
1716
|
+
const files = await listTranscriptFiles(repoRoot);
|
|
1717
|
+
const turns = [];
|
|
1718
|
+
for (const file of files) {
|
|
1719
|
+
const raw = await readFile3(file, "utf8");
|
|
1720
|
+
turns.push(...parseClaudeCodeTranscript(raw));
|
|
1721
|
+
}
|
|
1722
|
+
return turns;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// src/docs/read.ts
|
|
1726
|
+
import { readFile as readFile4, stat } from "fs/promises";
|
|
1727
|
+
import { join as join4 } from "path";
|
|
1728
|
+
var DEFAULT_PATHSPECS = ["*.md"];
|
|
1729
|
+
async function listDocFiles(repoRoot, opts = {}) {
|
|
1730
|
+
const pathspecs = opts.include ?? DEFAULT_PATHSPECS;
|
|
1731
|
+
const out = await git(repoRoot, ["ls-files", "--", ...pathspecs]);
|
|
1732
|
+
return out.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
1733
|
+
}
|
|
1734
|
+
async function readDocFiles(repoRoot, opts = {}) {
|
|
1735
|
+
const paths = await listDocFiles(repoRoot, opts);
|
|
1736
|
+
const files = [];
|
|
1737
|
+
const unreadable = [];
|
|
1738
|
+
for (const relPath of paths) {
|
|
1739
|
+
const path = relPath.replace(/\\/g, "/");
|
|
1740
|
+
const absPath = join4(repoRoot, relPath);
|
|
1741
|
+
let content;
|
|
1742
|
+
let mtime;
|
|
1743
|
+
try {
|
|
1744
|
+
[content, { mtime }] = await Promise.all([readFile4(absPath, "utf8"), stat(absPath)]);
|
|
1745
|
+
} catch {
|
|
1746
|
+
unreadable.push(path);
|
|
1747
|
+
continue;
|
|
1748
|
+
}
|
|
1749
|
+
files.push({ path, content, ts: mtime.toISOString() });
|
|
1750
|
+
}
|
|
1751
|
+
return { files, unreadable };
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// src/shell/detect.ts
|
|
1755
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1756
|
+
import { readFile as readFile6, stat as stat2 } from "fs/promises";
|
|
1757
|
+
|
|
1758
|
+
// src/shell/hook-log.ts
|
|
1759
|
+
import { appendFile, mkdir as mkdir3, readFile as readFile5 } from "fs/promises";
|
|
1760
|
+
import { dirname as dirname3 } from "path";
|
|
1761
|
+
function parseHookLogLine(line) {
|
|
1762
|
+
const trimmed = line.trim();
|
|
1763
|
+
if (!trimmed) return null;
|
|
1764
|
+
let obj;
|
|
1765
|
+
try {
|
|
1766
|
+
obj = JSON.parse(trimmed);
|
|
1767
|
+
} catch {
|
|
1768
|
+
return null;
|
|
1769
|
+
}
|
|
1770
|
+
if (typeof obj !== "object" || obj === null) return null;
|
|
1771
|
+
const o = obj;
|
|
1772
|
+
if (typeof o.ts !== "string" || typeof o.cwd !== "string" || typeof o.command !== "string") return null;
|
|
1773
|
+
return {
|
|
1774
|
+
ts: o.ts,
|
|
1775
|
+
cwd: o.cwd,
|
|
1776
|
+
exitCode: typeof o.exitCode === "number" ? o.exitCode : null,
|
|
1777
|
+
durationMs: typeof o.durationMs === "number" ? o.durationMs : null,
|
|
1778
|
+
command: o.command
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
async function readHookLog(path, fromLine) {
|
|
1782
|
+
let raw;
|
|
1783
|
+
try {
|
|
1784
|
+
raw = await readFile5(path, "utf8");
|
|
1785
|
+
} catch {
|
|
1786
|
+
return { entries: [], totalLines: fromLine };
|
|
1787
|
+
}
|
|
1788
|
+
const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
|
|
1789
|
+
const slice = fromLine > 0 && fromLine <= lines.length ? lines.slice(fromLine) : lines;
|
|
1790
|
+
const entries = slice.map(parseHookLogLine).filter((e) => e !== null);
|
|
1791
|
+
return { entries, totalLines: lines.length };
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// src/shell/parse-bash.ts
|
|
1795
|
+
var EPOCH_COMMENT = /^#(\d{9,10})$/;
|
|
1796
|
+
function parseBashHistory(raw, mtimeMs, opts = {}) {
|
|
1797
|
+
const lines = raw.split(/\r?\n/);
|
|
1798
|
+
const prelim = [];
|
|
1799
|
+
let pendingEpoch = null;
|
|
1800
|
+
for (const line of lines) {
|
|
1801
|
+
if (line.trim().length === 0) continue;
|
|
1802
|
+
const m = EPOCH_COMMENT.exec(line.trim());
|
|
1803
|
+
if (m) {
|
|
1804
|
+
pendingEpoch = Number(m[1]);
|
|
1805
|
+
continue;
|
|
1806
|
+
}
|
|
1807
|
+
prelim.push({ command: line, ts: pendingEpoch !== null ? new Date(pendingEpoch * 1e3).toISOString() : null });
|
|
1808
|
+
pendingEpoch = null;
|
|
1809
|
+
}
|
|
1810
|
+
const tail = opts.tailLines ? prelim.slice(-opts.tailLines) : prelim;
|
|
1811
|
+
const startIndex = prelim.length - tail.length;
|
|
1812
|
+
return tail.map((p, i) => {
|
|
1813
|
+
const fromEnd = tail.length - 1 - i;
|
|
1814
|
+
const approx = p.ts === null;
|
|
1815
|
+
return {
|
|
1816
|
+
naturalKey: `bash:${startIndex + i}:${sha256Hex(p.command).slice(0, 12)}`,
|
|
1817
|
+
command: p.command,
|
|
1818
|
+
ts: p.ts ?? new Date(mtimeMs - fromEnd * 1e3).toISOString(),
|
|
1819
|
+
tsApprox: approx,
|
|
1820
|
+
exitCode: null,
|
|
1821
|
+
cwd: null,
|
|
1822
|
+
durationMs: null,
|
|
1823
|
+
shell: "bash"
|
|
1824
|
+
};
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// src/shell/parse-psreadline.ts
|
|
1829
|
+
function parsePsReadLineHistory(raw, mtimeMs, opts = {}) {
|
|
1830
|
+
const allLines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
1831
|
+
const tail = opts.tailLines ? allLines.slice(-opts.tailLines) : allLines;
|
|
1832
|
+
const startIndex = allLines.length - tail.length;
|
|
1833
|
+
return tail.map((command, i) => {
|
|
1834
|
+
const fromEnd = tail.length - 1 - i;
|
|
1835
|
+
return {
|
|
1836
|
+
naturalKey: `pwsh:${startIndex + i}:${sha256Hex(command).slice(0, 12)}`,
|
|
1837
|
+
command,
|
|
1838
|
+
ts: new Date(mtimeMs - fromEnd * 1e3).toISOString(),
|
|
1839
|
+
tsApprox: true,
|
|
1840
|
+
exitCode: null,
|
|
1841
|
+
cwd: null,
|
|
1842
|
+
durationMs: null,
|
|
1843
|
+
shell: "pwsh"
|
|
1844
|
+
};
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// src/shell/parse-zsh.ts
|
|
1849
|
+
var EXTENDED_PREFIX = /^: (\d+):(\d+);(.*)$/;
|
|
1850
|
+
function parseZshHistory(raw, mtimeMs, opts = {}) {
|
|
1851
|
+
const rawLines = raw.split(/\r?\n/);
|
|
1852
|
+
const prelim = [];
|
|
1853
|
+
let i = 0;
|
|
1854
|
+
while (i < rawLines.length) {
|
|
1855
|
+
const line = rawLines[i] ?? "";
|
|
1856
|
+
if (line.trim().length === 0) {
|
|
1857
|
+
i += 1;
|
|
1858
|
+
continue;
|
|
1859
|
+
}
|
|
1860
|
+
const m = EXTENDED_PREFIX.exec(line);
|
|
1861
|
+
let epoch = null;
|
|
1862
|
+
let duration = null;
|
|
1863
|
+
let cmd;
|
|
1864
|
+
if (m) {
|
|
1865
|
+
epoch = Number(m[1]);
|
|
1866
|
+
duration = Number(m[2]);
|
|
1867
|
+
cmd = m[3] ?? "";
|
|
1868
|
+
} else {
|
|
1869
|
+
cmd = line;
|
|
1870
|
+
}
|
|
1871
|
+
while (cmd.endsWith("\\") && i + 1 < rawLines.length) {
|
|
1872
|
+
i += 1;
|
|
1873
|
+
cmd = `${cmd.slice(0, -1)}
|
|
1874
|
+
${rawLines[i]}`;
|
|
1875
|
+
}
|
|
1876
|
+
prelim.push({ command: cmd, ts: epoch !== null ? new Date(epoch * 1e3).toISOString() : null, durationMs: duration });
|
|
1877
|
+
i += 1;
|
|
1878
|
+
}
|
|
1879
|
+
const tail = opts.tailLines ? prelim.slice(-opts.tailLines) : prelim;
|
|
1880
|
+
const startIndex = prelim.length - tail.length;
|
|
1881
|
+
return tail.map((p, idx) => {
|
|
1882
|
+
const fromEnd = tail.length - 1 - idx;
|
|
1883
|
+
const approx = p.ts === null;
|
|
1884
|
+
return {
|
|
1885
|
+
naturalKey: `zsh:${startIndex + idx}:${sha256Hex(p.command).slice(0, 12)}`,
|
|
1886
|
+
command: p.command,
|
|
1887
|
+
ts: p.ts ?? new Date(mtimeMs - fromEnd * 1e3).toISOString(),
|
|
1888
|
+
tsApprox: approx,
|
|
1889
|
+
exitCode: null,
|
|
1890
|
+
cwd: null,
|
|
1891
|
+
durationMs: p.durationMs,
|
|
1892
|
+
shell: "zsh"
|
|
1893
|
+
};
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// src/shell/detect.ts
|
|
1898
|
+
function isUnderRoot(cwd, root) {
|
|
1899
|
+
const norm = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
1900
|
+
const c = norm(cwd);
|
|
1901
|
+
const r = norm(root);
|
|
1902
|
+
return c === r || c.startsWith(`${r}/`);
|
|
1903
|
+
}
|
|
1904
|
+
function hookEntryToRaw(e) {
|
|
1905
|
+
return {
|
|
1906
|
+
naturalKey: `pwsh-hook:${e.ts}:${sha256Hex(e.command).slice(0, 12)}`,
|
|
1907
|
+
command: e.command,
|
|
1908
|
+
ts: e.ts,
|
|
1909
|
+
tsApprox: false,
|
|
1910
|
+
exitCode: e.exitCode,
|
|
1911
|
+
cwd: e.cwd,
|
|
1912
|
+
durationMs: e.durationMs,
|
|
1913
|
+
shell: "pwsh-hook"
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
async function tryReadScrapeSource(path, parse, tailLines) {
|
|
1917
|
+
if (!existsSync3(path)) return null;
|
|
1918
|
+
const [raw, stats] = await Promise.all([readFile6(path, "utf8"), stat2(path)]);
|
|
1919
|
+
return parse(raw, stats.mtimeMs, { tailLines });
|
|
1920
|
+
}
|
|
1921
|
+
async function collectAvailableShellHistory(opts = {}) {
|
|
1922
|
+
const results = [];
|
|
1923
|
+
const tailLines = opts.tailLines ?? 300;
|
|
1924
|
+
const preferHook = opts.preferHook ?? true;
|
|
1925
|
+
const hookPath = hookLogPath();
|
|
1926
|
+
const hookExists = existsSync3(hookPath);
|
|
1927
|
+
if (hookExists) {
|
|
1928
|
+
const fromLine = Number(opts.hookCursor ?? "0") || 0;
|
|
1929
|
+
const { entries, totalLines } = await readHookLog(hookPath, fromLine);
|
|
1930
|
+
const scoped = opts.repoRoot ? entries.filter((e) => isUnderRoot(e.cwd, opts.repoRoot)) : entries;
|
|
1931
|
+
results.push({ name: "pwsh-hook", entries: scoped.map(hookEntryToRaw), cursorAfter: String(totalLines) });
|
|
1932
|
+
}
|
|
1933
|
+
const skipPwshScrape = preferHook && hookExists;
|
|
1934
|
+
if (!skipPwshScrape && process.platform === "win32") {
|
|
1935
|
+
const entries = await tryReadScrapeSource(psReadLineHistoryPath(), parsePsReadLineHistory, tailLines);
|
|
1936
|
+
if (entries) results.push({ name: "pwsh", entries });
|
|
1937
|
+
}
|
|
1938
|
+
const bashEntries = await tryReadScrapeSource(bashHistoryPath(), parseBashHistory, tailLines);
|
|
1939
|
+
if (bashEntries) results.push({ name: "bash", entries: bashEntries });
|
|
1940
|
+
const zshEntries = await tryReadScrapeSource(zshHistoryPath(), parseZshHistory, tailLines);
|
|
1941
|
+
if (zshEntries) results.push({ name: "zsh", entries: zshEntries });
|
|
1942
|
+
return results;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// src/vector/sync.ts
|
|
1946
|
+
async function embedPendingNodes(store, provider, projectId, opts = {}) {
|
|
1947
|
+
const pending = store.findNodesNeedingEmbedding(projectId, opts.batchLimit ?? 200);
|
|
1948
|
+
let embedded = 0;
|
|
1949
|
+
let skipped = 0;
|
|
1950
|
+
for (const node of pending) {
|
|
1951
|
+
const vector = await provider.embed(`${node.title}
|
|
1952
|
+
${node.body}`);
|
|
1953
|
+
if (vector && vector.length === provider.dimension) {
|
|
1954
|
+
store.upsertEmbedding(node.rowid, vector);
|
|
1955
|
+
embedded += 1;
|
|
1956
|
+
} else {
|
|
1957
|
+
skipped += 1;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
return { embedded, skipped, providerUnavailable: pending.length > 0 && embedded === 0 };
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// src/cli/context.ts
|
|
1964
|
+
async function loadContext(cwd) {
|
|
1965
|
+
const repo = await readRepoInfo(cwd);
|
|
1966
|
+
const ws = resolveWorkspace(repo.root);
|
|
1967
|
+
const config = await readConfig(ws);
|
|
1968
|
+
return { repo, ws, projectId: makeProjectId({ root: repo.root, originUrl: repo.originUrl }), config };
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// src/cli/commands/sync.ts
|
|
1972
|
+
var BATCH_SIZE = 500;
|
|
1973
|
+
var GIT_SOURCE = "git";
|
|
1974
|
+
function addStats(into, from) {
|
|
1975
|
+
into.inserted += from.inserted;
|
|
1976
|
+
into.updated += from.updated;
|
|
1977
|
+
into.unchanged += from.unchanged;
|
|
1978
|
+
}
|
|
1979
|
+
async function syncGit(store, projectId, opts, repo, config, log) {
|
|
1980
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
1981
|
+
if (!repo.head) {
|
|
1982
|
+
log(`${pc3.yellow("git")} skipped -- repository has no commits yet`);
|
|
1983
|
+
return { totals, seen: 0 };
|
|
1984
|
+
}
|
|
1985
|
+
if (!config.sources.git.enabled) {
|
|
1986
|
+
log(`${pc3.dim("git")} disabled in config`);
|
|
1987
|
+
return { totals, seen: 0 };
|
|
1988
|
+
}
|
|
1989
|
+
let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, GIT_SOURCE);
|
|
1990
|
+
if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
|
|
1991
|
+
log(`${pc3.yellow("git cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a full walk`);
|
|
1992
|
+
cursor = null;
|
|
1993
|
+
}
|
|
1994
|
+
if (cursor === repo.head) {
|
|
1995
|
+
log(`${pc3.green("git up to date")} at ${repo.head.slice(0, 7)}`);
|
|
1996
|
+
store.setSyncCursor(projectId, GIT_SOURCE, repo.head);
|
|
1997
|
+
return { totals, seen: 0 };
|
|
1998
|
+
}
|
|
1999
|
+
log(
|
|
2000
|
+
`${pc3.dim("git syncing")} ${repo.branch ?? "HEAD"} ${cursor ? `${cursor.slice(0, 7)}..${repo.head.slice(0, 7)}` : "(full history)"}`
|
|
2001
|
+
);
|
|
2002
|
+
let batch = [];
|
|
2003
|
+
let seen = 0;
|
|
2004
|
+
const flush = () => {
|
|
2005
|
+
if (batch.length === 0) return;
|
|
2006
|
+
addStats(totals, store.upsertNodes(batch));
|
|
2007
|
+
batch = [];
|
|
2008
|
+
log(` ${pc3.dim(`${seen} commits read, ${totals.inserted} new`)}`);
|
|
2009
|
+
};
|
|
2010
|
+
const nodes = collectGitCommits(repo.root, projectId, {
|
|
2011
|
+
afterCommit: cursor,
|
|
2012
|
+
since: opts.since ?? config.sources.git.since,
|
|
2013
|
+
includeMerges: config.sources.git.includeMerges,
|
|
2014
|
+
maxFilesPerNode: config.limits.maxFilesPerNode,
|
|
2015
|
+
maxBodyChars: config.limits.maxBodyChars
|
|
2016
|
+
});
|
|
2017
|
+
for await (const node of nodes) {
|
|
2018
|
+
batch.push(node);
|
|
2019
|
+
seen += 1;
|
|
2020
|
+
if (batch.length >= BATCH_SIZE) flush();
|
|
2021
|
+
}
|
|
2022
|
+
flush();
|
|
2023
|
+
store.setSyncCursor(projectId, GIT_SOURCE, repo.head);
|
|
2024
|
+
return { totals, seen };
|
|
2025
|
+
}
|
|
2026
|
+
async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
2027
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
2028
|
+
if (!config.sources.shell.enabled) {
|
|
2029
|
+
log(`${pc3.dim("shell")} disabled in config`);
|
|
2030
|
+
return { totals, seen: 0 };
|
|
2031
|
+
}
|
|
2032
|
+
const results = await collectAvailableShellHistory({
|
|
2033
|
+
tailLines: opts.shellTailLines ?? config.sources.shell.tailLines,
|
|
2034
|
+
repoRoot,
|
|
2035
|
+
hookCursor: store.getSyncCursor(projectId, "shell:pwsh-hook")
|
|
2036
|
+
});
|
|
2037
|
+
if (results.length === 0) {
|
|
2038
|
+
log(`${pc3.dim("shell")} no history source found on this machine`);
|
|
2039
|
+
return { totals, seen: 0 };
|
|
2040
|
+
}
|
|
2041
|
+
let seen = 0;
|
|
2042
|
+
for (const result of results) {
|
|
2043
|
+
const sourceKey = `shell:${result.name}`;
|
|
2044
|
+
const nodes = collectShellHistory(result.entries, projectId, { maxBodyChars: config.limits.maxBodyChars });
|
|
2045
|
+
seen += nodes.length;
|
|
2046
|
+
if (nodes.length > 0) {
|
|
2047
|
+
addStats(totals, store.upsertNodes(nodes));
|
|
2048
|
+
}
|
|
2049
|
+
store.setSyncCursor(projectId, sourceKey, result.cursorAfter ?? `scanned:${result.entries.length}`);
|
|
2050
|
+
log(` ${pc3.dim(`${sourceKey}: ${nodes.length} entr${nodes.length === 1 ? "y" : "ies"} read`)}`);
|
|
2051
|
+
}
|
|
2052
|
+
return { totals, seen };
|
|
2053
|
+
}
|
|
2054
|
+
var CONVERSATION_SOURCE = "conversation:claude-code";
|
|
2055
|
+
async function syncConversation(store, projectId, repoRoot, config, log, forceEnabled) {
|
|
2056
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
2057
|
+
const enabled = forceEnabled ?? config.sources.conversation.enabled;
|
|
2058
|
+
if (!enabled) {
|
|
2059
|
+
return { totals, seen: 0 };
|
|
2060
|
+
}
|
|
2061
|
+
const turns = await collectClaudeCodeTranscripts(repoRoot);
|
|
2062
|
+
if (turns.length === 0) {
|
|
2063
|
+
log(`${pc3.dim("conversation")} no transcripts found`);
|
|
2064
|
+
return { totals, seen: 0 };
|
|
2065
|
+
}
|
|
2066
|
+
const nodes = collectConversationTurns(turns, projectId, { maxBodyChars: config.limits.maxBodyChars });
|
|
2067
|
+
if (nodes.length > 0) addStats(totals, store.upsertNodes(nodes));
|
|
2068
|
+
store.setSyncCursor(projectId, CONVERSATION_SOURCE, `scanned:${nodes.length}`);
|
|
2069
|
+
log(` ${pc3.dim(`${CONVERSATION_SOURCE}: ${nodes.length} of ${turns.length} exchange(s) kept`)}`);
|
|
2070
|
+
return { totals, seen: nodes.length };
|
|
2071
|
+
}
|
|
2072
|
+
var DOCS_SOURCE = "docs";
|
|
2073
|
+
async function syncDocs(store, projectId, repoRoot, config, log) {
|
|
2074
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
2075
|
+
if (!config.sources.docs.enabled) {
|
|
2076
|
+
log(`${pc3.dim("docs")} disabled in config`);
|
|
2077
|
+
return { totals, seen: 0 };
|
|
2078
|
+
}
|
|
2079
|
+
const { files, unreadable } = await readDocFiles(repoRoot, { include: config.sources.docs.include });
|
|
2080
|
+
const nodes = collectDocFiles(files, projectId, { maxBodyChars: config.limits.maxBodyChars });
|
|
2081
|
+
if (nodes.length > 0) addStats(totals, store.upsertNodes(nodes));
|
|
2082
|
+
const pruned = store.pruneSourceNodes(
|
|
2083
|
+
projectId,
|
|
2084
|
+
DOCS_SOURCE,
|
|
2085
|
+
nodes.map((node) => node.id),
|
|
2086
|
+
{ keepPaths: unreadable }
|
|
2087
|
+
);
|
|
2088
|
+
store.setSyncCursor(projectId, DOCS_SOURCE, `scanned:${nodes.length}`);
|
|
2089
|
+
if (files.length === 0 && unreadable.length === 0) {
|
|
2090
|
+
log(`${pc3.dim("docs")} no tracked .md files found`);
|
|
2091
|
+
} else {
|
|
2092
|
+
const prunedPart = pruned > 0 ? `, ${pc3.yellow(`${pruned} stale removed`)}` : "";
|
|
2093
|
+
const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (kept)` : "";
|
|
2094
|
+
log(` ${pc3.dim(`${DOCS_SOURCE}: ${nodes.length} section(s) from ${files.length} file(s)`)}${prunedPart}${pc3.dim(skippedPart)}`);
|
|
2095
|
+
}
|
|
2096
|
+
return { totals, seen: nodes.length };
|
|
2097
|
+
}
|
|
2098
|
+
async function runSync(opts) {
|
|
2099
|
+
const { repo, ws, projectId, config } = await loadContext(opts.cwd);
|
|
2100
|
+
const log = (line) => {
|
|
2101
|
+
if (!opts.quiet) process.stderr.write(`${line}
|
|
2102
|
+
`);
|
|
2103
|
+
};
|
|
2104
|
+
const out = opts.out ?? ((chunk) => void process.stdout.write(chunk));
|
|
2105
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
2106
|
+
const started = Date.now();
|
|
2107
|
+
try {
|
|
2108
|
+
store.upsertProject({ id: projectId, root: repo.root, originUrl: repo.originUrl });
|
|
2109
|
+
if (opts.rebuild) {
|
|
2110
|
+
const removed = store.clearProject(projectId);
|
|
2111
|
+
log(`${pc3.dim("rebuild")} dropped ${removed} existing node(s)`);
|
|
2112
|
+
}
|
|
2113
|
+
const git2 = await syncGit(store, projectId, opts, repo, config, log);
|
|
2114
|
+
const shell = await syncShell(store, projectId, opts, repo.root, config, log);
|
|
2115
|
+
const conversation = await syncConversation(store, projectId, repo.root, config, log, opts.conversationOverride);
|
|
2116
|
+
const docs = await syncDocs(store, projectId, repo.root, config, log);
|
|
2117
|
+
let embedLine = "";
|
|
2118
|
+
if (!opts.noEmbed) {
|
|
2119
|
+
const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId);
|
|
2120
|
+
if (result.embedded > 0) {
|
|
2121
|
+
embedLine = ` ${pc3.dim(`vector: ${result.embedded} node(s) embedded`)}${result.skipped > 0 ? pc3.dim(`, ${result.skipped} skipped`) : ""}
|
|
2122
|
+
`;
|
|
2123
|
+
} else if (result.providerUnavailable) {
|
|
2124
|
+
log(`${pc3.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
store.markSynced(projectId);
|
|
2128
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
2129
|
+
addStats(totals, git2.totals);
|
|
2130
|
+
addStats(totals, shell.totals);
|
|
2131
|
+
addStats(totals, conversation.totals);
|
|
2132
|
+
addStats(totals, docs.totals);
|
|
2133
|
+
const stats = store.stats(projectId);
|
|
2134
|
+
const elapsed = ((Date.now() - started) / 1e3).toFixed(2);
|
|
2135
|
+
const conversationEnabled = opts.conversationOverride ?? config.sources.conversation.enabled;
|
|
2136
|
+
const conversationPart = conversationEnabled ? `, ${conversation.seen} conversation exchange(s)` : "";
|
|
2137
|
+
const docsPart = config.sources.docs.enabled ? `, ${docs.seen} doc section(s)` : "";
|
|
2138
|
+
out(
|
|
2139
|
+
[
|
|
2140
|
+
`${pc3.green("synced")} ${git2.seen} commit(s), ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${docsPart} in ${elapsed}s`,
|
|
2141
|
+
` ${pc3.green(`+${totals.inserted} new`)} ${pc3.yellow(`~${totals.updated} updated`)} ${pc3.dim(`=${totals.unchanged} unchanged`)}`,
|
|
2142
|
+
` ${pc3.dim(`${stats.total} node(s) total across ${stats.distinctFiles} file path(s)`)}`,
|
|
2143
|
+
""
|
|
2144
|
+
].join("\n") + embedLine
|
|
2145
|
+
);
|
|
2146
|
+
return 0;
|
|
2147
|
+
} finally {
|
|
2148
|
+
store.close();
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// src/mcp/tools.ts
|
|
2153
|
+
async function searchMemory(input) {
|
|
2154
|
+
const repo = await readRepoInfo(input.projectRoot);
|
|
2155
|
+
const ws = resolveWorkspace(repo.root);
|
|
2156
|
+
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
2157
|
+
const budget = input.budget ?? 2e3;
|
|
2158
|
+
const candidates = input.candidates ?? 30;
|
|
2159
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
2160
|
+
try {
|
|
2161
|
+
const { bm25Count, vectorCount, hits, packed } = await runHybridQuery(store, projectId, input.query, {
|
|
2162
|
+
budget,
|
|
2163
|
+
candidates,
|
|
2164
|
+
embeddingProvider: input.noVector ? null : new OllamaEmbeddingProvider()
|
|
2165
|
+
});
|
|
2166
|
+
return {
|
|
2167
|
+
text: renderContextBlock(input.query, packed),
|
|
2168
|
+
matched: hits.length,
|
|
2169
|
+
bm25Matched: bm25Count,
|
|
2170
|
+
vectorMatched: vectorCount,
|
|
2171
|
+
tokensUsed: packed.tokensUsed,
|
|
2172
|
+
tokensBudget: packed.tokensBudget
|
|
2173
|
+
};
|
|
2174
|
+
} finally {
|
|
2175
|
+
store.close();
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
async function syncProject(input) {
|
|
2179
|
+
const chunks = [];
|
|
2180
|
+
const out = (chunk) => {
|
|
2181
|
+
chunks.push(chunk);
|
|
2182
|
+
};
|
|
2183
|
+
await runInit({ cwd: input.projectRoot, force: false, hook: false, enableConversation: false, out });
|
|
2184
|
+
const opts = {
|
|
2185
|
+
cwd: input.projectRoot,
|
|
2186
|
+
full: false,
|
|
2187
|
+
rebuild: false,
|
|
2188
|
+
quiet: true,
|
|
2189
|
+
noEmbed: input.noEmbed,
|
|
2190
|
+
out
|
|
2191
|
+
};
|
|
2192
|
+
await runSync(opts);
|
|
2193
|
+
return { summary: chunks.join("").trim() };
|
|
2194
|
+
}
|
|
2195
|
+
async function getStatus(input) {
|
|
2196
|
+
const repo = await readRepoInfo(input.projectRoot);
|
|
2197
|
+
const ws = resolveWorkspace(repo.root);
|
|
2198
|
+
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
2199
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
2200
|
+
try {
|
|
2201
|
+
const stats = store.stats(projectId);
|
|
2202
|
+
return { total: stats.total, byKind: stats.byKind, sources: store.listSyncState(projectId) };
|
|
2203
|
+
} finally {
|
|
2204
|
+
store.close();
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
// src/mcp/server.ts
|
|
2209
|
+
function createServer() {
|
|
2210
|
+
const server = new McpServer({ name: "nexusmem", version: "0.1.0" });
|
|
2211
|
+
server.registerTool(
|
|
2212
|
+
"search_memory",
|
|
2213
|
+
{
|
|
2214
|
+
title: "Search remembered project history",
|
|
2215
|
+
description: "Search a NexusMem-tracked repository's remembered history: git commits, shell commands, tracked markdown docs, and (if enabled) conversation transcripts. Returns a token-budgeted, ranked context block -- not raw search results.",
|
|
2216
|
+
inputSchema: {
|
|
2217
|
+
projectRoot: z2.string().describe("Absolute path to the repository root"),
|
|
2218
|
+
query: z2.string().describe("Free-text question or search terms"),
|
|
2219
|
+
budget: z2.number().int().positive().optional().describe("Max tokens in the returned context block. Default 2000.")
|
|
2220
|
+
}
|
|
2221
|
+
},
|
|
2222
|
+
async ({ projectRoot, query, budget }) => {
|
|
2223
|
+
const result = await searchMemory({ projectRoot, query, budget });
|
|
2224
|
+
return {
|
|
2225
|
+
content: [{ type: "text", text: result.text }],
|
|
2226
|
+
structuredContent: {
|
|
2227
|
+
text: result.text,
|
|
2228
|
+
matched: result.matched,
|
|
2229
|
+
bm25Matched: result.bm25Matched,
|
|
2230
|
+
vectorMatched: result.vectorMatched,
|
|
2231
|
+
tokensUsed: result.tokensUsed,
|
|
2232
|
+
tokensBudget: result.tokensBudget
|
|
2233
|
+
}
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
2236
|
+
);
|
|
2237
|
+
server.registerTool(
|
|
2238
|
+
"sync_project",
|
|
2239
|
+
{
|
|
2240
|
+
title: "Sync remembered history",
|
|
2241
|
+
description: "Ingest new git, shell, docs and (if enabled) conversation history for a NexusMem-tracked repository into its local database.",
|
|
2242
|
+
inputSchema: {
|
|
2243
|
+
projectRoot: z2.string().describe("Absolute path to the repository root")
|
|
2244
|
+
}
|
|
2245
|
+
},
|
|
2246
|
+
async ({ projectRoot }) => {
|
|
2247
|
+
const result = await syncProject({ projectRoot });
|
|
2248
|
+
return { content: [{ type: "text", text: result.summary }] };
|
|
2249
|
+
}
|
|
2250
|
+
);
|
|
2251
|
+
server.registerTool(
|
|
2252
|
+
"get_status",
|
|
2253
|
+
{
|
|
2254
|
+
title: "Show what is remembered",
|
|
2255
|
+
description: "Report how many nodes NexusMem currently remembers for a repository, broken down by kind and source.",
|
|
2256
|
+
inputSchema: {
|
|
2257
|
+
projectRoot: z2.string().describe("Absolute path to the repository root")
|
|
2258
|
+
}
|
|
2259
|
+
},
|
|
2260
|
+
async ({ projectRoot }) => {
|
|
2261
|
+
const result = await getStatus({ projectRoot });
|
|
2262
|
+
return {
|
|
2263
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
2264
|
+
structuredContent: result
|
|
2265
|
+
};
|
|
2266
|
+
}
|
|
2267
|
+
);
|
|
2268
|
+
return server;
|
|
2269
|
+
}
|
|
2270
|
+
async function runMcpServer() {
|
|
2271
|
+
const server = createServer();
|
|
2272
|
+
const transport = new StdioServerTransport();
|
|
2273
|
+
await server.connect(transport);
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
// src/cli/commands/query.ts
|
|
2277
|
+
import pc4 from "picocolors";
|
|
2278
|
+
async function runQuery(opts) {
|
|
2279
|
+
const { ws, projectId } = await loadContext(opts.cwd);
|
|
2280
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
2281
|
+
try {
|
|
2282
|
+
const { bm25Count, vectorCount, hits, packed } = await runHybridQuery(store, projectId, opts.query, {
|
|
2283
|
+
budget: opts.budget,
|
|
2284
|
+
candidates: opts.candidates,
|
|
2285
|
+
halfLifeDays: opts.halfLifeDays,
|
|
2286
|
+
embeddingProvider: opts.noVector ? null : new OllamaEmbeddingProvider()
|
|
2287
|
+
});
|
|
2288
|
+
const matched = hits.length;
|
|
2289
|
+
const rawTokens = hits.reduce((n, h) => n + approxTokens(h.body), 0);
|
|
2290
|
+
const packerEfficiency = rawTokens > 0 ? 1 - packed.tokensUsed / rawTokens : 0;
|
|
2291
|
+
if (opts.json) {
|
|
2292
|
+
process.stdout.write(
|
|
2293
|
+
`${JSON.stringify(
|
|
2294
|
+
{
|
|
2295
|
+
query: opts.query,
|
|
2296
|
+
matched,
|
|
2297
|
+
bm25Matched: bm25Count,
|
|
2298
|
+
vectorMatched: vectorCount,
|
|
2299
|
+
packed: packed.nodes,
|
|
2300
|
+
tokensUsed: packed.tokensUsed,
|
|
2301
|
+
tokensBudget: packed.tokensBudget,
|
|
2302
|
+
droppedForBudget: packed.droppedForBudget
|
|
2303
|
+
},
|
|
2304
|
+
null,
|
|
2305
|
+
2
|
|
2306
|
+
)}
|
|
2307
|
+
`
|
|
2308
|
+
);
|
|
2309
|
+
return 0;
|
|
2310
|
+
}
|
|
2311
|
+
if (matched === 0) {
|
|
2312
|
+
process.stderr.write(`${pc4.yellow("no matches")} for "${opts.query}"
|
|
2313
|
+
`);
|
|
2314
|
+
return 0;
|
|
2315
|
+
}
|
|
2316
|
+
process.stderr.write(
|
|
2317
|
+
[
|
|
2318
|
+
`${pc4.dim("matched")} ${bm25Count} bm25${vectorCount > 0 ? ` + ${vectorCount} vector` : ""}, packed ${pc4.bold(String(packed.nodes.length))} into budget`,
|
|
2319
|
+
`${pc4.dim("tokens ")} ${packed.tokensUsed}/${packed.tokensBudget}` + (packed.droppedForBudget ? pc4.dim(` (${packed.droppedForBudget} dropped for budget)`) : ""),
|
|
2320
|
+
rawTokens > 0 ? `${pc4.dim("vs raw ")} ${rawTokens} tokens if these same matches were sent unpacked ${packerEfficiency >= 0 ? pc4.green(`(${(packerEfficiency * 100).toFixed(0)}% packer efficiency)`) : pc4.yellow(`(${(-packerEfficiency * 100).toFixed(0)}% larger -- overhead dominates on small result sets)`)}` : "",
|
|
2321
|
+
""
|
|
2322
|
+
].filter(Boolean).join("\n")
|
|
2323
|
+
);
|
|
2324
|
+
process.stdout.write(`${renderContextBlock(opts.query, packed)}
|
|
2325
|
+
`);
|
|
2326
|
+
return 0;
|
|
2327
|
+
} finally {
|
|
2328
|
+
store.close();
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
// src/cli/commands/scan-conversation.ts
|
|
2333
|
+
import pc6 from "picocolors";
|
|
2334
|
+
|
|
2335
|
+
// src/cli/format.ts
|
|
2336
|
+
import pc5 from "picocolors";
|
|
2337
|
+
var GIT_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
|
|
2338
|
+
var SHELL_SIGNAL_BANDS = { high: 0.6, medium: 0.4 };
|
|
2339
|
+
var CONVERSATION_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
|
|
2340
|
+
var DOCS_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
|
|
2341
|
+
function signalBand(signal, bands) {
|
|
2342
|
+
if (signal >= bands.high) return "high";
|
|
2343
|
+
if (signal >= bands.medium) return "medium";
|
|
2344
|
+
return "low";
|
|
2345
|
+
}
|
|
2346
|
+
var BAND_COLOR = {
|
|
2347
|
+
high: pc5.green,
|
|
2348
|
+
medium: pc5.yellow,
|
|
2349
|
+
low: pc5.dim
|
|
2350
|
+
};
|
|
2351
|
+
function formatSignal(signal, bands) {
|
|
2352
|
+
return BAND_COLOR[signalBand(signal, bands)](signal.toFixed(2));
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
// src/cli/commands/scan-conversation.ts
|
|
2356
|
+
async function runScanConversation(opts) {
|
|
2357
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
2358
|
+
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
2359
|
+
const files = await listTranscriptFiles(repo.root);
|
|
2360
|
+
if (!opts.json) {
|
|
2361
|
+
process.stderr.write(
|
|
2362
|
+
files.length ? `${pc6.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
|
|
2363
|
+
|
|
2364
|
+
` : `${pc6.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
|
|
2365
|
+
`
|
|
2366
|
+
);
|
|
2367
|
+
}
|
|
2368
|
+
const turns = await collectClaudeCodeTranscripts(repo.root);
|
|
2369
|
+
const nodes = collectConversationTurns(turns, projectId).filter((n) => n.signal >= opts.minSignal);
|
|
2370
|
+
if (opts.json) {
|
|
2371
|
+
process.stdout.write(`${JSON.stringify(nodes, null, 2)}
|
|
2372
|
+
`);
|
|
2373
|
+
return 0;
|
|
2374
|
+
}
|
|
2375
|
+
for (const node of nodes) process.stdout.write(`${formatNode(node)}
|
|
2376
|
+
`);
|
|
2377
|
+
const redactedTotal = nodes.reduce((n, x) => n + (Number(x.meta.redactedCount) || 0), 0);
|
|
2378
|
+
const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
2379
|
+
process.stderr.write(
|
|
2380
|
+
`
|
|
2381
|
+
${pc6.bold(String(nodes.length))} of ${turns.length} exchange(s) above threshold ${pc6.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}` + (redactedTotal > 0 ? ` ${pc6.yellow(`${redactedTotal} secret-like value(s) redacted`)}` : "") + "\n"
|
|
2382
|
+
);
|
|
2383
|
+
return 0;
|
|
2384
|
+
}
|
|
2385
|
+
function formatNode(node) {
|
|
2386
|
+
return [formatSignal(node.signal, CONVERSATION_SIGNAL_BANDS), node.ts.slice(0, 16).replace("T", " "), node.title].join(" ");
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
// src/cli/commands/scan-docs.ts
|
|
2390
|
+
import pc7 from "picocolors";
|
|
2391
|
+
async function runScanDocs(opts) {
|
|
2392
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
2393
|
+
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
2394
|
+
const { files, unreadable } = await readDocFiles(repo.root);
|
|
2395
|
+
if (!opts.json) {
|
|
2396
|
+
process.stderr.write(
|
|
2397
|
+
files.length ? `${pc7.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
|
|
2398
|
+
|
|
2399
|
+
` : `${pc7.yellow("no tracked .md files found")}
|
|
2400
|
+
`
|
|
2401
|
+
);
|
|
2402
|
+
if (unreadable.length > 0) {
|
|
2403
|
+
process.stderr.write(`${pc7.yellow("unreadable")} ${unreadable.join(", ")}
|
|
2404
|
+
|
|
2405
|
+
`);
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
const nodes = collectDocFiles(files, projectId).filter((n) => n.signal >= opts.minSignal);
|
|
2409
|
+
if (opts.json) {
|
|
2410
|
+
process.stdout.write(`${JSON.stringify(nodes, null, 2)}
|
|
2411
|
+
`);
|
|
2412
|
+
return 0;
|
|
2413
|
+
}
|
|
2414
|
+
for (const node of nodes) process.stdout.write(`${formatNode2(node)}
|
|
2415
|
+
`);
|
|
2416
|
+
const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
2417
|
+
process.stderr.write(
|
|
2418
|
+
`
|
|
2419
|
+
${pc7.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc7.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
|
|
2420
|
+
`
|
|
2421
|
+
);
|
|
2422
|
+
return 0;
|
|
2423
|
+
}
|
|
2424
|
+
function formatNode2(node) {
|
|
2425
|
+
return [formatSignal(node.signal, DOCS_SIGNAL_BANDS), node.title].join(" ");
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// src/cli/commands/scan-git.ts
|
|
2429
|
+
import pc8 from "picocolors";
|
|
2430
|
+
async function runScanGit(opts) {
|
|
2431
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
2432
|
+
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
2433
|
+
if (!opts.json) {
|
|
2434
|
+
process.stderr.write(
|
|
2435
|
+
[
|
|
2436
|
+
`${pc8.dim("repo ")} ${repo.root}`,
|
|
2437
|
+
`${pc8.dim("branch ")} ${repo.branch ?? pc8.yellow("(detached)")}`,
|
|
2438
|
+
`${pc8.dim("origin ")} ${repo.originUrl ?? pc8.dim("(none)")}`,
|
|
2439
|
+
`${pc8.dim("project")} ${pc8.cyan(projectId)}`,
|
|
2440
|
+
""
|
|
2441
|
+
].join("\n")
|
|
2442
|
+
);
|
|
2443
|
+
}
|
|
2444
|
+
const nodes = [];
|
|
2445
|
+
const collectOpts = {
|
|
2446
|
+
since: opts.since ?? null,
|
|
2447
|
+
maxCount: opts.limit ?? null,
|
|
2448
|
+
includeMerges: opts.merges
|
|
2449
|
+
};
|
|
2450
|
+
for await (const node of collectGitCommits(repo.root, projectId, collectOpts)) {
|
|
2451
|
+
if (node.signal < opts.minSignal) continue;
|
|
2452
|
+
nodes.push(node);
|
|
2453
|
+
if (!opts.json) process.stdout.write(`${formatNode3(node)}
|
|
2454
|
+
`);
|
|
2455
|
+
}
|
|
2456
|
+
if (opts.json) {
|
|
2457
|
+
process.stdout.write(`${JSON.stringify(nodes, null, 2)}
|
|
2458
|
+
`);
|
|
2459
|
+
return 0;
|
|
2460
|
+
}
|
|
2461
|
+
process.stderr.write(`
|
|
2462
|
+
${summarize2(nodes)}
|
|
2463
|
+
`);
|
|
2464
|
+
return 0;
|
|
2465
|
+
}
|
|
2466
|
+
function formatNode3(node) {
|
|
2467
|
+
const sha = String(node.meta.shortSha ?? "").padEnd(9);
|
|
2468
|
+
const date = node.ts.slice(0, 10);
|
|
2469
|
+
const files = Number(node.meta.filesChanged ?? 0);
|
|
2470
|
+
const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
|
|
2471
|
+
return [
|
|
2472
|
+
formatSignal(node.signal, GIT_SIGNAL_BANDS),
|
|
2473
|
+
pc8.dim(date),
|
|
2474
|
+
pc8.magenta(sha),
|
|
2475
|
+
node.title,
|
|
2476
|
+
pc8.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
|
|
2477
|
+
].join(" ");
|
|
2478
|
+
}
|
|
2479
|
+
function summarize2(nodes) {
|
|
2480
|
+
if (nodes.length === 0) return pc8.yellow("no commits matched");
|
|
2481
|
+
const timestamps = nodes.map((n) => n.ts).sort();
|
|
2482
|
+
const avgSignal = nodes.reduce((n, x) => n + x.signal, 0) / nodes.length;
|
|
2483
|
+
const totalTokens = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
2484
|
+
const fileHits = /* @__PURE__ */ new Map();
|
|
2485
|
+
for (const node of nodes) {
|
|
2486
|
+
for (const f of node.files) fileHits.set(f.path, (fileHits.get(f.path) ?? 0) + 1);
|
|
2487
|
+
}
|
|
2488
|
+
const hottest = [...fileHits.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([path, count]) => ` ${String(count).padStart(3)}x ${path}`);
|
|
2489
|
+
return [
|
|
2490
|
+
`${pc8.bold(String(nodes.length))} nodes ${pc8.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
|
|
2491
|
+
` avg signal ${avgSignal.toFixed(3)} ~${totalTokens.toLocaleString()} tokens if sent raw`,
|
|
2492
|
+
hottest.length ? ` hottest files:
|
|
2493
|
+
${hottest.join("\n")}` : ""
|
|
2494
|
+
].filter(Boolean).join("\n");
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
// src/cli/commands/scan-shell.ts
|
|
2498
|
+
import pc9 from "picocolors";
|
|
2499
|
+
async function runScanShell(opts) {
|
|
2500
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
2501
|
+
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
2502
|
+
const results = await collectAvailableShellHistory({ tailLines: opts.tailLines, repoRoot: repo.root });
|
|
2503
|
+
if (!opts.json) {
|
|
2504
|
+
process.stderr.write(
|
|
2505
|
+
results.length ? `${pc9.dim("sources found")} ${results.map((r) => r.name).join(", ")}
|
|
2506
|
+
|
|
2507
|
+
` : `${pc9.yellow("no shell history source found on this machine")}
|
|
2508
|
+
`
|
|
2509
|
+
);
|
|
2510
|
+
}
|
|
2511
|
+
const allNodes = [];
|
|
2512
|
+
for (const result of results) {
|
|
2513
|
+
const nodes = collectShellHistory(result.entries, projectId).filter((n) => n.signal >= opts.minSignal);
|
|
2514
|
+
allNodes.push(...nodes);
|
|
2515
|
+
if (!opts.json) {
|
|
2516
|
+
process.stdout.write(`${pc9.bold(`shell:${result.name}`)} ${pc9.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
|
|
2517
|
+
`);
|
|
2518
|
+
for (const node of nodes) process.stdout.write(`${formatNode4(node)}
|
|
2519
|
+
`);
|
|
2520
|
+
process.stdout.write("\n");
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
if (opts.json) {
|
|
2524
|
+
process.stdout.write(`${JSON.stringify(allNodes, null, 2)}
|
|
2525
|
+
`);
|
|
2526
|
+
return 0;
|
|
2527
|
+
}
|
|
2528
|
+
const approxTotal = allNodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
2529
|
+
process.stderr.write(`${pc9.bold(String(allNodes.length))} node(s) total ${pc9.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
|
|
2530
|
+
`);
|
|
2531
|
+
return 0;
|
|
2532
|
+
}
|
|
2533
|
+
function formatNode4(node) {
|
|
2534
|
+
const approx = node.meta.tsApprox ? pc9.dim("~") : " ";
|
|
2535
|
+
const exit = node.meta.exitCode;
|
|
2536
|
+
const exitLabel = typeof exit === "number" && exit !== 0 ? pc9.red(`exit ${exit}`) : "";
|
|
2537
|
+
return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// src/cli/commands/status.ts
|
|
2541
|
+
import { statSync } from "fs";
|
|
2542
|
+
import pc10 from "picocolors";
|
|
2543
|
+
function humanBytes(bytes) {
|
|
2544
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
2545
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
2546
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
2547
|
+
}
|
|
2548
|
+
function fileSize(path) {
|
|
2549
|
+
try {
|
|
2550
|
+
return statSync(path).size;
|
|
2551
|
+
} catch {
|
|
2552
|
+
return 0;
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
async function runStatus(opts) {
|
|
2556
|
+
const { repo, ws, projectId } = await loadContext(opts.cwd);
|
|
2557
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
2558
|
+
try {
|
|
2559
|
+
const stats = store.stats(projectId);
|
|
2560
|
+
const sources = store.listSyncState(projectId);
|
|
2561
|
+
const gitCursor = sources.find((s) => s.source === "git")?.cursor ?? null;
|
|
2562
|
+
const schema = currentSchemaVersion(store.raw);
|
|
2563
|
+
const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
|
|
2564
|
+
const kinds = Object.entries(stats.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
|
|
2565
|
+
process.stdout.write(
|
|
2566
|
+
[
|
|
2567
|
+
`${pc10.dim("repo ")} ${repo.root}`,
|
|
2568
|
+
`${pc10.dim("branch ")} ${repo.branch ?? pc10.yellow("(detached)")}`,
|
|
2569
|
+
`${pc10.dim("project ")} ${pc10.cyan(projectId)}`,
|
|
2570
|
+
`${pc10.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc10.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
|
|
2571
|
+
`${pc10.dim("database")} ${ws.dbPath} ${pc10.dim(`(${humanBytes(dbBytes)})`)}`,
|
|
2572
|
+
"",
|
|
2573
|
+
`${pc10.bold(String(stats.total))} node(s)${stats.total ? ` ${pc10.dim(`${stats.oldest?.slice(0, 10)} .. ${stats.newest?.slice(0, 10)}`)}` : ""}`,
|
|
2574
|
+
...kinds,
|
|
2575
|
+
stats.total ? ` ${pc10.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
|
|
2576
|
+
"",
|
|
2577
|
+
sources.length ? pc10.dim("sources") : pc10.yellow("no sources synced yet"),
|
|
2578
|
+
...sources.map((s) => {
|
|
2579
|
+
const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
|
|
2580
|
+
const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
|
|
2581
|
+
return ` ${s.source.padEnd(14)} ${pc10.dim(`last run ${when}`)} ${pc10.dim(`cursor ${cursorLabel}`)}`;
|
|
2582
|
+
}),
|
|
2583
|
+
gitCursor && gitCursor !== repo.head ? `${pc10.yellow("git behind HEAD")} \u2014 run ${pc10.bold("nexusmem sync")}` : "",
|
|
2584
|
+
""
|
|
2585
|
+
].filter((line) => line !== "").join("\n").concat("\n")
|
|
2586
|
+
);
|
|
2587
|
+
return 0;
|
|
2588
|
+
} finally {
|
|
2589
|
+
store.close();
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// src/cli/index.ts
|
|
2594
|
+
function isExpected(err) {
|
|
2595
|
+
return err instanceof NotAGitRepositoryError || // "git isn't installed" and "the spawn failed, try again" are both things
|
|
2596
|
+
// the user fixes, not stack traces they debug.
|
|
2597
|
+
err instanceof GitSpawnError || // Survived every retry, so git is genuinely unstable on this machine
|
|
2598
|
+
// (antivirus, a bad install). Actionable, and not our stack to print.
|
|
2599
|
+
err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError;
|
|
2600
|
+
}
|
|
2601
|
+
function guard(run) {
|
|
2602
|
+
return async () => {
|
|
2603
|
+
try {
|
|
2604
|
+
process.exitCode = await run();
|
|
2605
|
+
} catch (err) {
|
|
2606
|
+
if (isExpected(err)) {
|
|
2607
|
+
process.stderr.write(`${pc11.red("error")} ${err.message}
|
|
2608
|
+
`);
|
|
2609
|
+
process.exitCode = 1;
|
|
2610
|
+
return;
|
|
2611
|
+
}
|
|
2612
|
+
throw err;
|
|
2613
|
+
}
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
var program = new Command();
|
|
2617
|
+
program.name("nexusmem").description("NexusMem \u2014 local-first persistent memory for AI coding agents").version("0.1.0");
|
|
2618
|
+
program.command("init").description("Create the .nexusmem workspace and database for this repository").option("-C, --cwd <path>", "repository path", process.cwd()).option("--force", "overwrite an existing config (the database is kept)", false).option("--hook", "also install the opt-in PowerShell hook (cwd + exit code + timestamp)", false).option("--enable-conversation", "opt in to the conversation-transcript source (off by default -- see docs/phase-2-spec.md)", false).action(
|
|
2619
|
+
(options) => guard(
|
|
2620
|
+
() => runInit({ cwd: options.cwd, force: options.force, hook: options.hook, enableConversation: options.enableConversation })
|
|
2621
|
+
)()
|
|
2622
|
+
);
|
|
2623
|
+
program.command("sync").description("Ingest new history into the local database").option("-C, --cwd <path>", "repository path", process.cwd()).option("--full", "ignore the stored cursor and re-walk all history", false).option("--rebuild", "drop this project's nodes and re-ingest from scratch", false).option("--since <date>", "override the configured git cutoff, e.g. 1.year.ago").option("--shell-lines <count>", "override the configured shell tail-window size", (v) => Number.parseInt(v, 10)).option("--conversation", "force the conversation source on for this run, without persisting it to config", false).option("--no-embed", "skip the vector-embedding pass for this run").option("-q, --quiet", "only print the final summary", false).action(
|
|
2624
|
+
(options) => guard(
|
|
2625
|
+
() => runSync({
|
|
2626
|
+
cwd: options.cwd,
|
|
2627
|
+
full: options.full,
|
|
2628
|
+
rebuild: options.rebuild,
|
|
2629
|
+
since: options.since,
|
|
2630
|
+
shellTailLines: options.shellLines,
|
|
2631
|
+
conversationOverride: options.conversation ? true : void 0,
|
|
2632
|
+
noEmbed: !options.embed,
|
|
2633
|
+
quiet: options.quiet
|
|
2634
|
+
})
|
|
2635
|
+
)()
|
|
2636
|
+
);
|
|
2637
|
+
program.command("hook").description("Manage the opt-in PowerShell hook that logs cwd + exit code + timestamp").addCommand(
|
|
2638
|
+
new Command("install").description("Install (or update) the hook in your PowerShell profile").option("--profile <path>", "override the auto-detected $PROFILE path").action((options) => guard(() => runHookInstall({ profile: options.profile }))())
|
|
2639
|
+
).addCommand(
|
|
2640
|
+
new Command("remove").description("Remove the hook block from your PowerShell profile").option("--profile <path>", "override the auto-detected $PROFILE path").action((options) => guard(() => runHookRemove({ profile: options.profile }))())
|
|
2641
|
+
).addCommand(
|
|
2642
|
+
new Command("status").description("Show whether the hook is installed").option("--profile <path>", "override the auto-detected $PROFILE path").action((options) => guard(() => runHookStatus({ profile: options.profile }))())
|
|
2643
|
+
);
|
|
2644
|
+
program.command("status").description("Show what is currently remembered for this repository").option("-C, --cwd <path>", "repository path", process.cwd()).action((options) => guard(() => runStatus({ cwd: options.cwd }))());
|
|
2645
|
+
program.command("query").description("Search remembered history and print a token-budgeted context block").argument("<text>", "free-text query").option("-C, --cwd <path>", "repository path", process.cwd()).option("-b, --budget <tokens>", "max tokens in the packed context", (v) => Number.parseInt(v, 10), 2e3).option("-n, --candidates <count>", "how many search hits to rank before packing", (v) => Number.parseInt(v, 10), 30).option("--half-life <days>", "days for a node's recency weight to halve", (v) => Number.parseFloat(v)).option("--no-vector", "BM25 only -- skip embedding the query and vector search").option("--json", "emit the packed result as JSON on stdout", false).action(
|
|
2646
|
+
(text, options) => guard(
|
|
2647
|
+
() => runQuery({
|
|
2648
|
+
cwd: options.cwd,
|
|
2649
|
+
query: text,
|
|
2650
|
+
budget: options.budget,
|
|
2651
|
+
candidates: options.candidates,
|
|
2652
|
+
halfLifeDays: options.halfLife,
|
|
2653
|
+
noVector: !options.vector,
|
|
2654
|
+
json: options.json
|
|
2655
|
+
})
|
|
2656
|
+
)()
|
|
2657
|
+
);
|
|
2658
|
+
program.command("scan-git").description("Preview the MemoryNodes git history would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--since <date>", "only commits newer than this git date expression, e.g. 90.days.ago").option("-n, --limit <count>", "stop after N commits", (v) => Number.parseInt(v, 10)).option("--no-merges", "skip merge commits").option("--min-signal <score>", "drop nodes below this signal", (v) => Number.parseFloat(v), 0).option("--json", "emit MemoryNodes as JSON on stdout", false).action(
|
|
2659
|
+
(options) => guard(
|
|
2660
|
+
() => runScanGit({
|
|
2661
|
+
cwd: options.cwd,
|
|
2662
|
+
since: options.since,
|
|
2663
|
+
limit: options.limit,
|
|
2664
|
+
merges: options.merges,
|
|
2665
|
+
json: options.json,
|
|
2666
|
+
minSignal: options.minSignal
|
|
2667
|
+
})
|
|
2668
|
+
)()
|
|
2669
|
+
);
|
|
2670
|
+
program.command("scan-shell").description("Preview the MemoryNodes shell history would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("-n, --tail-lines <count>", "lines kept from each scrape-based source", (v) => Number.parseInt(v, 10), 300).option("--min-signal <score>", "drop nodes below this signal", (v) => Number.parseFloat(v), 0).option("--json", "emit MemoryNodes as JSON on stdout", false).action(
|
|
2671
|
+
(options) => guard(
|
|
2672
|
+
() => runScanShell({ cwd: options.cwd, tailLines: options.tailLines, minSignal: options.minSignal, json: options.json })
|
|
2673
|
+
)()
|
|
2674
|
+
);
|
|
2675
|
+
program.command("scan-conversation").description("Preview the MemoryNodes the conversation transcript would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--min-signal <score>", "drop nodes below this signal", (v) => Number.parseFloat(v), 0).option("--json", "emit MemoryNodes as JSON on stdout", false).action(
|
|
2676
|
+
(options) => guard(() => runScanConversation({ cwd: options.cwd, minSignal: options.minSignal, json: options.json }))()
|
|
2677
|
+
);
|
|
2678
|
+
program.command("scan-docs").description("Preview the MemoryNodes tracked .md files would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--min-signal <score>", "drop nodes below this signal", (v) => Number.parseFloat(v), 0).option("--json", "emit MemoryNodes as JSON on stdout", false).action((options) => guard(() => runScanDocs({ cwd: options.cwd, minSignal: options.minSignal, json: options.json }))());
|
|
2679
|
+
program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
|
|
2680
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
2681
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2682
|
+
process.stderr.write(`${pc11.red("error")} ${message}
|
|
2683
|
+
`);
|
|
2684
|
+
process.exitCode = 1;
|
|
2685
|
+
});
|
|
2686
|
+
//# sourceMappingURL=index.js.map
|