clitrigger 0.1.13 → 0.1.15
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 +15 -4
- package/README_KR.md +15 -4
- package/bin/clitrigger.js +41 -4
- package/dist/client/assets/index-1hay-n37.js +686 -0
- package/dist/client/assets/index-CRSNebDI.css +32 -0
- package/dist/client/index.html +2 -2
- package/dist/server/db/app-settings.d.ts +3 -0
- package/dist/server/db/app-settings.d.ts.map +1 -0
- package/dist/server/db/app-settings.js +16 -0
- package/dist/server/db/app-settings.js.map +1 -0
- package/dist/server/db/queries.d.ts +27 -3
- package/dist/server/db/queries.d.ts.map +1 -1
- package/dist/server/db/queries.js +25 -5
- package/dist/server/db/queries.js.map +1 -1
- package/dist/server/db/schema.d.ts.map +1 -1
- package/dist/server/db/schema.js +23 -0
- package/dist/server/db/schema.js.map +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +4 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/discussions.d.ts.map +1 -1
- package/dist/server/routes/discussions.js +1 -1
- package/dist/server/routes/discussions.js.map +1 -1
- package/dist/server/routes/memory.d.ts.map +1 -1
- package/dist/server/routes/memory.js +336 -2
- package/dist/server/routes/memory.js.map +1 -1
- package/dist/server/routes/sessions.d.ts.map +1 -1
- package/dist/server/routes/sessions.js +86 -3
- package/dist/server/routes/sessions.js.map +1 -1
- package/dist/server/routes/todos.js +2 -2
- package/dist/server/routes/todos.js.map +1 -1
- package/dist/server/routes/tunnel.d.ts.map +1 -1
- package/dist/server/routes/tunnel.js +37 -2
- package/dist/server/routes/tunnel.js.map +1 -1
- package/dist/server/services/discussion-orchestrator.d.ts.map +1 -1
- package/dist/server/services/discussion-orchestrator.js +4 -5
- package/dist/server/services/discussion-orchestrator.js.map +1 -1
- package/dist/server/services/memory-ingest.d.ts +25 -1
- package/dist/server/services/memory-ingest.d.ts.map +1 -1
- package/dist/server/services/memory-ingest.js +453 -72
- package/dist/server/services/memory-ingest.js.map +1 -1
- package/dist/server/services/memory-inject-hook.d.ts +3 -1
- package/dist/server/services/memory-inject-hook.d.ts.map +1 -1
- package/dist/server/services/memory-inject-hook.js +23 -3
- package/dist/server/services/memory-inject-hook.js.map +1 -1
- package/dist/server/services/memory-injector.d.ts +1 -1
- package/dist/server/services/memory-injector.d.ts.map +1 -1
- package/dist/server/services/memory-injector.js +18 -2
- package/dist/server/services/memory-injector.js.map +1 -1
- package/dist/server/services/memory-retriever.d.ts +16 -0
- package/dist/server/services/memory-retriever.d.ts.map +1 -0
- package/dist/server/services/memory-retriever.js +170 -0
- package/dist/server/services/memory-retriever.js.map +1 -0
- package/dist/server/services/orchestrator.d.ts.map +1 -1
- package/dist/server/services/orchestrator.js +4 -5
- package/dist/server/services/orchestrator.js.map +1 -1
- package/dist/server/services/session-manager.d.ts +21 -0
- package/dist/server/services/session-manager.d.ts.map +1 -1
- package/dist/server/services/session-manager.js +91 -2
- package/dist/server/services/session-manager.js.map +1 -1
- package/dist/server/services/tunnel-manager.d.ts +3 -1
- package/dist/server/services/tunnel-manager.d.ts.map +1 -1
- package/dist/server/services/tunnel-manager.js +18 -8
- package/dist/server/services/tunnel-manager.js.map +1 -1
- package/dist/server/services/wiki-exporter.d.ts +32 -0
- package/dist/server/services/wiki-exporter.d.ts.map +1 -0
- package/dist/server/services/wiki-exporter.js +430 -0
- package/dist/server/services/wiki-exporter.js.map +1 -0
- package/dist/server/services/wiki-index.d.ts +10 -0
- package/dist/server/services/wiki-index.d.ts.map +1 -0
- package/dist/server/services/wiki-index.js +100 -0
- package/dist/server/services/wiki-index.js.map +1 -0
- package/dist/server/websocket/events.d.ts +23 -0
- package/dist/server/websocket/events.d.ts.map +1 -1
- package/dist/server/websocket/index.d.ts.map +1 -1
- package/dist/server/websocket/index.js +8 -7
- package/dist/server/websocket/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/client/assets/index-BWNQgE_E.js +0 -649
- package/dist/client/assets/index-Ck_mmrzu.css +0 -32
|
@@ -2,6 +2,9 @@ import { spawn } from 'child_process';
|
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import * as queries from '../db/queries.js';
|
|
5
|
+
import { broadcaster } from '../websocket/broadcaster.js';
|
|
6
|
+
import { debugLogger } from './debug-logger.js';
|
|
7
|
+
import { dispatchWikiExport } from './wiki-exporter.js';
|
|
5
8
|
const RAW_DIR_NAME = '.clitrigger';
|
|
6
9
|
const RAW_SUBDIR = 'raw';
|
|
7
10
|
const VALID_SOURCE_TYPES = new Set(['todo', 'discussion', 'manual']);
|
|
@@ -46,7 +49,9 @@ function writeRawSnapshot(project, sourceType, sourceId, fullText, titleHint) {
|
|
|
46
49
|
try {
|
|
47
50
|
const baseDir = path.join(project.path, RAW_DIR_NAME, RAW_SUBDIR, sourceType);
|
|
48
51
|
fs.mkdirSync(baseDir, { recursive: true });
|
|
49
|
-
|
|
52
|
+
// Only ignore the raw snapshot subdirectory — leave wiki/ trackable in git
|
|
53
|
+
// so users can opt into committing the auto-exported markdown wiki.
|
|
54
|
+
ensureGitignore(project.path, `${RAW_DIR_NAME}/${RAW_SUBDIR}/`);
|
|
50
55
|
const ts = timestampStr();
|
|
51
56
|
const idPart = sourceId ? `-${sourceId.slice(0, 8)}` : '';
|
|
52
57
|
const slug = slugify(titleHint || sourceType);
|
|
@@ -67,6 +72,7 @@ function writeRawSnapshot(project, sourceType, sourceId, fullText, titleHint) {
|
|
|
67
72
|
return null;
|
|
68
73
|
}
|
|
69
74
|
}
|
|
75
|
+
const WIKI_INDEX_TAG = '__wiki_index__';
|
|
70
76
|
const DEFAULT_WIKI_SCHEMA = `# Wiki Schema
|
|
71
77
|
|
|
72
78
|
## Entity Types
|
|
@@ -99,21 +105,22 @@ function safeParseIngestOp(raw) {
|
|
|
99
105
|
catch {
|
|
100
106
|
const m = cleaned.match(/\{[\s\S]*\}/);
|
|
101
107
|
if (!m)
|
|
102
|
-
return empty;
|
|
108
|
+
return { op: empty, parseFailed: true };
|
|
103
109
|
try {
|
|
104
110
|
parsed = JSON.parse(m[0]);
|
|
105
111
|
}
|
|
106
112
|
catch {
|
|
107
|
-
return empty;
|
|
113
|
+
return { op: empty, parseFailed: true };
|
|
108
114
|
}
|
|
109
115
|
}
|
|
110
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
111
|
-
return empty;
|
|
116
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
117
|
+
return { op: empty, parseFailed: true };
|
|
118
|
+
}
|
|
112
119
|
const p = parsed;
|
|
113
120
|
const create = Array.isArray(p.create) ? p.create : [];
|
|
114
121
|
const update = Array.isArray(p.update) ? p.update : [];
|
|
115
122
|
const edges = Array.isArray(p.edges) ? p.edges : [];
|
|
116
|
-
return { create, update, edges };
|
|
123
|
+
return { op: { create, update, edges }, parseFailed: false };
|
|
117
124
|
}
|
|
118
125
|
function safeParseLintIssues(raw) {
|
|
119
126
|
const cleaned = stripCodeFences(raw);
|
|
@@ -154,7 +161,35 @@ function buildInvocation(cliTool) {
|
|
|
154
161
|
default: return { command: 'claude', args: ['--print'] };
|
|
155
162
|
}
|
|
156
163
|
}
|
|
157
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Open a debug session for a memory-ingest or lint run when the project has debug_logging enabled.
|
|
166
|
+
* Reuses the existing `.debug-logs/` directory and rotation policy.
|
|
167
|
+
* The synthetic todoId encodes intent (`mem-{kind}-{sourceType}-{sourceId|ts}`) so logs sort/filter
|
|
168
|
+
* cleanly alongside real todo logs.
|
|
169
|
+
*/
|
|
170
|
+
function startDebugSession(project, cliTool, sourceType, sourceId, kind) {
|
|
171
|
+
if (!project.debug_logging || !project.path)
|
|
172
|
+
return undefined;
|
|
173
|
+
const { command, args } = buildInvocation(cliTool);
|
|
174
|
+
const idPart = sourceId ? sourceId.slice(0, 8) : Date.now().toString(36);
|
|
175
|
+
const stypePart = sourceType ?? 'manual';
|
|
176
|
+
const todoId = `mem-${kind}-${stypePart}-${idPart}`;
|
|
177
|
+
try {
|
|
178
|
+
return debugLogger.startSession({
|
|
179
|
+
todoId,
|
|
180
|
+
projectPath: project.path,
|
|
181
|
+
cliTool,
|
|
182
|
+
command,
|
|
183
|
+
args,
|
|
184
|
+
workDir: process.env.HOME || process.env.USERPROFILE || '.',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
console.warn('[memory-ingest] failed to open debug session:', err);
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
export function runHeadless(cliTool, prompt, timeoutMs = 180_000, debugSession) {
|
|
158
193
|
return new Promise((resolve, reject) => {
|
|
159
194
|
const { command, args } = buildInvocation(cliTool);
|
|
160
195
|
const isWin = process.platform === 'win32';
|
|
@@ -168,6 +203,11 @@ function runHeadless(cliTool, prompt, timeoutMs = 180_000) {
|
|
|
168
203
|
env: { ...process.env },
|
|
169
204
|
cwd: process.env.HOME || process.env.USERPROFILE || '.',
|
|
170
205
|
});
|
|
206
|
+
if (debugSession) {
|
|
207
|
+
// tee returns a passthrough we don't need; the side-effect is appending to the debug log file.
|
|
208
|
+
debugSession.teeStdout(proc.stdout);
|
|
209
|
+
debugSession.teeStderr(proc.stderr);
|
|
210
|
+
}
|
|
171
211
|
const timer = setTimeout(() => {
|
|
172
212
|
if (settled)
|
|
173
213
|
return;
|
|
@@ -176,6 +216,10 @@ function runHeadless(cliTool, prompt, timeoutMs = 180_000) {
|
|
|
176
216
|
proc.kill();
|
|
177
217
|
}
|
|
178
218
|
catch { /* ignore */ }
|
|
219
|
+
try {
|
|
220
|
+
debugSession?.finalize(-1);
|
|
221
|
+
}
|
|
222
|
+
catch { /* ignore */ }
|
|
179
223
|
reject(new Error('Memory ingest timed out'));
|
|
180
224
|
}, timeoutMs);
|
|
181
225
|
proc.stdout.on('data', (c) => { stdout += c.toString('utf8'); });
|
|
@@ -185,6 +229,10 @@ function runHeadless(cliTool, prompt, timeoutMs = 180_000) {
|
|
|
185
229
|
return;
|
|
186
230
|
settled = true;
|
|
187
231
|
clearTimeout(timer);
|
|
232
|
+
try {
|
|
233
|
+
debugSession?.finalize(-1);
|
|
234
|
+
}
|
|
235
|
+
catch { /* ignore */ }
|
|
188
236
|
reject(err);
|
|
189
237
|
});
|
|
190
238
|
proc.on('close', (code) => {
|
|
@@ -192,6 +240,10 @@ function runHeadless(cliTool, prompt, timeoutMs = 180_000) {
|
|
|
192
240
|
return;
|
|
193
241
|
settled = true;
|
|
194
242
|
clearTimeout(timer);
|
|
243
|
+
try {
|
|
244
|
+
debugSession?.finalize(code ?? 0);
|
|
245
|
+
}
|
|
246
|
+
catch { /* ignore */ }
|
|
195
247
|
if (code === 0)
|
|
196
248
|
resolve(stdout);
|
|
197
249
|
else
|
|
@@ -200,12 +252,20 @@ function runHeadless(cliTool, prompt, timeoutMs = 180_000) {
|
|
|
200
252
|
try {
|
|
201
253
|
proc.stdin.write(prompt + '\n');
|
|
202
254
|
proc.stdin.end();
|
|
255
|
+
try {
|
|
256
|
+
debugSession?.writeStdin(prompt);
|
|
257
|
+
}
|
|
258
|
+
catch { /* ignore */ }
|
|
203
259
|
}
|
|
204
260
|
catch (err) {
|
|
205
261
|
if (settled)
|
|
206
262
|
return;
|
|
207
263
|
settled = true;
|
|
208
264
|
clearTimeout(timer);
|
|
265
|
+
try {
|
|
266
|
+
debugSession?.finalize(-1);
|
|
267
|
+
}
|
|
268
|
+
catch { /* ignore */ }
|
|
209
269
|
try {
|
|
210
270
|
proc.kill();
|
|
211
271
|
}
|
|
@@ -214,7 +274,7 @@ function runHeadless(cliTool, prompt, timeoutMs = 180_000) {
|
|
|
214
274
|
}
|
|
215
275
|
});
|
|
216
276
|
}
|
|
217
|
-
function resolveCliTool(value) {
|
|
277
|
+
export function resolveCliTool(value) {
|
|
218
278
|
if (value === 'claude' || value === 'gemini' || value === 'codex')
|
|
219
279
|
return value;
|
|
220
280
|
return 'claude';
|
|
@@ -235,11 +295,28 @@ function getOrCreateSchemaNode(projectId) {
|
|
|
235
295
|
queries.createMemoryNode(projectId, 'Wiki Schema', DEFAULT_WIKI_SCHEMA, JSON.stringify([WIKI_SCHEMA_TAG]), 1);
|
|
236
296
|
return DEFAULT_WIKI_SCHEMA;
|
|
237
297
|
}
|
|
298
|
+
const NODE_SUMMARY_FULL_CHAR_BUDGET = 6000;
|
|
299
|
+
const NODE_SUMMARY_TITLES_FALLBACK_BUDGET = 1500;
|
|
300
|
+
const NODE_SUMMARY_PINNED_BODY_PREVIEW = 300;
|
|
301
|
+
function formatFullNodeLine(n) {
|
|
302
|
+
try {
|
|
303
|
+
const tags = JSON.parse(n.tags ?? '[]');
|
|
304
|
+
const tagStr = tags.filter(t => t !== WIKI_SCHEMA_TAG && t !== WIKI_INDEX_TAG).join(', ');
|
|
305
|
+
const pinnedStr = n.pinned ? ' [pinned]' : '';
|
|
306
|
+
const bodyPreview = n.pinned ? `\n ${(n.body || '').slice(0, NODE_SUMMARY_PINNED_BODY_PREVIEW)}` : '';
|
|
307
|
+
return `- id="${n.id}" title="${n.title}"${tagStr ? ` tags=[${tagStr}]` : ''}${pinnedStr}${bodyPreview}`;
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return `- id="${n.id}" title="${n.title}"`;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
238
313
|
function buildNodeSummary(nodes) {
|
|
239
314
|
const visible = nodes.filter(n => {
|
|
240
315
|
try {
|
|
241
316
|
const tags = JSON.parse(n.tags ?? '[]');
|
|
242
|
-
|
|
317
|
+
if (!Array.isArray(tags))
|
|
318
|
+
return true;
|
|
319
|
+
return !tags.includes(WIKI_SCHEMA_TAG) && !tags.includes(WIKI_INDEX_TAG);
|
|
243
320
|
}
|
|
244
321
|
catch {
|
|
245
322
|
return true;
|
|
@@ -247,20 +324,52 @@ function buildNodeSummary(nodes) {
|
|
|
247
324
|
});
|
|
248
325
|
if (visible.length === 0)
|
|
249
326
|
return '(no existing pages)';
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
327
|
+
// Pinned first (user-curated importance), then most-recently-updated.
|
|
328
|
+
const sorted = [...visible].sort((a, b) => {
|
|
329
|
+
const pinDiff = (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0);
|
|
330
|
+
if (pinDiff !== 0)
|
|
331
|
+
return pinDiff;
|
|
332
|
+
return (b.updated_at ?? '').localeCompare(a.updated_at ?? '');
|
|
333
|
+
});
|
|
334
|
+
const lines = [];
|
|
335
|
+
let used = 0;
|
|
336
|
+
let inlined = 0;
|
|
337
|
+
for (const n of sorted) {
|
|
338
|
+
const line = formatFullNodeLine(n);
|
|
339
|
+
// Always include pinned nodes regardless of budget; gate non-pinned by char budget.
|
|
340
|
+
if (!n.pinned && used + line.length > NODE_SUMMARY_FULL_CHAR_BUDGET)
|
|
341
|
+
break;
|
|
342
|
+
lines.push(line);
|
|
343
|
+
used += line.length + 1;
|
|
344
|
+
inlined++;
|
|
345
|
+
}
|
|
346
|
+
const remaining = sorted.slice(inlined);
|
|
347
|
+
if (remaining.length > 0) {
|
|
348
|
+
// Titles-only tail so the LLM can still match by exact title (avoid duplicate creates),
|
|
349
|
+
// even though older nodes' IDs aren't shown so they cannot be updated in this batch.
|
|
350
|
+
const titles = [];
|
|
351
|
+
let titleBudget = NODE_SUMMARY_TITLES_FALLBACK_BUDGET;
|
|
352
|
+
for (const n of remaining) {
|
|
353
|
+
const piece = `"${n.title}"`;
|
|
354
|
+
if (titleBudget - piece.length - 2 < 0)
|
|
355
|
+
break;
|
|
356
|
+
titles.push(piece);
|
|
357
|
+
titleBudget -= piece.length + 2;
|
|
257
358
|
}
|
|
258
|
-
|
|
259
|
-
|
|
359
|
+
const hiddenCount = remaining.length - titles.length;
|
|
360
|
+
if (titles.length > 0) {
|
|
361
|
+
const tail = hiddenCount > 0 ? ` … and ${hiddenCount} more` : '';
|
|
362
|
+
lines.push('');
|
|
363
|
+
lines.push(`(other existing titles — match exactly to avoid duplicates; IDs not shown so these are not updatable in this batch: ${titles.join(', ')}${tail})`);
|
|
260
364
|
}
|
|
261
|
-
|
|
365
|
+
else {
|
|
366
|
+
lines.push('');
|
|
367
|
+
lines.push(`(${remaining.length} older entries omitted to fit context)`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return lines.join('\n');
|
|
262
371
|
}
|
|
263
|
-
const INGEST_PROMPT_HEADER = `You are maintaining a project knowledge wiki using the LLM Wiki pattern.
|
|
372
|
+
const INGEST_PROMPT_HEADER = `You are maintaining a project knowledge wiki using the LLM Wiki pattern.{CHUNK_PREAMBLE}
|
|
264
373
|
|
|
265
374
|
## Wiki Schema
|
|
266
375
|
{SCHEMA}
|
|
@@ -268,7 +377,7 @@ const INGEST_PROMPT_HEADER = `You are maintaining a project knowledge wiki using
|
|
|
268
377
|
## Existing Wiki Pages
|
|
269
378
|
{NODES}
|
|
270
379
|
|
|
271
|
-
## New Source Material
|
|
380
|
+
## New Source Material{CHUNK_NOTE}
|
|
272
381
|
{SOURCE}
|
|
273
382
|
|
|
274
383
|
---
|
|
@@ -314,53 +423,90 @@ Issue types:
|
|
|
314
423
|
Rules:
|
|
315
424
|
- Maximum 10 issues. Only flag real problems.
|
|
316
425
|
- Return [] if the wiki looks healthy.`;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
426
|
+
const CHUNK_CHARS = 7000;
|
|
427
|
+
const CHUNK_THRESHOLD = 8000;
|
|
428
|
+
const MAX_CHUNKS = 4;
|
|
429
|
+
const VALID_RELATIONS = new Set(['related', 'precedes', 'example_of', 'counter_example', 'refines']);
|
|
430
|
+
/**
|
|
431
|
+
* Split a long source into chunks at paragraph boundaries. Hard-splits any single paragraph
|
|
432
|
+
* that exceeds maxChars. Caps total chunks at maxChunks (later content is dropped).
|
|
433
|
+
* Returns a single-element array when text fits without splitting.
|
|
434
|
+
*/
|
|
435
|
+
function chunkSourceText(text, maxChars, maxChunks) {
|
|
436
|
+
if (text.length <= CHUNK_THRESHOLD)
|
|
437
|
+
return [text];
|
|
438
|
+
const paragraphs = text.split(/\n\s*\n/);
|
|
439
|
+
const chunks = [];
|
|
440
|
+
let cur = '';
|
|
441
|
+
for (const para of paragraphs) {
|
|
442
|
+
const p = para.trim();
|
|
443
|
+
if (!p)
|
|
444
|
+
continue;
|
|
445
|
+
if (chunks.length >= maxChunks)
|
|
446
|
+
break;
|
|
447
|
+
if (cur.length + p.length + 2 <= maxChars) {
|
|
448
|
+
cur = cur ? `${cur}\n\n${p}` : p;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
if (cur) {
|
|
452
|
+
chunks.push(cur);
|
|
453
|
+
cur = '';
|
|
454
|
+
if (chunks.length >= maxChunks)
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
if (p.length > maxChars) {
|
|
458
|
+
for (let i = 0; i < p.length && chunks.length < maxChunks; i += maxChars) {
|
|
459
|
+
chunks.push(p.slice(i, i + maxChars));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
cur = p;
|
|
464
|
+
}
|
|
327
465
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const op = safeParseIngestOp(raw);
|
|
337
|
-
const titleToId = new Map(nodes.map(n => [n.title.toLowerCase(), n.id]));
|
|
338
|
-
const createdIds = [];
|
|
339
|
-
let edgesAdded = 0;
|
|
340
|
-
const VALID_RELATIONS = new Set(['related', 'precedes', 'example_of', 'counter_example', 'refines']);
|
|
466
|
+
if (cur && chunks.length < maxChunks)
|
|
467
|
+
chunks.push(cur);
|
|
468
|
+
return chunks.slice(0, maxChunks);
|
|
469
|
+
}
|
|
470
|
+
function applyIngestOp(ctx, op, existingNodes) {
|
|
471
|
+
ctx.skipped.proposedCreate += op.create.length;
|
|
472
|
+
ctx.skipped.proposedUpdate += op.update.length;
|
|
473
|
+
ctx.skipped.proposedEdges += op.edges.length;
|
|
341
474
|
for (const c of op.create.slice(0, 10)) {
|
|
342
|
-
if (!c.title?.trim())
|
|
475
|
+
if (!c.title?.trim()) {
|
|
476
|
+
ctx.skipped.emptyTitle++;
|
|
343
477
|
continue;
|
|
478
|
+
}
|
|
344
479
|
const title = String(c.title).trim().slice(0, 120);
|
|
345
|
-
if (titleToId.has(title.toLowerCase()))
|
|
480
|
+
if (ctx.titleToId.has(title.toLowerCase())) {
|
|
481
|
+
ctx.skipped.duplicateTitle++;
|
|
346
482
|
continue;
|
|
483
|
+
}
|
|
347
484
|
try {
|
|
348
485
|
const tags = Array.isArray(c.tags) ? JSON.stringify(c.tags.map(String).filter(Boolean)) : null;
|
|
349
|
-
const node = queries.createMemoryNode(projectId, title, typeof c.body === 'string' ? c.body : '', tags, 0, sourceType, sourceId, rawPath);
|
|
350
|
-
titleToId.set(title.toLowerCase(), node.id);
|
|
351
|
-
createdIds.push(node.id);
|
|
486
|
+
const node = queries.createMemoryNode(ctx.projectId, title, typeof c.body === 'string' ? c.body : '', tags, 0, ctx.sourceType, ctx.sourceId, ctx.rawPath);
|
|
487
|
+
ctx.titleToId.set(title.toLowerCase(), node.id);
|
|
488
|
+
ctx.createdIds.push(node.id);
|
|
352
489
|
}
|
|
353
|
-
catch {
|
|
354
|
-
|
|
490
|
+
catch (err) {
|
|
491
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
492
|
+
if (msg.includes('UNIQUE'))
|
|
493
|
+
ctx.skipped.uniqueConflict++;
|
|
494
|
+
else {
|
|
495
|
+
ctx.skipped.uniqueConflict++;
|
|
496
|
+
console.warn('[memory-ingest] createMemoryNode failed:', msg);
|
|
497
|
+
}
|
|
355
498
|
}
|
|
356
499
|
}
|
|
357
|
-
let updatedCount = 0;
|
|
358
500
|
for (const u of op.update.slice(0, 10)) {
|
|
359
|
-
if (!u.id)
|
|
501
|
+
if (!u.id) {
|
|
502
|
+
ctx.skipped.invalidUpdateId++;
|
|
360
503
|
continue;
|
|
361
|
-
|
|
362
|
-
|
|
504
|
+
}
|
|
505
|
+
const existing = existingNodes.find(n => n.id === u.id);
|
|
506
|
+
if (!existing) {
|
|
507
|
+
ctx.skipped.invalidUpdateId++;
|
|
363
508
|
continue;
|
|
509
|
+
}
|
|
364
510
|
const upd = {};
|
|
365
511
|
if (typeof u.body === 'string')
|
|
366
512
|
upd.body = u.body;
|
|
@@ -368,30 +514,189 @@ export async function ingestSource(projectId, sourceText, sourceType, sourceId,
|
|
|
368
514
|
upd.tags = JSON.stringify(u.tags.map(String).filter(Boolean));
|
|
369
515
|
if (Object.keys(upd).length > 0) {
|
|
370
516
|
queries.updateMemoryNode(u.id, upd);
|
|
371
|
-
|
|
517
|
+
ctx.updatedIds.add(u.id);
|
|
372
518
|
}
|
|
373
519
|
}
|
|
374
520
|
for (const e of op.edges.slice(0, 20)) {
|
|
375
|
-
const fromId = titleToId.get(String(e.from_title || '').toLowerCase());
|
|
376
|
-
const toId = titleToId.get(String(e.to_title || '').toLowerCase());
|
|
377
|
-
if (!fromId || !toId
|
|
521
|
+
const fromId = ctx.titleToId.get(String(e.from_title || '').toLowerCase());
|
|
522
|
+
const toId = ctx.titleToId.get(String(e.to_title || '').toLowerCase());
|
|
523
|
+
if (!fromId || !toId) {
|
|
524
|
+
ctx.skipped.invalidEdgeRef++;
|
|
378
525
|
continue;
|
|
526
|
+
}
|
|
527
|
+
if (fromId === toId) {
|
|
528
|
+
ctx.skipped.selfEdge++;
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
379
531
|
const rt = VALID_RELATIONS.has(e.relation_type ?? '') ? e.relation_type : 'related';
|
|
380
532
|
try {
|
|
381
|
-
queries.createMemoryEdge(projectId, fromId, toId, rt, e.label ?? null);
|
|
382
|
-
edgesAdded++;
|
|
533
|
+
queries.createMemoryEdge(ctx.projectId, fromId, toId, rt, e.label ?? null);
|
|
534
|
+
ctx.edgesAdded++;
|
|
383
535
|
}
|
|
384
|
-
catch {
|
|
385
|
-
|
|
536
|
+
catch (err) {
|
|
537
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
538
|
+
if (msg.includes('UNIQUE'))
|
|
539
|
+
ctx.skipped.edgeUniqueConflict++;
|
|
540
|
+
else {
|
|
541
|
+
ctx.skipped.edgeUniqueConflict++;
|
|
542
|
+
console.warn('[memory-ingest] createMemoryEdge failed:', msg);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
export async function ingestSource(projectId, sourceText, sourceType, sourceId, titleHint, locale) {
|
|
548
|
+
const project = queries.getProjectById(projectId);
|
|
549
|
+
if (!project)
|
|
550
|
+
throw new Error('Project not found');
|
|
551
|
+
const cliTool = resolveCliTool(project.cli_tool);
|
|
552
|
+
// Step 1: persist raw snapshot (immutable). Failure is non-fatal.
|
|
553
|
+
let rawPath = null;
|
|
554
|
+
if (sourceType && VALID_SOURCE_TYPES.has(sourceType)) {
|
|
555
|
+
const hint = (titleHint && titleHint.trim()) || sourceText.split('\n').find(l => l.trim())?.trim().slice(0, 60) || sourceType;
|
|
556
|
+
rawPath = writeRawSnapshot(project, sourceType, sourceId, sourceText, hint);
|
|
557
|
+
}
|
|
558
|
+
const schema = getOrCreateSchemaNode(projectId);
|
|
559
|
+
const langRule = locale === 'en'
|
|
560
|
+
? '- Write all titles, body text, tags, and edge labels in English.'
|
|
561
|
+
: '- Write all titles, body text, tags, and edge labels in Korean (한국어).';
|
|
562
|
+
const chunks = chunkSourceText(sourceText, CHUNK_CHARS, MAX_CHUNKS);
|
|
563
|
+
const total = chunks.length;
|
|
564
|
+
const ctx = {
|
|
565
|
+
projectId,
|
|
566
|
+
sourceType,
|
|
567
|
+
sourceId,
|
|
568
|
+
rawPath,
|
|
569
|
+
titleToId: new Map(),
|
|
570
|
+
createdIds: [],
|
|
571
|
+
updatedIds: new Set(),
|
|
572
|
+
edgesAdded: 0,
|
|
573
|
+
skipped: {
|
|
574
|
+
parseFailed: false,
|
|
575
|
+
proposedCreate: 0, proposedUpdate: 0, proposedEdges: 0,
|
|
576
|
+
duplicateTitle: 0, uniqueConflict: 0, emptyTitle: 0,
|
|
577
|
+
invalidUpdateId: 0, invalidEdgeRef: 0, selfEdge: 0, edgeUniqueConflict: 0,
|
|
578
|
+
},
|
|
579
|
+
lastRaw: '',
|
|
580
|
+
};
|
|
581
|
+
for (let i = 0; i < total; i++) {
|
|
582
|
+
// Re-fetch nodes between chunks so dedup sees nodes added by earlier chunks.
|
|
583
|
+
const nodes = queries.getMemoryNodesByProjectId(projectId);
|
|
584
|
+
ctx.titleToId = new Map(nodes.map(n => [n.title.toLowerCase(), n.id]));
|
|
585
|
+
const nodeSummary = buildNodeSummary(nodes);
|
|
586
|
+
const chunkPreamble = total > 1
|
|
587
|
+
? `\n\nThis source has been split into ${total} parts due to length. You are processing part ${i + 1}. Earlier parts may have added new pages — see "Existing Wiki Pages" for the current state. Avoid creating duplicates of pages already added in earlier parts.`
|
|
588
|
+
: '';
|
|
589
|
+
const chunkNote = total > 1 ? ` (part ${i + 1} of ${total})` : '';
|
|
590
|
+
const prompt = INGEST_PROMPT_HEADER
|
|
591
|
+
.replace('Rules:\n', `Rules:\n${langRule}\n`)
|
|
592
|
+
.replace('{CHUNK_PREAMBLE}', chunkPreamble)
|
|
593
|
+
.replace('{CHUNK_NOTE}', chunkNote)
|
|
594
|
+
.replace('{SCHEMA}', schema)
|
|
595
|
+
.replace('{NODES}', nodeSummary)
|
|
596
|
+
.replace('{SOURCE}', chunks[i]);
|
|
597
|
+
const debugSession = startDebugSession(project, cliTool, sourceType, sourceId, total > 1 ? `ingest-${i + 1}of${total}` : 'ingest');
|
|
598
|
+
const raw = await runHeadless(cliTool, prompt, 180_000, debugSession);
|
|
599
|
+
ctx.lastRaw = raw;
|
|
600
|
+
const { op, parseFailed } = safeParseIngestOp(raw);
|
|
601
|
+
if (parseFailed)
|
|
602
|
+
ctx.skipped.parseFailed = true;
|
|
603
|
+
applyIngestOp(ctx, op, nodes);
|
|
604
|
+
}
|
|
605
|
+
const created = ctx.createdIds.length;
|
|
606
|
+
const updated = ctx.updatedIds.size;
|
|
607
|
+
const edgesAdded = ctx.edgesAdded;
|
|
608
|
+
if (ctx.skipped.parseFailed || (created === 0 && updated === 0 && edgesAdded === 0)) {
|
|
609
|
+
console.warn(`[memory-ingest] no-op result project=${projectId} cli=${cliTool} chunks=${total} ` +
|
|
610
|
+
`parseFailed=${ctx.skipped.parseFailed} ` +
|
|
611
|
+
`proposed(c/u/e)=${ctx.skipped.proposedCreate}/${ctx.skipped.proposedUpdate}/${ctx.skipped.proposedEdges} ` +
|
|
612
|
+
`skip=dup:${ctx.skipped.duplicateTitle}/uniq:${ctx.skipped.uniqueConflict}/badId:${ctx.skipped.invalidUpdateId}/` +
|
|
613
|
+
`badEdge:${ctx.skipped.invalidEdgeRef}/empty:${ctx.skipped.emptyTitle}`);
|
|
614
|
+
if (ctx.skipped.parseFailed) {
|
|
615
|
+
console.warn('[memory-ingest] last raw response head:', ctx.lastRaw.slice(0, 500));
|
|
386
616
|
}
|
|
387
617
|
}
|
|
618
|
+
// Mirror DB → `.clitrigger/wiki/` markdown files (best-effort, fire-and-forget).
|
|
619
|
+
dispatchWikiExport(projectId);
|
|
620
|
+
// Project-scoped activity log entry — feeds the Wiki Activity tab.
|
|
621
|
+
try {
|
|
622
|
+
const applied = created + updated + edgesAdded;
|
|
623
|
+
let severity = 'info';
|
|
624
|
+
let message;
|
|
625
|
+
if (ctx.skipped.parseFailed) {
|
|
626
|
+
severity = 'error';
|
|
627
|
+
message = `Ingest failed to parse model output (${total} chunk${total > 1 ? 's' : ''})`;
|
|
628
|
+
}
|
|
629
|
+
else if (applied === 0) {
|
|
630
|
+
severity = 'warning';
|
|
631
|
+
message = `Ingest produced no changes — nothing new in source`;
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
message = `Ingested ${created} new, ${updated} updated, ${edgesAdded} edge${edgesAdded === 1 ? '' : 's'}`;
|
|
635
|
+
}
|
|
636
|
+
queries.createMemoryLog(projectId, 'ingest', message, {
|
|
637
|
+
severity,
|
|
638
|
+
sourceType: sourceType ?? 'manual',
|
|
639
|
+
sourceId,
|
|
640
|
+
sourceTitle: titleHint ?? null,
|
|
641
|
+
metadata: {
|
|
642
|
+
cliTool,
|
|
643
|
+
chunks: total,
|
|
644
|
+
created,
|
|
645
|
+
updated,
|
|
646
|
+
edgesAdded,
|
|
647
|
+
skipped: ctx.skipped,
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
console.warn('[memory-ingest] failed to write memory_logs entry:', err);
|
|
653
|
+
}
|
|
388
654
|
return {
|
|
389
|
-
created
|
|
390
|
-
updated
|
|
655
|
+
created,
|
|
656
|
+
updated,
|
|
391
657
|
edgesAdded,
|
|
392
|
-
nodeIds: createdIds,
|
|
658
|
+
nodeIds: ctx.createdIds,
|
|
659
|
+
skipped: ctx.skipped,
|
|
660
|
+
rawResponseSnippet: ctx.skipped.parseFailed ? ctx.lastRaw.slice(0, 500) : undefined,
|
|
393
661
|
};
|
|
394
662
|
}
|
|
663
|
+
const LINT_CHUNK_CHARS = 10000;
|
|
664
|
+
const LINT_MAX_CHUNKS = 5;
|
|
665
|
+
function chunkLintEntries(entries, maxChars, maxChunks) {
|
|
666
|
+
const chunks = [];
|
|
667
|
+
let cur = '';
|
|
668
|
+
for (const entry of entries) {
|
|
669
|
+
if (chunks.length >= maxChunks)
|
|
670
|
+
break;
|
|
671
|
+
const sep = cur ? '\n\n' : '';
|
|
672
|
+
if (cur && cur.length + sep.length + entry.length > maxChars) {
|
|
673
|
+
chunks.push(cur);
|
|
674
|
+
if (chunks.length >= maxChunks) {
|
|
675
|
+
cur = '';
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
cur = entry.length > maxChars ? entry.slice(0, maxChars) : entry;
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
cur = `${cur}${sep}${entry.length > maxChars ? entry.slice(0, maxChars) : entry}`;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (cur && chunks.length < maxChunks)
|
|
685
|
+
chunks.push(cur);
|
|
686
|
+
return chunks;
|
|
687
|
+
}
|
|
688
|
+
function dedupeLintIssues(issues) {
|
|
689
|
+
const seen = new Set();
|
|
690
|
+
const out = [];
|
|
691
|
+
for (const issue of issues) {
|
|
692
|
+
const key = `${issue.type}::${[...issue.node_titles].map(s => s.toLowerCase()).sort().join('|')}::${issue.message.toLowerCase()}`;
|
|
693
|
+
if (seen.has(key))
|
|
694
|
+
continue;
|
|
695
|
+
seen.add(key);
|
|
696
|
+
out.push(issue);
|
|
697
|
+
}
|
|
698
|
+
return out;
|
|
699
|
+
}
|
|
395
700
|
export async function lintWiki(projectId) {
|
|
396
701
|
const project = queries.getProjectById(projectId);
|
|
397
702
|
if (!project)
|
|
@@ -401,7 +706,9 @@ export async function lintWiki(projectId) {
|
|
|
401
706
|
const visible = nodes.filter(n => {
|
|
402
707
|
try {
|
|
403
708
|
const tags = JSON.parse(n.tags ?? '[]');
|
|
404
|
-
|
|
709
|
+
if (!Array.isArray(tags))
|
|
710
|
+
return true;
|
|
711
|
+
return !tags.includes(WIKI_SCHEMA_TAG) && !tags.includes(WIKI_INDEX_TAG);
|
|
405
712
|
}
|
|
406
713
|
catch {
|
|
407
714
|
return true;
|
|
@@ -411,14 +718,88 @@ export async function lintWiki(projectId) {
|
|
|
411
718
|
return [];
|
|
412
719
|
const edges = queries.getMemoryEdgesByProjectId(projectId);
|
|
413
720
|
const edgeSet = new Set(edges.flatMap(e => [e.from_node_id, e.to_node_id]));
|
|
414
|
-
const
|
|
721
|
+
const entries = visible.map(n => {
|
|
415
722
|
const body = (n.body || '').slice(0, 400);
|
|
416
723
|
const hasEdge = edgeSet.has(n.id) ? '' : ' [no-edges]';
|
|
417
724
|
return `### ${n.title}${hasEdge}\n${body}`;
|
|
418
|
-
})
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
725
|
+
});
|
|
726
|
+
// Larger wikis used to be silently truncated to 12KB — the tail nodes never
|
|
727
|
+
// got linted. Split into chunks so every node is seen by at least one pass.
|
|
728
|
+
// Cross-chunk duplicates aren't detected (each chunk only sees its own
|
|
729
|
+
// entries), but orphan/stale/contradiction within a chunk still work.
|
|
730
|
+
const chunks = chunkLintEntries(entries, LINT_CHUNK_CHARS, LINT_MAX_CHUNKS);
|
|
731
|
+
const truncated = entries.length > 0 && chunks.join('\n\n').length < entries.join('\n\n').length;
|
|
732
|
+
const collected = [];
|
|
733
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
734
|
+
const prompt = LINT_PROMPT_HEADER.replace('{NODES}', chunks[i]);
|
|
735
|
+
const kind = chunks.length > 1 ? `lint-${i + 1}of${chunks.length}` : 'lint';
|
|
736
|
+
const debugSession = startDebugSession(project, cliTool, null, null, kind);
|
|
737
|
+
const raw = await runHeadless(cliTool, prompt, 180_000, debugSession);
|
|
738
|
+
collected.push(...safeParseLintIssues(raw));
|
|
739
|
+
}
|
|
740
|
+
const issues = dedupeLintIssues(collected);
|
|
741
|
+
try {
|
|
742
|
+
const counts = {};
|
|
743
|
+
for (const issue of issues) {
|
|
744
|
+
counts[issue.type] = (counts[issue.type] ?? 0) + 1;
|
|
745
|
+
}
|
|
746
|
+
const summary = issues.length === 0
|
|
747
|
+
? `Wiki looks healthy — no issues found${chunks.length > 1 ? ` (${chunks.length} chunks)` : ''}`
|
|
748
|
+
: `Lint found ${issues.length} issue${issues.length === 1 ? '' : 's'}: ${Object.entries(counts).map(([k, v]) => `${k}=${v}`).join(', ')}${chunks.length > 1 ? ` (across ${chunks.length} chunks)` : ''}`;
|
|
749
|
+
queries.createMemoryLog(projectId, 'lint', summary, {
|
|
750
|
+
severity: issues.length === 0 ? 'info' : 'warning',
|
|
751
|
+
metadata: {
|
|
752
|
+
cliTool,
|
|
753
|
+
total: issues.length,
|
|
754
|
+
counts,
|
|
755
|
+
nodeCount: visible.length,
|
|
756
|
+
chunks: chunks.length,
|
|
757
|
+
truncated,
|
|
758
|
+
},
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
catch (err) {
|
|
762
|
+
console.warn('[memory-lint] failed to write memory_logs entry:', err);
|
|
763
|
+
}
|
|
764
|
+
return issues;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Run an auto-ingest and broadcast the result over WebSocket so the client can show a toast.
|
|
768
|
+
* Errors are swallowed (auto-ingest is best-effort) but reported as a failure event.
|
|
769
|
+
*/
|
|
770
|
+
export function runAutoIngestAndBroadcast(projectId, sourceType, sourceId, sourceTitle, sourceText) {
|
|
771
|
+
ingestSource(projectId, sourceText, sourceType, sourceId, sourceTitle).then((res) => {
|
|
772
|
+
broadcaster.broadcast({
|
|
773
|
+
type: 'memory:ingest-finished',
|
|
774
|
+
projectId,
|
|
775
|
+
sourceType,
|
|
776
|
+
sourceId,
|
|
777
|
+
sourceTitle,
|
|
778
|
+
created: res.created,
|
|
779
|
+
updated: res.updated,
|
|
780
|
+
edgesAdded: res.edgesAdded,
|
|
781
|
+
skipped: res.skipped,
|
|
782
|
+
});
|
|
783
|
+
}).catch((err) => {
|
|
784
|
+
console.error(`[memory-ingest] auto-ingest failed (${sourceType}):`, err);
|
|
785
|
+
broadcaster.broadcast({
|
|
786
|
+
type: 'memory:ingest-finished',
|
|
787
|
+
projectId,
|
|
788
|
+
sourceType,
|
|
789
|
+
sourceId,
|
|
790
|
+
sourceTitle,
|
|
791
|
+
created: 0,
|
|
792
|
+
updated: 0,
|
|
793
|
+
edgesAdded: 0,
|
|
794
|
+
skipped: {
|
|
795
|
+
parseFailed: false,
|
|
796
|
+
proposedCreate: 0, proposedUpdate: 0, proposedEdges: 0,
|
|
797
|
+
duplicateTitle: 0, uniqueConflict: 0, emptyTitle: 0,
|
|
798
|
+
invalidUpdateId: 0, invalidEdgeRef: 0, selfEdge: 0, edgeUniqueConflict: 0,
|
|
799
|
+
},
|
|
800
|
+
error: err instanceof Error ? err.message : String(err),
|
|
801
|
+
});
|
|
802
|
+
});
|
|
422
803
|
}
|
|
423
804
|
export function buildSourceTextFromTodo(todoId) {
|
|
424
805
|
const todo = queries.getTodoById(todoId);
|