nexusmem 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -1
- package/README.md +62 -17
- package/dist/cli/index.js +668 -92
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -2,13 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import
|
|
5
|
+
import pc14 from "picocolors";
|
|
6
6
|
|
|
7
7
|
// src/config/workspace.ts
|
|
8
8
|
import { existsSync } from "fs";
|
|
9
9
|
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
10
10
|
import { join } from "path";
|
|
11
11
|
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
// src/slm/provider.ts
|
|
14
|
+
var DEFAULT_BASE_URL = "http://127.0.0.1:11434";
|
|
15
|
+
var DEFAULT_SLM_MODEL = "qwen2.5:3b";
|
|
16
|
+
var DEFAULT_TIMEOUT_MS = 12e4;
|
|
17
|
+
var DEFAULT_MAX_TOKENS = 400;
|
|
18
|
+
var OllamaChatProvider = class {
|
|
19
|
+
identity;
|
|
20
|
+
baseUrl;
|
|
21
|
+
model;
|
|
22
|
+
timeoutMs;
|
|
23
|
+
maxTokens;
|
|
24
|
+
constructor(opts = {}) {
|
|
25
|
+
this.baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL;
|
|
26
|
+
this.model = opts.model ?? DEFAULT_SLM_MODEL;
|
|
27
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
28
|
+
this.maxTokens = opts.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
29
|
+
this.identity = `ollama:${this.model}`;
|
|
30
|
+
}
|
|
31
|
+
async complete(prompt) {
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(`${this.baseUrl}/api/generate`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "content-type": "application/json" },
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
model: this.model,
|
|
40
|
+
prompt,
|
|
41
|
+
stream: false,
|
|
42
|
+
options: {
|
|
43
|
+
// Deterministic: the same session must summarize to the same text
|
|
44
|
+
// across syncs, or the content hash that suppresses re-work would
|
|
45
|
+
// never match and every sync would rewrite every summary node.
|
|
46
|
+
temperature: 0,
|
|
47
|
+
seed: 1,
|
|
48
|
+
num_predict: this.maxTokens
|
|
49
|
+
}
|
|
50
|
+
}),
|
|
51
|
+
signal: controller.signal
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) return null;
|
|
54
|
+
const data = await res.json();
|
|
55
|
+
if (typeof data.response !== "string") return null;
|
|
56
|
+
const text = data.response.trim();
|
|
57
|
+
return text.length > 0 ? text : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(timeout);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// src/config/workspace.ts
|
|
12
67
|
var WORKSPACE_DIR = ".nexusmem";
|
|
13
68
|
function resolveWorkspace(repoRoot) {
|
|
14
69
|
const dir = join(repoRoot, WORKSPACE_DIR);
|
|
@@ -46,6 +101,32 @@ var ConfigSchema = z.object({
|
|
|
46
101
|
conversation: z.object({
|
|
47
102
|
enabled: z.boolean().default(false)
|
|
48
103
|
}).default({ enabled: false }),
|
|
104
|
+
/**
|
|
105
|
+
* One distilled node per finished working session, written by a local
|
|
106
|
+
* small language model.
|
|
107
|
+
*
|
|
108
|
+
* Opt-in for the same reason as `conversation` -- it reads the same
|
|
109
|
+
* transcripts -- and additionally because it is the only source that
|
|
110
|
+
* costs real compute. Independent of `conversation.enabled`: summaries
|
|
111
|
+
* without the raw exchanges is a legitimate, and much smaller, way to
|
|
112
|
+
* remember a session.
|
|
113
|
+
*/
|
|
114
|
+
session: z.object({
|
|
115
|
+
enabled: z.boolean().default(false),
|
|
116
|
+
/** Ollama model tag. Must be pulled locally; nothing is downloaded automatically. */
|
|
117
|
+
model: z.string().default(DEFAULT_SLM_MODEL),
|
|
118
|
+
/** Minutes of quiet before a session counts as finished and can be summarized. */
|
|
119
|
+
settleMinutes: z.number().int().nonnegative().default(30),
|
|
120
|
+
/** Sessions summarized per sync. Each is a model call measured in seconds. */
|
|
121
|
+
maxSessions: z.number().int().positive().default(10),
|
|
122
|
+
maxPromptChars: z.number().int().positive().default(12e3)
|
|
123
|
+
}).default({
|
|
124
|
+
enabled: false,
|
|
125
|
+
model: DEFAULT_SLM_MODEL,
|
|
126
|
+
settleMinutes: 30,
|
|
127
|
+
maxSessions: 10,
|
|
128
|
+
maxPromptChars: 12e3
|
|
129
|
+
}),
|
|
49
130
|
/** Tracked `.md` files -- README, architecture docs. On by default like git/shell: no secrets risk, just project prose. */
|
|
50
131
|
docs: z.object({
|
|
51
132
|
enabled: z.boolean().default(true),
|
|
@@ -71,6 +152,13 @@ var ConfigSchema = z.object({
|
|
|
71
152
|
git: { enabled: true, since: null, includeMerges: true },
|
|
72
153
|
shell: { enabled: true, tailLines: 300 },
|
|
73
154
|
conversation: { enabled: false },
|
|
155
|
+
session: {
|
|
156
|
+
enabled: false,
|
|
157
|
+
model: DEFAULT_SLM_MODEL,
|
|
158
|
+
settleMinutes: 30,
|
|
159
|
+
maxSessions: 10,
|
|
160
|
+
maxPromptChars: 12e3
|
|
161
|
+
},
|
|
74
162
|
docs: { enabled: true, include: ["*.md"] },
|
|
75
163
|
diff: { enabled: true, maxCommits: 200, maxFilesPerCommit: 20, contextLines: 3 }
|
|
76
164
|
}),
|
|
@@ -209,9 +297,9 @@ async function* gitStream(cwd, args, opts = {}) {
|
|
|
209
297
|
for (let attempt = 0; ; attempt += 1) {
|
|
210
298
|
let produced = false;
|
|
211
299
|
try {
|
|
212
|
-
for await (const
|
|
300
|
+
for await (const chunk2 of runGitOnce(cwd, args, opts)) {
|
|
213
301
|
produced = true;
|
|
214
|
-
yield
|
|
302
|
+
yield chunk2;
|
|
215
303
|
}
|
|
216
304
|
return;
|
|
217
305
|
} catch (err) {
|
|
@@ -227,8 +315,8 @@ async function* runGitOnce(cwd, args, opts) {
|
|
|
227
315
|
child.stdout.setEncoding("utf8");
|
|
228
316
|
child.stderr.setEncoding("utf8");
|
|
229
317
|
let stderr = "";
|
|
230
|
-
child.stderr.on("data", (
|
|
231
|
-
if (stderr.length < 64 * 1024) stderr +=
|
|
318
|
+
child.stderr.on("data", (chunk2) => {
|
|
319
|
+
if (stderr.length < 64 * 1024) stderr += chunk2;
|
|
232
320
|
});
|
|
233
321
|
const exited = new Promise((resolve2, reject) => {
|
|
234
322
|
child.once("error", (err) => reject(toSpawnError(err, cwd, fullArgs)));
|
|
@@ -237,8 +325,8 @@ async function* runGitOnce(cwd, args, opts) {
|
|
|
237
325
|
exited.catch(() => {
|
|
238
326
|
});
|
|
239
327
|
try {
|
|
240
|
-
for await (const
|
|
241
|
-
yield
|
|
328
|
+
for await (const chunk2 of child.stdout) {
|
|
329
|
+
yield chunk2;
|
|
242
330
|
}
|
|
243
331
|
} finally {
|
|
244
332
|
if (child.exitCode === null) child.kill();
|
|
@@ -267,7 +355,7 @@ async function* runGitOnce(cwd, args, opts) {
|
|
|
267
355
|
}
|
|
268
356
|
async function git(cwd, args, opts = {}) {
|
|
269
357
|
let out = "";
|
|
270
|
-
for await (const
|
|
358
|
+
for await (const chunk2 of gitStream(cwd, args, opts)) out += chunk2;
|
|
271
359
|
return out;
|
|
272
360
|
}
|
|
273
361
|
async function gitOrNull(cwd, args, opts = {}) {
|
|
@@ -831,6 +919,20 @@ var MemoryStore = class _MemoryStore {
|
|
|
831
919
|
run(nodes);
|
|
832
920
|
return stats;
|
|
833
921
|
}
|
|
922
|
+
/**
|
|
923
|
+
* The stored `meta` blob for one node, or null if it has never been
|
|
924
|
+
* written. Used by the session summarizer to recognise work it has
|
|
925
|
+
* already done without re-reading the node's whole body.
|
|
926
|
+
*/
|
|
927
|
+
getNodeMeta(id) {
|
|
928
|
+
const row = this.db.prepare("SELECT meta FROM nodes WHERE id = ?").get(id);
|
|
929
|
+
if (!row) return null;
|
|
930
|
+
try {
|
|
931
|
+
return JSON.parse(row.meta);
|
|
932
|
+
} catch {
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
834
936
|
getSyncCursor(projectId, source) {
|
|
835
937
|
const row = this.db.prepare("SELECT cursor FROM sync_state WHERE project_id = ? AND source = ?").get(projectId, source);
|
|
836
938
|
return row?.cursor ?? null;
|
|
@@ -893,19 +995,57 @@ var MemoryStore = class _MemoryStore {
|
|
|
893
995
|
return this.db.prepare(`DELETE FROM nodes WHERE ${scope}`).run(params).changes;
|
|
894
996
|
})();
|
|
895
997
|
}
|
|
896
|
-
/**
|
|
897
|
-
|
|
998
|
+
/**
|
|
999
|
+
* Nodes for this project that have no embedding yet (new, or invalidated
|
|
1000
|
+
* by a content change).
|
|
1001
|
+
*
|
|
1002
|
+
* `afterRowid` makes paging monotonic: the pass walks rowids strictly
|
|
1003
|
+
* upward instead of re-reading "the first N still pending". That matters
|
|
1004
|
+
* because a node the provider *failed* on stays pending -- an offset-free
|
|
1005
|
+
* loop would fetch the same failures forever, which is exactly the shape
|
|
1006
|
+
* of an infinite sync.
|
|
1007
|
+
*/
|
|
1008
|
+
findNodesNeedingEmbedding(projectId, limit = 200, afterRowid = 0) {
|
|
898
1009
|
return this.db.prepare(
|
|
899
1010
|
`SELECT n.rowid AS rowid, n.id AS id, n.title AS title, n.body AS body
|
|
900
1011
|
FROM nodes n
|
|
901
1012
|
LEFT JOIN nodes_vec v ON v.rowid = n.rowid
|
|
902
|
-
WHERE n.project_id = ? AND v.rowid IS NULL
|
|
1013
|
+
WHERE n.project_id = ? AND v.rowid IS NULL AND n.rowid > ?
|
|
1014
|
+
ORDER BY n.rowid
|
|
903
1015
|
LIMIT ?`
|
|
904
|
-
).all(projectId, limit);
|
|
1016
|
+
).all(projectId, afterRowid, limit);
|
|
1017
|
+
}
|
|
1018
|
+
/** How many of this project's nodes still need a vector. For progress reporting. */
|
|
1019
|
+
countNodesNeedingEmbedding(projectId) {
|
|
1020
|
+
const row = this.db.prepare(
|
|
1021
|
+
`SELECT COUNT(*) AS n
|
|
1022
|
+
FROM nodes n
|
|
1023
|
+
LEFT JOIN nodes_vec v ON v.rowid = n.rowid
|
|
1024
|
+
WHERE n.project_id = ? AND v.rowid IS NULL`
|
|
1025
|
+
).get(projectId);
|
|
1026
|
+
return row.n;
|
|
905
1027
|
}
|
|
906
1028
|
upsertEmbedding(rowid, embedding) {
|
|
907
1029
|
this.db.prepare("INSERT OR REPLACE INTO nodes_vec (rowid, embedding) VALUES (?, ?)").run(BigInt(rowid), embedding);
|
|
908
1030
|
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Drop every vector in this database, across all projects.
|
|
1033
|
+
*
|
|
1034
|
+
* Whole-database on purpose: `nodes_vec` is shared and holds no
|
|
1035
|
+
* provenance, so once the vectors in it stopped being comparable there is
|
|
1036
|
+
* no subset that is still trustworthy. Nodes are untouched, so the next
|
|
1037
|
+
* embedding pass simply rebuilds them.
|
|
1038
|
+
*/
|
|
1039
|
+
dropAllEmbeddings() {
|
|
1040
|
+
return this.db.prepare("DELETE FROM nodes_vec").run().changes;
|
|
1041
|
+
}
|
|
1042
|
+
getMeta(key) {
|
|
1043
|
+
const row = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
|
|
1044
|
+
return row?.value ?? null;
|
|
1045
|
+
}
|
|
1046
|
+
setMeta(key, value) {
|
|
1047
|
+
this.db.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
|
|
1048
|
+
}
|
|
909
1049
|
/**
|
|
910
1050
|
* Nearest-neighbour search over the corpus.
|
|
911
1051
|
*
|
|
@@ -970,7 +1110,7 @@ var MemoryStore = class _MemoryStore {
|
|
|
970
1110
|
|
|
971
1111
|
// src/cli/commands/init.ts
|
|
972
1112
|
async function runInit(opts) {
|
|
973
|
-
const out = opts.out ?? ((
|
|
1113
|
+
const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
|
|
974
1114
|
const repo = await readRepoInfo(opts.cwd);
|
|
975
1115
|
const ws = resolveWorkspace(repo.root);
|
|
976
1116
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
@@ -1091,7 +1231,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
1091
1231
|
import { z as z3 } from "zod";
|
|
1092
1232
|
|
|
1093
1233
|
// src/mcp/tools.ts
|
|
1094
|
-
import { basename as
|
|
1234
|
+
import { basename as basename3 } from "path";
|
|
1095
1235
|
|
|
1096
1236
|
// src/core/text.ts
|
|
1097
1237
|
function truncate(s, max) {
|
|
@@ -1291,8 +1431,10 @@ var RECENCY_FLOOR = 0.3;
|
|
|
1291
1431
|
var DEFAULT_HALF_LIFE_DAYS = 30;
|
|
1292
1432
|
var MS_PER_DAY = 864e5;
|
|
1293
1433
|
var MAX_PRIOR_OVERTURN = 2;
|
|
1294
|
-
var
|
|
1295
|
-
var
|
|
1434
|
+
var PRIOR_COUNT = 2;
|
|
1435
|
+
var PER_PRIOR_OVERTURN = MAX_PRIOR_OVERTURN ** (1 / PRIOR_COUNT);
|
|
1436
|
+
var SIGNAL_EXPONENT = Math.log(PER_PRIOR_OVERTURN) / Math.log(1 / SIGNAL_FLOOR);
|
|
1437
|
+
var RECENCY_EXPONENT = Math.log(PER_PRIOR_OVERTURN) / Math.log(1 / RECENCY_FLOOR);
|
|
1296
1438
|
function normalizeRelevance(hits) {
|
|
1297
1439
|
const costs = hits.map((h) => h.rank);
|
|
1298
1440
|
const min = Math.min(...costs);
|
|
@@ -1420,37 +1562,52 @@ function labelProjects(projects) {
|
|
|
1420
1562
|
}
|
|
1421
1563
|
|
|
1422
1564
|
// src/vector/embed.ts
|
|
1423
|
-
var
|
|
1565
|
+
var DEFAULT_BASE_URL2 = "http://127.0.0.1:11434";
|
|
1424
1566
|
var DEFAULT_MODEL = "nomic-embed-text";
|
|
1425
1567
|
var DEFAULT_DIMENSION = 768;
|
|
1426
|
-
var
|
|
1568
|
+
var DEFAULT_TIMEOUT_MS2 = 1e4;
|
|
1569
|
+
var DEFAULT_MAX_TIMEOUT_MS = 12e4;
|
|
1570
|
+
var EMBED_PATH = "/api/embed";
|
|
1427
1571
|
var OllamaEmbeddingProvider = class {
|
|
1428
1572
|
dimension;
|
|
1573
|
+
identity;
|
|
1429
1574
|
baseUrl;
|
|
1430
1575
|
model;
|
|
1431
1576
|
timeoutMs;
|
|
1577
|
+
maxTimeoutMs;
|
|
1432
1578
|
constructor(opts = {}) {
|
|
1433
|
-
this.baseUrl = opts.baseUrl ??
|
|
1579
|
+
this.baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL2;
|
|
1434
1580
|
this.model = opts.model ?? DEFAULT_MODEL;
|
|
1435
1581
|
this.dimension = opts.dimension ?? DEFAULT_DIMENSION;
|
|
1436
|
-
this.timeoutMs = opts.timeoutMs ??
|
|
1582
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
|
|
1583
|
+
this.maxTimeoutMs = opts.maxTimeoutMs ?? DEFAULT_MAX_TIMEOUT_MS;
|
|
1584
|
+
this.identity = `ollama${EMBED_PATH}:${this.model}:${this.dimension}`;
|
|
1437
1585
|
}
|
|
1438
1586
|
async embed(text) {
|
|
1587
|
+
const [only] = await this.embedBatch([text]);
|
|
1588
|
+
return only ?? null;
|
|
1589
|
+
}
|
|
1590
|
+
async embedBatch(texts) {
|
|
1591
|
+
if (texts.length === 0) return [];
|
|
1439
1592
|
const controller = new AbortController();
|
|
1440
|
-
const
|
|
1593
|
+
const budget = Math.min(this.timeoutMs * texts.length, this.maxTimeoutMs);
|
|
1594
|
+
const timeout = setTimeout(() => controller.abort(), budget);
|
|
1441
1595
|
try {
|
|
1442
|
-
const res = await fetch(`${this.baseUrl}
|
|
1596
|
+
const res = await fetch(`${this.baseUrl}${EMBED_PATH}`, {
|
|
1443
1597
|
method: "POST",
|
|
1444
1598
|
headers: { "content-type": "application/json" },
|
|
1445
|
-
body: JSON.stringify({ model: this.model,
|
|
1599
|
+
body: JSON.stringify({ model: this.model, input: texts }),
|
|
1446
1600
|
signal: controller.signal
|
|
1447
1601
|
});
|
|
1448
|
-
if (!res.ok) return null;
|
|
1602
|
+
if (!res.ok) return texts.map(() => null);
|
|
1449
1603
|
const data = await res.json();
|
|
1450
|
-
if (!Array.isArray(data.
|
|
1451
|
-
|
|
1604
|
+
if (!Array.isArray(data.embeddings)) return texts.map(() => null);
|
|
1605
|
+
if (data.embeddings.length !== texts.length) return texts.map(() => null);
|
|
1606
|
+
return data.embeddings.map(
|
|
1607
|
+
(row) => Array.isArray(row) && row.length === this.dimension ? new Float32Array(row) : null
|
|
1608
|
+
);
|
|
1452
1609
|
} catch {
|
|
1453
|
-
return null;
|
|
1610
|
+
return texts.map(() => null);
|
|
1454
1611
|
} finally {
|
|
1455
1612
|
clearTimeout(timeout);
|
|
1456
1613
|
}
|
|
@@ -1580,25 +1737,25 @@ function toMemoryNodes(turn, projectId, opts = {}) {
|
|
|
1580
1737
|
const assistantRedacted = redact(turn.assistantText);
|
|
1581
1738
|
const chunks = chunkAssistantText(assistantRedacted.text, maxChunk);
|
|
1582
1739
|
if (chunks.length === 0) return [];
|
|
1583
|
-
return chunks.map((
|
|
1584
|
-
const body = [`Q: ${userRedacted.text}`, "", `A: ${
|
|
1740
|
+
return chunks.map((chunk2, index) => {
|
|
1741
|
+
const body = [`Q: ${userRedacted.text}`, "", `A: ${chunk2.text}`].join("\n");
|
|
1585
1742
|
return {
|
|
1586
1743
|
id: makeNodeId(projectId, "conversation_turn", `${turn.naturalKey}:${index}`),
|
|
1587
1744
|
kind: "conversation_turn",
|
|
1588
1745
|
projectId,
|
|
1589
1746
|
ts: turn.ts,
|
|
1590
1747
|
source: `conversation:${turn.source}`,
|
|
1591
|
-
title: chunkTitle(userFirstLine,
|
|
1748
|
+
title: chunkTitle(userFirstLine, chunk2.heading, index, chunks.length),
|
|
1592
1749
|
body: truncate(body, maxBody),
|
|
1593
1750
|
files: extractMentionedFiles(`${userRedacted.text}
|
|
1594
|
-
${
|
|
1595
|
-
signal: scoreConversationTurn(userRedacted.text,
|
|
1751
|
+
${chunk2.text}`),
|
|
1752
|
+
signal: scoreConversationTurn(userRedacted.text, chunk2.text),
|
|
1596
1753
|
meta: {
|
|
1597
1754
|
cwd: turn.cwd,
|
|
1598
1755
|
source: turn.source,
|
|
1599
1756
|
chunkIndex: index,
|
|
1600
1757
|
chunkCount: chunks.length,
|
|
1601
|
-
heading:
|
|
1758
|
+
heading: chunk2.heading,
|
|
1602
1759
|
// Redaction runs once over the whole reply before chunking (so a
|
|
1603
1760
|
// secret can never straddle a chunk boundary and slip through) --
|
|
1604
1761
|
// this is the turn's total, repeated on every chunk it produced,
|
|
@@ -1754,8 +1911,8 @@ async function* readCommitDiffs(cwd, opts = {}) {
|
|
|
1754
1911
|
const args = buildDiffLogArgs(opts);
|
|
1755
1912
|
let buffer = "";
|
|
1756
1913
|
try {
|
|
1757
|
-
for await (const
|
|
1758
|
-
buffer +=
|
|
1914
|
+
for await (const chunk2 of gitStream(cwd, args)) {
|
|
1915
|
+
buffer += chunk2;
|
|
1759
1916
|
const { records, rest } = splitRecords(buffer);
|
|
1760
1917
|
buffer = rest;
|
|
1761
1918
|
for (const record of records) {
|
|
@@ -1911,8 +2068,8 @@ async function* readCommits(cwd, opts = {}) {
|
|
|
1911
2068
|
const args = buildLogArgs(opts);
|
|
1912
2069
|
let buffer = "";
|
|
1913
2070
|
try {
|
|
1914
|
-
for await (const
|
|
1915
|
-
buffer +=
|
|
2071
|
+
for await (const chunk2 of gitStream(cwd, args)) {
|
|
2072
|
+
buffer += chunk2;
|
|
1916
2073
|
const { records, rest } = splitRecords(buffer);
|
|
1917
2074
|
buffer = rest;
|
|
1918
2075
|
for (const record of records) {
|
|
@@ -2147,8 +2304,8 @@ function toMemoryNodes3(file, projectId, opts = {}) {
|
|
|
2147
2304
|
const chunks = chunkAssistantText(file.content, maxChunk);
|
|
2148
2305
|
if (chunks.length === 0) return [];
|
|
2149
2306
|
const seenSlugs = /* @__PURE__ */ new Map();
|
|
2150
|
-
return chunks.map((
|
|
2151
|
-
const baseSlug = slugify(
|
|
2307
|
+
return chunks.map((chunk2, index) => {
|
|
2308
|
+
const baseSlug = slugify(chunk2.heading, index);
|
|
2152
2309
|
const occurrence = seenSlugs.get(baseSlug) ?? 0;
|
|
2153
2310
|
seenSlugs.set(baseSlug, occurrence + 1);
|
|
2154
2311
|
const naturalKey = occurrence === 0 ? `${file.path}#${baseSlug}` : `${file.path}#${baseSlug}:${occurrence}`;
|
|
@@ -2158,13 +2315,13 @@ function toMemoryNodes3(file, projectId, opts = {}) {
|
|
|
2158
2315
|
projectId,
|
|
2159
2316
|
ts: file.ts,
|
|
2160
2317
|
source: "docs",
|
|
2161
|
-
title: sectionTitle(file.path,
|
|
2162
|
-
body: truncate(
|
|
2318
|
+
title: sectionTitle(file.path, chunk2.heading, index, chunks.length),
|
|
2319
|
+
body: truncate(chunk2.text, maxBody),
|
|
2163
2320
|
files: [{ path: file.path, insertions: null, deletions: null, binary: false }],
|
|
2164
|
-
signal: scoreDocSection(file.path,
|
|
2321
|
+
signal: scoreDocSection(file.path, chunk2.heading, chunk2.text),
|
|
2165
2322
|
meta: {
|
|
2166
2323
|
path: file.path,
|
|
2167
|
-
heading:
|
|
2324
|
+
heading: chunk2.heading,
|
|
2168
2325
|
chunkIndex: index,
|
|
2169
2326
|
chunkCount: chunks.length
|
|
2170
2327
|
}
|
|
@@ -2175,9 +2332,217 @@ function collectDocFiles(files, projectId, opts = {}) {
|
|
|
2175
2332
|
return files.flatMap((file) => toMemoryNodes3(file, projectId, opts));
|
|
2176
2333
|
}
|
|
2177
2334
|
|
|
2178
|
-
// src/
|
|
2179
|
-
var
|
|
2335
|
+
// src/slm/summarize.ts
|
|
2336
|
+
var MAX_USER_CHARS = 400;
|
|
2337
|
+
var MAX_REPLY_CHARS = 700;
|
|
2338
|
+
var DEFAULT_MAX_PROMPT_CHARS = 12e3;
|
|
2339
|
+
function groupTurnsIntoSessions(turns) {
|
|
2340
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2341
|
+
for (const turn of turns) {
|
|
2342
|
+
const existing = groups.get(turn.sessionKey);
|
|
2343
|
+
if (existing) {
|
|
2344
|
+
existing.turns.push(turn);
|
|
2345
|
+
if (turn.ts < existing.startedAt) existing.startedAt = turn.ts;
|
|
2346
|
+
if (turn.ts > existing.endedAt) existing.endedAt = turn.ts;
|
|
2347
|
+
existing.cwd ??= turn.cwd;
|
|
2348
|
+
continue;
|
|
2349
|
+
}
|
|
2350
|
+
groups.set(turn.sessionKey, {
|
|
2351
|
+
sessionKey: turn.sessionKey,
|
|
2352
|
+
source: turn.source,
|
|
2353
|
+
cwd: turn.cwd,
|
|
2354
|
+
turns: [turn],
|
|
2355
|
+
startedAt: turn.ts,
|
|
2356
|
+
endedAt: turn.ts
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
for (const group of groups.values()) {
|
|
2360
|
+
group.turns.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
2361
|
+
}
|
|
2362
|
+
return [...groups.values()].sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
2363
|
+
}
|
|
2364
|
+
function selectSettledSessions(sessions, settleMinutes, now = /* @__PURE__ */ new Date()) {
|
|
2365
|
+
const cutoff = now.getTime() - settleMinutes * 6e4;
|
|
2366
|
+
return sessions.filter((s) => {
|
|
2367
|
+
const ended = Date.parse(s.endedAt);
|
|
2368
|
+
return Number.isNaN(ended) || ended <= cutoff;
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
2371
|
+
var SESSION_INSTRUCTIONS = `You are summarizing one working session between a developer and an AI coding assistant, so that a future assistant can recall what happened without re-reading the transcript.
|
|
2372
|
+
|
|
2373
|
+
Your first line MUST begin with "TITLE: " and nothing else. Start your reply with those six characters.
|
|
2374
|
+
|
|
2375
|
+
Write your answer in exactly this shape:
|
|
2376
|
+
TITLE: <under 15 words, naming the specific work, e.g. "Raised the Node floor to 22 after a CI failure">
|
|
2377
|
+
- <a decision that was made, and why>
|
|
2378
|
+
- <a problem that was diagnosed, and its cause>
|
|
2379
|
+
- <what was left unfinished or explicitly deferred>
|
|
2380
|
+
|
|
2381
|
+
Rules:
|
|
2382
|
+
- Answer in English, whatever language the transcript is in. This summary is stored in a keyword index that cannot segment languages without spaces between words.
|
|
2383
|
+
- The title must name this session specifically. "Summary of the session" or "Project update" are wrong.
|
|
2384
|
+
- Prefer reasons over narration. "Chose X over Y because Z" is worth more than "worked on X".
|
|
2385
|
+
- Only state what the transcript supports. Do not guess or invent.
|
|
2386
|
+
- Between 3 and 6 bullets. No preamble, no closing remarks, no markdown bold.`;
|
|
2387
|
+
function buildSessionPrompt(session, maxPromptChars = DEFAULT_MAX_PROMPT_CHARS) {
|
|
2388
|
+
const rendered = session.turns.map((turn) => {
|
|
2389
|
+
const user = redact(turn.userText).text;
|
|
2390
|
+
const reply = redact(turn.assistantText).text;
|
|
2391
|
+
return {
|
|
2392
|
+
ts: turn.ts,
|
|
2393
|
+
text: [`[${turn.ts}] developer: ${truncate(user, MAX_USER_CHARS)}`, `assistant: ${truncate(reply, MAX_REPLY_CHARS)}`].join(
|
|
2394
|
+
"\n"
|
|
2395
|
+
),
|
|
2396
|
+
signal: scoreConversationTurn(user, reply)
|
|
2397
|
+
};
|
|
2398
|
+
});
|
|
2399
|
+
const budget = maxPromptChars - SESSION_INSTRUCTIONS.length;
|
|
2400
|
+
const kept = [];
|
|
2401
|
+
let used = 0;
|
|
2402
|
+
for (const turn of [...rendered].sort((a, b) => b.signal - a.signal)) {
|
|
2403
|
+
if (used + turn.text.length > budget) continue;
|
|
2404
|
+
kept.push(turn);
|
|
2405
|
+
used += turn.text.length;
|
|
2406
|
+
}
|
|
2407
|
+
kept.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
2408
|
+
const body = kept.map((t) => t.text).join("\n\n");
|
|
2409
|
+
const prompt = `${SESSION_INSTRUCTIONS}
|
|
2410
|
+
|
|
2411
|
+
---
|
|
2412
|
+
|
|
2413
|
+
${body}
|
|
2414
|
+
|
|
2415
|
+
---
|
|
2416
|
+
|
|
2417
|
+
Summary:`;
|
|
2418
|
+
return { prompt, hash: sha256Hex(prompt), includedTurns: kept.length };
|
|
2419
|
+
}
|
|
2420
|
+
function sessionFallbackTitle(session) {
|
|
2421
|
+
const opening = session.turns[0];
|
|
2422
|
+
if (!opening) return "Working session";
|
|
2423
|
+
const line = redact(opening.userText).text.split(/\r?\n/)[0]?.trim();
|
|
2424
|
+
return line && line.length > 0 ? line : "Working session";
|
|
2425
|
+
}
|
|
2180
2426
|
var MAX_TITLE_CHARS5 = 200;
|
|
2427
|
+
var GENERIC_TITLE = /^(a |the )?(session |conversation |project |work )?(summary|update|overview|recap|status)\b/i;
|
|
2428
|
+
function cleanTitle(line) {
|
|
2429
|
+
return line.replace(/^[-*#>\s]+/, "").replace(/\*+/g, "").replace(/\s*:\s*$/, "").trim();
|
|
2430
|
+
}
|
|
2431
|
+
function parseSummary(raw, fallbackTitle) {
|
|
2432
|
+
const text = redact(raw).text.trim();
|
|
2433
|
+
if (text.length === 0) return null;
|
|
2434
|
+
const lines = text.split(/\r?\n/);
|
|
2435
|
+
const firstIndex = lines.findIndex((line) => line.trim().length > 0);
|
|
2436
|
+
if (firstIndex === -1) return null;
|
|
2437
|
+
const first = lines[firstIndex].trim();
|
|
2438
|
+
const labelled = /^TITLE:\s*(.+)$/i.exec(first);
|
|
2439
|
+
const fallback = truncate(cleanTitle(fallbackTitle) || "Working session", MAX_TITLE_CHARS5);
|
|
2440
|
+
if (!labelled) return { title: fallback, body: text };
|
|
2441
|
+
const candidate = cleanTitle(labelled[1]);
|
|
2442
|
+
const rest = lines.slice(firstIndex + 1).join("\n").trim();
|
|
2443
|
+
return {
|
|
2444
|
+
title: candidate.length > 0 && !GENERIC_TITLE.test(candidate) ? truncate(candidate, MAX_TITLE_CHARS5) : fallback,
|
|
2445
|
+
// A model that emitted only a title still gets a usable node: the title
|
|
2446
|
+
// doubles as the body rather than storing an empty one.
|
|
2447
|
+
body: rest.length > 0 ? rest : candidate
|
|
2448
|
+
};
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
// src/collectors/sessions.ts
|
|
2452
|
+
var SESSION_SOURCE_PREFIX = "session";
|
|
2453
|
+
var DEFAULT_SETTLE_MINUTES = 30;
|
|
2454
|
+
var DEFAULT_MAX_BODY_CHARS3 = 2500;
|
|
2455
|
+
var DEFAULT_MAX_SESSIONS = 10;
|
|
2456
|
+
function scoreSession(turnCount) {
|
|
2457
|
+
const score = 0.6 + Math.min(0.25, turnCount / 100);
|
|
2458
|
+
return Number(score.toFixed(3));
|
|
2459
|
+
}
|
|
2460
|
+
function toNode(session, projectId, summary, meta, maxBodyChars) {
|
|
2461
|
+
const header = `Session of ${session.startedAt.slice(0, 10)} \u2014 ${session.turns.length} exchange(s)`;
|
|
2462
|
+
const body = `${header}
|
|
2463
|
+
|
|
2464
|
+
${summary.body}`;
|
|
2465
|
+
return {
|
|
2466
|
+
id: makeNodeId(projectId, "session_summary", session.sessionKey),
|
|
2467
|
+
kind: "session_summary",
|
|
2468
|
+
projectId,
|
|
2469
|
+
// The session's end, not its start: a summary describes a finished piece
|
|
2470
|
+
// of work, and recency ranking should treat it as being as fresh as the
|
|
2471
|
+
// last thing that happened in it.
|
|
2472
|
+
ts: session.endedAt,
|
|
2473
|
+
source: `${SESSION_SOURCE_PREFIX}:${session.source}`,
|
|
2474
|
+
title: truncate(summary.title, 200),
|
|
2475
|
+
body: truncate(body, maxBodyChars),
|
|
2476
|
+
// Drawn from the whole session rather than only the summary: the model
|
|
2477
|
+
// mentions few paths, but `node_files` is what lets "why is this file
|
|
2478
|
+
// like this" reach the session that explains it.
|
|
2479
|
+
files: extractMentionedFiles(session.turns.map((t) => `${t.userText}
|
|
2480
|
+
${t.assistantText}`).join("\n")),
|
|
2481
|
+
signal: scoreSession(session.turns.length),
|
|
2482
|
+
meta: {
|
|
2483
|
+
sessionKey: session.sessionKey,
|
|
2484
|
+
source: session.source,
|
|
2485
|
+
turnCount: session.turns.length,
|
|
2486
|
+
summarizedTurns: meta.includedTurns,
|
|
2487
|
+
startedAt: session.startedAt,
|
|
2488
|
+
endedAt: session.endedAt,
|
|
2489
|
+
cwd: session.cwd,
|
|
2490
|
+
model: meta.model,
|
|
2491
|
+
contentHash: meta.hash
|
|
2492
|
+
}
|
|
2493
|
+
};
|
|
2494
|
+
}
|
|
2495
|
+
async function collectSessionSummaries(turns, projectId, provider, opts = {}) {
|
|
2496
|
+
const maxBodyChars = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS3;
|
|
2497
|
+
const maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
2498
|
+
const all = groupTurnsIntoSessions(turns);
|
|
2499
|
+
const settled = selectSettledSessions(all, opts.settleMinutes ?? DEFAULT_SETTLE_MINUTES, opts.now);
|
|
2500
|
+
const nodes = [];
|
|
2501
|
+
let cached = 0;
|
|
2502
|
+
let failed = 0;
|
|
2503
|
+
let attempted = 0;
|
|
2504
|
+
const candidates = [...settled].sort((a, b) => b.endedAt.localeCompare(a.endedAt));
|
|
2505
|
+
const pending = [];
|
|
2506
|
+
for (const session of candidates) {
|
|
2507
|
+
const prompt = buildSessionPrompt(session, opts.maxPromptChars);
|
|
2508
|
+
if (opts.knownHash?.(session.sessionKey) === prompt.hash) {
|
|
2509
|
+
cached += 1;
|
|
2510
|
+
continue;
|
|
2511
|
+
}
|
|
2512
|
+
pending.push({ session, prompt });
|
|
2513
|
+
}
|
|
2514
|
+
for (const { session, prompt } of pending.slice(0, maxSessions)) {
|
|
2515
|
+
const raw = await provider.complete(prompt.prompt);
|
|
2516
|
+
attempted += 1;
|
|
2517
|
+
const summary = raw === null ? null : parseSummary(raw, sessionFallbackTitle(session));
|
|
2518
|
+
if (!summary) {
|
|
2519
|
+
failed += 1;
|
|
2520
|
+
continue;
|
|
2521
|
+
}
|
|
2522
|
+
nodes.push(
|
|
2523
|
+
toNode(
|
|
2524
|
+
session,
|
|
2525
|
+
projectId,
|
|
2526
|
+
summary,
|
|
2527
|
+
{ hash: prompt.hash, model: provider.identity, includedTurns: prompt.includedTurns },
|
|
2528
|
+
maxBodyChars
|
|
2529
|
+
)
|
|
2530
|
+
);
|
|
2531
|
+
opts.onProgress?.(nodes.length, Math.min(pending.length, maxSessions));
|
|
2532
|
+
}
|
|
2533
|
+
return {
|
|
2534
|
+
nodes,
|
|
2535
|
+
cached,
|
|
2536
|
+
unsettled: all.length - settled.length,
|
|
2537
|
+
deferred: Math.max(0, pending.length - maxSessions),
|
|
2538
|
+
failed,
|
|
2539
|
+
providerUnavailable: attempted > 0 && nodes.length === 0
|
|
2540
|
+
};
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
// src/collectors/shell-history.ts
|
|
2544
|
+
var DEFAULT_MAX_BODY_CHARS4 = 1e3;
|
|
2545
|
+
var MAX_TITLE_CHARS6 = 200;
|
|
2181
2546
|
var NOISE = /^(cd|ls|dir|pwd|clear|cls|exit|history|whoami|date|type|cat|more|less|ll|la)\b/i;
|
|
2182
2547
|
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;
|
|
2183
2548
|
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;
|
|
@@ -2210,7 +2575,7 @@ function renderBody(entry, maxChars) {
|
|
|
2210
2575
|
return truncate(parts.join("\n"), maxChars);
|
|
2211
2576
|
}
|
|
2212
2577
|
function toMemoryNode2(entry, projectId, opts = {}) {
|
|
2213
|
-
const maxBody = opts.maxBodyChars ??
|
|
2578
|
+
const maxBody = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS4;
|
|
2214
2579
|
const titleLine = entry.command.split(/\r?\n/)[0] ?? entry.command;
|
|
2215
2580
|
return {
|
|
2216
2581
|
id: makeNodeId(projectId, "shell_command", entry.naturalKey),
|
|
@@ -2218,7 +2583,7 @@ function toMemoryNode2(entry, projectId, opts = {}) {
|
|
|
2218
2583
|
projectId,
|
|
2219
2584
|
ts: entry.ts,
|
|
2220
2585
|
source: `shell:${entry.shell}`,
|
|
2221
|
-
title: truncate(titleLine,
|
|
2586
|
+
title: truncate(titleLine, MAX_TITLE_CHARS6),
|
|
2222
2587
|
body: renderBody(entry, maxBody),
|
|
2223
2588
|
files: [],
|
|
2224
2589
|
signal: scoreShellCommand(entry),
|
|
@@ -2238,6 +2603,7 @@ function collectShellHistory(entries, projectId, opts = {}) {
|
|
|
2238
2603
|
|
|
2239
2604
|
// src/conversation/claude-code-reader.ts
|
|
2240
2605
|
import { readFile as readFile4 } from "fs/promises";
|
|
2606
|
+
import { basename as basename2 } from "path";
|
|
2241
2607
|
|
|
2242
2608
|
// src/conversation/paths.ts
|
|
2243
2609
|
import { existsSync as existsSync3 } from "fs";
|
|
@@ -2284,6 +2650,7 @@ function extractAssistantText(line) {
|
|
|
2284
2650
|
}
|
|
2285
2651
|
function parseClaudeCodeTranscript(raw, opts = {}) {
|
|
2286
2652
|
const source = opts.source ?? "claude-code";
|
|
2653
|
+
const sessionKey = `${source}:${opts.sessionId ?? "unknown"}`;
|
|
2287
2654
|
const turns = [];
|
|
2288
2655
|
let current = null;
|
|
2289
2656
|
const flush = () => {
|
|
@@ -2295,7 +2662,8 @@ function parseClaudeCodeTranscript(raw, opts = {}) {
|
|
|
2295
2662
|
assistantText,
|
|
2296
2663
|
ts: current.ts,
|
|
2297
2664
|
cwd: current.cwd,
|
|
2298
|
-
source
|
|
2665
|
+
source,
|
|
2666
|
+
sessionKey
|
|
2299
2667
|
});
|
|
2300
2668
|
current = null;
|
|
2301
2669
|
};
|
|
@@ -2328,7 +2696,7 @@ async function collectClaudeCodeTranscripts(repoRoot) {
|
|
|
2328
2696
|
const turns = [];
|
|
2329
2697
|
for (const file of files) {
|
|
2330
2698
|
const raw = await readFile4(file, "utf8");
|
|
2331
|
-
turns.push(...parseClaudeCodeTranscript(raw));
|
|
2699
|
+
turns.push(...parseClaudeCodeTranscript(raw, { sessionId: basename2(file, ".jsonl") }));
|
|
2332
2700
|
}
|
|
2333
2701
|
return turns;
|
|
2334
2702
|
}
|
|
@@ -2554,21 +2922,76 @@ async function collectAvailableShellHistory(opts = {}) {
|
|
|
2554
2922
|
}
|
|
2555
2923
|
|
|
2556
2924
|
// src/vector/sync.ts
|
|
2925
|
+
var EMBEDDING_IDENTITY_KEY = "embedding.identity";
|
|
2926
|
+
var DEFAULT_BATCH_SIZE = 32;
|
|
2927
|
+
var DEFAULT_PAGE_SIZE = 500;
|
|
2928
|
+
var DEFAULT_FAILURE_TOLERANCE = 3;
|
|
2929
|
+
function reconcileProviderIdentity(store, provider) {
|
|
2930
|
+
if (store.getMeta(EMBEDDING_IDENTITY_KEY) === provider.identity) return 0;
|
|
2931
|
+
const invalidated = store.dropAllEmbeddings();
|
|
2932
|
+
store.setMeta(EMBEDDING_IDENTITY_KEY, provider.identity);
|
|
2933
|
+
return invalidated;
|
|
2934
|
+
}
|
|
2935
|
+
async function embedTexts(provider, texts) {
|
|
2936
|
+
if (provider.embedBatch) return provider.embedBatch(texts);
|
|
2937
|
+
const out = [];
|
|
2938
|
+
for (const text of texts) out.push(await provider.embed(text));
|
|
2939
|
+
return out;
|
|
2940
|
+
}
|
|
2941
|
+
function chunk(items, size) {
|
|
2942
|
+
const out = [];
|
|
2943
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
2944
|
+
return out;
|
|
2945
|
+
}
|
|
2557
2946
|
async function embedPendingNodes(store, provider, projectId, opts = {}) {
|
|
2558
|
-
const
|
|
2947
|
+
const batchSize = opts.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2948
|
+
const pageSize = opts.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
2949
|
+
const failureTolerance = opts.failureTolerance ?? DEFAULT_FAILURE_TOLERANCE;
|
|
2950
|
+
const maxNodes = opts.maxNodes ?? Number.POSITIVE_INFINITY;
|
|
2951
|
+
const invalidated = reconcileProviderIdentity(store, provider);
|
|
2952
|
+
if (invalidated > 0) opts.onInvalidated?.(invalidated);
|
|
2953
|
+
const total = Math.min(store.countNodesNeedingEmbedding(projectId), maxNodes);
|
|
2559
2954
|
let embedded = 0;
|
|
2560
2955
|
let skipped = 0;
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2956
|
+
let attempted = 0;
|
|
2957
|
+
let consecutiveFailedRequests = 0;
|
|
2958
|
+
let cursor = 0;
|
|
2959
|
+
outer: while (attempted < maxNodes) {
|
|
2960
|
+
const page = store.findNodesNeedingEmbedding(projectId, Math.min(pageSize, maxNodes - attempted), cursor);
|
|
2961
|
+
if (page.length === 0) break;
|
|
2962
|
+
cursor = page[page.length - 1].rowid;
|
|
2963
|
+
for (const group of chunk(page, batchSize)) {
|
|
2964
|
+
const vectors = await embedTexts(
|
|
2965
|
+
provider,
|
|
2966
|
+
group.map((node) => `${node.title}
|
|
2967
|
+
${node.body}`)
|
|
2968
|
+
);
|
|
2969
|
+
let embeddedHere = 0;
|
|
2970
|
+
for (const [index, node] of group.entries()) {
|
|
2971
|
+
const vector = vectors[index];
|
|
2972
|
+
if (vector && vector.length === provider.dimension) {
|
|
2973
|
+
store.upsertEmbedding(node.rowid, vector);
|
|
2974
|
+
embedded += 1;
|
|
2975
|
+
embeddedHere += 1;
|
|
2976
|
+
} else {
|
|
2977
|
+
skipped += 1;
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
attempted += group.length;
|
|
2981
|
+
consecutiveFailedRequests = embeddedHere === 0 ? consecutiveFailedRequests + 1 : 0;
|
|
2982
|
+
opts.onProgress?.(attempted, total);
|
|
2983
|
+
if (consecutiveFailedRequests >= failureTolerance) break outer;
|
|
2569
2984
|
}
|
|
2570
2985
|
}
|
|
2571
|
-
return {
|
|
2986
|
+
return {
|
|
2987
|
+
embedded,
|
|
2988
|
+
skipped,
|
|
2989
|
+
// Unchanged meaning: nothing came back at all. Reached far sooner now --
|
|
2990
|
+
// `failureTolerance` requests instead of the whole first page.
|
|
2991
|
+
providerUnavailable: attempted > 0 && embedded === 0,
|
|
2992
|
+
invalidated,
|
|
2993
|
+
remaining: store.countNodesNeedingEmbedding(projectId)
|
|
2994
|
+
};
|
|
2572
2995
|
}
|
|
2573
2996
|
|
|
2574
2997
|
// src/cli/context.ts
|
|
@@ -2581,6 +3004,8 @@ async function loadContext(cwd) {
|
|
|
2581
3004
|
|
|
2582
3005
|
// src/cli/commands/sync.ts
|
|
2583
3006
|
var BATCH_SIZE = 500;
|
|
3007
|
+
var PROGRESS_THRESHOLD = 200;
|
|
3008
|
+
var PROGRESS_EVERY = 100;
|
|
2584
3009
|
var GIT_SOURCE = "git";
|
|
2585
3010
|
function addStats(into, from) {
|
|
2586
3011
|
into.inserted += from.inserted;
|
|
@@ -2704,13 +3129,12 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
|
2704
3129
|
return { totals, seen };
|
|
2705
3130
|
}
|
|
2706
3131
|
var CONVERSATION_SOURCE = "conversation:claude-code";
|
|
2707
|
-
|
|
3132
|
+
function syncConversation(store, projectId, turns, config, log, forceEnabled) {
|
|
2708
3133
|
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
2709
3134
|
const enabled = forceEnabled ?? config.sources.conversation.enabled;
|
|
2710
3135
|
if (!enabled) {
|
|
2711
3136
|
return { totals, seen: 0 };
|
|
2712
3137
|
}
|
|
2713
|
-
const turns = await collectClaudeCodeTranscripts(repoRoot);
|
|
2714
3138
|
if (turns.length === 0) {
|
|
2715
3139
|
log(`${pc4.dim("conversation")} no transcripts found`);
|
|
2716
3140
|
return { totals, seen: 0 };
|
|
@@ -2721,6 +3145,42 @@ async function syncConversation(store, projectId, repoRoot, config, log, forceEn
|
|
|
2721
3145
|
log(` ${pc4.dim(`${CONVERSATION_SOURCE}: ${nodes.length} of ${turns.length} exchange(s) kept`)}`);
|
|
2722
3146
|
return { totals, seen: nodes.length };
|
|
2723
3147
|
}
|
|
3148
|
+
var SESSION_SOURCE = "session:claude-code";
|
|
3149
|
+
async function syncSessions(store, projectId, turns, config, log) {
|
|
3150
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
3151
|
+
const settings = config.sources.session;
|
|
3152
|
+
if (!settings.enabled) return { totals, seen: 0 };
|
|
3153
|
+
if (turns.length === 0) {
|
|
3154
|
+
log(`${pc4.dim("session")} no transcripts found`);
|
|
3155
|
+
return { totals, seen: 0 };
|
|
3156
|
+
}
|
|
3157
|
+
const result = await collectSessionSummaries(turns, projectId, new OllamaChatProvider({ model: settings.model }), {
|
|
3158
|
+
settleMinutes: settings.settleMinutes,
|
|
3159
|
+
maxSessions: settings.maxSessions,
|
|
3160
|
+
maxPromptChars: settings.maxPromptChars,
|
|
3161
|
+
maxBodyChars: config.limits.maxBodyChars,
|
|
3162
|
+
knownHash: (sessionKey) => {
|
|
3163
|
+
const meta = store.getNodeMeta(makeNodeId(projectId, "session_summary", sessionKey));
|
|
3164
|
+
return typeof meta?.contentHash === "string" ? meta.contentHash : null;
|
|
3165
|
+
},
|
|
3166
|
+
onProgress: (done, total) => log(` ${pc4.dim(`session: summarizing ${done}/${total}`)}`)
|
|
3167
|
+
});
|
|
3168
|
+
if (result.nodes.length > 0) addStats(totals, store.upsertNodes(result.nodes));
|
|
3169
|
+
if (result.providerUnavailable) {
|
|
3170
|
+
log(
|
|
3171
|
+
`${pc4.dim("session")} summarization model unavailable (is Ollama running with \`${settings.model}\` pulled?) -- skipped`
|
|
3172
|
+
);
|
|
3173
|
+
} else {
|
|
3174
|
+
const parts = [`${result.nodes.length} summarized`];
|
|
3175
|
+
if (result.cached > 0) parts.push(`${result.cached} unchanged`);
|
|
3176
|
+
if (result.deferred > 0) parts.push(`${result.deferred} queued for the next sync`);
|
|
3177
|
+
if (result.unsettled > 0) parts.push(`${result.unsettled} still active`);
|
|
3178
|
+
if (result.failed > 0) parts.push(`${result.failed} failed`);
|
|
3179
|
+
log(` ${pc4.dim(`${SESSION_SOURCE}: ${parts.join(", ")}`)}`);
|
|
3180
|
+
}
|
|
3181
|
+
store.setSyncCursor(projectId, SESSION_SOURCE, `scanned:${result.nodes.length}`);
|
|
3182
|
+
return { totals, seen: result.nodes.length };
|
|
3183
|
+
}
|
|
2724
3184
|
var DOCS_SOURCE = "docs";
|
|
2725
3185
|
async function syncDocs(store, projectId, repoRoot, config, log) {
|
|
2726
3186
|
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
@@ -2753,7 +3213,7 @@ async function runSync(opts) {
|
|
|
2753
3213
|
if (!opts.quiet) process.stderr.write(`${line}
|
|
2754
3214
|
`);
|
|
2755
3215
|
};
|
|
2756
|
-
const out = opts.out ?? ((
|
|
3216
|
+
const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
|
|
2757
3217
|
const store = MemoryStore.open(ws.dbPath);
|
|
2758
3218
|
const started = Date.now();
|
|
2759
3219
|
try {
|
|
@@ -2766,13 +3226,27 @@ async function runSync(opts) {
|
|
|
2766
3226
|
const git2 = await syncGit(store, projectId, opts, repo, config, log);
|
|
2767
3227
|
const diffs = await syncDiffs(store, projectId, opts, repo, config, log);
|
|
2768
3228
|
const shell = await syncShell(store, projectId, opts, repo.root, config, log);
|
|
2769
|
-
const
|
|
3229
|
+
const conversationEnabled = opts.conversationOverride ?? config.sources.conversation.enabled;
|
|
3230
|
+
const turns = conversationEnabled || config.sources.session.enabled ? await collectClaudeCodeTranscripts(repo.root) : [];
|
|
3231
|
+
const conversation = syncConversation(store, projectId, turns, config, log, opts.conversationOverride);
|
|
3232
|
+
const sessions = await syncSessions(store, projectId, turns, config, log);
|
|
2770
3233
|
const docs = await syncDocs(store, projectId, repo.root, config, log);
|
|
2771
3234
|
let embedLine = "";
|
|
2772
3235
|
if (!opts.noEmbed) {
|
|
2773
|
-
|
|
3236
|
+
let lastLogged = 0;
|
|
3237
|
+
const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId, {
|
|
3238
|
+
maxNodes: opts.embedLimit,
|
|
3239
|
+
onInvalidated: (count) => log(`${pc4.yellow("vector")} embedding model changed -- dropped ${count} vector(s), re-embedding from scratch`),
|
|
3240
|
+
onProgress: (attempted, total) => {
|
|
3241
|
+
if (total < PROGRESS_THRESHOLD || attempted - lastLogged < PROGRESS_EVERY) return;
|
|
3242
|
+
lastLogged = attempted;
|
|
3243
|
+
log(` ${pc4.dim(`vector: ${attempted}/${total} embedded`)}`);
|
|
3244
|
+
}
|
|
3245
|
+
});
|
|
2774
3246
|
if (result.embedded > 0) {
|
|
2775
|
-
|
|
3247
|
+
const skippedPart = result.skipped > 0 ? pc4.dim(`, ${result.skipped} skipped`) : "";
|
|
3248
|
+
const remainingPart = result.remaining > 0 ? pc4.yellow(`, ${result.remaining} still pending`) : "";
|
|
3249
|
+
embedLine = ` ${pc4.dim(`vector: ${result.embedded} node(s) embedded`)}${skippedPart}${remainingPart}
|
|
2776
3250
|
`;
|
|
2777
3251
|
} else if (result.providerUnavailable) {
|
|
2778
3252
|
log(`${pc4.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
|
|
@@ -2784,16 +3258,17 @@ async function runSync(opts) {
|
|
|
2784
3258
|
addStats(totals, diffs.totals);
|
|
2785
3259
|
addStats(totals, shell.totals);
|
|
2786
3260
|
addStats(totals, conversation.totals);
|
|
3261
|
+
addStats(totals, sessions.totals);
|
|
2787
3262
|
addStats(totals, docs.totals);
|
|
2788
3263
|
const stats = store.stats(projectId);
|
|
2789
3264
|
const elapsed = ((Date.now() - started) / 1e3).toFixed(2);
|
|
2790
|
-
const conversationEnabled = opts.conversationOverride ?? config.sources.conversation.enabled;
|
|
2791
3265
|
const conversationPart = conversationEnabled ? `, ${conversation.seen} conversation exchange(s)` : "";
|
|
3266
|
+
const sessionPart = config.sources.session.enabled ? `, ${sessions.seen} session summar${sessions.seen === 1 ? "y" : "ies"}` : "";
|
|
2792
3267
|
const docsPart = config.sources.docs.enabled ? `, ${docs.seen} doc section(s)` : "";
|
|
2793
3268
|
const diffPart = config.sources.diff.enabled ? `, ${diffs.seen} file diff(s)` : "";
|
|
2794
3269
|
out(
|
|
2795
3270
|
[
|
|
2796
|
-
`${pc4.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${docsPart} in ${elapsed}s`,
|
|
3271
|
+
`${pc4.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${sessionPart}${docsPart} in ${elapsed}s`,
|
|
2797
3272
|
` ${pc4.green(`+${totals.inserted} new`)} ${pc4.yellow(`~${totals.updated} updated`)} ${pc4.dim(`=${totals.unchanged} unchanged`)}`,
|
|
2798
3273
|
` ${pc4.dim(`${stats.total} node(s) total across ${stats.distinctFiles} file path(s)`)}`,
|
|
2799
3274
|
""
|
|
@@ -2844,7 +3319,7 @@ async function searchMemory(input) {
|
|
|
2844
3319
|
vectorMatched: vectorCount,
|
|
2845
3320
|
tokensUsed: packed.tokensUsed,
|
|
2846
3321
|
tokensBudget: packed.tokensBudget,
|
|
2847
|
-
projectsSearched: [
|
|
3322
|
+
projectsSearched: [basename3(repo.root) || repo.root]
|
|
2848
3323
|
};
|
|
2849
3324
|
} finally {
|
|
2850
3325
|
store.close();
|
|
@@ -2852,8 +3327,8 @@ async function searchMemory(input) {
|
|
|
2852
3327
|
}
|
|
2853
3328
|
async function syncProject(input) {
|
|
2854
3329
|
const chunks = [];
|
|
2855
|
-
const out = (
|
|
2856
|
-
chunks.push(
|
|
3330
|
+
const out = (chunk2) => {
|
|
3331
|
+
chunks.push(chunk2);
|
|
2857
3332
|
};
|
|
2858
3333
|
await runInit({ cwd: input.projectRoot, force: false, hook: false, enableConversation: false, out });
|
|
2859
3334
|
const opts = {
|
|
@@ -2887,7 +3362,7 @@ function createServer() {
|
|
|
2887
3362
|
"search_memory",
|
|
2888
3363
|
{
|
|
2889
3364
|
title: "Search remembered project history",
|
|
2890
|
-
description: "Search a NexusMem-tracked repository's remembered history: git commits, code diffs (the patch of each changed file), shell commands, tracked markdown docs, and (if enabled) conversation transcripts. Returns a token-budgeted, ranked context block -- not raw search results.",
|
|
3365
|
+
description: "Search a NexusMem-tracked repository's remembered history: git commits, code diffs (the patch of each changed file), shell commands, tracked markdown docs, and (if enabled) conversation transcripts and per-session summaries. Returns a token-budgeted, ranked context block -- not raw search results.",
|
|
2891
3366
|
inputSchema: {
|
|
2892
3367
|
projectRoot: z3.string().describe("Absolute path to the repository root"),
|
|
2893
3368
|
query: z3.string().describe("Free-text question or search terms"),
|
|
@@ -3249,17 +3724,101 @@ function formatNode4(node) {
|
|
|
3249
3724
|
return [formatSignal(node.signal, DOCS_SIGNAL_BANDS), node.title].join(" ");
|
|
3250
3725
|
}
|
|
3251
3726
|
|
|
3252
|
-
// src/cli/commands/scan-
|
|
3727
|
+
// src/cli/commands/scan-session.ts
|
|
3253
3728
|
import pc11 from "picocolors";
|
|
3729
|
+
async function runScanSession(opts) {
|
|
3730
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
3731
|
+
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
3732
|
+
const turns = await collectClaudeCodeTranscripts(repo.root);
|
|
3733
|
+
if (turns.length === 0) {
|
|
3734
|
+
process.stderr.write(`${pc11.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
|
|
3735
|
+
`);
|
|
3736
|
+
return 0;
|
|
3737
|
+
}
|
|
3738
|
+
const sessions = groupTurnsIntoSessions(turns);
|
|
3739
|
+
const settled = selectSettledSessions(sessions, opts.settleMinutes);
|
|
3740
|
+
if (!opts.json) {
|
|
3741
|
+
process.stderr.write(
|
|
3742
|
+
`${pc11.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
|
|
3743
|
+
|
|
3744
|
+
`
|
|
3745
|
+
);
|
|
3746
|
+
}
|
|
3747
|
+
if (opts.dryRun) {
|
|
3748
|
+
const previews = settled.slice(0, opts.maxSessions).map((session) => {
|
|
3749
|
+
const { prompt, hash, includedTurns } = buildSessionPrompt(session);
|
|
3750
|
+
return {
|
|
3751
|
+
sessionKey: session.sessionKey,
|
|
3752
|
+
startedAt: session.startedAt,
|
|
3753
|
+
endedAt: session.endedAt,
|
|
3754
|
+
turns: session.turns.length,
|
|
3755
|
+
includedTurns,
|
|
3756
|
+
hash,
|
|
3757
|
+
promptChars: prompt.length,
|
|
3758
|
+
prompt
|
|
3759
|
+
};
|
|
3760
|
+
});
|
|
3761
|
+
if (opts.json) {
|
|
3762
|
+
process.stdout.write(`${JSON.stringify(previews, null, 2)}
|
|
3763
|
+
`);
|
|
3764
|
+
return 0;
|
|
3765
|
+
}
|
|
3766
|
+
for (const preview of previews) {
|
|
3767
|
+
process.stdout.write(
|
|
3768
|
+
`${pc11.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
|
|
3769
|
+
${preview.prompt}
|
|
3770
|
+
|
|
3771
|
+
`
|
|
3772
|
+
);
|
|
3773
|
+
}
|
|
3774
|
+
return 0;
|
|
3775
|
+
}
|
|
3776
|
+
const result = await collectSessionSummaries(turns, projectId, new OllamaChatProvider({ model: opts.model }), {
|
|
3777
|
+
settleMinutes: opts.settleMinutes,
|
|
3778
|
+
maxSessions: opts.maxSessions,
|
|
3779
|
+
onProgress: (done, total) => {
|
|
3780
|
+
if (!opts.json) process.stderr.write(` ${pc11.dim(`summarizing ${done}/${total}`)}
|
|
3781
|
+
`);
|
|
3782
|
+
}
|
|
3783
|
+
});
|
|
3784
|
+
if (opts.json) {
|
|
3785
|
+
process.stdout.write(`${JSON.stringify(result.nodes, null, 2)}
|
|
3786
|
+
`);
|
|
3787
|
+
return 0;
|
|
3788
|
+
}
|
|
3789
|
+
for (const node of result.nodes) {
|
|
3790
|
+
process.stdout.write(`${pc11.bold(node.title)}
|
|
3791
|
+
${pc11.dim(node.ts.slice(0, 16).replace("T", " "))}
|
|
3792
|
+
${node.body}
|
|
3793
|
+
|
|
3794
|
+
`);
|
|
3795
|
+
}
|
|
3796
|
+
if (result.providerUnavailable) {
|
|
3797
|
+
process.stderr.write(
|
|
3798
|
+
`${pc11.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
|
|
3799
|
+
`
|
|
3800
|
+
);
|
|
3801
|
+
return 0;
|
|
3802
|
+
}
|
|
3803
|
+
process.stderr.write(
|
|
3804
|
+
`${pc11.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc11.yellow(`${result.failed} failed`)}` : "") + ` ${pc11.dim(`(model ${opts.model})`)}
|
|
3805
|
+
`
|
|
3806
|
+
);
|
|
3807
|
+
return 0;
|
|
3808
|
+
}
|
|
3809
|
+
var SCAN_SESSION_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
|
|
3810
|
+
|
|
3811
|
+
// src/cli/commands/scan-shell.ts
|
|
3812
|
+
import pc12 from "picocolors";
|
|
3254
3813
|
async function runScanShell(opts) {
|
|
3255
3814
|
const repo = await readRepoInfo(opts.cwd);
|
|
3256
3815
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
3257
3816
|
const results = await collectAvailableShellHistory({ tailLines: opts.tailLines, repoRoot: repo.root });
|
|
3258
3817
|
if (!opts.json) {
|
|
3259
3818
|
process.stderr.write(
|
|
3260
|
-
results.length ? `${
|
|
3819
|
+
results.length ? `${pc12.dim("sources found")} ${results.map((r) => r.name).join(", ")}
|
|
3261
3820
|
|
|
3262
|
-
` : `${
|
|
3821
|
+
` : `${pc12.yellow("no shell history source found on this machine")}
|
|
3263
3822
|
`
|
|
3264
3823
|
);
|
|
3265
3824
|
}
|
|
@@ -3268,7 +3827,7 @@ async function runScanShell(opts) {
|
|
|
3268
3827
|
const nodes = collectShellHistory(result.entries, projectId).filter((n) => n.signal >= opts.minSignal);
|
|
3269
3828
|
allNodes.push(...nodes);
|
|
3270
3829
|
if (!opts.json) {
|
|
3271
|
-
process.stdout.write(`${
|
|
3830
|
+
process.stdout.write(`${pc12.bold(`shell:${result.name}`)} ${pc12.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
|
|
3272
3831
|
`);
|
|
3273
3832
|
for (const node of nodes) process.stdout.write(`${formatNode5(node)}
|
|
3274
3833
|
`);
|
|
@@ -3281,20 +3840,20 @@ async function runScanShell(opts) {
|
|
|
3281
3840
|
return 0;
|
|
3282
3841
|
}
|
|
3283
3842
|
const approxTotal = allNodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
3284
|
-
process.stderr.write(`${
|
|
3843
|
+
process.stderr.write(`${pc12.bold(String(allNodes.length))} node(s) total ${pc12.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
|
|
3285
3844
|
`);
|
|
3286
3845
|
return 0;
|
|
3287
3846
|
}
|
|
3288
3847
|
function formatNode5(node) {
|
|
3289
|
-
const approx = node.meta.tsApprox ?
|
|
3848
|
+
const approx = node.meta.tsApprox ? pc12.dim("~") : " ";
|
|
3290
3849
|
const exit = node.meta.exitCode;
|
|
3291
|
-
const exitLabel = typeof exit === "number" && exit !== 0 ?
|
|
3850
|
+
const exitLabel = typeof exit === "number" && exit !== 0 ? pc12.red(`exit ${exit}`) : "";
|
|
3292
3851
|
return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
|
|
3293
3852
|
}
|
|
3294
3853
|
|
|
3295
3854
|
// src/cli/commands/status.ts
|
|
3296
3855
|
import { statSync } from "fs";
|
|
3297
|
-
import
|
|
3856
|
+
import pc13 from "picocolors";
|
|
3298
3857
|
function humanBytes(bytes) {
|
|
3299
3858
|
if (bytes < 1024) return `${bytes} B`;
|
|
3300
3859
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -3319,23 +3878,23 @@ async function runStatus(opts) {
|
|
|
3319
3878
|
const kinds = Object.entries(stats.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
|
|
3320
3879
|
process.stdout.write(
|
|
3321
3880
|
[
|
|
3322
|
-
`${
|
|
3323
|
-
`${
|
|
3324
|
-
`${
|
|
3325
|
-
`${
|
|
3326
|
-
`${
|
|
3881
|
+
`${pc13.dim("repo ")} ${repo.root}`,
|
|
3882
|
+
`${pc13.dim("branch ")} ${repo.branch ?? pc13.yellow("(detached)")}`,
|
|
3883
|
+
`${pc13.dim("project ")} ${pc13.cyan(projectId)}`,
|
|
3884
|
+
`${pc13.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc13.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
|
|
3885
|
+
`${pc13.dim("database")} ${ws.dbPath} ${pc13.dim(`(${humanBytes(dbBytes)})`)}`,
|
|
3327
3886
|
"",
|
|
3328
|
-
`${
|
|
3887
|
+
`${pc13.bold(String(stats.total))} node(s)${stats.total ? ` ${pc13.dim(`${stats.oldest?.slice(0, 10)} .. ${stats.newest?.slice(0, 10)}`)}` : ""}`,
|
|
3329
3888
|
...kinds,
|
|
3330
|
-
stats.total ? ` ${
|
|
3889
|
+
stats.total ? ` ${pc13.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
|
|
3331
3890
|
"",
|
|
3332
|
-
sources.length ?
|
|
3891
|
+
sources.length ? pc13.dim("sources") : pc13.yellow("no sources synced yet"),
|
|
3333
3892
|
...sources.map((s) => {
|
|
3334
3893
|
const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
|
|
3335
3894
|
const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
|
|
3336
|
-
return ` ${s.source.padEnd(14)} ${
|
|
3895
|
+
return ` ${s.source.padEnd(14)} ${pc13.dim(`last run ${when}`)} ${pc13.dim(`cursor ${cursorLabel}`)}`;
|
|
3337
3896
|
}),
|
|
3338
|
-
gitCursor && gitCursor !== repo.head ? `${
|
|
3897
|
+
gitCursor && gitCursor !== repo.head ? `${pc13.yellow("git behind HEAD")} \u2014 run ${pc13.bold("nexusmem sync")}` : "",
|
|
3339
3898
|
""
|
|
3340
3899
|
].filter((line) => line !== "").join("\n").concat("\n")
|
|
3341
3900
|
);
|
|
@@ -3359,7 +3918,7 @@ function guard(run) {
|
|
|
3359
3918
|
process.exitCode = await run();
|
|
3360
3919
|
} catch (err) {
|
|
3361
3920
|
if (isExpected(err)) {
|
|
3362
|
-
process.stderr.write(`${
|
|
3921
|
+
process.stderr.write(`${pc14.red("error")} ${err.message}
|
|
3363
3922
|
`);
|
|
3364
3923
|
process.exitCode = 1;
|
|
3365
3924
|
return;
|
|
@@ -3375,7 +3934,11 @@ program.command("init").description("Create the .nexusmem workspace and database
|
|
|
3375
3934
|
() => runInit({ cwd: options.cwd, force: options.force, hook: options.hook, enableConversation: options.enableConversation })
|
|
3376
3935
|
)()
|
|
3377
3936
|
);
|
|
3378
|
-
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(
|
|
3937
|
+
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(
|
|
3938
|
+
"--embed-limit <count>",
|
|
3939
|
+
"stop embedding after this many nodes (default: embed everything pending)",
|
|
3940
|
+
(v) => Number.parseInt(v, 10)
|
|
3941
|
+
).option("-q, --quiet", "only print the final summary", false).action(
|
|
3379
3942
|
(options) => guard(
|
|
3380
3943
|
() => runSync({
|
|
3381
3944
|
cwd: options.cwd,
|
|
@@ -3385,6 +3948,7 @@ program.command("sync").description("Ingest new history into the local database"
|
|
|
3385
3948
|
shellTailLines: options.shellLines,
|
|
3386
3949
|
conversationOverride: options.conversation ? true : void 0,
|
|
3387
3950
|
noEmbed: !options.embed,
|
|
3951
|
+
embedLimit: options.embedLimit,
|
|
3388
3952
|
quiet: options.quiet
|
|
3389
3953
|
})
|
|
3390
3954
|
)()
|
|
@@ -3443,11 +4007,23 @@ program.command("scan-shell").description("Preview the MemoryNodes shell history
|
|
|
3443
4007
|
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(
|
|
3444
4008
|
(options) => guard(() => runScanConversation({ cwd: options.cwd, minSignal: options.minSignal, json: options.json }))()
|
|
3445
4009
|
);
|
|
4010
|
+
program.command("scan-session").description("Preview the session summaries a local model would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--model <name>", "Ollama model to summarize with", SCAN_SESSION_DEFAULT_MODEL).option("--settle-minutes <count>", "minutes of quiet before a session counts as finished", (v) => Number.parseInt(v, 10), 30).option("-n, --max-sessions <count>", "how many sessions to summarize", (v) => Number.parseInt(v, 10), 3).option("--dry-run", "print the prompts instead of calling the model", false).option("--json", "emit as JSON on stdout", false).action(
|
|
4011
|
+
(options) => guard(
|
|
4012
|
+
() => runScanSession({
|
|
4013
|
+
cwd: options.cwd,
|
|
4014
|
+
model: options.model,
|
|
4015
|
+
settleMinutes: options.settleMinutes,
|
|
4016
|
+
maxSessions: options.maxSessions,
|
|
4017
|
+
dryRun: options.dryRun,
|
|
4018
|
+
json: options.json
|
|
4019
|
+
})
|
|
4020
|
+
)()
|
|
4021
|
+
);
|
|
3446
4022
|
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 }))());
|
|
3447
4023
|
program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
|
|
3448
4024
|
program.parseAsync(process.argv).catch((err) => {
|
|
3449
4025
|
const message = err instanceof Error ? err.message : String(err);
|
|
3450
|
-
process.stderr.write(`${
|
|
4026
|
+
process.stderr.write(`${pc14.red("error")} ${message}
|
|
3451
4027
|
`);
|
|
3452
4028
|
process.exitCode = 1;
|
|
3453
4029
|
});
|