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/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 pc13 from "picocolors";
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 chunk of runGitOnce(cwd, args, opts)) {
300
+ for await (const chunk2 of runGitOnce(cwd, args, opts)) {
213
301
  produced = true;
214
- yield chunk;
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", (chunk) => {
231
- if (stderr.length < 64 * 1024) stderr += chunk;
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 chunk of child.stdout) {
241
- yield chunk;
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 chunk of gitStream(cwd, args, opts)) out += chunk;
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
- /** Nodes for this project that have no embedding yet (new, or invalidated by a content change). */
897
- findNodesNeedingEmbedding(projectId, limit = 200) {
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 ?? ((chunk) => void process.stdout.write(chunk));
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 basename2 } from "path";
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 SIGNAL_EXPONENT = Math.log(MAX_PRIOR_OVERTURN) / Math.log(1 / SIGNAL_FLOOR);
1295
- var RECENCY_EXPONENT = Math.log(MAX_PRIOR_OVERTURN) / Math.log(1 / RECENCY_FLOOR);
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 DEFAULT_BASE_URL = "http://127.0.0.1:11434";
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 DEFAULT_TIMEOUT_MS = 1e4;
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 ?? DEFAULT_BASE_URL;
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 ?? DEFAULT_TIMEOUT_MS;
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 timeout = setTimeout(() => controller.abort(), this.timeoutMs);
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}/api/embeddings`, {
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, prompt: text }),
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.embedding) || data.embedding.length !== this.dimension) return null;
1451
- return new Float32Array(data.embedding);
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((chunk, index) => {
1584
- const body = [`Q: ${userRedacted.text}`, "", `A: ${chunk.text}`].join("\n");
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, chunk.heading, index, chunks.length),
1748
+ title: chunkTitle(userFirstLine, chunk2.heading, index, chunks.length),
1592
1749
  body: truncate(body, maxBody),
1593
1750
  files: extractMentionedFiles(`${userRedacted.text}
1594
- ${chunk.text}`),
1595
- signal: scoreConversationTurn(userRedacted.text, chunk.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: chunk.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 chunk of gitStream(cwd, args)) {
1758
- buffer += chunk;
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 chunk of gitStream(cwd, args)) {
1915
- buffer += chunk;
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((chunk, index) => {
2151
- const baseSlug = slugify(chunk.heading, index);
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, chunk.heading, index, chunks.length),
2162
- body: truncate(chunk.text, maxBody),
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, chunk.heading, chunk.text),
2321
+ signal: scoreDocSection(file.path, chunk2.heading, chunk2.text),
2165
2322
  meta: {
2166
2323
  path: file.path,
2167
- heading: chunk.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/collectors/shell-history.ts
2179
- var DEFAULT_MAX_BODY_CHARS3 = 1e3;
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 ?? DEFAULT_MAX_BODY_CHARS3;
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, MAX_TITLE_CHARS5),
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 pending = store.findNodesNeedingEmbedding(projectId, opts.batchLimit ?? 200);
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
- for (const node of pending) {
2562
- const vector = await provider.embed(`${node.title}
2563
- ${node.body}`);
2564
- if (vector && vector.length === provider.dimension) {
2565
- store.upsertEmbedding(node.rowid, vector);
2566
- embedded += 1;
2567
- } else {
2568
- skipped += 1;
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 { embedded, skipped, providerUnavailable: pending.length > 0 && embedded === 0 };
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
- async function syncConversation(store, projectId, repoRoot, config, log, forceEnabled) {
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 ?? ((chunk) => void process.stdout.write(chunk));
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 conversation = await syncConversation(store, projectId, repo.root, config, log, opts.conversationOverride);
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
- const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId);
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
- embedLine = ` ${pc4.dim(`vector: ${result.embedded} node(s) embedded`)}${result.skipped > 0 ? pc4.dim(`, ${result.skipped} skipped`) : ""}
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: [basename2(repo.root) || repo.root]
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 = (chunk) => {
2856
- chunks.push(chunk);
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-shell.ts
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 ? `${pc11.dim("sources found")} ${results.map((r) => r.name).join(", ")}
3819
+ results.length ? `${pc12.dim("sources found")} ${results.map((r) => r.name).join(", ")}
3261
3820
 
3262
- ` : `${pc11.yellow("no shell history source found on this machine")}
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(`${pc11.bold(`shell:${result.name}`)} ${pc11.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
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(`${pc11.bold(String(allNodes.length))} node(s) total ${pc11.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
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 ? pc11.dim("~") : " ";
3848
+ const approx = node.meta.tsApprox ? pc12.dim("~") : " ";
3290
3849
  const exit = node.meta.exitCode;
3291
- const exitLabel = typeof exit === "number" && exit !== 0 ? pc11.red(`exit ${exit}`) : "";
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 pc12 from "picocolors";
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
- `${pc12.dim("repo ")} ${repo.root}`,
3323
- `${pc12.dim("branch ")} ${repo.branch ?? pc12.yellow("(detached)")}`,
3324
- `${pc12.dim("project ")} ${pc12.cyan(projectId)}`,
3325
- `${pc12.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc12.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
3326
- `${pc12.dim("database")} ${ws.dbPath} ${pc12.dim(`(${humanBytes(dbBytes)})`)}`,
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
- `${pc12.bold(String(stats.total))} node(s)${stats.total ? ` ${pc12.dim(`${stats.oldest?.slice(0, 10)} .. ${stats.newest?.slice(0, 10)}`)}` : ""}`,
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 ? ` ${pc12.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
3889
+ stats.total ? ` ${pc13.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
3331
3890
  "",
3332
- sources.length ? pc12.dim("sources") : pc12.yellow("no sources synced yet"),
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)} ${pc12.dim(`last run ${when}`)} ${pc12.dim(`cursor ${cursorLabel}`)}`;
3895
+ return ` ${s.source.padEnd(14)} ${pc13.dim(`last run ${when}`)} ${pc13.dim(`cursor ${cursorLabel}`)}`;
3337
3896
  }),
3338
- gitCursor && gitCursor !== repo.head ? `${pc12.yellow("git behind HEAD")} \u2014 run ${pc12.bold("nexusmem sync")}` : "",
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(`${pc13.red("error")} ${err.message}
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("-q, --quiet", "only print the final summary", false).action(
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(`${pc13.red("error")} ${message}
4026
+ process.stderr.write(`${pc14.red("error")} ${message}
3451
4027
  `);
3452
4028
  process.exitCode = 1;
3453
4029
  });