@dahch/muninn 0.1.0 → 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.
- package/README.md +260 -17
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +18 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/memory.d.ts.map +1 -1
- package/dist/commands/memory.js +52 -3
- package/dist/commands/memory.js.map +1 -1
- package/dist/commands/setup.d.ts +3 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/setup.js +397 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/db/client.d.ts +17 -1
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +54 -0
- package/dist/db/client.js.map +1 -1
- package/dist/db/schema.sql +2 -0
- package/dist/mcp/server.d.ts +27 -6
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +156 -15
- package/dist/mcp/server.js.map +1 -1
- package/dist/service/memory-service.d.ts +121 -4
- package/dist/service/memory-service.d.ts.map +1 -1
- package/dist/service/memory-service.js +665 -45
- package/dist/service/memory-service.js.map +1 -1
- package/dist/setup/index.d.ts +6 -0
- package/dist/setup/index.d.ts.map +1 -0
- package/dist/setup/index.js +6 -0
- package/dist/setup/index.js.map +1 -0
- package/dist/setup/json-target.d.ts +30 -0
- package/dist/setup/json-target.d.ts.map +1 -0
- package/dist/setup/json-target.js +263 -0
- package/dist/setup/json-target.js.map +1 -0
- package/dist/setup/portable.d.ts +56 -0
- package/dist/setup/portable.d.ts.map +1 -0
- package/dist/setup/portable.js +365 -0
- package/dist/setup/portable.js.map +1 -0
- package/dist/setup/registry.d.ts +54 -0
- package/dist/setup/registry.d.ts.map +1 -0
- package/dist/setup/registry.js +225 -0
- package/dist/setup/registry.js.map +1 -0
- package/dist/setup/rules-target.d.ts +31 -0
- package/dist/setup/rules-target.d.ts.map +1 -0
- package/dist/setup/rules-target.js +270 -0
- package/dist/setup/rules-target.js.map +1 -0
- package/dist/setup/toml-target.d.ts +62 -0
- package/dist/setup/toml-target.d.ts.map +1 -0
- package/dist/setup/toml-target.js +869 -0
- package/dist/setup/toml-target.js.map +1 -0
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
4
|
import { assertDocPath } from "../util/path-safety.js";
|
|
5
|
-
import { getDatabase, ensureProject, findGitRoot, } from "../db/client.js";
|
|
5
|
+
import { getDatabase, getReadOnlyDatabase, ensureProject, findGitRoot, tryGetGitRemote, } from "../db/client.js";
|
|
6
6
|
export const VALID_CATEGORIES = [
|
|
7
7
|
"decision",
|
|
8
8
|
"convention",
|
|
@@ -17,6 +17,15 @@ export const VALID_ENTITY_TYPES = [
|
|
|
17
17
|
"interface",
|
|
18
18
|
"module",
|
|
19
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;
|
|
20
29
|
/**
|
|
21
30
|
* Normalizes input symbols into canonical identifier, filePath, and entityType.
|
|
22
31
|
*/
|
|
@@ -151,6 +160,582 @@ export function sanitizeFtsQuery(query) {
|
|
|
151
160
|
}
|
|
152
161
|
return terms.join(" ");
|
|
153
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
|
+
}
|
|
154
739
|
export class MemoryService {
|
|
155
740
|
_db;
|
|
156
741
|
_currentProject;
|
|
@@ -229,12 +814,31 @@ export class MemoryService {
|
|
|
229
814
|
const topicKey = input.topicKey !== undefined
|
|
230
815
|
? input.topicKey
|
|
231
816
|
: (input.topic_key ?? null);
|
|
817
|
+
const supersedesTopicKey = input.supersedesTopicKey !== undefined
|
|
818
|
+
? input.supersedesTopicKey
|
|
819
|
+
: (input.supersedes_topic_key ?? null);
|
|
232
820
|
const observationId = crypto.randomUUID();
|
|
233
821
|
return this._db.transaction(() => {
|
|
234
822
|
this._db
|
|
235
823
|
.prepare(`INSERT INTO observations (id, project_id, category, title, content, topic_key)
|
|
236
824
|
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
237
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
|
+
}
|
|
238
842
|
const symbols = input.symbols ?? [];
|
|
239
843
|
const selectEntityStmt = this._db.prepare(`SELECT id, project_id, entity_type, identifier, file_path
|
|
240
844
|
FROM entities
|
|
@@ -260,7 +864,7 @@ export class MemoryService {
|
|
|
260
864
|
linkStmt.run(observationId, entity.id);
|
|
261
865
|
}
|
|
262
866
|
const observation = this._db
|
|
263
|
-
.prepare(`SELECT id, project_id, category, title, content, topic_key, created_at, updated_at
|
|
867
|
+
.prepare(`SELECT id, project_id, category, title, content, topic_key, status, superseded_by, created_at, updated_at
|
|
264
868
|
FROM observations
|
|
265
869
|
WHERE id = ?`)
|
|
266
870
|
.get(observationId);
|
|
@@ -336,6 +940,9 @@ export class MemoryService {
|
|
|
336
940
|
conditions.push("o.category = ?");
|
|
337
941
|
params.push(options.category);
|
|
338
942
|
}
|
|
943
|
+
if (!options.includeSuperseded && !options.include_superseded) {
|
|
944
|
+
conditions.push("o.status = 'active'");
|
|
945
|
+
}
|
|
339
946
|
if (!options?.allProjects) {
|
|
340
947
|
const explicitProjectId = options?.projectId !== undefined
|
|
341
948
|
? options.projectId
|
|
@@ -361,6 +968,8 @@ export class MemoryService {
|
|
|
361
968
|
o.title,
|
|
362
969
|
o.content,
|
|
363
970
|
o.topic_key,
|
|
971
|
+
o.status,
|
|
972
|
+
o.superseded_by,
|
|
364
973
|
o.created_at,
|
|
365
974
|
o.updated_at,
|
|
366
975
|
bm25(observations_fts) AS rank
|
|
@@ -404,6 +1013,8 @@ export class MemoryService {
|
|
|
404
1013
|
title: r.title,
|
|
405
1014
|
content: r.content,
|
|
406
1015
|
topic_key: r.topic_key,
|
|
1016
|
+
status: r.status,
|
|
1017
|
+
superseded_by: r.superseded_by,
|
|
407
1018
|
created_at: r.created_at,
|
|
408
1019
|
updated_at: r.updated_at,
|
|
409
1020
|
rank: r.rank,
|
|
@@ -420,6 +1031,9 @@ export class MemoryService {
|
|
|
420
1031
|
conditions.push("category = ?");
|
|
421
1032
|
params.push(options.category);
|
|
422
1033
|
}
|
|
1034
|
+
if (!options?.includeSuperseded && !options?.include_superseded) {
|
|
1035
|
+
conditions.push("status = 'active'");
|
|
1036
|
+
}
|
|
423
1037
|
const topicKey = options?.topicKey !== undefined ? options.topicKey : options?.topic_key;
|
|
424
1038
|
if (topicKey !== undefined && topicKey !== null) {
|
|
425
1039
|
conditions.push("topic_key = ?");
|
|
@@ -444,7 +1058,7 @@ export class MemoryService {
|
|
|
444
1058
|
const limit = Math.min(Math.max(1, parsedLimit), 500);
|
|
445
1059
|
params.push(limit);
|
|
446
1060
|
const sql = `
|
|
447
|
-
SELECT id, project_id, category, title, content, topic_key, created_at, updated_at
|
|
1061
|
+
SELECT id, project_id, category, title, content, topic_key, status, superseded_by, created_at, updated_at
|
|
448
1062
|
FROM observations
|
|
449
1063
|
${whereClause}
|
|
450
1064
|
ORDER BY updated_at DESC, created_at DESC
|
|
@@ -463,6 +1077,8 @@ export class MemoryService {
|
|
|
463
1077
|
title: r.title,
|
|
464
1078
|
content: r.content,
|
|
465
1079
|
topic_key: r.topic_key,
|
|
1080
|
+
status: r.status,
|
|
1081
|
+
superseded_by: r.superseded_by,
|
|
466
1082
|
created_at: r.created_at,
|
|
467
1083
|
updated_at: r.updated_at,
|
|
468
1084
|
entities: entityMap.get(r.id) ?? [],
|
|
@@ -494,7 +1110,7 @@ export class MemoryService {
|
|
|
494
1110
|
throw new Error("symbol is required for linkSymbol");
|
|
495
1111
|
}
|
|
496
1112
|
const observation = this._db
|
|
497
|
-
.prepare(`SELECT id, project_id, category, title, content, topic_key, created_at, updated_at
|
|
1113
|
+
.prepare(`SELECT id, project_id, category, title, content, topic_key, status, superseded_by, created_at, updated_at
|
|
498
1114
|
FROM observations
|
|
499
1115
|
WHERE id = ?`)
|
|
500
1116
|
.get(observationId);
|
|
@@ -533,12 +1149,6 @@ export class MemoryService {
|
|
|
533
1149
|
};
|
|
534
1150
|
})();
|
|
535
1151
|
}
|
|
536
|
-
/**
|
|
537
|
-
* Escapes LIKE pattern special characters (%, _, \)
|
|
538
|
-
*/
|
|
539
|
-
_escapeLikePattern(str) {
|
|
540
|
-
return str.replace(/[\\%_]/g, "\\$&");
|
|
541
|
-
}
|
|
542
1152
|
/**
|
|
543
1153
|
* Inspects a symbol in Muninn memory, returning its entity metadata,
|
|
544
1154
|
* incoming and outgoing dependencies, and linked observations.
|
|
@@ -548,35 +1158,7 @@ export class MemoryService {
|
|
|
548
1158
|
return null;
|
|
549
1159
|
}
|
|
550
1160
|
const targetProjectId = projectId ?? this._currentProject.id;
|
|
551
|
-
const
|
|
552
|
-
// 1. Try exact match
|
|
553
|
-
let entity = this._db
|
|
554
|
-
.prepare(`SELECT id, project_id, entity_type, identifier, file_path
|
|
555
|
-
FROM entities
|
|
556
|
-
WHERE project_id = ? AND identifier = ?`)
|
|
557
|
-
.get(targetProjectId, query);
|
|
558
|
-
const escapedQuery = this._escapeLikePattern(query);
|
|
559
|
-
// 2. Try suffix match (e.g. searching 'run' matches 'src/app.ts::run', or 'login' matches 'src/auth.ts::AuthManager.login')
|
|
560
|
-
// Supports matching class methods with canonical format <relPath>::<ClassName>.<methodName> (REV-005, REV-008, SEC-004)
|
|
561
|
-
if (!entity) {
|
|
562
|
-
entity = this._db
|
|
563
|
-
.prepare(`SELECT id, project_id, entity_type, identifier, file_path
|
|
564
|
-
FROM entities
|
|
565
|
-
WHERE project_id = ? AND (identifier LIKE '%::' || ? ESCAPE '\\' OR identifier LIKE '%.' || ? ESCAPE '\\')
|
|
566
|
-
ORDER BY identifier ASC
|
|
567
|
-
LIMIT 1`)
|
|
568
|
-
.get(targetProjectId, escapedQuery, escapedQuery);
|
|
569
|
-
}
|
|
570
|
-
// 3. Try prefix match or file_path match
|
|
571
|
-
if (!entity) {
|
|
572
|
-
entity = this._db
|
|
573
|
-
.prepare(`SELECT id, project_id, entity_type, identifier, file_path
|
|
574
|
-
FROM entities
|
|
575
|
-
WHERE project_id = ? AND (identifier LIKE ? || '%' ESCAPE '\\' OR file_path = ?)
|
|
576
|
-
ORDER BY identifier ASC
|
|
577
|
-
LIMIT 1`)
|
|
578
|
-
.get(targetProjectId, escapedQuery, query);
|
|
579
|
-
}
|
|
1161
|
+
const entity = resolveEntityByIdentifier(this._db, targetProjectId, symbol.trim());
|
|
580
1162
|
if (!entity) {
|
|
581
1163
|
return null;
|
|
582
1164
|
}
|
|
@@ -617,7 +1199,7 @@ export class MemoryService {
|
|
|
617
1199
|
}));
|
|
618
1200
|
// Fetch linked observations
|
|
619
1201
|
const observations = this._db
|
|
620
|
-
.prepare(`SELECT o.id, o.project_id, o.category, o.title, o.content, o.topic_key, o.created_at, o.updated_at
|
|
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
|
|
621
1203
|
FROM observations o
|
|
622
1204
|
JOIN observation_entities oe ON oe.observation_id = o.id
|
|
623
1205
|
WHERE oe.entity_id = ?
|
|
@@ -629,9 +1211,45 @@ export class MemoryService {
|
|
|
629
1211
|
outgoing,
|
|
630
1212
|
incoming,
|
|
631
1213
|
},
|
|
1214
|
+
neighbors: collectNeighbors(this._db, entity, DEFAULT_NEIGHBOR_DEPTH, DEFAULT_NEIGHBOR_LIMIT),
|
|
632
1215
|
observations,
|
|
633
1216
|
};
|
|
634
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
|
+
}
|
|
635
1253
|
/**
|
|
636
1254
|
* Retrieves aggregate counts of projects, observations, entities, and links.
|
|
637
1255
|
*/
|
|
@@ -713,7 +1331,7 @@ export class MemoryService {
|
|
|
713
1331
|
let entityRows;
|
|
714
1332
|
if (options?.allProjects === true) {
|
|
715
1333
|
observations = this._db
|
|
716
|
-
.prepare(`SELECT id, project_id, category, title, content, topic_key, created_at, updated_at
|
|
1334
|
+
.prepare(`SELECT id, project_id, category, title, content, topic_key, status, superseded_by, created_at, updated_at
|
|
717
1335
|
FROM observations
|
|
718
1336
|
ORDER BY created_at ASC`)
|
|
719
1337
|
.all();
|
|
@@ -726,7 +1344,7 @@ export class MemoryService {
|
|
|
726
1344
|
}
|
|
727
1345
|
else {
|
|
728
1346
|
observations = this._db
|
|
729
|
-
.prepare(`SELECT id, project_id, category, title, content, topic_key, created_at, updated_at
|
|
1347
|
+
.prepare(`SELECT id, project_id, category, title, content, topic_key, status, superseded_by, created_at, updated_at
|
|
730
1348
|
FROM observations
|
|
731
1349
|
WHERE project_id = ?
|
|
732
1350
|
ORDER BY created_at ASC`)
|
|
@@ -821,8 +1439,8 @@ export class MemoryService {
|
|
|
821
1439
|
const importTx = this._db.transaction(() => {
|
|
822
1440
|
const checkObsStmt = this._db.prepare("SELECT id FROM observations WHERE id = ?");
|
|
823
1441
|
const checkProjStmt = this._db.prepare("SELECT id FROM projects WHERE id = ?");
|
|
824
|
-
const insertObsStmt = this._db.prepare(`INSERT INTO observations (id, project_id, category, title, content, topic_key, created_at, updated_at)
|
|
825
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
826
1444
|
const findEntityStmt = this._db.prepare(`SELECT id, project_id, entity_type, identifier, file_path
|
|
827
1445
|
FROM entities
|
|
828
1446
|
WHERE project_id = ? AND identifier = ?`);
|
|
@@ -864,7 +1482,9 @@ export class MemoryService {
|
|
|
864
1482
|
: (record.topicKey ?? null);
|
|
865
1483
|
const createdAt = record.created_at ?? new Date().toISOString();
|
|
866
1484
|
const updatedAt = record.updated_at ?? createdAt;
|
|
867
|
-
|
|
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);
|
|
868
1488
|
const symbols = record.entities ?? record.symbols ?? [];
|
|
869
1489
|
if (Array.isArray(symbols)) {
|
|
870
1490
|
for (const sym of symbols) {
|