neurain 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +57 -0
  3. package/README.md +205 -0
  4. package/SECURITY.md +22 -0
  5. package/bin/neurain.mjs +7 -0
  6. package/docs/comparison-mem0.en.md +22 -0
  7. package/docs/connect-claude.en.md +48 -0
  8. package/docs/connect-claude.kr.md +51 -0
  9. package/docs/connect-codex.en.md +38 -0
  10. package/docs/connect-codex.kr.md +40 -0
  11. package/docs/connect-gemini.en.md +71 -0
  12. package/docs/connect-gemini.kr.md +71 -0
  13. package/docs/connect-runtime.en.md +61 -0
  14. package/docs/connect-runtime.kr.md +61 -0
  15. package/docs/development-status.en.md +157 -0
  16. package/docs/development-status.kr.md +157 -0
  17. package/docs/knowledge-os.en.md +105 -0
  18. package/docs/knowledge-os.kr.md +106 -0
  19. package/docs/pricing.en.md +14 -0
  20. package/docs/privacy-and-data-flow.en.md +25 -0
  21. package/docs/public-saas-readiness.en.md +39 -0
  22. package/docs/quickstart.en.md +64 -0
  23. package/docs/quickstart.kr.md +64 -0
  24. package/docs/release-checklist.en.md +38 -0
  25. package/docs/safety.en.md +36 -0
  26. package/docs/self-improvement-90-roadmap.en.md +429 -0
  27. package/docs/self-improvement-90-roadmap.kr.md +429 -0
  28. package/docs/self-improving-workflows.en.md +163 -0
  29. package/docs/self-improving-workflows.kr.md +163 -0
  30. package/docs/support.en.md +17 -0
  31. package/docs/troubleshooting.en.md +35 -0
  32. package/package.json +36 -0
  33. package/src/cli.mjs +261 -0
  34. package/src/core/adopt.mjs +304 -0
  35. package/src/core/answer_eval.mjs +450 -0
  36. package/src/core/capabilities.mjs +217 -0
  37. package/src/core/capture_durable.mjs +181 -0
  38. package/src/core/classify.mjs +237 -0
  39. package/src/core/compile_desk.mjs +324 -0
  40. package/src/core/complete.mjs +108 -0
  41. package/src/core/config.mjs +142 -0
  42. package/src/core/connect.mjs +355 -0
  43. package/src/core/curator.mjs +351 -0
  44. package/src/core/daemon.mjs +536 -0
  45. package/src/core/digest.mjs +155 -0
  46. package/src/core/doctor.mjs +115 -0
  47. package/src/core/durable.mjs +96 -0
  48. package/src/core/envelope.mjs +97 -0
  49. package/src/core/flush.mjs +190 -0
  50. package/src/core/fs.mjs +121 -0
  51. package/src/core/init.mjs +194 -0
  52. package/src/core/journal.mjs +269 -0
  53. package/src/core/labels.mjs +117 -0
  54. package/src/core/lessons.mjs +793 -0
  55. package/src/core/lifecycle.mjs +1138 -0
  56. package/src/core/link_check.mjs +180 -0
  57. package/src/core/live_cases.mjs +221 -0
  58. package/src/core/onboard.mjs +175 -0
  59. package/src/core/plan_receipt.mjs +177 -0
  60. package/src/core/plan_writeback.mjs +176 -0
  61. package/src/core/queue.mjs +62 -0
  62. package/src/core/queue_archive.mjs +87 -0
  63. package/src/core/queue_model.mjs +161 -0
  64. package/src/core/queue_write.mjs +28 -0
  65. package/src/core/recall.mjs +1802 -0
  66. package/src/core/recall_bench.mjs +275 -0
  67. package/src/core/recall_corpus.mjs +152 -0
  68. package/src/core/recall_facts.mjs +233 -0
  69. package/src/core/recall_intel.mjs +233 -0
  70. package/src/core/recall_lexical.mjs +269 -0
  71. package/src/core/recap.mjs +78 -0
  72. package/src/core/review_queue.mjs +131 -0
  73. package/src/core/review_worker.mjs +284 -0
  74. package/src/core/route.mjs +73 -0
  75. package/src/core/safety.mjs +57 -0
  76. package/src/core/scheduler.mjs +697 -0
  77. package/src/core/search.mjs +54 -0
  78. package/src/core/secret_scan.mjs +143 -0
  79. package/src/core/semantic.mjs +187 -0
  80. package/src/core/source_digest.mjs +56 -0
  81. package/src/core/source_digest_gen.mjs +311 -0
  82. package/src/core/stage.mjs +105 -0
  83. package/src/core/status.mjs +175 -0
  84. package/src/core/vault_state.mjs +115 -0
  85. package/src/core/watch.mjs +282 -0
  86. package/src/core/wiki_log.mjs +29 -0
  87. package/src/core/wrap.mjs +62 -0
  88. package/src/mcp/server.mjs +865 -0
  89. package/templates/starter-vault/README.md +9 -0
@@ -0,0 +1,324 @@
1
+ // `compile` command (W-B, read-only desk). The interactive !compile picker: it
2
+ // reads the pending writeback queue + the source-digest manifest and returns a
3
+ // priority-ranked candidate desk (safe queue items first, then high-value
4
+ // uncompiled raw by score), with private / needs-decision / official-memory items
5
+ // surfaced separately and NEVER silently mixed in. It reads no raw bodies and
6
+ // writes nothing. Faithful port of the vault neurain-compile tool (nondeterministic
7
+ // elapsed timing dropped; parity normalizes).
8
+ import path from 'node:path';
9
+ import { absPath, timestamp } from './fs.mjs';
10
+ import { vaultConfig } from './config.mjs';
11
+ import { readJsonSafe, readJsonl } from './vault_state.mjs';
12
+
13
+ function manifestEntries(value) {
14
+ return Object.values((value && value.files) || {});
15
+ }
16
+
17
+ function isCompiled(entry) {
18
+ return ['compiled', 'flushed', 'archived', 'superseded'].includes(entry.status) || (entry.compiled_to || []).length > 0;
19
+ }
20
+
21
+ function compiledEntries(value) {
22
+ return manifestEntries(value).filter((entry) => isCompiled(entry));
23
+ }
24
+
25
+ function uncompiledEntries(value) {
26
+ return manifestEntries(value).filter((entry) => !isCompiled(entry));
27
+ }
28
+
29
+ function inferTargetLayer(item) {
30
+ if (item.write_intent === 'update_current') return 'current';
31
+ if (item.write_intent === 'create_task') return 'task_memory';
32
+ if (item.write_intent === 'output_request') return 'output';
33
+ if (item.write_intent === 'evidence_only') return 'raw';
34
+ return 'wiki';
35
+ }
36
+
37
+ function inferFlushLevel(item) {
38
+ if (item.requires_user_decision || item.sensitivity === 'private') return 'full';
39
+ if (['update_current', 'create_task'].includes(item.write_intent)) return 'full';
40
+ if (item.write_intent === 'evidence_only') return 'light';
41
+ return 'standard';
42
+ }
43
+
44
+ function reviewReasons(item) {
45
+ const reasons = [];
46
+ const targetLayer = item.target_layer || inferTargetLayer(item);
47
+ if (item.requires_user_decision) reasons.push('requires user decision');
48
+ if (item.sensitivity === 'private') reasons.push('private source');
49
+ if (['current', 'fact_ledger', 'task_memory'].includes(targetLayer)) reasons.push('official memory layer');
50
+ if (String(item.flush_level || inferFlushLevel(item)).toLowerCase() === 'full') reasons.push('permanent save (needs confirmation)');
51
+ return [...new Set(reasons)];
52
+ }
53
+
54
+ function summarizeQueueItem(item, reasons = []) {
55
+ return {
56
+ source_id: item.source_id || '',
57
+ title: item.title || '',
58
+ raw_path: item.raw_path || '',
59
+ target_layer: item.target_layer || inferTargetLayer(item),
60
+ target_path: item.target_path || '',
61
+ sensitivity: item.sensitivity || 'internal',
62
+ flush_level: item.flush_level || inferFlushLevel(item),
63
+ reasons,
64
+ };
65
+ }
66
+
67
+ function deepCompileCandidates(value) {
68
+ return uncompiledEntries(value)
69
+ .filter((entry) => Number(entry.high_value_score || 0) >= 2)
70
+ .sort((a, b) => Number(b.high_value_score || 0) - Number(a.high_value_score || 0) || Number(b.size_bytes || 0) - Number(a.size_bytes || 0))
71
+ .map((entry) => ({
72
+ path: entry.path,
73
+ source_id: entry.source_id || '',
74
+ source_type: entry.source_type || '',
75
+ sensitivity: entry.sensitivity || 'internal',
76
+ high_value_score: entry.high_value_score || 0,
77
+ reasons: entry.high_value_reasons || [],
78
+ }));
79
+ }
80
+
81
+ function priorityLabel(score) {
82
+ const s = Number(score || 0);
83
+ if (s >= 4) return 'high';
84
+ if (s >= 2) return 'medium';
85
+ return 'low';
86
+ }
87
+
88
+ function rankedCandidates({ safe: safeItems, deep }) {
89
+ const out = [];
90
+ for (const item of safeItems) {
91
+ out.push({
92
+ kind: 'queue_item',
93
+ source_id: item.source_id || '',
94
+ title: item.title || '',
95
+ sensitivity: item.sensitivity || 'internal',
96
+ priority: 'queue-ready',
97
+ score: 1000,
98
+ target_layer: item.target_layer || inferTargetLayer(item),
99
+ });
100
+ }
101
+ for (const c of deep) {
102
+ out.push({
103
+ kind: 'raw_source',
104
+ source_id: c.source_id || '',
105
+ title: c.path || '',
106
+ sensitivity: c.sensitivity || 'internal',
107
+ priority: priorityLabel(c.high_value_score),
108
+ score: Number(c.high_value_score || 0),
109
+ reasons: c.reasons || [],
110
+ });
111
+ }
112
+ return out.sort((a, b) => b.score - a.score).map((c, i) => ({ rank: i + 1, ...c }));
113
+ }
114
+
115
+ function summarizeManifestTarget(entry, selectedBy) {
116
+ return {
117
+ kind: 'raw_source',
118
+ selected_by: selectedBy,
119
+ source_id: entry.source_id || '',
120
+ path: entry.path,
121
+ source_type: entry.source_type || '',
122
+ sensitivity: entry.sensitivity || 'internal',
123
+ status: entry.status || 'discovered',
124
+ high_value_score: entry.high_value_score || 0,
125
+ reasons: entry.high_value_reasons || [],
126
+ compiled_to: entry.compiled_to || [],
127
+ };
128
+ }
129
+
130
+ function selectTarget(query, value, queueItems, rawDir) {
131
+ const lower = String(query || '').toLowerCase();
132
+ const sourceIdMatch = String(query || '').match(/\b(?:raw-\d{8}-\d{3}|clipper-\d{8}-\d{6})\b/);
133
+ if (sourceIdMatch) {
134
+ const sourceId = sourceIdMatch[0];
135
+ const queueMatch = queueItems.find((item) => item.source_id === sourceId);
136
+ if (queueMatch) return { kind: 'queue_item', ...summarizeQueueItem(queueMatch, reviewReasons(queueMatch)) };
137
+ const manifestMatch = manifestEntries(value).find((entry) => entry.source_id === sourceId);
138
+ if (manifestMatch) return summarizeManifestTarget(manifestMatch, 'source_id');
139
+ }
140
+
141
+ const rawPathMatch = String(query || '').match(new RegExp(`\\b${rawDir}\\/[^\\n\\r]+`));
142
+ if (rawPathMatch) {
143
+ const rawPath = rawPathMatch[0].trim();
144
+ const manifestMatch = manifestEntries(value).find((entry) => entry.path === rawPath || entry.path.includes(rawPath));
145
+ if (manifestMatch) return summarizeManifestTarget(manifestMatch, 'raw_path');
146
+ }
147
+
148
+ if (/latest|recent|최근|방금|clip/.test(lower)) {
149
+ const entries = uncompiledEntries(value).filter((entry) => {
150
+ if (/clip|web|영상|youtube|유튜브/.test(lower)) return String(entry.path || '').startsWith(`${rawDir}/web/`);
151
+ return true;
152
+ });
153
+ const latest = entries.sort((a, b) => Number(b.mtime_ms || 0) - Number(a.mtime_ms || 0))[0];
154
+ if (latest) return summarizeManifestTarget(latest, 'latest');
155
+ }
156
+ return null;
157
+ }
158
+
159
+ function selectAutoTarget({ safe: safeItems, needsConfirmation: confirmationItems, manifest: value, sessionId: targetSessionId }) {
160
+ if (safeItems.length > 0) {
161
+ const item = safeItems[0];
162
+ return { auto_selected: true, selected_by: 'safe_queue', kind: 'queue_item', ...summarizeQueueItem(item, []) };
163
+ }
164
+ if (confirmationItems.length > 0) return null;
165
+
166
+ const latestSmallSource = uncompiledEntries(value)
167
+ .filter((entry) => ['public', 'internal'].includes(entry.sensitivity || 'internal'))
168
+ .filter((entry) => Number(entry.size_bytes || 0) <= 250000)
169
+ .filter((entry) => {
170
+ if (!targetSessionId) return true;
171
+ const pathText = String(entry.path || '').toLowerCase();
172
+ return !pathText.includes('/parents/') || targetSessionId.startsWith('parents-');
173
+ })
174
+ .sort((a, b) => Number(b.mtime_ms || 0) - Number(a.mtime_ms || 0))[0];
175
+ if (latestSmallSource) return { auto_selected: true, ...summarizeManifestTarget(latestSmallSource, 'latest_safe_raw') };
176
+
177
+ const topDigestCandidate = deepCompileCandidates(value)
178
+ .map((entry) => manifestEntries(value).find((candidate) => candidate.path === entry.path))
179
+ .filter(Boolean)
180
+ .filter((entry) => ['public', 'internal'].includes(entry.sensitivity || 'internal'))
181
+ .filter((entry) => Number(entry.size_bytes || 0) <= 500000)
182
+ .find(Boolean);
183
+ if (topDigestCandidate) return { auto_selected: true, ...summarizeManifestTarget(topDigestCandidate, 'high_value_safe_raw') };
184
+
185
+ return null;
186
+ }
187
+
188
+ function nextAction({ selected, safe: safeItems, needsConfirmation: confirmationItems, deepCandidates: candidates }) {
189
+ if (selected && selected.sensitivity === 'private') return 'Ask before compiling this private source into official memory.';
190
+ if (selected) return 'Compile this selected source only. Do not scan or read unrelated raw files.';
191
+ if (safeItems.length === 1) return 'One safe queue item is ready. Compile only that item if the user wants automatic progress.';
192
+ if (safeItems.length > 1) return 'Multiple safe queue items exist. Show the shortlist and process one at a time.';
193
+ if (confirmationItems.length > 0) return 'Only confirmation-needed items are pending. Ask before official memory changes.';
194
+ if (candidates.length > 0) return 'Show deep compile candidates. Do not deep read any candidate until one is selected.';
195
+ return 'Nothing useful is waiting for compile.';
196
+ }
197
+
198
+ export async function compileCommand(args) {
199
+ const root = absPath(args._[0] || args.root || process.cwd());
200
+ const vaultCfg = vaultConfig(root);
201
+ const rawDir = vaultCfg.raw_dir;
202
+ const positional = args._.slice(1).join(' ').trim();
203
+ const detail = String(args.target || args.text || positional || '').trim();
204
+ const sessionId = String(args['session-id'] || '');
205
+ const top = Number(args.top || 5);
206
+ const manifestRel = String(args.manifest || vaultCfg.source_digest_manifest);
207
+ const queueRel = String(args.queue || vaultCfg.writeback_queue);
208
+
209
+ const manifest = readJsonSafe(path.join(root, manifestRel), null);
210
+ const pending = readJsonl(path.join(root, queueRel)).filter((row) => {
211
+ if (row.status !== 'pending') return false;
212
+ if (!sessionId) return true;
213
+ return row.session_id === sessionId;
214
+ });
215
+
216
+ const safe = pending.filter((item) => reviewReasons(item).length === 0);
217
+ const needsConfirmation = pending
218
+ .filter((item) => reviewReasons(item).length > 0)
219
+ .map((item) => {
220
+ const s = summarizeQueueItem(item, reviewReasons(item));
221
+ // Never expose a private item's file paths in output (R3-#1).
222
+ if (s.sensitivity === 'private') { s.raw_path = '[redacted:private]'; s.target_path = ''; }
223
+ return s;
224
+ });
225
+
226
+ const allDeep = deepCompileCandidates(manifest);
227
+ const safeDeep = allDeep.filter((e) => ['public', 'internal'].includes(e.sensitivity || 'internal'));
228
+ const excludedDeep = allDeep
229
+ .filter((e) => !['public', 'internal'].includes(e.sensitivity || 'internal'))
230
+ .map((e) => ({ source_id: e.source_id || '', title: '[private/sensitive raw source]', sensitivity: e.sensitivity || 'private', reasons: ['private/sensitive raw source'] }));
231
+ const deepCandidates = safeDeep.slice(0, top);
232
+ const candidates = rankedCandidates({ safe, deep: safeDeep }).slice(0, 20);
233
+ const excludedAll = [...needsConfirmation, ...excludedDeep];
234
+
235
+ const wantList = Boolean(args.list || args.pick);
236
+ const wantAuto = Boolean(args.auto);
237
+ const deskOnly = Boolean(args.desk || args['no-auto-select']) || wantList || (!detail && !wantAuto);
238
+ const selected = detail
239
+ ? selectTarget(detail, manifest, pending, rawDir)
240
+ : deskOnly
241
+ ? null
242
+ : selectAutoTarget({ safe, needsConfirmation, manifest, sessionId });
243
+ const mode = selected
244
+ ? (selected.auto_selected ? 'auto_selected_compile_plan' : 'selected_compile_plan')
245
+ : deskOnly
246
+ ? 'compile_desk_fast'
247
+ : needsConfirmation.length > 0
248
+ ? 'compile_confirmation_required'
249
+ : 'compile_desk_fast';
250
+ const noTarget = !selected;
251
+
252
+ const payload = {
253
+ ok: true,
254
+ generated_at: timestamp(),
255
+ command: 'compile',
256
+ durable_write: false,
257
+ mode,
258
+ session_id: sessionId || null,
259
+ target_query: detail || null,
260
+ performance_policy: {
261
+ default_target_seconds: noTarget ? 5 : 60,
262
+ raw_full_reads_allowed: noTarget ? 0 : 1,
263
+ broad_raw_scan_allowed: false,
264
+ deep_compile_requires_selected_target: true,
265
+ },
266
+ queue: {
267
+ pending_count: pending.length,
268
+ safe_count: safe.length,
269
+ needs_confirmation_count: needsConfirmation.length,
270
+ safe: safe.slice(0, top).map((item) => summarizeQueueItem(item, [])),
271
+ needs_confirmation: needsConfirmation.slice(0, top),
272
+ },
273
+ source_digest: {
274
+ manifest: manifestRel,
275
+ available: Boolean(manifest),
276
+ compiled_count: compiledEntries(manifest).length,
277
+ uncompiled_count: uncompiledEntries(manifest).length,
278
+ deep_compile_candidate_count: safeDeep.length,
279
+ deep_compile_candidates: deepCandidates,
280
+ },
281
+ selected_target: selected,
282
+ candidate_count: candidates.length,
283
+ candidates,
284
+ excluded: excludedAll,
285
+ next_action: nextAction({ selected, safe, needsConfirmation, deepCandidates }),
286
+ mutates_canonical: false,
287
+ user_explanation: selected
288
+ ? (selected.auto_selected
289
+ ? 'No target was specified. One safe compile target was selected automatically. Read only this source, then update the minimal wiki or area target and sync the area brief.'
290
+ : 'Selected compile target found. Read only this source, then update the minimal wiki or area target and sync the area brief.')
291
+ : deskOnly
292
+ ? 'Desk mode selected. This fast desk only shows what can be compiled and does not read raw bodies.'
293
+ : 'No safe compile target was selected. This desk shows candidates and does not read raw bodies.',
294
+ };
295
+
296
+ if (args.json) return { json: true, payload };
297
+ return { text: render(payload) };
298
+ }
299
+
300
+ function render(value) {
301
+ const lines = ['# Neurain Compile Desk', ''];
302
+ lines.push(`- Mode: ${value.mode}`);
303
+ lines.push(`- Pending queue: ${value.queue.pending_count}`);
304
+ lines.push(`- Safe queue: ${value.queue.safe_count}`);
305
+ lines.push(`- Needs confirmation: ${value.queue.needs_confirmation_count}`);
306
+ lines.push(`- Deep compile candidates: ${value.source_digest.deep_compile_candidate_count}`);
307
+ lines.push(`- Raw full reads allowed: ${value.performance_policy.raw_full_reads_allowed}`);
308
+ if (value.candidates && value.candidates.length) {
309
+ lines.push('', `## Candidates (priority order): ${value.candidates.length}`);
310
+ for (const c of value.candidates) {
311
+ lines.push(` ${c.rank}. [${c.priority}] ${c.title || c.source_id || c.kind}${c.source_id ? ` (${c.source_id})` : ''}`);
312
+ }
313
+ }
314
+ if (value.excluded && value.excluded.length) {
315
+ lines.push('', `제외(확인필요/비공개) ${value.excluded.length}건: ${value.excluded.map((e) => e.title || e.source_id).join(', ')}`);
316
+ }
317
+ if (value.candidates && value.candidates.length) {
318
+ lines.push('', '몇 건 정리할까요? (숫자 1–10; "전부"는 안전한 것 최대 10건 + 나머지 개수 공시)');
319
+ } else if (!value.selected_target && value.excluded && value.excluded.length) {
320
+ lines.push('', '정리 가능한 안전 후보가 없습니다. 위 제외 항목은 확인/승인 후에만 처리됩니다.');
321
+ }
322
+ lines.push(`- Next action: ${value.next_action}`);
323
+ return lines.join('\n');
324
+ }
@@ -0,0 +1,108 @@
1
+ // `complete` command (W-B, B4, durable). Marks a captured source compiled: it
2
+ // updates the _inbox envelope, rewrites the matching writeback-queue row UNDER A
3
+ // LOCK (re-reading inside the lock so a concurrent append is never lost), and
4
+ // appends one wiki/log line. Faithful port of the vault neurain-complete.
5
+ //
6
+ // W-D BOUNDARY: the engine NEVER writes session-state.json, the handoff .md, or
7
+ // the area brief .md. It returns a session_state_delta carrying the patch +
8
+ // handoff_note + area_brief_note the future vault shuttle applies; pending_count
9
+ // is ADVISORY (the shuttle recomputes at apply time). See the B4 cross-review.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { absPath, timestamp } from './fs.mjs';
13
+ import { vaultConfig } from './config.mjs';
14
+ import { loadSessionState, pendingCountForSession, readJsonl, readJsonSafe } from './vault_state.mjs';
15
+ import { atomicWriteJson } from './durable.mjs';
16
+ import { rewriteQueueLocked } from './queue_write.mjs';
17
+ import { normalizeList } from './envelope.mjs';
18
+ import { appendWikiLog } from './wiki_log.mjs';
19
+
20
+ export async function completeCommand(args) {
21
+ const root = absPath(args._[0] || args.root || process.cwd());
22
+ const vaultCfg = vaultConfig(root);
23
+ const sourceId = String(args['source-id'] || '');
24
+ if (!sourceId) return done(args, { ok: false, command: 'complete', durable_write: false, error: 'Missing --source-id.' }, 1);
25
+
26
+ const compiledTo = normalizeList(args['compiled-to']);
27
+ if (compiledTo.length === 0) return done(args, { ok: false, command: 'complete', durable_write: false, error: 'Missing --compiled-to path list.' }, 1);
28
+
29
+ const envelopeRel = `${vaultCfg.raw_inbox_dir}/${sourceId}.json`;
30
+ const envelopeAbs = path.join(root, envelopeRel);
31
+ const envelope = readJsonSafe(envelopeAbs, null);
32
+ if (!envelope) return done(args, { ok: false, command: 'complete', durable_write: false, error: `Envelope not found: ${envelopeRel}` }, 1);
33
+
34
+ const missingTargets = compiledTo.filter((target) => !fs.existsSync(path.join(root, target)));
35
+ if (missingTargets.length > 0) return done(args, { ok: false, command: 'complete', durable_write: false, error: `Compiled target missing: ${missingTargets.join(', ')}` }, 1);
36
+
37
+ const completedAt = args['completed-at'] || timestamp();
38
+ const status = args.status || 'compiled';
39
+ let sessionId = envelope.session_id || '';
40
+ // if the envelope has no session, recover it from the matching queue row
41
+ if (!sessionId) {
42
+ const row = readJsonl(path.join(root, vaultCfg.writeback_queue)).find((r) => r && r.source_id === sourceId && r.session_id);
43
+ if (row) sessionId = row.session_id;
44
+ }
45
+
46
+ const updatedEnvelope = {
47
+ ...envelope,
48
+ status,
49
+ compiled_to: [...new Set([...(envelope.compiled_to || []), ...compiledTo])],
50
+ completed_at: completedAt,
51
+ };
52
+
53
+ if (args['dry-run']) {
54
+ return done(args, { ok: true, command: 'complete', durable_write: false, dry_run: true, source_id: sourceId, status, compiled_to: updatedEnvelope.compiled_to, session_id: sessionId });
55
+ }
56
+
57
+ atomicWriteJson(envelopeAbs, updatedEnvelope);
58
+
59
+ let queueUpdated = false;
60
+ if (fs.existsSync(path.join(root, vaultCfg.writeback_queue))) {
61
+ rewriteQueueLocked(root, vaultCfg, (rows) => rows.map((row) => {
62
+ if (row.source_id !== sourceId) return row;
63
+ queueUpdated = true;
64
+ return { ...row, status, compiled_at: completedAt, compiled_to: [...new Set([...(row.compiled_to || []), ...compiledTo])] };
65
+ }));
66
+ }
67
+
68
+ appendWikiLog(root, vaultCfg, 'compile', envelope.title || sourceId, [
69
+ `Source: ${sourceId}`, `Compiled to: ${compiledTo.join(', ')}`, `Status: ${status}`,
70
+ ]);
71
+
72
+ let sessionStateDelta = null;
73
+ if (sessionId) {
74
+ let session = null;
75
+ try { session = (loadSessionState(root, vaultCfg).sessions || {})[sessionId] || null; } catch { session = null; }
76
+ const pendingCount = pendingCountForSession(root, vaultCfg, sessionId); // after rewrite
77
+ const patch = { last_pulse: completedAt, last_flush: completedAt, pending_count: pendingCount };
78
+ const summary = `Compiled ${envelope.title || sourceId} to ${compiledTo.join(', ')}.`;
79
+ sessionStateDelta = {
80
+ applied: false,
81
+ reason: 'reserved-for-W-D: engine never writes session-state / handoff / area brief; the vault shuttle applies these.',
82
+ recompute_pending_at_apply: true,
83
+ session_id: sessionId,
84
+ patch,
85
+ handoff_note: { session_id: sessionId, summary, now: completedAt, state_patch: patch },
86
+ area_brief_note: session && session.area ? { area: session.area, session_id: sessionId, summary, now: completedAt, sensitivity: session.sensitivity } : null,
87
+ };
88
+ }
89
+
90
+ return done(args, {
91
+ ok: true, command: 'complete', durable_write: true,
92
+ source_id: sourceId, status, compiled_to: updatedEnvelope.compiled_to,
93
+ queue_updated: queueUpdated, session_id: sessionId,
94
+ session_state_delta: sessionStateDelta,
95
+ });
96
+ }
97
+
98
+ function done(args, payload, exitCode) {
99
+ if (exitCode !== undefined) process.exitCode = exitCode;
100
+ if (args.json) return { json: true, payload };
101
+ if (!payload.ok) return { text: `# Complete Writeback\n\n- ${payload.error}` };
102
+ const lines = ['# Complete Writeback', '', `- Source ID: ${payload.source_id}`, `- Status: ${payload.status}`];
103
+ if (payload.session_id) lines.push(`- Session: ${payload.session_id}`);
104
+ lines.push(`- Queue updated: ${payload.queue_updated ? 'yes' : 'no'}`);
105
+ lines.push(`- Compiled to: ${(payload.compiled_to || []).join(', ')}`);
106
+ if (payload.session_state_delta) lines.push('- Session-state delta (unapplied) returned for the vault shuttle.');
107
+ return { text: lines.join('\n') };
108
+ }
@@ -0,0 +1,142 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Read neurain.config.json from a target root. Mirrors doctor.readConfig: a
5
+ // missing or unreadable config is never fatal (recall must keep working against
6
+ // an un-initialized folder), so callers get an empty object and fall back to the
7
+ // engine defaults below. The structural defaults match `neurain init` and the
8
+ // reference vault layout, so the engine works against either with no config.
9
+ export function readConfig(root) {
10
+ try {
11
+ const parsed = JSON.parse(fs.readFileSync(path.join(root, 'neurain.config.json'), 'utf8'));
12
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
13
+ } catch {
14
+ return {};
15
+ }
16
+ }
17
+
18
+ const STRUCTURAL_DEFAULTS = {
19
+ areas_dir: '10_areas',
20
+ wiki_dir: 'wiki',
21
+ system_dir: '00_system',
22
+ hubs_dir: '20_hubs',
23
+ archive_dir: '90_archive',
24
+ output_dir: 'output',
25
+ raw_dir: 'raw',
26
+ };
27
+
28
+ const RECALL_DEFAULTS = {
29
+ include: [],
30
+ exclude: [],
31
+ labels: {
32
+ default_sensitivity: 'internal',
33
+ // null => labels.DEFAULT_PATH_MARKERS (kept there so the marker vocabulary
34
+ // and its boundary semantics live in one module).
35
+ path_markers: null,
36
+ areas: {},
37
+ },
38
+ intel: {
39
+ registry: '00_system/neurain/search-index-registry.json',
40
+ aliases: '00_system/neurain/search-aliases.json',
41
+ queue: '00_system/neurain/writeback-queue.jsonl',
42
+ memory_write_registry: '00_system/neurain/memory-write-registry.json',
43
+ },
44
+ routing: { enabled: null, max_per_layer: 3 },
45
+ bench: { baseline_source_recall: null },
46
+ };
47
+
48
+ function asObject(value) {
49
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
50
+ }
51
+
52
+ // Structural file/dir paths used by the read-only view tools (status, digest,
53
+ // queue, review-queue). Defaults match the reference vault layout and the keys
54
+ // the vault's own config exposes; any can be overridden at the top level of
55
+ // neurain.config.json so a different layout works identically.
56
+ const VAULT_PATH_DEFAULTS = {
57
+ session_state: '00_system/sessions/session-state.json',
58
+ wrap_journal: '00_system/sessions/wrap-journal.jsonl',
59
+ session_handoff_dir: '00_system/sessions/handoffs',
60
+ writeback_queue: '00_system/neurain/writeback-queue.jsonl',
61
+ writeback_queue_archive: '00_system/neurain/writeback-queue.archive.jsonl',
62
+ source_digest_manifest: '00_system/neurain/source-digest-manifest.json',
63
+ search_index_registry: '00_system/neurain/search-index-registry.json',
64
+ };
65
+
66
+ // Structural directory keys the W-B capture/queue/compile pipeline needs (raw
67
+ // capture roots, output drafts). Match the vault's neurain-config defaults and
68
+ // the recall STRUCTURAL_DEFAULTS so route/plan-writeback/compile resolve the same
69
+ // paths the legacy tools do. Overridable at the top level of neurain.config.json.
70
+ const VAULT_DIR_DEFAULTS = {
71
+ areas_dir: '10_areas',
72
+ wiki_dir: 'wiki',
73
+ system_dir: '00_system',
74
+ hubs_dir: '20_hubs',
75
+ archive_dir: '90_archive',
76
+ output_dir: 'output',
77
+ raw_dir: 'raw',
78
+ raw_inbox_dir: 'raw/_inbox',
79
+ };
80
+
81
+ const STATUS_REQUIRED_FILES_DEFAULT = [
82
+ 'AGENTS.md',
83
+ 'CLAUDE.md',
84
+ '00_system/neurain-startup.md',
85
+ 'index.md',
86
+ 'wiki/index.md',
87
+ '00_system/area-registry.md',
88
+ '00_system/sessions/session-registry.md',
89
+ ];
90
+
91
+ export function vaultConfig(root, config = readConfig(root)) {
92
+ const out = {};
93
+ for (const [key, fallback] of Object.entries(VAULT_DIR_DEFAULTS)) {
94
+ out[key] = typeof config[key] === 'string' && config[key] ? config[key] : fallback;
95
+ }
96
+ for (const [key, fallback] of Object.entries(VAULT_PATH_DEFAULTS)) {
97
+ out[key] = typeof config[key] === 'string' && config[key] ? config[key] : fallback;
98
+ }
99
+ const r = asObject(config.recall);
100
+ out.status_required_files = Array.isArray(r.status_required_files) ? r.status_required_files : STATUS_REQUIRED_FILES_DEFAULT;
101
+ return out;
102
+ }
103
+
104
+ // Merge the recall block over engine defaults. Structural dir keys are read from
105
+ // the top-level config (matching neurain.config.json's existing shape); the
106
+ // recall-specific knobs live under `config.recall`. Every field is optional and
107
+ // defaulted, so the reference vault (which has no config file yet) hits the same
108
+ // behavior as an explicitly configured adopter.
109
+ export function recallConfig(root, config = readConfig(root)) {
110
+ const structural = {};
111
+ for (const [key, fallback] of Object.entries(STRUCTURAL_DEFAULTS)) {
112
+ structural[key] = typeof config[key] === 'string' && config[key] ? config[key] : fallback;
113
+ }
114
+ const r = asObject(config.recall);
115
+ const labels = asObject(r.labels);
116
+ const intel = asObject(r.intel);
117
+ const routing = asObject(r.routing);
118
+ const bench = asObject(r.bench);
119
+ return {
120
+ ...structural,
121
+ include: Array.isArray(r.include) ? r.include : RECALL_DEFAULTS.include,
122
+ exclude: Array.isArray(r.exclude) ? r.exclude : RECALL_DEFAULTS.exclude,
123
+ labels: {
124
+ default_sensitivity: labels.default_sensitivity || RECALL_DEFAULTS.labels.default_sensitivity,
125
+ path_markers: Array.isArray(labels.path_markers) ? labels.path_markers : RECALL_DEFAULTS.labels.path_markers,
126
+ areas: asObject(labels.areas),
127
+ },
128
+ intel: {
129
+ registry: intel.registry || RECALL_DEFAULTS.intel.registry,
130
+ aliases: intel.aliases || RECALL_DEFAULTS.intel.aliases,
131
+ queue: intel.queue || RECALL_DEFAULTS.intel.queue,
132
+ memory_write_registry: intel.memory_write_registry || RECALL_DEFAULTS.intel.memory_write_registry,
133
+ },
134
+ routing: {
135
+ enabled: 'enabled' in routing ? routing.enabled : RECALL_DEFAULTS.routing.enabled,
136
+ max_per_layer: Number.isFinite(Number(routing.max_per_layer)) ? Number(routing.max_per_layer) : RECALL_DEFAULTS.routing.max_per_layer,
137
+ },
138
+ bench: {
139
+ baseline_source_recall: Number.isFinite(Number(bench.baseline_source_recall)) ? Number(bench.baseline_source_recall) : RECALL_DEFAULTS.bench.baseline_source_recall,
140
+ },
141
+ };
142
+ }