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.
Files changed (80) hide show
  1. package/README.md +15 -4
  2. package/README_KR.md +15 -4
  3. package/bin/clitrigger.js +41 -4
  4. package/dist/client/assets/index-1hay-n37.js +686 -0
  5. package/dist/client/assets/index-CRSNebDI.css +32 -0
  6. package/dist/client/index.html +2 -2
  7. package/dist/server/db/app-settings.d.ts +3 -0
  8. package/dist/server/db/app-settings.d.ts.map +1 -0
  9. package/dist/server/db/app-settings.js +16 -0
  10. package/dist/server/db/app-settings.js.map +1 -0
  11. package/dist/server/db/queries.d.ts +27 -3
  12. package/dist/server/db/queries.d.ts.map +1 -1
  13. package/dist/server/db/queries.js +25 -5
  14. package/dist/server/db/queries.js.map +1 -1
  15. package/dist/server/db/schema.d.ts.map +1 -1
  16. package/dist/server/db/schema.js +23 -0
  17. package/dist/server/db/schema.js.map +1 -1
  18. package/dist/server/index.d.ts.map +1 -1
  19. package/dist/server/index.js +4 -2
  20. package/dist/server/index.js.map +1 -1
  21. package/dist/server/routes/discussions.d.ts.map +1 -1
  22. package/dist/server/routes/discussions.js +1 -1
  23. package/dist/server/routes/discussions.js.map +1 -1
  24. package/dist/server/routes/memory.d.ts.map +1 -1
  25. package/dist/server/routes/memory.js +336 -2
  26. package/dist/server/routes/memory.js.map +1 -1
  27. package/dist/server/routes/sessions.d.ts.map +1 -1
  28. package/dist/server/routes/sessions.js +86 -3
  29. package/dist/server/routes/sessions.js.map +1 -1
  30. package/dist/server/routes/todos.js +2 -2
  31. package/dist/server/routes/todos.js.map +1 -1
  32. package/dist/server/routes/tunnel.d.ts.map +1 -1
  33. package/dist/server/routes/tunnel.js +37 -2
  34. package/dist/server/routes/tunnel.js.map +1 -1
  35. package/dist/server/services/discussion-orchestrator.d.ts.map +1 -1
  36. package/dist/server/services/discussion-orchestrator.js +4 -5
  37. package/dist/server/services/discussion-orchestrator.js.map +1 -1
  38. package/dist/server/services/memory-ingest.d.ts +25 -1
  39. package/dist/server/services/memory-ingest.d.ts.map +1 -1
  40. package/dist/server/services/memory-ingest.js +453 -72
  41. package/dist/server/services/memory-ingest.js.map +1 -1
  42. package/dist/server/services/memory-inject-hook.d.ts +3 -1
  43. package/dist/server/services/memory-inject-hook.d.ts.map +1 -1
  44. package/dist/server/services/memory-inject-hook.js +23 -3
  45. package/dist/server/services/memory-inject-hook.js.map +1 -1
  46. package/dist/server/services/memory-injector.d.ts +1 -1
  47. package/dist/server/services/memory-injector.d.ts.map +1 -1
  48. package/dist/server/services/memory-injector.js +18 -2
  49. package/dist/server/services/memory-injector.js.map +1 -1
  50. package/dist/server/services/memory-retriever.d.ts +16 -0
  51. package/dist/server/services/memory-retriever.d.ts.map +1 -0
  52. package/dist/server/services/memory-retriever.js +170 -0
  53. package/dist/server/services/memory-retriever.js.map +1 -0
  54. package/dist/server/services/orchestrator.d.ts.map +1 -1
  55. package/dist/server/services/orchestrator.js +4 -5
  56. package/dist/server/services/orchestrator.js.map +1 -1
  57. package/dist/server/services/session-manager.d.ts +21 -0
  58. package/dist/server/services/session-manager.d.ts.map +1 -1
  59. package/dist/server/services/session-manager.js +91 -2
  60. package/dist/server/services/session-manager.js.map +1 -1
  61. package/dist/server/services/tunnel-manager.d.ts +3 -1
  62. package/dist/server/services/tunnel-manager.d.ts.map +1 -1
  63. package/dist/server/services/tunnel-manager.js +18 -8
  64. package/dist/server/services/tunnel-manager.js.map +1 -1
  65. package/dist/server/services/wiki-exporter.d.ts +32 -0
  66. package/dist/server/services/wiki-exporter.d.ts.map +1 -0
  67. package/dist/server/services/wiki-exporter.js +430 -0
  68. package/dist/server/services/wiki-exporter.js.map +1 -0
  69. package/dist/server/services/wiki-index.d.ts +10 -0
  70. package/dist/server/services/wiki-index.d.ts.map +1 -0
  71. package/dist/server/services/wiki-index.js +100 -0
  72. package/dist/server/services/wiki-index.js.map +1 -0
  73. package/dist/server/websocket/events.d.ts +23 -0
  74. package/dist/server/websocket/events.d.ts.map +1 -1
  75. package/dist/server/websocket/index.d.ts.map +1 -1
  76. package/dist/server/websocket/index.js +8 -7
  77. package/dist/server/websocket/index.js.map +1 -1
  78. package/package.json +1 -1
  79. package/dist/client/assets/index-BWNQgE_E.js +0 -649
  80. 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
- ensureGitignore(project.path, `${RAW_DIR_NAME}/`);
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
- function runHeadless(cliTool, prompt, timeoutMs = 180_000) {
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
- return !Array.isArray(tags) || !tags.includes(WIKI_SCHEMA_TAG);
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
- return visible.map(n => {
251
- try {
252
- const tags = JSON.parse(n.tags ?? '[]');
253
- const tagStr = tags.filter(t => t !== WIKI_SCHEMA_TAG).join(', ');
254
- const pinned = n.pinned ? ' [pinned]' : '';
255
- const bodyPreview = n.pinned ? `\n ${(n.body || '').slice(0, 300)}` : '';
256
- return `- id="${n.id}" title="${n.title}"${tagStr ? ` tags=[${tagStr}]` : ''}${pinned}${bodyPreview}`;
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
- catch {
259
- return `- id="${n.id}" title="${n.title}"`;
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
- }).join('\n');
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
- export async function ingestSource(projectId, sourceText, sourceType, sourceId, titleHint) {
318
- const project = queries.getProjectById(projectId);
319
- if (!project)
320
- throw new Error('Project not found');
321
- const cliTool = resolveCliTool(project.cli_tool);
322
- // Step 1: persist raw snapshot (immutable). Failure is non-fatal.
323
- let rawPath = null;
324
- if (sourceType && VALID_SOURCE_TYPES.has(sourceType)) {
325
- const hint = (titleHint && titleHint.trim()) || sourceText.split('\n').find(l => l.trim())?.trim().slice(0, 60) || sourceType;
326
- rawPath = writeRawSnapshot(project, sourceType, sourceId, sourceText, hint);
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
- const schema = getOrCreateSchemaNode(projectId);
329
- const nodes = queries.getMemoryNodesByProjectId(projectId);
330
- const nodeSummary = buildNodeSummary(nodes);
331
- const prompt = INGEST_PROMPT_HEADER
332
- .replace('{SCHEMA}', schema)
333
- .replace('{NODES}', nodeSummary)
334
- .replace('{SOURCE}', sourceText.slice(0, 8000));
335
- const raw = await runHeadless(cliTool, prompt);
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
- /* skip on UNIQUE conflict */
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
- const existing = nodes.find(n => n.id === u.id);
362
- if (!existing)
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
- updatedCount++;
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 || 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
- /* skip duplicates */
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: createdIds.length,
390
- updated: updatedCount,
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
- return !Array.isArray(tags) || !tags.includes(WIKI_SCHEMA_TAG);
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 nodeText = visible.map(n => {
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
- }).join('\n\n');
419
- const prompt = LINT_PROMPT_HEADER.replace('{NODES}', nodeText.slice(0, 12000));
420
- const raw = await runHeadless(cliTool, prompt);
421
- return safeParseLintIssues(raw);
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);