@dahch/muninn 0.0.0-stage → 0.2.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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +395 -2
  3. package/dist/cli.d.ts +23 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +186 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/commands/check.d.ts +3 -0
  8. package/dist/commands/check.d.ts.map +1 -0
  9. package/dist/commands/check.js +56 -0
  10. package/dist/commands/check.js.map +1 -0
  11. package/dist/commands/memory.d.ts +6 -0
  12. package/dist/commands/memory.d.ts.map +1 -0
  13. package/dist/commands/memory.js +374 -0
  14. package/dist/commands/memory.js.map +1 -0
  15. package/dist/commands/setup.d.ts +3 -0
  16. package/dist/commands/setup.d.ts.map +1 -0
  17. package/dist/commands/setup.js +397 -0
  18. package/dist/commands/setup.js.map +1 -0
  19. package/dist/contracts/compiler.d.ts +71 -0
  20. package/dist/contracts/compiler.d.ts.map +1 -0
  21. package/dist/contracts/compiler.js +362 -0
  22. package/dist/contracts/compiler.js.map +1 -0
  23. package/dist/contracts/index.d.ts +2 -0
  24. package/dist/contracts/index.d.ts.map +1 -0
  25. package/dist/contracts/index.js +2 -0
  26. package/dist/contracts/index.js.map +1 -0
  27. package/dist/db/client.d.ts +100 -0
  28. package/dist/db/client.d.ts.map +1 -0
  29. package/dist/db/client.js +318 -0
  30. package/dist/db/client.js.map +1 -0
  31. package/dist/db/schema.sql +99 -0
  32. package/dist/index.d.ts +23 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +23 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/indexer/ast-indexer.d.ts +55 -0
  37. package/dist/indexer/ast-indexer.d.ts.map +1 -0
  38. package/dist/indexer/ast-indexer.js +445 -0
  39. package/dist/indexer/ast-indexer.js.map +1 -0
  40. package/dist/indexer/index.d.ts +2 -0
  41. package/dist/indexer/index.d.ts.map +1 -0
  42. package/dist/indexer/index.js +2 -0
  43. package/dist/indexer/index.js.map +1 -0
  44. package/dist/mcp/index.d.ts +2 -0
  45. package/dist/mcp/index.d.ts.map +1 -0
  46. package/dist/mcp/index.js +2 -0
  47. package/dist/mcp/index.js.map +1 -0
  48. package/dist/mcp/server.d.ts +254 -0
  49. package/dist/mcp/server.d.ts.map +1 -0
  50. package/dist/mcp/server.js +636 -0
  51. package/dist/mcp/server.js.map +1 -0
  52. package/dist/service/index.d.ts +2 -0
  53. package/dist/service/index.d.ts.map +1 -0
  54. package/dist/service/index.js +2 -0
  55. package/dist/service/index.js.map +1 -0
  56. package/dist/service/memory-service.d.ts +320 -0
  57. package/dist/service/memory-service.d.ts.map +1 -0
  58. package/dist/service/memory-service.js +1511 -0
  59. package/dist/service/memory-service.js.map +1 -0
  60. package/dist/setup/index.d.ts +6 -0
  61. package/dist/setup/index.d.ts.map +1 -0
  62. package/dist/setup/index.js +6 -0
  63. package/dist/setup/index.js.map +1 -0
  64. package/dist/setup/json-target.d.ts +30 -0
  65. package/dist/setup/json-target.d.ts.map +1 -0
  66. package/dist/setup/json-target.js +263 -0
  67. package/dist/setup/json-target.js.map +1 -0
  68. package/dist/setup/portable.d.ts +56 -0
  69. package/dist/setup/portable.d.ts.map +1 -0
  70. package/dist/setup/portable.js +365 -0
  71. package/dist/setup/portable.js.map +1 -0
  72. package/dist/setup/registry.d.ts +54 -0
  73. package/dist/setup/registry.d.ts.map +1 -0
  74. package/dist/setup/registry.js +225 -0
  75. package/dist/setup/registry.js.map +1 -0
  76. package/dist/setup/rules-target.d.ts +31 -0
  77. package/dist/setup/rules-target.d.ts.map +1 -0
  78. package/dist/setup/rules-target.js +270 -0
  79. package/dist/setup/rules-target.js.map +1 -0
  80. package/dist/setup/toml-target.d.ts +62 -0
  81. package/dist/setup/toml-target.d.ts.map +1 -0
  82. package/dist/setup/toml-target.js +869 -0
  83. package/dist/setup/toml-target.js.map +1 -0
  84. package/dist/util/path-safety.d.ts +94 -0
  85. package/dist/util/path-safety.d.ts.map +1 -0
  86. package/dist/util/path-safety.js +162 -0
  87. package/dist/util/path-safety.js.map +1 -0
  88. package/dist/util/text.d.ts +9 -0
  89. package/dist/util/text.d.ts.map +1 -0
  90. package/dist/util/text.js +19 -0
  91. package/dist/util/text.js.map +1 -0
  92. package/package.json +87 -4
@@ -0,0 +1,1511 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { assertDocPath } from "../util/path-safety.js";
5
+ import { getDatabase, getReadOnlyDatabase, ensureProject, findGitRoot, tryGetGitRemote, } from "../db/client.js";
6
+ export const VALID_CATEGORIES = [
7
+ "decision",
8
+ "convention",
9
+ "discovery",
10
+ "bugfix",
11
+ "architecture",
12
+ ];
13
+ export const VALID_ENTITY_TYPES = [
14
+ "file",
15
+ "function",
16
+ "class",
17
+ "interface",
18
+ "module",
19
+ ];
20
+ export const DEFAULT_SESSION_BRIEF_BUDGET = 8_000;
21
+ export const MAX_SESSION_BRIEF_BUDGET = 20_000;
22
+ export const MAX_SESSION_BRIEF_PATHS = 20;
23
+ export const SESSION_BRIEF_RECENT_ACTIVITY_LIMIT = 20;
24
+ export const DEFAULT_NEIGHBOR_DEPTH = 1;
25
+ export const MAX_NEIGHBOR_DEPTH = 2;
26
+ export const DEFAULT_NEIGHBOR_LIMIT = 20;
27
+ export const MAX_NEIGHBOR_LIMIT = 50;
28
+ export const SESSION_BRIEF_RELATED_LIMIT = MAX_NEIGHBOR_LIMIT;
29
+ /**
30
+ * Normalizes input symbols into canonical identifier, filePath, and entityType.
31
+ */
32
+ export function normalizeSymbol(symbol) {
33
+ if (!symbol || (typeof symbol !== "string" && typeof symbol !== "object")) {
34
+ return { identifier: "unknown", filePath: "unknown", entityType: "module" };
35
+ }
36
+ if (typeof symbol === "string") {
37
+ const raw = symbol.trim();
38
+ if (!raw) {
39
+ return { identifier: "unknown", filePath: "unknown", entityType: "module" };
40
+ }
41
+ if (raw.includes("::")) {
42
+ const parts = raw.split("::");
43
+ const filePath = parts[0] || "unknown";
44
+ return {
45
+ identifier: raw,
46
+ filePath,
47
+ entityType: "module",
48
+ };
49
+ }
50
+ if (raw.includes("/") ||
51
+ raw.endsWith(".ts") ||
52
+ raw.endsWith(".js") ||
53
+ raw.endsWith(".json")) {
54
+ return {
55
+ identifier: raw,
56
+ filePath: raw,
57
+ entityType: "file",
58
+ };
59
+ }
60
+ return {
61
+ identifier: raw,
62
+ filePath: "unknown",
63
+ entityType: "module",
64
+ };
65
+ }
66
+ const rawName = symbol.name?.trim();
67
+ const rawFilePath = (symbol.filePath ?? symbol.file_path)?.trim();
68
+ const rawIdentifier = symbol.identifier?.trim();
69
+ const rawType = (symbol.type ?? symbol.entity_type);
70
+ let identifier;
71
+ if (rawIdentifier) {
72
+ identifier = rawIdentifier;
73
+ }
74
+ else if (rawFilePath) {
75
+ identifier =
76
+ rawName && rawName !== "default"
77
+ ? `${rawFilePath}::${rawName}`
78
+ : rawFilePath;
79
+ }
80
+ else if (rawName) {
81
+ identifier = rawName;
82
+ }
83
+ else {
84
+ identifier = "unknown";
85
+ }
86
+ let filePath;
87
+ if (rawFilePath) {
88
+ filePath = rawFilePath;
89
+ }
90
+ else if (rawIdentifier && rawIdentifier.includes("::")) {
91
+ filePath = rawIdentifier.split("::")[0] || "unknown";
92
+ }
93
+ else if (rawIdentifier &&
94
+ (rawIdentifier.includes("/") ||
95
+ rawIdentifier.endsWith(".ts") ||
96
+ rawIdentifier.endsWith(".js") ||
97
+ rawIdentifier.endsWith(".json"))) {
98
+ filePath = rawIdentifier;
99
+ }
100
+ else {
101
+ filePath = "unknown";
102
+ }
103
+ let entityType = "module";
104
+ if (rawType && VALID_ENTITY_TYPES.includes(rawType)) {
105
+ entityType = rawType;
106
+ }
107
+ else if (rawFilePath && (!rawName || rawName === "default")) {
108
+ entityType = "file";
109
+ }
110
+ return {
111
+ identifier,
112
+ filePath,
113
+ entityType,
114
+ };
115
+ }
116
+ /**
117
+ * Safely format query terms for SQLite FTS5 syntax.
118
+ * Escapes special characters, wraps terms in quotes, and safely supports prefix queries.
119
+ * Returns an empty string if no valid search terms remain.
120
+ */
121
+ export function sanitizeFtsQuery(query) {
122
+ if (!query || typeof query !== "string") {
123
+ return "";
124
+ }
125
+ const trimmed = query.trim();
126
+ if (!trimmed) {
127
+ return "";
128
+ }
129
+ // Regex to extract double-quoted phrases OR non-whitespace words
130
+ const tokenRegex = /"([^"]*)"|(\S+)/g;
131
+ const terms = [];
132
+ let match;
133
+ while ((match = tokenRegex.exec(trimmed)) !== null) {
134
+ if (match[1] !== undefined) {
135
+ // It was inside double quotes
136
+ const inner = match[1].replace(/"/g, "").trim();
137
+ if (inner && /[\p{L}\p{N}]/u.test(inner)) {
138
+ terms.push(`"${inner}"`);
139
+ }
140
+ }
141
+ else if (match[2] !== undefined) {
142
+ const raw = match[2].trim();
143
+ const cleaned = raw.replace(/"/g, "");
144
+ if (!cleaned) {
145
+ continue;
146
+ }
147
+ // Check if ends with wildcard *
148
+ if (cleaned.endsWith("*") && cleaned.length > 1) {
149
+ const base = cleaned.slice(0, -1);
150
+ if (/[\p{L}\p{N}]/u.test(base)) {
151
+ terms.push(`"${base}"*`);
152
+ continue;
153
+ }
154
+ }
155
+ // Check if contains letters or numbers
156
+ if (/[\p{L}\p{N}]/u.test(cleaned)) {
157
+ terms.push(`"${cleaned}"`);
158
+ }
159
+ }
160
+ }
161
+ return terms.join(" ");
162
+ }
163
+ function sessionBriefError(err) {
164
+ if (err instanceof Error && err.message.trim())
165
+ return err.message.trim();
166
+ const message = String(err).trim();
167
+ return message || "unknown error";
168
+ }
169
+ function resolveSessionBriefBudget(value) {
170
+ const budget = value ?? DEFAULT_SESSION_BRIEF_BUDGET;
171
+ if (!Number.isInteger(budget) || budget <= 0) {
172
+ throw new Error("budgetChars must be a positive integer");
173
+ }
174
+ if (budget > MAX_SESSION_BRIEF_BUDGET) {
175
+ throw new Error(`budgetChars cannot exceed ${MAX_SESSION_BRIEF_BUDGET} characters`);
176
+ }
177
+ return budget;
178
+ }
179
+ /**
180
+ * Resolve file/directory selections against the project root without touching the
181
+ * filesystem. The result is a deduplicated list of project-relative POSIX paths;
182
+ * an empty string means "the whole project root".
183
+ */
184
+ function resolveSessionBriefPaths(paths, rootPath) {
185
+ const rawPaths = paths ?? [];
186
+ if (rawPaths.length > MAX_SESSION_BRIEF_PATHS) {
187
+ throw new Error(`At most ${MAX_SESSION_BRIEF_PATHS} paths may be requested for a session brief`);
188
+ }
189
+ const root = path.resolve(rootPath);
190
+ const resolved = rawPaths.map((raw) => {
191
+ if (typeof raw !== "string") {
192
+ throw new Error("Session brief paths must be strings");
193
+ }
194
+ const trimmed = raw.trim();
195
+ if (!trimmed) {
196
+ throw new Error("Session brief paths cannot be empty");
197
+ }
198
+ // Accept Windows and POSIX separators, then compare in one canonical form.
199
+ const withPlatformSeparators = trimmed.replace(/\\/g, path.sep);
200
+ const absolute = path.isAbsolute(withPlatformSeparators)
201
+ ? path.resolve(withPlatformSeparators)
202
+ : path.resolve(root, withPlatformSeparators);
203
+ const relative = path.relative(root, absolute);
204
+ if (relative === ".." ||
205
+ relative.startsWith(`..${path.sep}`) ||
206
+ path.isAbsolute(relative)) {
207
+ throw new Error(`Path "${raw}" is outside the project root ${root}`);
208
+ }
209
+ return relative.split(path.sep).filter(Boolean).join("/");
210
+ });
211
+ return [...new Set(resolved)];
212
+ }
213
+ function normalizeIndexedFilePath(filePath, rootPath) {
214
+ let normalized = filePath.replace(/\\/g, "/");
215
+ if (path.isAbsolute(filePath)) {
216
+ const relative = path.relative(path.resolve(rootPath), path.resolve(filePath));
217
+ if (relative !== ".." &&
218
+ !relative.startsWith(`..${path.sep}`) &&
219
+ !path.isAbsolute(relative)) {
220
+ normalized = relative.replace(/\\/g, "/");
221
+ }
222
+ }
223
+ return normalized
224
+ .replace(/^\.\/+/, "")
225
+ .replace(/\/+/g, "/")
226
+ .replace(/\/$/, "");
227
+ }
228
+ function entityMatchesSelectedPaths(entityPath, selectedPaths, rootPath) {
229
+ if (selectedPaths.length === 0)
230
+ return false;
231
+ const normalized = normalizeIndexedFilePath(entityPath, rootPath);
232
+ return selectedPaths.some((selected) => selected === "" ||
233
+ normalized === selected ||
234
+ normalized.startsWith(`${selected}/`));
235
+ }
236
+ const NEIGHBOR_RELATION_ORDER = [
237
+ "imports",
238
+ "calls",
239
+ "implements",
240
+ "extends",
241
+ "references",
242
+ ];
243
+ function escapeLikePattern(value) {
244
+ return value.replace(/[\\%_]/g, "\\$&");
245
+ }
246
+ function compareEntities(left, right) {
247
+ if (left.identifier !== right.identifier) {
248
+ return left.identifier < right.identifier ? -1 : 1;
249
+ }
250
+ return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
251
+ }
252
+ /**
253
+ * Resolves an entity using the same exact, suffix, and prefix/path tiers used by
254
+ * symbol inspection. Callers always pass a concrete project id.
255
+ */
256
+ function resolveEntityByIdentifier(db, projectId, identifier) {
257
+ if (!identifier || typeof identifier !== "string" || !identifier.trim()) {
258
+ return undefined;
259
+ }
260
+ const query = identifier.trim();
261
+ const exact = db
262
+ .prepare(`SELECT id, project_id, entity_type, identifier, file_path
263
+ FROM entities
264
+ WHERE project_id = ? AND identifier = ?`)
265
+ .get(projectId, query);
266
+ if (exact)
267
+ return exact;
268
+ const escapedQuery = escapeLikePattern(query);
269
+ const suffix = db
270
+ .prepare(`SELECT id, project_id, entity_type, identifier, file_path
271
+ FROM entities
272
+ WHERE project_id = ? AND (identifier LIKE '%::' || ? ESCAPE '\\' OR identifier LIKE '%.' || ? ESCAPE '\\')
273
+ ORDER BY identifier ASC, id ASC
274
+ LIMIT 1`)
275
+ .get(projectId, escapedQuery, escapedQuery);
276
+ if (suffix)
277
+ return suffix;
278
+ return db
279
+ .prepare(`SELECT id, project_id, entity_type, identifier, file_path
280
+ FROM entities
281
+ WHERE project_id = ? AND (identifier LIKE ? || '%' ESCAPE '\\' OR file_path = ?)
282
+ ORDER BY identifier ASC, id ASC
283
+ LIMIT 1`)
284
+ .get(projectId, escapedQuery, query);
285
+ }
286
+ function resolveNeighborDepth(value) {
287
+ if (value === null || value === undefined || !Number.isFinite(value)) {
288
+ return DEFAULT_NEIGHBOR_DEPTH;
289
+ }
290
+ const depth = Math.floor(value);
291
+ if (depth < 1)
292
+ return DEFAULT_NEIGHBOR_DEPTH;
293
+ return Math.min(depth, MAX_NEIGHBOR_DEPTH);
294
+ }
295
+ function resolveNeighborLimit(value) {
296
+ if (value === null || value === undefined || !Number.isFinite(value)) {
297
+ return DEFAULT_NEIGHBOR_LIMIT;
298
+ }
299
+ const limit = Math.floor(value);
300
+ if (limit < 1)
301
+ return DEFAULT_NEIGHBOR_LIMIT;
302
+ return Math.min(limit, MAX_NEIGHBOR_LIMIT);
303
+ }
304
+ function collectNeighbors(db, root, depthValue, limitValue) {
305
+ const depth = resolveNeighborDepth(depthValue);
306
+ const limit = resolveNeighborLimit(limitValue);
307
+ const visited = new Set([root.id]);
308
+ const flat = [];
309
+ let frontier = [root];
310
+ let truncated = false;
311
+ const outgoingStatement = db.prepare(`SELECT e.id, e.project_id, e.entity_type, e.identifier, e.file_path, ed.relation_type
312
+ FROM entity_dependencies ed
313
+ JOIN entities e ON e.id = ed.target_entity_id
314
+ WHERE ed.source_entity_id = ?
315
+ ORDER BY ed.relation_type ASC, e.identifier ASC, e.id ASC`);
316
+ const incomingStatement = db.prepare(`SELECT e.id, e.project_id, e.entity_type, e.identifier, e.file_path, ed.relation_type
317
+ FROM entity_dependencies ed
318
+ JOIN entities e ON e.id = ed.source_entity_id
319
+ WHERE ed.target_entity_id = ?
320
+ ORDER BY ed.relation_type ASC, e.identifier ASC, e.id ASC`);
321
+ outer: for (let level = 1; level <= depth && frontier.length > 0; level++) {
322
+ const nextFrontier = [];
323
+ const orderedFrontier = [...frontier].sort(compareEntities);
324
+ for (const current of orderedFrontier) {
325
+ const directions = [
326
+ { direction: "outgoing", rows: outgoingStatement.all(current.id) },
327
+ { direction: "incoming", rows: incomingStatement.all(current.id) },
328
+ ];
329
+ for (const { direction, rows } of directions) {
330
+ const orderedRows = [...rows].sort((left, right) => {
331
+ const leftRelation = NEIGHBOR_RELATION_ORDER.indexOf(left.relation_type);
332
+ const rightRelation = NEIGHBOR_RELATION_ORDER.indexOf(right.relation_type);
333
+ if (leftRelation !== rightRelation) {
334
+ return leftRelation - rightRelation;
335
+ }
336
+ return compareEntities(left, right);
337
+ });
338
+ for (const row of orderedRows) {
339
+ if (visited.has(row.id))
340
+ continue;
341
+ if (flat.length >= limit) {
342
+ truncated = true;
343
+ break outer;
344
+ }
345
+ visited.add(row.id);
346
+ const entity = {
347
+ id: row.id,
348
+ project_id: row.project_id,
349
+ entity_type: row.entity_type,
350
+ identifier: row.identifier,
351
+ file_path: row.file_path,
352
+ };
353
+ flat.push({
354
+ entity,
355
+ relationType: row.relation_type,
356
+ direction,
357
+ depth: level,
358
+ });
359
+ nextFrontier.push(entity);
360
+ }
361
+ }
362
+ }
363
+ frontier = nextFrontier;
364
+ }
365
+ const groupsByKey = new Map();
366
+ for (const neighbor of flat) {
367
+ const key = `${neighbor.direction}:${neighbor.relationType}`;
368
+ const existing = groupsByKey.get(key);
369
+ if (existing) {
370
+ existing.neighbors.push(neighbor);
371
+ }
372
+ else {
373
+ groupsByKey.set(key, {
374
+ direction: neighbor.direction,
375
+ relationType: neighbor.relationType,
376
+ neighbors: [neighbor],
377
+ });
378
+ }
379
+ }
380
+ return {
381
+ entity: root,
382
+ depth,
383
+ limit,
384
+ groups: [...groupsByKey.values()],
385
+ neighbors: flat,
386
+ truncated,
387
+ };
388
+ }
389
+ function deriveSessionBriefProject(rootPath) {
390
+ const root = path.resolve(rootPath);
391
+ return {
392
+ id: null,
393
+ name: path.basename(root) || "project",
394
+ rootPath: root,
395
+ gitRemote: tryGetGitRemote(root),
396
+ };
397
+ }
398
+ function sessionBriefProjectFromRow(project) {
399
+ return {
400
+ id: project.id,
401
+ name: project.name,
402
+ rootPath: project.root_path,
403
+ gitRemote: project.git_remote,
404
+ };
405
+ }
406
+ function findSessionBriefProjectRow(db, rootPath) {
407
+ const root = path.resolve(rootPath);
408
+ return db
409
+ .prepare(`SELECT id, name, git_remote, root_path, created_at
410
+ FROM projects
411
+ WHERE root_path = ?`)
412
+ .get(root);
413
+ }
414
+ function formatCount(count) {
415
+ return count === null ? "unavailable" : String(count);
416
+ }
417
+ function appendBriefObservation(lines, observation, labelSuperseded) {
418
+ const supersededLabel = labelSuperseded && observation.status === "superseded"
419
+ ? ` — SUPERSEDED${observation.superseded_by
420
+ ? ` (replaced by \`${observation.superseded_by}\`)`
421
+ : ""}`
422
+ : "";
423
+ lines.push(`- **${observation.title}** \`[${observation.category}]\`${supersededLabel}`);
424
+ if (observation.topic_key) {
425
+ lines.push(` Topic key: \`${observation.topic_key}\``);
426
+ }
427
+ const contentLines = observation.content.split(/\r?\n/);
428
+ for (const contentLine of contentLines) {
429
+ lines.push(` ${contentLine}`);
430
+ }
431
+ }
432
+ function buildSessionBriefMarkdown(content) {
433
+ const lines = [];
434
+ lines.push(`# Session Brief: ${content.project.name}`, "");
435
+ lines.push("## Project");
436
+ lines.push(`- Name: ${content.project.name}`);
437
+ lines.push(`- Root: \`${content.project.rootPath}\``);
438
+ if (content.project.id) {
439
+ lines.push(`- ID: \`${content.project.id}\``);
440
+ }
441
+ if (content.project.gitRemote) {
442
+ lines.push(`- Git remote: ${content.project.gitRemote}`);
443
+ }
444
+ lines.push("", "## Counts");
445
+ lines.push(`- Observations: ${formatCount(content.counts.observations)}`);
446
+ lines.push(`- Entities: ${formatCount(content.counts.entities)}`);
447
+ lines.push(`- Links: ${formatCount(content.counts.links)}`);
448
+ const keyedDecisions = content.activeDecisions.keyed;
449
+ const unkeyedDecisions = content.activeDecisions.unkeyed;
450
+ if (keyedDecisions.length > 0 || unkeyedDecisions.length > 0) {
451
+ lines.push("", "## Active Decisions");
452
+ for (const group of keyedDecisions) {
453
+ lines.push("", `### Topic: ${group.topicKey}`);
454
+ for (const decision of group.decisions) {
455
+ appendBriefObservation(lines, decision, false);
456
+ }
457
+ }
458
+ if (unkeyedDecisions.length > 0) {
459
+ lines.push("", "### Unkeyed decisions");
460
+ for (const decision of unkeyedDecisions) {
461
+ appendBriefObservation(lines, decision, false);
462
+ }
463
+ }
464
+ }
465
+ if (content.recentActivity.length > 0) {
466
+ lines.push("", "## Recent Activity");
467
+ for (const observation of content.recentActivity) {
468
+ appendBriefObservation(lines, observation, true);
469
+ }
470
+ }
471
+ if (content.symbols.length > 0) {
472
+ lines.push("", "## Indexed Symbols");
473
+ for (const symbol of content.symbols) {
474
+ lines.push(`- \`${symbol.identifier}\` (${symbol.entity_type}) — \`${symbol.file_path}\``);
475
+ }
476
+ }
477
+ if (content.related.length > 0) {
478
+ lines.push("", "## Related Symbols");
479
+ for (const neighbor of content.related) {
480
+ lines.push(`- \`${neighbor.entity.identifier}\` (${neighbor.entity.entity_type}) — \`${neighbor.entity.file_path}\``);
481
+ }
482
+ }
483
+ if (content.notes.length > 0) {
484
+ lines.push("", "## Notes");
485
+ for (const note of content.notes) {
486
+ lines.push(`- ${note}`);
487
+ }
488
+ }
489
+ return lines.join("\n").trimEnd();
490
+ }
491
+ function applySessionBriefBudget(markdown, budgetChars) {
492
+ if (markdown.length <= budgetChars) {
493
+ return { markdown, truncated: false };
494
+ }
495
+ const marker = `\n\n[truncated: output limited to ${budgetChars} characters; increase budgetChars to see the rest]`;
496
+ if (budgetChars <= marker.length) {
497
+ // Use a compact marker in small budgets so some useful content still fits.
498
+ // Even a one-character budget must remain bounded and visibly clipped.
499
+ const shortMarker = "[truncated]";
500
+ const contentBudget = budgetChars - shortMarker.length;
501
+ if (contentBudget > 0) {
502
+ return {
503
+ markdown: markdown.slice(0, contentBudget) + shortMarker,
504
+ truncated: true,
505
+ };
506
+ }
507
+ return {
508
+ markdown: (shortMarker.slice(0, Math.max(0, budgetChars - 1)) + "…").slice(0, budgetChars),
509
+ truncated: true,
510
+ };
511
+ }
512
+ const keep = budgetChars - marker.length;
513
+ return {
514
+ markdown: markdown.slice(0, keep) + marker,
515
+ truncated: true,
516
+ };
517
+ }
518
+ /**
519
+ * Shared deterministic Markdown renderer used by the library, MCP and CLI.
520
+ * It measures the complete output, including headings, notes, and the marker,
521
+ * against `content.budgetChars`.
522
+ */
523
+ export function renderSessionBrief(content) {
524
+ return applySessionBriefBudget(buildSessionBriefMarkdown(content), content.budgetChars).markdown;
525
+ }
526
+ function assembleSessionBrief(db, project, projectId, selectedPaths, budgetChars, notes, expandGraph = true) {
527
+ let counts = {
528
+ observations: 0,
529
+ entities: 0,
530
+ links: 0,
531
+ };
532
+ let keyed = [];
533
+ let unkeyed = [];
534
+ let recentActivity = [];
535
+ let symbols = [];
536
+ const related = [];
537
+ if (projectId) {
538
+ const countObservations = db
539
+ .prepare("SELECT COUNT(*) as count FROM observations WHERE project_id = ?")
540
+ .get(projectId)?.count;
541
+ const countEntities = db
542
+ .prepare("SELECT COUNT(*) as count FROM entities WHERE project_id = ?")
543
+ .get(projectId)?.count;
544
+ const countLinks = db
545
+ .prepare(`SELECT COUNT(*) as count
546
+ FROM observation_entities oe
547
+ JOIN observations o ON o.id = oe.observation_id
548
+ WHERE o.project_id = ?`)
549
+ .get(projectId)?.count;
550
+ counts = {
551
+ observations: countObservations ?? 0,
552
+ entities: countEntities ?? 0,
553
+ links: countLinks ?? 0,
554
+ };
555
+ const decisions = db
556
+ .prepare(`SELECT id, project_id, category, title, content, topic_key, status,
557
+ superseded_by, created_at, updated_at
558
+ FROM observations
559
+ WHERE project_id = ? AND category = 'decision' AND status = 'active'
560
+ ORDER BY topic_key ASC, updated_at DESC, created_at DESC, rowid DESC`)
561
+ .all(projectId);
562
+ const keyedMap = new Map();
563
+ for (const decision of decisions) {
564
+ if (!decision.topic_key) {
565
+ unkeyed.push(decision);
566
+ continue;
567
+ }
568
+ const group = keyedMap.get(decision.topic_key);
569
+ if (group) {
570
+ group.push(decision);
571
+ }
572
+ else {
573
+ keyedMap.set(decision.topic_key, [decision]);
574
+ }
575
+ }
576
+ keyed = [...keyedMap.entries()]
577
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
578
+ .map(([topicKey, groupDecisions]) => ({
579
+ topicKey,
580
+ decisions: groupDecisions,
581
+ }));
582
+ const listedDecisionIds = decisions.map((decision) => decision.id);
583
+ const activityParams = [projectId];
584
+ let exclusion = "";
585
+ if (listedDecisionIds.length > 0) {
586
+ exclusion = ` AND id NOT IN (${listedDecisionIds.map(() => "?").join(",")})`;
587
+ activityParams.push(...listedDecisionIds);
588
+ }
589
+ activityParams.push(String(SESSION_BRIEF_RECENT_ACTIVITY_LIMIT));
590
+ recentActivity = db
591
+ .prepare(`SELECT id, project_id, category, title, content, topic_key, status,
592
+ superseded_by, created_at, updated_at
593
+ FROM observations
594
+ WHERE project_id = ?${exclusion}
595
+ ORDER BY updated_at DESC, created_at DESC, rowid DESC
596
+ LIMIT ?`)
597
+ .all(...activityParams);
598
+ if (selectedPaths.length > 0) {
599
+ const indexed = db
600
+ .prepare(`SELECT id, project_id, entity_type, identifier, file_path
601
+ FROM entities
602
+ WHERE project_id = ?
603
+ ORDER BY file_path ASC, identifier ASC, rowid ASC`)
604
+ .all(projectId);
605
+ symbols = indexed.filter((entity) => entityMatchesSelectedPaths(entity.file_path, selectedPaths, project.rootPath));
606
+ if (symbols.length === 0) {
607
+ notes.push("No indexed symbols matched the selected path(s).");
608
+ }
609
+ }
610
+ if (expandGraph && symbols.length > 0) {
611
+ const relatedById = new Map();
612
+ // The related section exists to add what the selected paths did not name.
613
+ // A neighbor that is itself path-selected is already printed under
614
+ // "Indexed Symbols", so listing it again would spend the brief's budget
615
+ // on a duplicate and read like a second, different symbol.
616
+ const listedSymbolIds = new Set(symbols.map((symbol) => symbol.id));
617
+ let traversedNeighbor = false;
618
+ for (const symbol of symbols) {
619
+ if (relatedById.size >= SESSION_BRIEF_RELATED_LIMIT)
620
+ break;
621
+ const traversal = collectNeighbors(db, symbol, DEFAULT_NEIGHBOR_DEPTH, SESSION_BRIEF_RELATED_LIMIT);
622
+ for (const neighbor of traversal.neighbors) {
623
+ traversedNeighbor = true;
624
+ if (relatedById.size >= SESSION_BRIEF_RELATED_LIMIT)
625
+ break;
626
+ if (listedSymbolIds.has(neighbor.entity.id))
627
+ continue;
628
+ if (!relatedById.has(neighbor.entity.id)) {
629
+ relatedById.set(neighbor.entity.id, neighbor);
630
+ }
631
+ }
632
+ }
633
+ related.push(...relatedById.values());
634
+ // "No neighbors" is only true when the graph had none to offer; a graph
635
+ // whose neighbors were all already listed omits the section silently.
636
+ if (!traversedNeighbor) {
637
+ notes.push("No indexed dependency neighbors were found for the selected symbol(s).");
638
+ }
639
+ }
640
+ }
641
+ else {
642
+ notes.push("No project record was found in the database; project identity was derived from the selected root.");
643
+ if (selectedPaths.length > 0) {
644
+ notes.push("Indexed symbols are unavailable because no project record exists for the selected root.");
645
+ }
646
+ }
647
+ const content = {
648
+ project,
649
+ activeDecisions: { keyed, unkeyed },
650
+ recentActivity,
651
+ symbols,
652
+ related,
653
+ counts,
654
+ notes,
655
+ budgetChars,
656
+ };
657
+ const rendered = applySessionBriefBudget(buildSessionBriefMarkdown(content), budgetChars);
658
+ return {
659
+ ...content,
660
+ truncated: rendered.truncated,
661
+ markdown: rendered.markdown,
662
+ };
663
+ }
664
+ function buildUnavailableSessionBrief(rootPath, selectedPaths, budgetChars, cause, projectRow) {
665
+ const project = projectRow
666
+ ? sessionBriefProjectFromRow(projectRow)
667
+ : deriveSessionBriefProject(rootPath);
668
+ const notes = [
669
+ `Muninn memory is unavailable (${sessionBriefError(cause)}); returning an empty session brief.`,
670
+ ];
671
+ if (selectedPaths.length > 0) {
672
+ notes.push("Indexed symbols are unavailable because the database could not be read.");
673
+ }
674
+ const content = {
675
+ project,
676
+ activeDecisions: { keyed: [], unkeyed: [] },
677
+ recentActivity: [],
678
+ symbols: [],
679
+ related: [],
680
+ counts: { observations: null, entities: null, links: null },
681
+ notes,
682
+ budgetChars,
683
+ };
684
+ const rendered = applySessionBriefBudget(buildSessionBriefMarkdown(content), budgetChars);
685
+ return {
686
+ ...content,
687
+ truncated: rendered.truncated,
688
+ markdown: rendered.markdown,
689
+ };
690
+ }
691
+ /**
692
+ * Read-only bootstrap entry point for the CLI and MCP default server.
693
+ *
694
+ * It never uses `getDatabase()` or `ensureProject()`: an existing database is
695
+ * opened read-only, and any missing/inaccessible/corrupt/uninitialized file
696
+ * yields an empty brief with a cause in `notes` instead of throwing or creating
697
+ * state.
698
+ */
699
+ export function readSessionBrief(request = {}) {
700
+ const rootPath = path.resolve(request.projectRoot ?? findGitRoot() ?? process.cwd());
701
+ const budgetChars = resolveSessionBriefBudget(request.budgetChars);
702
+ const selectedPaths = resolveSessionBriefPaths(request.paths, rootPath);
703
+ const expandGraph = request.expandGraph !== false;
704
+ if (request.db) {
705
+ try {
706
+ const projectRow = findSessionBriefProjectRow(request.db, rootPath);
707
+ const project = projectRow
708
+ ? sessionBriefProjectFromRow(projectRow)
709
+ : deriveSessionBriefProject(rootPath);
710
+ return assembleSessionBrief(request.db, project, projectRow?.id ?? null, selectedPaths, budgetChars, [], expandGraph);
711
+ }
712
+ catch (err) {
713
+ return buildUnavailableSessionBrief(rootPath, selectedPaths, budgetChars, err);
714
+ }
715
+ }
716
+ let db;
717
+ try {
718
+ db = getReadOnlyDatabase(request.dbPath, rootPath);
719
+ }
720
+ catch (err) {
721
+ return buildUnavailableSessionBrief(rootPath, selectedPaths, budgetChars, err);
722
+ }
723
+ try {
724
+ const projectRow = findSessionBriefProjectRow(db, rootPath);
725
+ const project = projectRow
726
+ ? sessionBriefProjectFromRow(projectRow)
727
+ : deriveSessionBriefProject(rootPath);
728
+ return assembleSessionBrief(db, project, projectRow?.id ?? null, selectedPaths, budgetChars, [], expandGraph);
729
+ }
730
+ catch (err) {
731
+ return buildUnavailableSessionBrief(rootPath, selectedPaths, budgetChars, err);
732
+ }
733
+ finally {
734
+ if (db.open) {
735
+ db.close();
736
+ }
737
+ }
738
+ }
739
+ export class MemoryService {
740
+ _db;
741
+ _currentProject;
742
+ isDbOwned;
743
+ constructor(options) {
744
+ if (options?.db) {
745
+ this._db = options.db;
746
+ this.isDbOwned = false;
747
+ }
748
+ else {
749
+ // ONE root for both (ADR-49): the database file and the project record are
750
+ // resolved from `projectRoot`, so `--project X` can never label a row in
751
+ // another project's database.
752
+ this._db = getDatabase(options?.dbPath, options?.projectRoot);
753
+ this.isDbOwned = true;
754
+ }
755
+ try {
756
+ this._currentProject = ensureProject(this._db, {
757
+ rootPath: options?.projectRoot,
758
+ startDir: options?.projectRoot,
759
+ });
760
+ }
761
+ catch (err) {
762
+ if (this.isDbOwned && this._db?.open) {
763
+ this._db.close();
764
+ }
765
+ throw err;
766
+ }
767
+ }
768
+ get db() {
769
+ return this._db;
770
+ }
771
+ get currentProject() {
772
+ return this._currentProject;
773
+ }
774
+ /**
775
+ * Screen the durable JSONL export path exactly like the database path
776
+ * (SEC-006): a `.huginn/memories.jsonl` a cloned repository ships as a symlink
777
+ * must be neither written through nor read from — either direction would move
778
+ * project content outside the project (or pull foreign content in, where the
779
+ * MCP memory tools would serve it to the agent).
780
+ */
781
+ assertMemoryPath(resolvedPath, action) {
782
+ const root = this._currentProject.root_path || findGitRoot() || process.cwd();
783
+ assertDocPath(resolvedPath, {
784
+ projectPath: root,
785
+ label: "the Muninn memory export",
786
+ action,
787
+ });
788
+ }
789
+ /**
790
+ * Closes the database connection if managed internally by the service.
791
+ */
792
+ close(force = false) {
793
+ if ((this.isDbOwned || force) && this._db.open) {
794
+ this._db.close();
795
+ }
796
+ }
797
+ /**
798
+ * Saves an observation and transactionally links any provided symbols.
799
+ */
800
+ saveObservation(input) {
801
+ const category = input.category;
802
+ if (!VALID_CATEGORIES.includes(category)) {
803
+ throw new Error(`Invalid category "${input.category}". Allowed categories: ${VALID_CATEGORIES.join(", ")}`);
804
+ }
805
+ if (!input.title || typeof input.title !== "string" || !input.title.trim()) {
806
+ throw new Error("Observation title is required and cannot be empty");
807
+ }
808
+ if (input.content === undefined ||
809
+ input.content === null ||
810
+ typeof input.content !== "string") {
811
+ throw new Error("Observation content is required");
812
+ }
813
+ const projectId = input.projectId ?? input.project_id ?? this._currentProject.id;
814
+ const topicKey = input.topicKey !== undefined
815
+ ? input.topicKey
816
+ : (input.topic_key ?? null);
817
+ const supersedesTopicKey = input.supersedesTopicKey !== undefined
818
+ ? input.supersedesTopicKey
819
+ : (input.supersedes_topic_key ?? null);
820
+ const observationId = crypto.randomUUID();
821
+ return this._db.transaction(() => {
822
+ this._db
823
+ .prepare(`INSERT INTO observations (id, project_id, category, title, content, topic_key)
824
+ VALUES (?, ?, ?, ?, ?, ?)`)
825
+ .run(observationId, projectId, category, input.title.trim(), input.content, topicKey);
826
+ // Explicit decision replacement is part of the same transaction: either
827
+ // the new decision and every supersession link commit together or not at
828
+ // all. Exclude the new row so it never supersedes itself.
829
+ if (category === "decision" &&
830
+ supersedesTopicKey !== undefined &&
831
+ supersedesTopicKey !== null) {
832
+ this._db
833
+ .prepare(`UPDATE observations
834
+ SET status = 'superseded', superseded_by = ?, updated_at = CURRENT_TIMESTAMP
835
+ WHERE project_id = ?
836
+ AND category = 'decision'
837
+ AND topic_key = ?
838
+ AND status = 'active'
839
+ AND id != ?`)
840
+ .run(observationId, projectId, supersedesTopicKey, observationId);
841
+ }
842
+ const symbols = input.symbols ?? [];
843
+ const selectEntityStmt = this._db.prepare(`SELECT id, project_id, entity_type, identifier, file_path
844
+ FROM entities
845
+ WHERE project_id = ? AND identifier = ?`);
846
+ const insertEntityStmt = this._db.prepare(`INSERT INTO entities (id, project_id, entity_type, identifier, file_path)
847
+ VALUES (?, ?, ?, ?, ?)`);
848
+ const linkStmt = this._db.prepare(`INSERT OR IGNORE INTO observation_entities (observation_id, entity_id)
849
+ VALUES (?, ?)`);
850
+ for (const sym of symbols) {
851
+ const norm = normalizeSymbol(sym);
852
+ let entity = selectEntityStmt.get(projectId, norm.identifier);
853
+ if (!entity) {
854
+ const entityId = crypto.randomUUID();
855
+ insertEntityStmt.run(entityId, projectId, norm.entityType, norm.identifier, norm.filePath);
856
+ entity = {
857
+ id: entityId,
858
+ project_id: projectId,
859
+ entity_type: norm.entityType,
860
+ identifier: norm.identifier,
861
+ file_path: norm.filePath,
862
+ };
863
+ }
864
+ linkStmt.run(observationId, entity.id);
865
+ }
866
+ const observation = this._db
867
+ .prepare(`SELECT id, project_id, category, title, content, topic_key, status, superseded_by, created_at, updated_at
868
+ FROM observations
869
+ WHERE id = ?`)
870
+ .get(observationId);
871
+ if (!observation) {
872
+ throw new Error(`Failed to retrieve newly created observation: ${observationId}`);
873
+ }
874
+ const entities = this._db
875
+ .prepare(`SELECT e.id, e.project_id, e.entity_type, e.identifier, e.file_path
876
+ FROM entities e
877
+ JOIN observation_entities oe ON oe.entity_id = e.id
878
+ WHERE oe.observation_id = ?
879
+ ORDER BY e.identifier ASC`)
880
+ .all(observationId);
881
+ return {
882
+ ...observation,
883
+ entities,
884
+ };
885
+ })();
886
+ }
887
+ /**
888
+ * Retrieves linked entities for observation IDs in chunks of at most 500
889
+ * to prevent exceeding SQLite parameter bounds.
890
+ */
891
+ _fetchEntitiesForObservations(obsIds) {
892
+ const entityMap = new Map();
893
+ if (obsIds.length === 0) {
894
+ return entityMap;
895
+ }
896
+ const CHUNK_SIZE = 500;
897
+ for (let i = 0; i < obsIds.length; i += CHUNK_SIZE) {
898
+ const chunk = obsIds.slice(i, i + CHUNK_SIZE);
899
+ const placeholders = chunk.map(() => "?").join(",");
900
+ const entityRows = this._db
901
+ .prepare(`SELECT e.id, e.project_id, e.entity_type, e.identifier, e.file_path, oe.observation_id
902
+ FROM entities e
903
+ JOIN observation_entities oe ON oe.entity_id = e.id
904
+ WHERE oe.observation_id IN (${placeholders})
905
+ ORDER BY e.identifier ASC`)
906
+ .all(...chunk);
907
+ for (const row of entityRows) {
908
+ let list = entityMap.get(row.observation_id);
909
+ if (!list) {
910
+ list = [];
911
+ entityMap.set(row.observation_id, list);
912
+ }
913
+ list.push({
914
+ id: row.id,
915
+ project_id: row.project_id,
916
+ entity_type: row.entity_type,
917
+ identifier: row.identifier,
918
+ file_path: row.file_path,
919
+ });
920
+ }
921
+ }
922
+ return entityMap;
923
+ }
924
+ /**
925
+ * Searches observations using FTS5 BM25 relevance ranking.
926
+ * Query terms are safely sanitized, rank ordering is ASC (lower BM25 = higher relevance),
927
+ * and linked entities are attached to each result.
928
+ */
929
+ search(options) {
930
+ if (!options || !options.query) {
931
+ return [];
932
+ }
933
+ const sanitized = sanitizeFtsQuery(options.query);
934
+ if (!sanitized) {
935
+ return [];
936
+ }
937
+ const conditions = ["observations_fts MATCH ?"];
938
+ const params = [sanitized];
939
+ if (options.category) {
940
+ conditions.push("o.category = ?");
941
+ params.push(options.category);
942
+ }
943
+ if (!options.includeSuperseded && !options.include_superseded) {
944
+ conditions.push("o.status = 'active'");
945
+ }
946
+ if (!options?.allProjects) {
947
+ const explicitProjectId = options?.projectId !== undefined
948
+ ? options.projectId
949
+ : options?.project_id;
950
+ const targetProjectId = explicitProjectId !== undefined
951
+ ? explicitProjectId
952
+ : this._currentProject.id;
953
+ if (targetProjectId) {
954
+ conditions.push("o.project_id = ?");
955
+ params.push(targetProjectId);
956
+ }
957
+ }
958
+ const parsedLimit = typeof options?.limit === "number" && Number.isFinite(options.limit)
959
+ ? options.limit
960
+ : 10;
961
+ const limit = Math.min(Math.max(1, parsedLimit), 500);
962
+ params.push(limit);
963
+ const sql = `
964
+ SELECT
965
+ o.id,
966
+ o.project_id,
967
+ o.category,
968
+ o.title,
969
+ o.content,
970
+ o.topic_key,
971
+ o.status,
972
+ o.superseded_by,
973
+ o.created_at,
974
+ o.updated_at,
975
+ bm25(observations_fts) AS rank
976
+ FROM observations_fts
977
+ JOIN observations o ON o.rowid = observations_fts.rowid
978
+ WHERE ${conditions.join(" AND ")}
979
+ ORDER BY rank ASC
980
+ LIMIT ?
981
+ `;
982
+ let rows;
983
+ try {
984
+ rows = this._db.prepare(sql).all(...params);
985
+ }
986
+ catch (err) {
987
+ const code = typeof err === "object" && err !== null && "code" in err
988
+ ? String(err.code)
989
+ : "";
990
+ const message = err instanceof Error ? err.message : String(err);
991
+ const isFatal = code.startsWith("SQLITE_IOERR") ||
992
+ code.startsWith("SQLITE_CORRUPT") ||
993
+ code === "SQLITE_NOTADB" ||
994
+ code === "SQLITE_FULL" ||
995
+ code === "SQLITE_CANTOPEN" ||
996
+ /disk i\/o|corrupt|not a database|disk is full/i.test(message);
997
+ if (isFatal) {
998
+ console.error("Fatal SQLite error during search query:", err);
999
+ throw err;
1000
+ }
1001
+ console.warn("FTS search query execution failed:", message);
1002
+ return [];
1003
+ }
1004
+ if (rows.length === 0) {
1005
+ return [];
1006
+ }
1007
+ const obsIds = rows.map((r) => r.id);
1008
+ const entityMap = this._fetchEntitiesForObservations(obsIds);
1009
+ return rows.map((r) => ({
1010
+ id: r.id,
1011
+ project_id: r.project_id,
1012
+ category: r.category,
1013
+ title: r.title,
1014
+ content: r.content,
1015
+ topic_key: r.topic_key,
1016
+ status: r.status,
1017
+ superseded_by: r.superseded_by,
1018
+ created_at: r.created_at,
1019
+ updated_at: r.updated_at,
1020
+ rank: r.rank,
1021
+ entities: entityMap.get(r.id) ?? [],
1022
+ }));
1023
+ }
1024
+ /**
1025
+ * Retrieves recent observations ordered by updated_at DESC, created_at DESC.
1026
+ */
1027
+ getContext(options) {
1028
+ const conditions = [];
1029
+ const params = [];
1030
+ if (options?.category) {
1031
+ conditions.push("category = ?");
1032
+ params.push(options.category);
1033
+ }
1034
+ if (!options?.includeSuperseded && !options?.include_superseded) {
1035
+ conditions.push("status = 'active'");
1036
+ }
1037
+ const topicKey = options?.topicKey !== undefined ? options.topicKey : options?.topic_key;
1038
+ if (topicKey !== undefined && topicKey !== null) {
1039
+ conditions.push("topic_key = ?");
1040
+ params.push(topicKey);
1041
+ }
1042
+ if (!options?.allProjects) {
1043
+ const explicitProjectId = options?.projectId !== undefined
1044
+ ? options.projectId
1045
+ : options?.project_id;
1046
+ const targetProjectId = explicitProjectId !== undefined
1047
+ ? explicitProjectId
1048
+ : this._currentProject.id;
1049
+ if (targetProjectId) {
1050
+ conditions.push("project_id = ?");
1051
+ params.push(targetProjectId);
1052
+ }
1053
+ }
1054
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1055
+ const parsedLimit = typeof options?.limit === "number" && Number.isFinite(options.limit)
1056
+ ? options.limit
1057
+ : 20;
1058
+ const limit = Math.min(Math.max(1, parsedLimit), 500);
1059
+ params.push(limit);
1060
+ const sql = `
1061
+ SELECT id, project_id, category, title, content, topic_key, status, superseded_by, created_at, updated_at
1062
+ FROM observations
1063
+ ${whereClause}
1064
+ ORDER BY updated_at DESC, created_at DESC
1065
+ LIMIT ?
1066
+ `;
1067
+ const rows = this._db.prepare(sql).all(...params);
1068
+ if (rows.length === 0) {
1069
+ return [];
1070
+ }
1071
+ const obsIds = rows.map((r) => r.id);
1072
+ const entityMap = this._fetchEntitiesForObservations(obsIds);
1073
+ return rows.map((r) => ({
1074
+ id: r.id,
1075
+ project_id: r.project_id,
1076
+ category: r.category,
1077
+ title: r.title,
1078
+ content: r.content,
1079
+ topic_key: r.topic_key,
1080
+ status: r.status,
1081
+ superseded_by: r.superseded_by,
1082
+ created_at: r.created_at,
1083
+ updated_at: r.updated_at,
1084
+ entities: entityMap.get(r.id) ?? [],
1085
+ }));
1086
+ }
1087
+ /**
1088
+ * Transactionally links an entity to an observation.
1089
+ */
1090
+ linkSymbol(inputOrObsId, symbolArg) {
1091
+ let observationId;
1092
+ let symbol;
1093
+ if (typeof inputOrObsId === "string") {
1094
+ observationId = inputOrObsId;
1095
+ if (!symbolArg) {
1096
+ throw new Error("Symbol must be provided to linkSymbol");
1097
+ }
1098
+ symbol = symbolArg;
1099
+ }
1100
+ else {
1101
+ observationId =
1102
+ inputOrObsId.observationId ??
1103
+ inputOrObsId.observation_id;
1104
+ symbol = inputOrObsId.symbol;
1105
+ }
1106
+ if (!observationId) {
1107
+ throw new Error("observationId is required for linkSymbol");
1108
+ }
1109
+ if (!symbol) {
1110
+ throw new Error("symbol is required for linkSymbol");
1111
+ }
1112
+ const observation = this._db
1113
+ .prepare(`SELECT id, project_id, category, title, content, topic_key, status, superseded_by, created_at, updated_at
1114
+ FROM observations
1115
+ WHERE id = ?`)
1116
+ .get(observationId);
1117
+ if (!observation) {
1118
+ throw new Error(`Observation with id "${observationId}" not found`);
1119
+ }
1120
+ const projectId = observation.project_id;
1121
+ const norm = normalizeSymbol(symbol);
1122
+ return this._db.transaction(() => {
1123
+ let entity = this._db
1124
+ .prepare(`SELECT id, project_id, entity_type, identifier, file_path
1125
+ FROM entities
1126
+ WHERE project_id = ? AND identifier = ?`)
1127
+ .get(projectId, norm.identifier);
1128
+ if (!entity) {
1129
+ const entityId = crypto.randomUUID();
1130
+ this._db
1131
+ .prepare(`INSERT INTO entities (id, project_id, entity_type, identifier, file_path)
1132
+ VALUES (?, ?, ?, ?, ?)`)
1133
+ .run(entityId, projectId, norm.entityType, norm.identifier, norm.filePath);
1134
+ entity = {
1135
+ id: entityId,
1136
+ project_id: projectId,
1137
+ entity_type: norm.entityType,
1138
+ identifier: norm.identifier,
1139
+ file_path: norm.filePath,
1140
+ };
1141
+ }
1142
+ this._db
1143
+ .prepare(`INSERT OR IGNORE INTO observation_entities (observation_id, entity_id)
1144
+ VALUES (?, ?)`)
1145
+ .run(observationId, entity.id);
1146
+ return {
1147
+ observation,
1148
+ entity,
1149
+ };
1150
+ })();
1151
+ }
1152
+ /**
1153
+ * Inspects a symbol in Muninn memory, returning its entity metadata,
1154
+ * incoming and outgoing dependencies, and linked observations.
1155
+ */
1156
+ inspectSymbol(symbol, projectId) {
1157
+ if (!symbol || typeof symbol !== "string" || !symbol.trim()) {
1158
+ return null;
1159
+ }
1160
+ const targetProjectId = projectId ?? this._currentProject.id;
1161
+ const entity = resolveEntityByIdentifier(this._db, targetProjectId, symbol.trim());
1162
+ if (!entity) {
1163
+ return null;
1164
+ }
1165
+ const outgoingRows = this._db
1166
+ .prepare(`SELECT e.id, e.project_id, e.entity_type, e.identifier, e.file_path, ed.relation_type
1167
+ FROM entity_dependencies ed
1168
+ JOIN entities e ON e.id = ed.target_entity_id
1169
+ WHERE ed.source_entity_id = ?
1170
+ ORDER BY e.identifier ASC`)
1171
+ .all(entity.id);
1172
+ const outgoing = outgoingRows.map((r) => ({
1173
+ entity: {
1174
+ id: r.id,
1175
+ project_id: r.project_id,
1176
+ entity_type: r.entity_type,
1177
+ identifier: r.identifier,
1178
+ file_path: r.file_path,
1179
+ },
1180
+ relationType: r.relation_type,
1181
+ }));
1182
+ // Fetch incoming dependencies
1183
+ const incomingRows = this._db
1184
+ .prepare(`SELECT e.id, e.project_id, e.entity_type, e.identifier, e.file_path, ed.relation_type
1185
+ FROM entity_dependencies ed
1186
+ JOIN entities e ON e.id = ed.source_entity_id
1187
+ WHERE ed.target_entity_id = ?
1188
+ ORDER BY e.identifier ASC`)
1189
+ .all(entity.id);
1190
+ const incoming = incomingRows.map((r) => ({
1191
+ entity: {
1192
+ id: r.id,
1193
+ project_id: r.project_id,
1194
+ entity_type: r.entity_type,
1195
+ identifier: r.identifier,
1196
+ file_path: r.file_path,
1197
+ },
1198
+ relationType: r.relation_type,
1199
+ }));
1200
+ // Fetch linked observations
1201
+ const observations = this._db
1202
+ .prepare(`SELECT o.id, o.project_id, o.category, o.title, o.content, o.topic_key, o.status, o.superseded_by, o.created_at, o.updated_at
1203
+ FROM observations o
1204
+ JOIN observation_entities oe ON oe.observation_id = o.id
1205
+ WHERE oe.entity_id = ?
1206
+ ORDER BY o.updated_at DESC, o.created_at DESC`)
1207
+ .all(entity.id);
1208
+ return {
1209
+ entity,
1210
+ dependencies: {
1211
+ outgoing,
1212
+ incoming,
1213
+ },
1214
+ neighbors: collectNeighbors(this._db, entity, DEFAULT_NEIGHBOR_DEPTH, DEFAULT_NEIGHBOR_LIMIT),
1215
+ observations,
1216
+ };
1217
+ }
1218
+ /**
1219
+ * Returns bounded, deterministic graph neighbors for an indexed symbol.
1220
+ *
1221
+ * Traversal follows stored `entity_dependencies` edges in both directions,
1222
+ * groups them by direction and relation, and never returns the same entity
1223
+ * twice. Depth is clamped to 1–2 (default 1) and the result limit to 1–50
1224
+ * (default 20).
1225
+ */
1226
+ neighbors(identifier, options) {
1227
+ if (!identifier || typeof identifier !== "string" || !identifier.trim()) {
1228
+ return null;
1229
+ }
1230
+ const entity = resolveEntityByIdentifier(this._db, this._currentProject.id, identifier.trim());
1231
+ if (!entity) {
1232
+ return null;
1233
+ }
1234
+ return collectNeighbors(this._db, entity, options?.depth, options?.limit);
1235
+ }
1236
+ /**
1237
+ * Builds the bounded, deterministic session brief from the already-initialized
1238
+ * service database. This method itself only reads; the standalone
1239
+ * {@link readSessionBrief} entry point is the one that opens a database
1240
+ * read-only and fails open when it is missing or unusable.
1241
+ */
1242
+ getSessionBrief(options) {
1243
+ const budgetChars = resolveSessionBriefBudget(options?.budgetChars);
1244
+ const selectedPaths = resolveSessionBriefPaths(options?.paths, this._currentProject.root_path);
1245
+ const expandGraph = options?.expandGraph !== false;
1246
+ try {
1247
+ return assembleSessionBrief(this._db, sessionBriefProjectFromRow(this._currentProject), this._currentProject.id, selectedPaths, budgetChars, [], expandGraph);
1248
+ }
1249
+ catch (err) {
1250
+ return buildUnavailableSessionBrief(this._currentProject.root_path, selectedPaths, budgetChars, err, this._currentProject);
1251
+ }
1252
+ }
1253
+ /**
1254
+ * Retrieves aggregate counts of projects, observations, entities, and links.
1255
+ */
1256
+ getStats(projectId) {
1257
+ if (projectId) {
1258
+ const pCount = this._db
1259
+ .prepare("SELECT COUNT(*) as count FROM projects WHERE id = ?")
1260
+ .get(projectId)?.count ?? 0;
1261
+ const oCount = this._db
1262
+ .prepare("SELECT COUNT(*) as count FROM observations WHERE project_id = ?")
1263
+ .get(projectId)?.count ?? 0;
1264
+ const eCount = this._db
1265
+ .prepare("SELECT COUNT(*) as count FROM entities WHERE project_id = ?")
1266
+ .get(projectId)?.count ?? 0;
1267
+ const lCount = this._db
1268
+ .prepare(`SELECT COUNT(*) as count
1269
+ FROM observation_entities oe
1270
+ JOIN observations o ON o.id = oe.observation_id
1271
+ WHERE o.project_id = ?`)
1272
+ .get(projectId)?.count ?? 0;
1273
+ return {
1274
+ projects: pCount,
1275
+ observations: oCount,
1276
+ entities: eCount,
1277
+ links: lCount,
1278
+ };
1279
+ }
1280
+ const pCount = this._db
1281
+ .prepare("SELECT COUNT(*) as count FROM projects")
1282
+ .get()?.count ?? 0;
1283
+ const oCount = this._db
1284
+ .prepare("SELECT COUNT(*) as count FROM observations")
1285
+ .get()?.count ?? 0;
1286
+ const eCount = this._db
1287
+ .prepare("SELECT COUNT(*) as count FROM entities")
1288
+ .get()?.count ?? 0;
1289
+ const lCount = this._db
1290
+ .prepare("SELECT COUNT(*) as count FROM observation_entities")
1291
+ .get()?.count ?? 0;
1292
+ return {
1293
+ projects: pCount,
1294
+ observations: oCount,
1295
+ entities: eCount,
1296
+ links: lCount,
1297
+ };
1298
+ }
1299
+ syncToDisk(targetPathOrOptions, optionsArg) {
1300
+ let targetPath;
1301
+ let options;
1302
+ if (typeof targetPathOrOptions === "object" && targetPathOrOptions !== null) {
1303
+ options = targetPathOrOptions;
1304
+ targetPath = undefined;
1305
+ }
1306
+ else {
1307
+ targetPath = targetPathOrOptions;
1308
+ options = optionsArg;
1309
+ }
1310
+ let resolvedPath;
1311
+ if (targetPath) {
1312
+ if (path.isAbsolute(targetPath)) {
1313
+ resolvedPath = path.resolve(targetPath);
1314
+ }
1315
+ else {
1316
+ const root = this._currentProject.root_path || findGitRoot() || process.cwd();
1317
+ resolvedPath = path.resolve(root, targetPath);
1318
+ }
1319
+ }
1320
+ else {
1321
+ const root = this._currentProject.root_path || findGitRoot() || process.cwd();
1322
+ resolvedPath = path.join(root, ".huginn", "memories.jsonl");
1323
+ }
1324
+ this.assertMemoryPath(resolvedPath, "write");
1325
+ const dir = path.dirname(resolvedPath);
1326
+ if (!fs.existsSync(dir)) {
1327
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
1328
+ }
1329
+ const targetProjectId = options?.projectId ?? this._currentProject.id;
1330
+ let observations;
1331
+ let entityRows;
1332
+ if (options?.allProjects === true) {
1333
+ observations = this._db
1334
+ .prepare(`SELECT id, project_id, category, title, content, topic_key, status, superseded_by, created_at, updated_at
1335
+ FROM observations
1336
+ ORDER BY created_at ASC`)
1337
+ .all();
1338
+ entityRows = this._db
1339
+ .prepare(`SELECT e.id, e.project_id, e.entity_type, e.identifier, e.file_path, oe.observation_id
1340
+ FROM entities e
1341
+ JOIN observation_entities oe ON oe.entity_id = e.id
1342
+ ORDER BY e.identifier ASC`)
1343
+ .all();
1344
+ }
1345
+ else {
1346
+ observations = this._db
1347
+ .prepare(`SELECT id, project_id, category, title, content, topic_key, status, superseded_by, created_at, updated_at
1348
+ FROM observations
1349
+ WHERE project_id = ?
1350
+ ORDER BY created_at ASC`)
1351
+ .all(targetProjectId);
1352
+ entityRows = this._db
1353
+ .prepare(`SELECT e.id, e.project_id, e.entity_type, e.identifier, e.file_path, oe.observation_id
1354
+ FROM entities e
1355
+ JOIN observation_entities oe ON oe.entity_id = e.id
1356
+ JOIN observations o ON o.id = oe.observation_id
1357
+ WHERE o.project_id = ?
1358
+ ORDER BY e.identifier ASC`)
1359
+ .all(targetProjectId);
1360
+ }
1361
+ const entityMap = new Map();
1362
+ for (const row of entityRows) {
1363
+ let list = entityMap.get(row.observation_id);
1364
+ if (!list) {
1365
+ list = [];
1366
+ entityMap.set(row.observation_id, list);
1367
+ }
1368
+ list.push({
1369
+ id: row.id,
1370
+ project_id: row.project_id,
1371
+ entity_type: row.entity_type,
1372
+ identifier: row.identifier,
1373
+ file_path: row.file_path,
1374
+ });
1375
+ }
1376
+ const lines = observations.map((obs) => {
1377
+ const record = {
1378
+ ...obs,
1379
+ entities: entityMap.get(obs.id) ?? [],
1380
+ };
1381
+ return JSON.stringify(record);
1382
+ });
1383
+ const tmpPath = `${resolvedPath}.tmp`;
1384
+ try {
1385
+ fs.writeFileSync(tmpPath, lines.length > 0 ? lines.join("\n") + "\n" : "", { encoding: "utf-8", mode: 0o600 });
1386
+ try {
1387
+ fs.chmodSync(tmpPath, 0o600);
1388
+ }
1389
+ catch {
1390
+ // ignore on platforms where chmod fails
1391
+ }
1392
+ fs.renameSync(tmpPath, resolvedPath);
1393
+ }
1394
+ catch (err) {
1395
+ if (fs.existsSync(tmpPath)) {
1396
+ try {
1397
+ fs.unlinkSync(tmpPath);
1398
+ }
1399
+ catch {
1400
+ // ignore cleanup error
1401
+ }
1402
+ }
1403
+ throw err;
1404
+ }
1405
+ return {
1406
+ path: resolvedPath,
1407
+ count: observations.length,
1408
+ };
1409
+ }
1410
+ /**
1411
+ * Reads a JSON Lines (.jsonl) file and idempotently imports records into the database.
1412
+ */
1413
+ importFromDisk(sourcePath) {
1414
+ let resolvedPath;
1415
+ if (sourcePath) {
1416
+ if (path.isAbsolute(sourcePath)) {
1417
+ resolvedPath = path.resolve(sourcePath);
1418
+ }
1419
+ else {
1420
+ const root = this._currentProject.root_path || findGitRoot() || process.cwd();
1421
+ resolvedPath = path.resolve(root, sourcePath);
1422
+ }
1423
+ }
1424
+ else {
1425
+ const root = this._currentProject.root_path || findGitRoot() || process.cwd();
1426
+ resolvedPath = path.join(root, ".huginn", "memories.jsonl");
1427
+ }
1428
+ this.assertMemoryPath(resolvedPath, "read");
1429
+ if (!fs.existsSync(resolvedPath)) {
1430
+ return { imported: 0, skipped: 0 };
1431
+ }
1432
+ const content = fs.readFileSync(resolvedPath, "utf-8");
1433
+ const rawLines = content
1434
+ .split("\n")
1435
+ .map((l) => l.trim())
1436
+ .filter(Boolean);
1437
+ let imported = 0;
1438
+ let skipped = 0;
1439
+ const importTx = this._db.transaction(() => {
1440
+ const checkObsStmt = this._db.prepare("SELECT id FROM observations WHERE id = ?");
1441
+ const checkProjStmt = this._db.prepare("SELECT id FROM projects WHERE id = ?");
1442
+ const insertObsStmt = this._db.prepare(`INSERT INTO observations (id, project_id, category, title, content, topic_key, status, superseded_by, created_at, updated_at)
1443
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
1444
+ const findEntityStmt = this._db.prepare(`SELECT id, project_id, entity_type, identifier, file_path
1445
+ FROM entities
1446
+ WHERE project_id = ? AND identifier = ?`);
1447
+ const insertEntityStmt = this._db.prepare(`INSERT INTO entities (id, project_id, entity_type, identifier, file_path)
1448
+ VALUES (?, ?, ?, ?, ?)`);
1449
+ const linkStmt = this._db.prepare(`INSERT OR IGNORE INTO observation_entities (observation_id, entity_id)
1450
+ VALUES (?, ?)`);
1451
+ for (const line of rawLines) {
1452
+ let record;
1453
+ try {
1454
+ record = JSON.parse(line);
1455
+ }
1456
+ catch {
1457
+ continue;
1458
+ }
1459
+ if (!record ||
1460
+ typeof record !== "object" ||
1461
+ !(typeof record.id === "string" &&
1462
+ typeof record.title === "string" &&
1463
+ typeof record.content === "string") ||
1464
+ !record.id.trim() ||
1465
+ !record.title.trim()) {
1466
+ continue;
1467
+ }
1468
+ const existing = checkObsStmt.get(record.id);
1469
+ if (existing) {
1470
+ skipped++;
1471
+ continue;
1472
+ }
1473
+ let projectId = record.project_id ?? record.projectId;
1474
+ if (!projectId || !checkProjStmt.get(projectId)) {
1475
+ projectId = this._currentProject.id;
1476
+ }
1477
+ const category = VALID_CATEGORIES.includes(record.category)
1478
+ ? record.category
1479
+ : "decision";
1480
+ const topicKey = record.topic_key !== undefined
1481
+ ? record.topic_key
1482
+ : (record.topicKey ?? null);
1483
+ const createdAt = record.created_at ?? new Date().toISOString();
1484
+ const updatedAt = record.updated_at ?? createdAt;
1485
+ const status = record.status === "superseded" ? "superseded" : "active";
1486
+ const supersededBy = record.superseded_by ?? record.supersededBy ?? null;
1487
+ insertObsStmt.run(record.id, projectId, category, record.title, record.content, topicKey, status, supersededBy, createdAt, updatedAt);
1488
+ const symbols = record.entities ?? record.symbols ?? [];
1489
+ if (Array.isArray(symbols)) {
1490
+ for (const sym of symbols) {
1491
+ const norm = normalizeSymbol(sym);
1492
+ let entity = findEntityStmt.get(projectId, norm.identifier);
1493
+ let entityId;
1494
+ if (entity) {
1495
+ entityId = entity.id;
1496
+ }
1497
+ else {
1498
+ entityId = sym.id ?? crypto.randomUUID();
1499
+ insertEntityStmt.run(entityId, projectId, norm.entityType, norm.identifier, norm.filePath);
1500
+ }
1501
+ linkStmt.run(record.id, entityId);
1502
+ }
1503
+ }
1504
+ imported++;
1505
+ }
1506
+ });
1507
+ importTx();
1508
+ return { imported, skipped };
1509
+ }
1510
+ }
1511
+ //# sourceMappingURL=memory-service.js.map