@dahch/muninn 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +152 -2
- package/dist/cli.d.ts +23 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +170 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/check.d.ts +3 -0
- package/dist/commands/check.d.ts.map +1 -0
- package/dist/commands/check.js +56 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/memory.d.ts +6 -0
- package/dist/commands/memory.d.ts.map +1 -0
- package/dist/commands/memory.js +325 -0
- package/dist/commands/memory.js.map +1 -0
- package/dist/contracts/compiler.d.ts +71 -0
- package/dist/contracts/compiler.d.ts.map +1 -0
- package/dist/contracts/compiler.js +362 -0
- package/dist/contracts/compiler.js.map +1 -0
- package/dist/contracts/index.d.ts +2 -0
- package/dist/contracts/index.d.ts.map +1 -0
- package/dist/contracts/index.js +2 -0
- package/dist/contracts/index.js.map +1 -0
- package/dist/db/client.d.ts +84 -0
- package/dist/db/client.d.ts.map +1 -0
- package/dist/db/client.js +264 -0
- package/dist/db/client.js.map +1 -0
- package/dist/db/schema.sql +97 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/indexer/ast-indexer.d.ts +55 -0
- package/dist/indexer/ast-indexer.d.ts.map +1 -0
- package/dist/indexer/ast-indexer.js +445 -0
- package/dist/indexer/ast-indexer.js.map +1 -0
- package/dist/indexer/index.d.ts +2 -0
- package/dist/indexer/index.d.ts.map +1 -0
- package/dist/indexer/index.js +2 -0
- package/dist/indexer/index.js.map +1 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/server.d.ts +233 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +495 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/service/index.d.ts +2 -0
- package/dist/service/index.d.ts.map +1 -0
- package/dist/service/index.js +2 -0
- package/dist/service/index.js.map +1 -0
- package/dist/service/memory-service.d.ts +203 -0
- package/dist/service/memory-service.d.ts.map +1 -0
- package/dist/service/memory-service.js +891 -0
- package/dist/service/memory-service.js.map +1 -0
- package/dist/util/path-safety.d.ts +94 -0
- package/dist/util/path-safety.d.ts.map +1 -0
- package/dist/util/path-safety.js +162 -0
- package/dist/util/path-safety.js.map +1 -0
- package/dist/util/text.d.ts +9 -0
- package/dist/util/text.d.ts.map +1 -0
- package/dist/util/text.js +19 -0
- package/dist/util/text.js.map +1 -0
- package/package.json +87 -4
|
@@ -0,0 +1,891 @@
|
|
|
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, ensureProject, findGitRoot, } 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
|
+
/**
|
|
21
|
+
* Normalizes input symbols into canonical identifier, filePath, and entityType.
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeSymbol(symbol) {
|
|
24
|
+
if (!symbol || (typeof symbol !== "string" && typeof symbol !== "object")) {
|
|
25
|
+
return { identifier: "unknown", filePath: "unknown", entityType: "module" };
|
|
26
|
+
}
|
|
27
|
+
if (typeof symbol === "string") {
|
|
28
|
+
const raw = symbol.trim();
|
|
29
|
+
if (!raw) {
|
|
30
|
+
return { identifier: "unknown", filePath: "unknown", entityType: "module" };
|
|
31
|
+
}
|
|
32
|
+
if (raw.includes("::")) {
|
|
33
|
+
const parts = raw.split("::");
|
|
34
|
+
const filePath = parts[0] || "unknown";
|
|
35
|
+
return {
|
|
36
|
+
identifier: raw,
|
|
37
|
+
filePath,
|
|
38
|
+
entityType: "module",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (raw.includes("/") ||
|
|
42
|
+
raw.endsWith(".ts") ||
|
|
43
|
+
raw.endsWith(".js") ||
|
|
44
|
+
raw.endsWith(".json")) {
|
|
45
|
+
return {
|
|
46
|
+
identifier: raw,
|
|
47
|
+
filePath: raw,
|
|
48
|
+
entityType: "file",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
identifier: raw,
|
|
53
|
+
filePath: "unknown",
|
|
54
|
+
entityType: "module",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const rawName = symbol.name?.trim();
|
|
58
|
+
const rawFilePath = (symbol.filePath ?? symbol.file_path)?.trim();
|
|
59
|
+
const rawIdentifier = symbol.identifier?.trim();
|
|
60
|
+
const rawType = (symbol.type ?? symbol.entity_type);
|
|
61
|
+
let identifier;
|
|
62
|
+
if (rawIdentifier) {
|
|
63
|
+
identifier = rawIdentifier;
|
|
64
|
+
}
|
|
65
|
+
else if (rawFilePath) {
|
|
66
|
+
identifier =
|
|
67
|
+
rawName && rawName !== "default"
|
|
68
|
+
? `${rawFilePath}::${rawName}`
|
|
69
|
+
: rawFilePath;
|
|
70
|
+
}
|
|
71
|
+
else if (rawName) {
|
|
72
|
+
identifier = rawName;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
identifier = "unknown";
|
|
76
|
+
}
|
|
77
|
+
let filePath;
|
|
78
|
+
if (rawFilePath) {
|
|
79
|
+
filePath = rawFilePath;
|
|
80
|
+
}
|
|
81
|
+
else if (rawIdentifier && rawIdentifier.includes("::")) {
|
|
82
|
+
filePath = rawIdentifier.split("::")[0] || "unknown";
|
|
83
|
+
}
|
|
84
|
+
else if (rawIdentifier &&
|
|
85
|
+
(rawIdentifier.includes("/") ||
|
|
86
|
+
rawIdentifier.endsWith(".ts") ||
|
|
87
|
+
rawIdentifier.endsWith(".js") ||
|
|
88
|
+
rawIdentifier.endsWith(".json"))) {
|
|
89
|
+
filePath = rawIdentifier;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
filePath = "unknown";
|
|
93
|
+
}
|
|
94
|
+
let entityType = "module";
|
|
95
|
+
if (rawType && VALID_ENTITY_TYPES.includes(rawType)) {
|
|
96
|
+
entityType = rawType;
|
|
97
|
+
}
|
|
98
|
+
else if (rawFilePath && (!rawName || rawName === "default")) {
|
|
99
|
+
entityType = "file";
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
identifier,
|
|
103
|
+
filePath,
|
|
104
|
+
entityType,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Safely format query terms for SQLite FTS5 syntax.
|
|
109
|
+
* Escapes special characters, wraps terms in quotes, and safely supports prefix queries.
|
|
110
|
+
* Returns an empty string if no valid search terms remain.
|
|
111
|
+
*/
|
|
112
|
+
export function sanitizeFtsQuery(query) {
|
|
113
|
+
if (!query || typeof query !== "string") {
|
|
114
|
+
return "";
|
|
115
|
+
}
|
|
116
|
+
const trimmed = query.trim();
|
|
117
|
+
if (!trimmed) {
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
// Regex to extract double-quoted phrases OR non-whitespace words
|
|
121
|
+
const tokenRegex = /"([^"]*)"|(\S+)/g;
|
|
122
|
+
const terms = [];
|
|
123
|
+
let match;
|
|
124
|
+
while ((match = tokenRegex.exec(trimmed)) !== null) {
|
|
125
|
+
if (match[1] !== undefined) {
|
|
126
|
+
// It was inside double quotes
|
|
127
|
+
const inner = match[1].replace(/"/g, "").trim();
|
|
128
|
+
if (inner && /[\p{L}\p{N}]/u.test(inner)) {
|
|
129
|
+
terms.push(`"${inner}"`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else if (match[2] !== undefined) {
|
|
133
|
+
const raw = match[2].trim();
|
|
134
|
+
const cleaned = raw.replace(/"/g, "");
|
|
135
|
+
if (!cleaned) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// Check if ends with wildcard *
|
|
139
|
+
if (cleaned.endsWith("*") && cleaned.length > 1) {
|
|
140
|
+
const base = cleaned.slice(0, -1);
|
|
141
|
+
if (/[\p{L}\p{N}]/u.test(base)) {
|
|
142
|
+
terms.push(`"${base}"*`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Check if contains letters or numbers
|
|
147
|
+
if (/[\p{L}\p{N}]/u.test(cleaned)) {
|
|
148
|
+
terms.push(`"${cleaned}"`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return terms.join(" ");
|
|
153
|
+
}
|
|
154
|
+
export class MemoryService {
|
|
155
|
+
_db;
|
|
156
|
+
_currentProject;
|
|
157
|
+
isDbOwned;
|
|
158
|
+
constructor(options) {
|
|
159
|
+
if (options?.db) {
|
|
160
|
+
this._db = options.db;
|
|
161
|
+
this.isDbOwned = false;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
// ONE root for both (ADR-49): the database file and the project record are
|
|
165
|
+
// resolved from `projectRoot`, so `--project X` can never label a row in
|
|
166
|
+
// another project's database.
|
|
167
|
+
this._db = getDatabase(options?.dbPath, options?.projectRoot);
|
|
168
|
+
this.isDbOwned = true;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
this._currentProject = ensureProject(this._db, {
|
|
172
|
+
rootPath: options?.projectRoot,
|
|
173
|
+
startDir: options?.projectRoot,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
if (this.isDbOwned && this._db?.open) {
|
|
178
|
+
this._db.close();
|
|
179
|
+
}
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
get db() {
|
|
184
|
+
return this._db;
|
|
185
|
+
}
|
|
186
|
+
get currentProject() {
|
|
187
|
+
return this._currentProject;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Screen the durable JSONL export path exactly like the database path
|
|
191
|
+
* (SEC-006): a `.huginn/memories.jsonl` a cloned repository ships as a symlink
|
|
192
|
+
* must be neither written through nor read from — either direction would move
|
|
193
|
+
* project content outside the project (or pull foreign content in, where the
|
|
194
|
+
* MCP memory tools would serve it to the agent).
|
|
195
|
+
*/
|
|
196
|
+
assertMemoryPath(resolvedPath, action) {
|
|
197
|
+
const root = this._currentProject.root_path || findGitRoot() || process.cwd();
|
|
198
|
+
assertDocPath(resolvedPath, {
|
|
199
|
+
projectPath: root,
|
|
200
|
+
label: "the Muninn memory export",
|
|
201
|
+
action,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Closes the database connection if managed internally by the service.
|
|
206
|
+
*/
|
|
207
|
+
close(force = false) {
|
|
208
|
+
if ((this.isDbOwned || force) && this._db.open) {
|
|
209
|
+
this._db.close();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Saves an observation and transactionally links any provided symbols.
|
|
214
|
+
*/
|
|
215
|
+
saveObservation(input) {
|
|
216
|
+
const category = input.category;
|
|
217
|
+
if (!VALID_CATEGORIES.includes(category)) {
|
|
218
|
+
throw new Error(`Invalid category "${input.category}". Allowed categories: ${VALID_CATEGORIES.join(", ")}`);
|
|
219
|
+
}
|
|
220
|
+
if (!input.title || typeof input.title !== "string" || !input.title.trim()) {
|
|
221
|
+
throw new Error("Observation title is required and cannot be empty");
|
|
222
|
+
}
|
|
223
|
+
if (input.content === undefined ||
|
|
224
|
+
input.content === null ||
|
|
225
|
+
typeof input.content !== "string") {
|
|
226
|
+
throw new Error("Observation content is required");
|
|
227
|
+
}
|
|
228
|
+
const projectId = input.projectId ?? input.project_id ?? this._currentProject.id;
|
|
229
|
+
const topicKey = input.topicKey !== undefined
|
|
230
|
+
? input.topicKey
|
|
231
|
+
: (input.topic_key ?? null);
|
|
232
|
+
const observationId = crypto.randomUUID();
|
|
233
|
+
return this._db.transaction(() => {
|
|
234
|
+
this._db
|
|
235
|
+
.prepare(`INSERT INTO observations (id, project_id, category, title, content, topic_key)
|
|
236
|
+
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
237
|
+
.run(observationId, projectId, category, input.title.trim(), input.content, topicKey);
|
|
238
|
+
const symbols = input.symbols ?? [];
|
|
239
|
+
const selectEntityStmt = this._db.prepare(`SELECT id, project_id, entity_type, identifier, file_path
|
|
240
|
+
FROM entities
|
|
241
|
+
WHERE project_id = ? AND identifier = ?`);
|
|
242
|
+
const insertEntityStmt = this._db.prepare(`INSERT INTO entities (id, project_id, entity_type, identifier, file_path)
|
|
243
|
+
VALUES (?, ?, ?, ?, ?)`);
|
|
244
|
+
const linkStmt = this._db.prepare(`INSERT OR IGNORE INTO observation_entities (observation_id, entity_id)
|
|
245
|
+
VALUES (?, ?)`);
|
|
246
|
+
for (const sym of symbols) {
|
|
247
|
+
const norm = normalizeSymbol(sym);
|
|
248
|
+
let entity = selectEntityStmt.get(projectId, norm.identifier);
|
|
249
|
+
if (!entity) {
|
|
250
|
+
const entityId = crypto.randomUUID();
|
|
251
|
+
insertEntityStmt.run(entityId, projectId, norm.entityType, norm.identifier, norm.filePath);
|
|
252
|
+
entity = {
|
|
253
|
+
id: entityId,
|
|
254
|
+
project_id: projectId,
|
|
255
|
+
entity_type: norm.entityType,
|
|
256
|
+
identifier: norm.identifier,
|
|
257
|
+
file_path: norm.filePath,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
linkStmt.run(observationId, entity.id);
|
|
261
|
+
}
|
|
262
|
+
const observation = this._db
|
|
263
|
+
.prepare(`SELECT id, project_id, category, title, content, topic_key, created_at, updated_at
|
|
264
|
+
FROM observations
|
|
265
|
+
WHERE id = ?`)
|
|
266
|
+
.get(observationId);
|
|
267
|
+
if (!observation) {
|
|
268
|
+
throw new Error(`Failed to retrieve newly created observation: ${observationId}`);
|
|
269
|
+
}
|
|
270
|
+
const entities = this._db
|
|
271
|
+
.prepare(`SELECT e.id, e.project_id, e.entity_type, e.identifier, e.file_path
|
|
272
|
+
FROM entities e
|
|
273
|
+
JOIN observation_entities oe ON oe.entity_id = e.id
|
|
274
|
+
WHERE oe.observation_id = ?
|
|
275
|
+
ORDER BY e.identifier ASC`)
|
|
276
|
+
.all(observationId);
|
|
277
|
+
return {
|
|
278
|
+
...observation,
|
|
279
|
+
entities,
|
|
280
|
+
};
|
|
281
|
+
})();
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Retrieves linked entities for observation IDs in chunks of at most 500
|
|
285
|
+
* to prevent exceeding SQLite parameter bounds.
|
|
286
|
+
*/
|
|
287
|
+
_fetchEntitiesForObservations(obsIds) {
|
|
288
|
+
const entityMap = new Map();
|
|
289
|
+
if (obsIds.length === 0) {
|
|
290
|
+
return entityMap;
|
|
291
|
+
}
|
|
292
|
+
const CHUNK_SIZE = 500;
|
|
293
|
+
for (let i = 0; i < obsIds.length; i += CHUNK_SIZE) {
|
|
294
|
+
const chunk = obsIds.slice(i, i + CHUNK_SIZE);
|
|
295
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
296
|
+
const entityRows = this._db
|
|
297
|
+
.prepare(`SELECT e.id, e.project_id, e.entity_type, e.identifier, e.file_path, oe.observation_id
|
|
298
|
+
FROM entities e
|
|
299
|
+
JOIN observation_entities oe ON oe.entity_id = e.id
|
|
300
|
+
WHERE oe.observation_id IN (${placeholders})
|
|
301
|
+
ORDER BY e.identifier ASC`)
|
|
302
|
+
.all(...chunk);
|
|
303
|
+
for (const row of entityRows) {
|
|
304
|
+
let list = entityMap.get(row.observation_id);
|
|
305
|
+
if (!list) {
|
|
306
|
+
list = [];
|
|
307
|
+
entityMap.set(row.observation_id, list);
|
|
308
|
+
}
|
|
309
|
+
list.push({
|
|
310
|
+
id: row.id,
|
|
311
|
+
project_id: row.project_id,
|
|
312
|
+
entity_type: row.entity_type,
|
|
313
|
+
identifier: row.identifier,
|
|
314
|
+
file_path: row.file_path,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return entityMap;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Searches observations using FTS5 BM25 relevance ranking.
|
|
322
|
+
* Query terms are safely sanitized, rank ordering is ASC (lower BM25 = higher relevance),
|
|
323
|
+
* and linked entities are attached to each result.
|
|
324
|
+
*/
|
|
325
|
+
search(options) {
|
|
326
|
+
if (!options || !options.query) {
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
const sanitized = sanitizeFtsQuery(options.query);
|
|
330
|
+
if (!sanitized) {
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
const conditions = ["observations_fts MATCH ?"];
|
|
334
|
+
const params = [sanitized];
|
|
335
|
+
if (options.category) {
|
|
336
|
+
conditions.push("o.category = ?");
|
|
337
|
+
params.push(options.category);
|
|
338
|
+
}
|
|
339
|
+
if (!options?.allProjects) {
|
|
340
|
+
const explicitProjectId = options?.projectId !== undefined
|
|
341
|
+
? options.projectId
|
|
342
|
+
: options?.project_id;
|
|
343
|
+
const targetProjectId = explicitProjectId !== undefined
|
|
344
|
+
? explicitProjectId
|
|
345
|
+
: this._currentProject.id;
|
|
346
|
+
if (targetProjectId) {
|
|
347
|
+
conditions.push("o.project_id = ?");
|
|
348
|
+
params.push(targetProjectId);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const parsedLimit = typeof options?.limit === "number" && Number.isFinite(options.limit)
|
|
352
|
+
? options.limit
|
|
353
|
+
: 10;
|
|
354
|
+
const limit = Math.min(Math.max(1, parsedLimit), 500);
|
|
355
|
+
params.push(limit);
|
|
356
|
+
const sql = `
|
|
357
|
+
SELECT
|
|
358
|
+
o.id,
|
|
359
|
+
o.project_id,
|
|
360
|
+
o.category,
|
|
361
|
+
o.title,
|
|
362
|
+
o.content,
|
|
363
|
+
o.topic_key,
|
|
364
|
+
o.created_at,
|
|
365
|
+
o.updated_at,
|
|
366
|
+
bm25(observations_fts) AS rank
|
|
367
|
+
FROM observations_fts
|
|
368
|
+
JOIN observations o ON o.rowid = observations_fts.rowid
|
|
369
|
+
WHERE ${conditions.join(" AND ")}
|
|
370
|
+
ORDER BY rank ASC
|
|
371
|
+
LIMIT ?
|
|
372
|
+
`;
|
|
373
|
+
let rows;
|
|
374
|
+
try {
|
|
375
|
+
rows = this._db.prepare(sql).all(...params);
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
const code = typeof err === "object" && err !== null && "code" in err
|
|
379
|
+
? String(err.code)
|
|
380
|
+
: "";
|
|
381
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
382
|
+
const isFatal = code.startsWith("SQLITE_IOERR") ||
|
|
383
|
+
code.startsWith("SQLITE_CORRUPT") ||
|
|
384
|
+
code === "SQLITE_NOTADB" ||
|
|
385
|
+
code === "SQLITE_FULL" ||
|
|
386
|
+
code === "SQLITE_CANTOPEN" ||
|
|
387
|
+
/disk i\/o|corrupt|not a database|disk is full/i.test(message);
|
|
388
|
+
if (isFatal) {
|
|
389
|
+
console.error("Fatal SQLite error during search query:", err);
|
|
390
|
+
throw err;
|
|
391
|
+
}
|
|
392
|
+
console.warn("FTS search query execution failed:", message);
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
if (rows.length === 0) {
|
|
396
|
+
return [];
|
|
397
|
+
}
|
|
398
|
+
const obsIds = rows.map((r) => r.id);
|
|
399
|
+
const entityMap = this._fetchEntitiesForObservations(obsIds);
|
|
400
|
+
return rows.map((r) => ({
|
|
401
|
+
id: r.id,
|
|
402
|
+
project_id: r.project_id,
|
|
403
|
+
category: r.category,
|
|
404
|
+
title: r.title,
|
|
405
|
+
content: r.content,
|
|
406
|
+
topic_key: r.topic_key,
|
|
407
|
+
created_at: r.created_at,
|
|
408
|
+
updated_at: r.updated_at,
|
|
409
|
+
rank: r.rank,
|
|
410
|
+
entities: entityMap.get(r.id) ?? [],
|
|
411
|
+
}));
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Retrieves recent observations ordered by updated_at DESC, created_at DESC.
|
|
415
|
+
*/
|
|
416
|
+
getContext(options) {
|
|
417
|
+
const conditions = [];
|
|
418
|
+
const params = [];
|
|
419
|
+
if (options?.category) {
|
|
420
|
+
conditions.push("category = ?");
|
|
421
|
+
params.push(options.category);
|
|
422
|
+
}
|
|
423
|
+
const topicKey = options?.topicKey !== undefined ? options.topicKey : options?.topic_key;
|
|
424
|
+
if (topicKey !== undefined && topicKey !== null) {
|
|
425
|
+
conditions.push("topic_key = ?");
|
|
426
|
+
params.push(topicKey);
|
|
427
|
+
}
|
|
428
|
+
if (!options?.allProjects) {
|
|
429
|
+
const explicitProjectId = options?.projectId !== undefined
|
|
430
|
+
? options.projectId
|
|
431
|
+
: options?.project_id;
|
|
432
|
+
const targetProjectId = explicitProjectId !== undefined
|
|
433
|
+
? explicitProjectId
|
|
434
|
+
: this._currentProject.id;
|
|
435
|
+
if (targetProjectId) {
|
|
436
|
+
conditions.push("project_id = ?");
|
|
437
|
+
params.push(targetProjectId);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
441
|
+
const parsedLimit = typeof options?.limit === "number" && Number.isFinite(options.limit)
|
|
442
|
+
? options.limit
|
|
443
|
+
: 20;
|
|
444
|
+
const limit = Math.min(Math.max(1, parsedLimit), 500);
|
|
445
|
+
params.push(limit);
|
|
446
|
+
const sql = `
|
|
447
|
+
SELECT id, project_id, category, title, content, topic_key, created_at, updated_at
|
|
448
|
+
FROM observations
|
|
449
|
+
${whereClause}
|
|
450
|
+
ORDER BY updated_at DESC, created_at DESC
|
|
451
|
+
LIMIT ?
|
|
452
|
+
`;
|
|
453
|
+
const rows = this._db.prepare(sql).all(...params);
|
|
454
|
+
if (rows.length === 0) {
|
|
455
|
+
return [];
|
|
456
|
+
}
|
|
457
|
+
const obsIds = rows.map((r) => r.id);
|
|
458
|
+
const entityMap = this._fetchEntitiesForObservations(obsIds);
|
|
459
|
+
return rows.map((r) => ({
|
|
460
|
+
id: r.id,
|
|
461
|
+
project_id: r.project_id,
|
|
462
|
+
category: r.category,
|
|
463
|
+
title: r.title,
|
|
464
|
+
content: r.content,
|
|
465
|
+
topic_key: r.topic_key,
|
|
466
|
+
created_at: r.created_at,
|
|
467
|
+
updated_at: r.updated_at,
|
|
468
|
+
entities: entityMap.get(r.id) ?? [],
|
|
469
|
+
}));
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Transactionally links an entity to an observation.
|
|
473
|
+
*/
|
|
474
|
+
linkSymbol(inputOrObsId, symbolArg) {
|
|
475
|
+
let observationId;
|
|
476
|
+
let symbol;
|
|
477
|
+
if (typeof inputOrObsId === "string") {
|
|
478
|
+
observationId = inputOrObsId;
|
|
479
|
+
if (!symbolArg) {
|
|
480
|
+
throw new Error("Symbol must be provided to linkSymbol");
|
|
481
|
+
}
|
|
482
|
+
symbol = symbolArg;
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
observationId =
|
|
486
|
+
inputOrObsId.observationId ??
|
|
487
|
+
inputOrObsId.observation_id;
|
|
488
|
+
symbol = inputOrObsId.symbol;
|
|
489
|
+
}
|
|
490
|
+
if (!observationId) {
|
|
491
|
+
throw new Error("observationId is required for linkSymbol");
|
|
492
|
+
}
|
|
493
|
+
if (!symbol) {
|
|
494
|
+
throw new Error("symbol is required for linkSymbol");
|
|
495
|
+
}
|
|
496
|
+
const observation = this._db
|
|
497
|
+
.prepare(`SELECT id, project_id, category, title, content, topic_key, created_at, updated_at
|
|
498
|
+
FROM observations
|
|
499
|
+
WHERE id = ?`)
|
|
500
|
+
.get(observationId);
|
|
501
|
+
if (!observation) {
|
|
502
|
+
throw new Error(`Observation with id "${observationId}" not found`);
|
|
503
|
+
}
|
|
504
|
+
const projectId = observation.project_id;
|
|
505
|
+
const norm = normalizeSymbol(symbol);
|
|
506
|
+
return this._db.transaction(() => {
|
|
507
|
+
let entity = this._db
|
|
508
|
+
.prepare(`SELECT id, project_id, entity_type, identifier, file_path
|
|
509
|
+
FROM entities
|
|
510
|
+
WHERE project_id = ? AND identifier = ?`)
|
|
511
|
+
.get(projectId, norm.identifier);
|
|
512
|
+
if (!entity) {
|
|
513
|
+
const entityId = crypto.randomUUID();
|
|
514
|
+
this._db
|
|
515
|
+
.prepare(`INSERT INTO entities (id, project_id, entity_type, identifier, file_path)
|
|
516
|
+
VALUES (?, ?, ?, ?, ?)`)
|
|
517
|
+
.run(entityId, projectId, norm.entityType, norm.identifier, norm.filePath);
|
|
518
|
+
entity = {
|
|
519
|
+
id: entityId,
|
|
520
|
+
project_id: projectId,
|
|
521
|
+
entity_type: norm.entityType,
|
|
522
|
+
identifier: norm.identifier,
|
|
523
|
+
file_path: norm.filePath,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
this._db
|
|
527
|
+
.prepare(`INSERT OR IGNORE INTO observation_entities (observation_id, entity_id)
|
|
528
|
+
VALUES (?, ?)`)
|
|
529
|
+
.run(observationId, entity.id);
|
|
530
|
+
return {
|
|
531
|
+
observation,
|
|
532
|
+
entity,
|
|
533
|
+
};
|
|
534
|
+
})();
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Escapes LIKE pattern special characters (%, _, \)
|
|
538
|
+
*/
|
|
539
|
+
_escapeLikePattern(str) {
|
|
540
|
+
return str.replace(/[\\%_]/g, "\\$&");
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Inspects a symbol in Muninn memory, returning its entity metadata,
|
|
544
|
+
* incoming and outgoing dependencies, and linked observations.
|
|
545
|
+
*/
|
|
546
|
+
inspectSymbol(symbol, projectId) {
|
|
547
|
+
if (!symbol || typeof symbol !== "string" || !symbol.trim()) {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
const targetProjectId = projectId ?? this._currentProject.id;
|
|
551
|
+
const query = symbol.trim();
|
|
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
|
+
}
|
|
580
|
+
if (!entity) {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
const outgoingRows = this._db
|
|
584
|
+
.prepare(`SELECT e.id, e.project_id, e.entity_type, e.identifier, e.file_path, ed.relation_type
|
|
585
|
+
FROM entity_dependencies ed
|
|
586
|
+
JOIN entities e ON e.id = ed.target_entity_id
|
|
587
|
+
WHERE ed.source_entity_id = ?
|
|
588
|
+
ORDER BY e.identifier ASC`)
|
|
589
|
+
.all(entity.id);
|
|
590
|
+
const outgoing = outgoingRows.map((r) => ({
|
|
591
|
+
entity: {
|
|
592
|
+
id: r.id,
|
|
593
|
+
project_id: r.project_id,
|
|
594
|
+
entity_type: r.entity_type,
|
|
595
|
+
identifier: r.identifier,
|
|
596
|
+
file_path: r.file_path,
|
|
597
|
+
},
|
|
598
|
+
relationType: r.relation_type,
|
|
599
|
+
}));
|
|
600
|
+
// Fetch incoming dependencies
|
|
601
|
+
const incomingRows = this._db
|
|
602
|
+
.prepare(`SELECT e.id, e.project_id, e.entity_type, e.identifier, e.file_path, ed.relation_type
|
|
603
|
+
FROM entity_dependencies ed
|
|
604
|
+
JOIN entities e ON e.id = ed.source_entity_id
|
|
605
|
+
WHERE ed.target_entity_id = ?
|
|
606
|
+
ORDER BY e.identifier ASC`)
|
|
607
|
+
.all(entity.id);
|
|
608
|
+
const incoming = incomingRows.map((r) => ({
|
|
609
|
+
entity: {
|
|
610
|
+
id: r.id,
|
|
611
|
+
project_id: r.project_id,
|
|
612
|
+
entity_type: r.entity_type,
|
|
613
|
+
identifier: r.identifier,
|
|
614
|
+
file_path: r.file_path,
|
|
615
|
+
},
|
|
616
|
+
relationType: r.relation_type,
|
|
617
|
+
}));
|
|
618
|
+
// Fetch linked observations
|
|
619
|
+
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
|
|
621
|
+
FROM observations o
|
|
622
|
+
JOIN observation_entities oe ON oe.observation_id = o.id
|
|
623
|
+
WHERE oe.entity_id = ?
|
|
624
|
+
ORDER BY o.updated_at DESC, o.created_at DESC`)
|
|
625
|
+
.all(entity.id);
|
|
626
|
+
return {
|
|
627
|
+
entity,
|
|
628
|
+
dependencies: {
|
|
629
|
+
outgoing,
|
|
630
|
+
incoming,
|
|
631
|
+
},
|
|
632
|
+
observations,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Retrieves aggregate counts of projects, observations, entities, and links.
|
|
637
|
+
*/
|
|
638
|
+
getStats(projectId) {
|
|
639
|
+
if (projectId) {
|
|
640
|
+
const pCount = this._db
|
|
641
|
+
.prepare("SELECT COUNT(*) as count FROM projects WHERE id = ?")
|
|
642
|
+
.get(projectId)?.count ?? 0;
|
|
643
|
+
const oCount = this._db
|
|
644
|
+
.prepare("SELECT COUNT(*) as count FROM observations WHERE project_id = ?")
|
|
645
|
+
.get(projectId)?.count ?? 0;
|
|
646
|
+
const eCount = this._db
|
|
647
|
+
.prepare("SELECT COUNT(*) as count FROM entities WHERE project_id = ?")
|
|
648
|
+
.get(projectId)?.count ?? 0;
|
|
649
|
+
const lCount = this._db
|
|
650
|
+
.prepare(`SELECT COUNT(*) as count
|
|
651
|
+
FROM observation_entities oe
|
|
652
|
+
JOIN observations o ON o.id = oe.observation_id
|
|
653
|
+
WHERE o.project_id = ?`)
|
|
654
|
+
.get(projectId)?.count ?? 0;
|
|
655
|
+
return {
|
|
656
|
+
projects: pCount,
|
|
657
|
+
observations: oCount,
|
|
658
|
+
entities: eCount,
|
|
659
|
+
links: lCount,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
const pCount = this._db
|
|
663
|
+
.prepare("SELECT COUNT(*) as count FROM projects")
|
|
664
|
+
.get()?.count ?? 0;
|
|
665
|
+
const oCount = this._db
|
|
666
|
+
.prepare("SELECT COUNT(*) as count FROM observations")
|
|
667
|
+
.get()?.count ?? 0;
|
|
668
|
+
const eCount = this._db
|
|
669
|
+
.prepare("SELECT COUNT(*) as count FROM entities")
|
|
670
|
+
.get()?.count ?? 0;
|
|
671
|
+
const lCount = this._db
|
|
672
|
+
.prepare("SELECT COUNT(*) as count FROM observation_entities")
|
|
673
|
+
.get()?.count ?? 0;
|
|
674
|
+
return {
|
|
675
|
+
projects: pCount,
|
|
676
|
+
observations: oCount,
|
|
677
|
+
entities: eCount,
|
|
678
|
+
links: lCount,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
syncToDisk(targetPathOrOptions, optionsArg) {
|
|
682
|
+
let targetPath;
|
|
683
|
+
let options;
|
|
684
|
+
if (typeof targetPathOrOptions === "object" && targetPathOrOptions !== null) {
|
|
685
|
+
options = targetPathOrOptions;
|
|
686
|
+
targetPath = undefined;
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
targetPath = targetPathOrOptions;
|
|
690
|
+
options = optionsArg;
|
|
691
|
+
}
|
|
692
|
+
let resolvedPath;
|
|
693
|
+
if (targetPath) {
|
|
694
|
+
if (path.isAbsolute(targetPath)) {
|
|
695
|
+
resolvedPath = path.resolve(targetPath);
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
const root = this._currentProject.root_path || findGitRoot() || process.cwd();
|
|
699
|
+
resolvedPath = path.resolve(root, targetPath);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
const root = this._currentProject.root_path || findGitRoot() || process.cwd();
|
|
704
|
+
resolvedPath = path.join(root, ".huginn", "memories.jsonl");
|
|
705
|
+
}
|
|
706
|
+
this.assertMemoryPath(resolvedPath, "write");
|
|
707
|
+
const dir = path.dirname(resolvedPath);
|
|
708
|
+
if (!fs.existsSync(dir)) {
|
|
709
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
710
|
+
}
|
|
711
|
+
const targetProjectId = options?.projectId ?? this._currentProject.id;
|
|
712
|
+
let observations;
|
|
713
|
+
let entityRows;
|
|
714
|
+
if (options?.allProjects === true) {
|
|
715
|
+
observations = this._db
|
|
716
|
+
.prepare(`SELECT id, project_id, category, title, content, topic_key, created_at, updated_at
|
|
717
|
+
FROM observations
|
|
718
|
+
ORDER BY created_at ASC`)
|
|
719
|
+
.all();
|
|
720
|
+
entityRows = this._db
|
|
721
|
+
.prepare(`SELECT e.id, e.project_id, e.entity_type, e.identifier, e.file_path, oe.observation_id
|
|
722
|
+
FROM entities e
|
|
723
|
+
JOIN observation_entities oe ON oe.entity_id = e.id
|
|
724
|
+
ORDER BY e.identifier ASC`)
|
|
725
|
+
.all();
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
observations = this._db
|
|
729
|
+
.prepare(`SELECT id, project_id, category, title, content, topic_key, created_at, updated_at
|
|
730
|
+
FROM observations
|
|
731
|
+
WHERE project_id = ?
|
|
732
|
+
ORDER BY created_at ASC`)
|
|
733
|
+
.all(targetProjectId);
|
|
734
|
+
entityRows = this._db
|
|
735
|
+
.prepare(`SELECT e.id, e.project_id, e.entity_type, e.identifier, e.file_path, oe.observation_id
|
|
736
|
+
FROM entities e
|
|
737
|
+
JOIN observation_entities oe ON oe.entity_id = e.id
|
|
738
|
+
JOIN observations o ON o.id = oe.observation_id
|
|
739
|
+
WHERE o.project_id = ?
|
|
740
|
+
ORDER BY e.identifier ASC`)
|
|
741
|
+
.all(targetProjectId);
|
|
742
|
+
}
|
|
743
|
+
const entityMap = new Map();
|
|
744
|
+
for (const row of entityRows) {
|
|
745
|
+
let list = entityMap.get(row.observation_id);
|
|
746
|
+
if (!list) {
|
|
747
|
+
list = [];
|
|
748
|
+
entityMap.set(row.observation_id, list);
|
|
749
|
+
}
|
|
750
|
+
list.push({
|
|
751
|
+
id: row.id,
|
|
752
|
+
project_id: row.project_id,
|
|
753
|
+
entity_type: row.entity_type,
|
|
754
|
+
identifier: row.identifier,
|
|
755
|
+
file_path: row.file_path,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
const lines = observations.map((obs) => {
|
|
759
|
+
const record = {
|
|
760
|
+
...obs,
|
|
761
|
+
entities: entityMap.get(obs.id) ?? [],
|
|
762
|
+
};
|
|
763
|
+
return JSON.stringify(record);
|
|
764
|
+
});
|
|
765
|
+
const tmpPath = `${resolvedPath}.tmp`;
|
|
766
|
+
try {
|
|
767
|
+
fs.writeFileSync(tmpPath, lines.length > 0 ? lines.join("\n") + "\n" : "", { encoding: "utf-8", mode: 0o600 });
|
|
768
|
+
try {
|
|
769
|
+
fs.chmodSync(tmpPath, 0o600);
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
// ignore on platforms where chmod fails
|
|
773
|
+
}
|
|
774
|
+
fs.renameSync(tmpPath, resolvedPath);
|
|
775
|
+
}
|
|
776
|
+
catch (err) {
|
|
777
|
+
if (fs.existsSync(tmpPath)) {
|
|
778
|
+
try {
|
|
779
|
+
fs.unlinkSync(tmpPath);
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
// ignore cleanup error
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
throw err;
|
|
786
|
+
}
|
|
787
|
+
return {
|
|
788
|
+
path: resolvedPath,
|
|
789
|
+
count: observations.length,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Reads a JSON Lines (.jsonl) file and idempotently imports records into the database.
|
|
794
|
+
*/
|
|
795
|
+
importFromDisk(sourcePath) {
|
|
796
|
+
let resolvedPath;
|
|
797
|
+
if (sourcePath) {
|
|
798
|
+
if (path.isAbsolute(sourcePath)) {
|
|
799
|
+
resolvedPath = path.resolve(sourcePath);
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
const root = this._currentProject.root_path || findGitRoot() || process.cwd();
|
|
803
|
+
resolvedPath = path.resolve(root, sourcePath);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
const root = this._currentProject.root_path || findGitRoot() || process.cwd();
|
|
808
|
+
resolvedPath = path.join(root, ".huginn", "memories.jsonl");
|
|
809
|
+
}
|
|
810
|
+
this.assertMemoryPath(resolvedPath, "read");
|
|
811
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
812
|
+
return { imported: 0, skipped: 0 };
|
|
813
|
+
}
|
|
814
|
+
const content = fs.readFileSync(resolvedPath, "utf-8");
|
|
815
|
+
const rawLines = content
|
|
816
|
+
.split("\n")
|
|
817
|
+
.map((l) => l.trim())
|
|
818
|
+
.filter(Boolean);
|
|
819
|
+
let imported = 0;
|
|
820
|
+
let skipped = 0;
|
|
821
|
+
const importTx = this._db.transaction(() => {
|
|
822
|
+
const checkObsStmt = this._db.prepare("SELECT id FROM observations WHERE id = ?");
|
|
823
|
+
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 (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
826
|
+
const findEntityStmt = this._db.prepare(`SELECT id, project_id, entity_type, identifier, file_path
|
|
827
|
+
FROM entities
|
|
828
|
+
WHERE project_id = ? AND identifier = ?`);
|
|
829
|
+
const insertEntityStmt = this._db.prepare(`INSERT INTO entities (id, project_id, entity_type, identifier, file_path)
|
|
830
|
+
VALUES (?, ?, ?, ?, ?)`);
|
|
831
|
+
const linkStmt = this._db.prepare(`INSERT OR IGNORE INTO observation_entities (observation_id, entity_id)
|
|
832
|
+
VALUES (?, ?)`);
|
|
833
|
+
for (const line of rawLines) {
|
|
834
|
+
let record;
|
|
835
|
+
try {
|
|
836
|
+
record = JSON.parse(line);
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
if (!record ||
|
|
842
|
+
typeof record !== "object" ||
|
|
843
|
+
!(typeof record.id === "string" &&
|
|
844
|
+
typeof record.title === "string" &&
|
|
845
|
+
typeof record.content === "string") ||
|
|
846
|
+
!record.id.trim() ||
|
|
847
|
+
!record.title.trim()) {
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
const existing = checkObsStmt.get(record.id);
|
|
851
|
+
if (existing) {
|
|
852
|
+
skipped++;
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
let projectId = record.project_id ?? record.projectId;
|
|
856
|
+
if (!projectId || !checkProjStmt.get(projectId)) {
|
|
857
|
+
projectId = this._currentProject.id;
|
|
858
|
+
}
|
|
859
|
+
const category = VALID_CATEGORIES.includes(record.category)
|
|
860
|
+
? record.category
|
|
861
|
+
: "decision";
|
|
862
|
+
const topicKey = record.topic_key !== undefined
|
|
863
|
+
? record.topic_key
|
|
864
|
+
: (record.topicKey ?? null);
|
|
865
|
+
const createdAt = record.created_at ?? new Date().toISOString();
|
|
866
|
+
const updatedAt = record.updated_at ?? createdAt;
|
|
867
|
+
insertObsStmt.run(record.id, projectId, category, record.title, record.content, topicKey, createdAt, updatedAt);
|
|
868
|
+
const symbols = record.entities ?? record.symbols ?? [];
|
|
869
|
+
if (Array.isArray(symbols)) {
|
|
870
|
+
for (const sym of symbols) {
|
|
871
|
+
const norm = normalizeSymbol(sym);
|
|
872
|
+
let entity = findEntityStmt.get(projectId, norm.identifier);
|
|
873
|
+
let entityId;
|
|
874
|
+
if (entity) {
|
|
875
|
+
entityId = entity.id;
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
entityId = sym.id ?? crypto.randomUUID();
|
|
879
|
+
insertEntityStmt.run(entityId, projectId, norm.entityType, norm.identifier, norm.filePath);
|
|
880
|
+
}
|
|
881
|
+
linkStmt.run(record.id, entityId);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
imported++;
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
importTx();
|
|
888
|
+
return { imported, skipped };
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
//# sourceMappingURL=memory-service.js.map
|