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,177 @@
1
+ // `plan-receipt` command (W-B). Validates an agent's declared write PLAN against
2
+ // the ACTUAL write set: required planned writes must be present, unplanned durable
3
+ // writes fail unless explicitly allowed. Its only write is an optional receipt
4
+ // under output/receipts/plan-receipts/ (never canonical knowledge). Faithful port
5
+ // of the vault neurain-plan-receipt tool. CLI-only in W-B.
6
+ import crypto from 'node:crypto';
7
+ import path from 'node:path';
8
+ import { absPath, readText, timestamp } from './fs.mjs';
9
+ import { vaultConfig } from './config.mjs';
10
+ import { atomicWriteJson } from './durable.mjs';
11
+
12
+ function readJsonAt(root, rel) {
13
+ const abs = path.join(root, String(rel));
14
+ return JSON.parse(readText(abs, '')); // throws on missing/empty -> caught by command
15
+ }
16
+
17
+ function normalizePathList(values) {
18
+ const out = [];
19
+ for (const value of Array.isArray(values) ? values : [values]) {
20
+ const rel = String(value || '').trim().replace(/\\/g, '/');
21
+ if (!rel) continue;
22
+ if (path.isAbsolute(rel) || rel === '..' || rel.startsWith('../') || rel.includes('/../')) {
23
+ throw new Error(`Unsafe path in plan/actual: ${rel}`);
24
+ }
25
+ out.push(rel);
26
+ }
27
+ return [...new Set(out)].sort();
28
+ }
29
+
30
+ function normalizePlan(value) {
31
+ return {
32
+ label: String(value.label || value.plan_id || 'write-plan'),
33
+ required_writes: normalizePathList(value.required_writes || value.required || []),
34
+ optional_writes: normalizePathList(value.optional_writes || value.optional || []),
35
+ storage_only: normalizePathList(value.storage_only || []),
36
+ };
37
+ }
38
+
39
+ function normalizeActual(value) {
40
+ return {
41
+ durable_writes: normalizePathList(value.durable_writes || value.writes || value.actual_writes || []),
42
+ storage_only: normalizePathList(value.storage_only || []),
43
+ };
44
+ }
45
+
46
+ function hash(value) {
47
+ return crypto.createHash('sha256').update(value).digest('hex');
48
+ }
49
+
50
+ function slug(value) {
51
+ return String(value || 'write-plan')
52
+ .toLowerCase()
53
+ .replace(/[^a-z0-9._-]+/g, '-')
54
+ .replace(/^-+|-+$/g, '')
55
+ .slice(0, 80) || 'write-plan';
56
+ }
57
+
58
+ export function validatePlan(plan, actual, options = {}) {
59
+ const required = new Set(plan.required_writes);
60
+ const optional = new Set(plan.optional_writes);
61
+ const allowedStorage = new Set(plan.storage_only);
62
+ const durable = new Set(actual.durable_writes);
63
+ const actualStorage = new Set(actual.storage_only);
64
+ const missingRequired = [...required].filter((rel) => !durable.has(rel));
65
+ const missingOptional = [...optional].filter((rel) => !durable.has(rel));
66
+ const plannedDurable = new Set([...required, ...optional]);
67
+ const unplannedDurable = [...durable].filter((rel) => !plannedDurable.has(rel));
68
+ const unplannedStorage = [...actualStorage].filter((rel) => !allowedStorage.has(rel));
69
+ const ok = missingRequired.length === 0 && (options.allowUnplanned || unplannedDurable.length === 0);
70
+ return {
71
+ ok,
72
+ command: 'plan-receipt',
73
+ durable_write: false,
74
+ tool: 'neurain-plan-receipt',
75
+ generated_at: timestamp(),
76
+ label: String(options.label || plan.label || 'write-plan'),
77
+ plan_hash: hash(JSON.stringify(plan)),
78
+ actual_hash: hash(JSON.stringify(actual)),
79
+ validation: {
80
+ required_writes: plan.required_writes,
81
+ optional_writes: plan.optional_writes,
82
+ actual_durable_writes: actual.durable_writes,
83
+ storage_only_allowed: plan.storage_only,
84
+ actual_storage_only: actual.storage_only,
85
+ missing_required: missingRequired,
86
+ missing_optional: missingOptional,
87
+ unplanned_durable: unplannedDurable,
88
+ unplanned_storage: unplannedStorage,
89
+ allow_unplanned: Boolean(options.allowUnplanned),
90
+ },
91
+ failures: [
92
+ ...missingRequired.map((rel) => `missing required write: ${rel}`),
93
+ ...(options.allowUnplanned ? [] : unplannedDurable.map((rel) => `unplanned durable write: ${rel}`)),
94
+ ],
95
+ notes: [
96
+ 'Required planned writes must be present in actual durable writes.',
97
+ 'Optional or idempotent planned writes can be absent but are surfaced in missing_optional.',
98
+ 'Unplanned durable writes fail unless --allow-unplanned is explicitly passed.',
99
+ ],
100
+ };
101
+ }
102
+
103
+ function runSelftest() {
104
+ const passing = validatePlan(
105
+ { label: 'selftest-pass', required_writes: ['wiki/source-summaries/example.md'], optional_writes: ['wiki/index.md'], storage_only: ['output/receipts/example.json'] },
106
+ { durable_writes: ['wiki/source-summaries/example.md'], storage_only: ['output/receipts/example.json'] },
107
+ );
108
+ const missingRequired = validatePlan(
109
+ { label: 'selftest-missing', required_writes: ['wiki/a.md'], optional_writes: [], storage_only: [] },
110
+ { durable_writes: [], storage_only: [] },
111
+ );
112
+ const unplanned = validatePlan(
113
+ { label: 'selftest-unplanned', required_writes: ['wiki/a.md'], optional_writes: [], storage_only: [] },
114
+ { durable_writes: ['wiki/a.md', 'wiki/b.md'], storage_only: [] },
115
+ );
116
+ return {
117
+ ok: passing.ok && !missingRequired.ok && !unplanned.ok,
118
+ command: 'plan-receipt',
119
+ durable_write: false,
120
+ tool: 'neurain-plan-receipt',
121
+ selftest: {
122
+ passing_ok: passing.ok,
123
+ missing_required_rejected: !missingRequired.ok,
124
+ unplanned_rejected: !unplanned.ok,
125
+ optional_missing_surfaced: passing.validation.missing_optional.includes('wiki/index.md'),
126
+ },
127
+ };
128
+ }
129
+
130
+ export async function planReceiptCommand(args) {
131
+ const root = absPath(args._[0] || args.root || process.cwd());
132
+ const vaultCfg = vaultConfig(root);
133
+
134
+ if (args.selftest) {
135
+ const result = runSelftest();
136
+ process.exitCode = result.ok ? 0 : 1;
137
+ return done(args, result);
138
+ }
139
+
140
+ let receipt;
141
+ try {
142
+ if (!args.plan) throw new Error('Missing --plan.');
143
+ if (!args.actual) throw new Error('Missing --actual.');
144
+ const plan = normalizePlan(readJsonAt(root, args.plan));
145
+ const actual = normalizeActual(readJsonAt(root, args.actual));
146
+ receipt = validatePlan(plan, actual, {
147
+ allowUnplanned: Boolean(args['allow-unplanned']),
148
+ label: String(args.label || plan.label || 'write-plan'),
149
+ });
150
+ } catch (error) {
151
+ process.exitCode = 1;
152
+ return done(args, { ok: false, command: 'plan-receipt', durable_write: false, error: error.message });
153
+ }
154
+
155
+ if (args.write) {
156
+ const stamp = timestamp().replace(/[:+]/g, '').replace('T', '-');
157
+ const rel = `${vaultCfg.output_dir}/receipts/plan-receipts/${stamp}-${slug(receipt.label)}.json`;
158
+ atomicWriteJson(path.join(root, rel), receipt);
159
+ receipt.receipt_path = rel;
160
+ receipt.durable_write = true; // a receipt file was written under output/receipts
161
+ }
162
+ process.exitCode = receipt.ok ? 0 : 1;
163
+ return done(args, receipt);
164
+ }
165
+
166
+ function done(args, payload) {
167
+ if (args.json) return { json: true, payload };
168
+ const lines = ['# Neurain Plan Receipt', '', `- OK: ${payload.ok ? 'yes' : 'no'}`];
169
+ if (payload.error) lines.push(`- Error: ${payload.error}`);
170
+ if (payload.label) lines.push(`- Label: ${payload.label}`);
171
+ if (payload.receipt_path) lines.push(`- Receipt: ${payload.receipt_path}`);
172
+ if (payload.failures && payload.failures.length) {
173
+ lines.push('', '## Failures', '');
174
+ for (const failure of payload.failures) lines.push(`- ${failure}`);
175
+ }
176
+ return { text: lines.join('\n') };
177
+ }
@@ -0,0 +1,176 @@
1
+ // `plan-writeback` command (W-B, read-only). Builds the deterministic writeback
2
+ // plan for a source: target layers (raw / wiki / area / output), confirmation
3
+ // gates (private, cross-area, current/task memory), a Light/Standard/Full scope,
4
+ // and the queue metadata a later capture would attach. Writes nothing. Faithful
5
+ // port of the vault neurain-plan-writeback tool.
6
+ import path from 'node:path';
7
+ import { absPath, timestamp } from './fs.mjs';
8
+ import { vaultConfig } from './config.mjs';
9
+ import { loadSessionState, readJsonSafe } from './vault_state.mjs';
10
+ import { firstLineTitle, inferFlushLevelFromEnvelope, inferTargetLayerFromIntent } from './classify.mjs';
11
+ import { makeEnvelope, readArgInput } from './envelope.mjs';
12
+
13
+ function handoffPathFor(vaultCfg, sessionId, session) {
14
+ return (session && session.handoff_path) || `${vaultCfg.session_handoff_dir}/${sessionId}.md`;
15
+ }
16
+
17
+ function slugFromTitle(title) {
18
+ return String(title || 'untitled')
19
+ .toLowerCase()
20
+ .replace(/['"]/g, '')
21
+ .replace(/[^a-z0-9가-힣]+/gi, '-')
22
+ .replace(/^-+|-+$/g, '')
23
+ .slice(0, 60) || 'untitled';
24
+ }
25
+
26
+ function candidateSourceSummaryPath(vaultCfg, source) {
27
+ const area = (source.area_candidates || [])[0] || 'general';
28
+ return `${vaultCfg.wiki_dir}/source-summaries/${area}_${slugFromTitle(source.title)}.md`;
29
+ }
30
+
31
+ export function buildWritebackPlan(source, vaultCfg, dayStamp) {
32
+ const targets = [];
33
+ const gates = [];
34
+ const areas = source.area_candidates || [];
35
+ const domains = source.domain_candidates || [];
36
+ const sensitivity = source.sensitivity || 'internal';
37
+ const intent = source.write_intent || 'evidence_only';
38
+
39
+ targets.push({
40
+ layer: 'raw',
41
+ path: source.raw_path || '(capture first)',
42
+ action: ['captured', 'planned', 'compiled', 'flushed'].includes(source.status) && source.raw_path ? 'preserve' : 'capture',
43
+ required: true,
44
+ });
45
+
46
+ if (intent !== 'evidence_only') {
47
+ targets.push({
48
+ layer: 'wiki',
49
+ path: candidateSourceSummaryPath(vaultCfg, source),
50
+ action: 'create_or_update_source_summary',
51
+ required: sensitivity !== 'private',
52
+ });
53
+ }
54
+
55
+ if (areas.length === 1 && ['remember', 'update_current', 'create_task'].includes(intent)) {
56
+ targets.push({
57
+ layer: 'area',
58
+ path: `${vaultCfg.areas_dir}/${areas[0]}/memory_layer/`,
59
+ action: intent === 'update_current' ? 'update_current_or_memory' : 'append_memory_candidate',
60
+ required: false,
61
+ });
62
+ }
63
+
64
+ if (intent === 'output_request') {
65
+ targets.push({
66
+ layer: 'output',
67
+ path: `${vaultCfg.output_dir}/drafts/${dayStamp}_${slugFromTitle(source.title)}.md`,
68
+ action: 'draft_output_candidate',
69
+ required: false,
70
+ });
71
+ }
72
+
73
+ targets.push({ layer: 'wiki', path: `${vaultCfg.wiki_dir}/index.md`, action: 'update_if_page_changes', required: true });
74
+ targets.push({ layer: 'wiki', path: `${vaultCfg.wiki_dir}/log.md`, action: 'append_operation', required: true });
75
+
76
+ if (sensitivity === 'private') gates.push('private source: user must choose raw-only or redacted wiki summary');
77
+ if (source.requires_user_decision) gates.push('route requires user decision');
78
+ if (areas.length > 1) gates.push('cross-area update or hub creation needs confirmation');
79
+ if (domains.includes('personal-records') && intent === 'output_request') gates.push('private material cannot enter output without explicit approval');
80
+ if (intent === 'update_current') gates.push('current cache update requires Full Flush');
81
+ if (intent === 'create_task') gates.push('task memory update requires Full Flush');
82
+
83
+ const scope = gates.length > 0 ? 'Full' : targets.length > 3 ? 'Standard' : 'Light';
84
+
85
+ return {
86
+ source_id: source.source_id,
87
+ title: source.title,
88
+ session_id: source.session_id || '',
89
+ session_scope: source.session_scope || '',
90
+ scope,
91
+ status: gates.length > 0 ? 'blocked_for_confirmation' : 'ready',
92
+ gates,
93
+ targets,
94
+ queue_metadata: {
95
+ session_id: source.session_id || '',
96
+ scope: source.session_scope || '',
97
+ flush_level: scope === 'Full' ? 'full' : inferFlushLevelFromEnvelope(source),
98
+ target_layer: inferTargetLayerFromIntent(source.write_intent),
99
+ target_path: '',
100
+ conflict_policy: 'queue_first',
101
+ handoff_path: source.handoff_path || '',
102
+ },
103
+ };
104
+ }
105
+
106
+ export async function planWritebackCommand(args) {
107
+ const root = absPath(args._[0] || args.root || process.cwd());
108
+ const vaultCfg = vaultConfig(root);
109
+ const dayStamp = timestamp().slice(0, 10);
110
+
111
+ const sourceId = args['source-id'];
112
+ const sessionId = String(args['session-id'] || '');
113
+ let session = null;
114
+ if (sessionId) {
115
+ let state = { sessions: {} };
116
+ try { state = loadSessionState(root, vaultCfg); } catch (error) {
117
+ return done(args, { ok: false, command: 'plan-writeback', durable_write: false, error: error.message });
118
+ }
119
+ session = (state.sessions || {})[sessionId];
120
+ if (!session) return done(args, { ok: false, command: 'plan-writeback', durable_write: false, error: `unknown session "${sessionId}"` });
121
+ }
122
+
123
+ let envelope;
124
+ if (sourceId) {
125
+ const envRel = `${vaultCfg.raw_inbox_dir}/${sourceId}.json`;
126
+ envelope = readJsonSafe(path.join(root, envRel), null);
127
+ if (!envelope) return done(args, { ok: false, command: 'plan-writeback', durable_write: false, error: `Envelope not found: ${envRel}` });
128
+ if (session) {
129
+ envelope.session_id = sessionId;
130
+ envelope.session_scope = session.scope || '';
131
+ envelope.handoff_path = handoffPathFor(vaultCfg, sessionId, session);
132
+ if (!(envelope.area_candidates && envelope.area_candidates.length) && session.area) envelope.area_candidates = [session.area];
133
+ }
134
+ } else {
135
+ const bodyText = readArgInput(args, root);
136
+ if (!bodyText.trim()) {
137
+ return done(args, { ok: false, command: 'plan-writeback', durable_write: false, error: 'No input provided. Pass --source-id, --file, stdin, or positional text.' });
138
+ }
139
+ envelope = makeEnvelope(root, {
140
+ sourceId: args['dry-source-id'] || `planned-${dayStamp}`,
141
+ sourceType: args.type || 'memo',
142
+ title: args.title || firstLineTitle(bodyText, 'Untitled plan'),
143
+ text: bodyText,
144
+ status: 'planned',
145
+ overrides: {
146
+ area: args.area || (session && session.area),
147
+ sensitivity: args.sensitivity,
148
+ intent: args.intent,
149
+ },
150
+ }, { vaultCfg });
151
+ if (session) {
152
+ envelope.session_id = sessionId;
153
+ envelope.session_scope = session.scope || '';
154
+ envelope.handoff_path = handoffPathFor(vaultCfg, sessionId, session);
155
+ }
156
+ }
157
+
158
+ const plan = buildWritebackPlan(envelope, vaultCfg, dayStamp);
159
+ return done(args, { ok: true, command: 'plan-writeback', durable_write: false, ...plan });
160
+ }
161
+
162
+ function done(args, payload) {
163
+ if (args.json) return { json: true, payload };
164
+ if (!payload.ok) return { text: `# Writeback Plan\n\n- ${payload.error}` };
165
+ const lines = ['# Writeback Plan', '', `- Source ID: ${payload.source_id}`, `- Title: ${payload.title}`];
166
+ if (payload.session_id) lines.push(`- Session: ${payload.session_id}`);
167
+ lines.push(`- Scope: ${payload.scope}`);
168
+ lines.push(`- Status: ${payload.status}`);
169
+ if (payload.gates.length) {
170
+ lines.push('', '## Confirmation Gates', '');
171
+ for (const gate of payload.gates) lines.push(`- ${gate}`);
172
+ }
173
+ lines.push('', '## Targets', '');
174
+ for (const target of payload.targets) lines.push(`- ${target.layer}: ${target.action} -> ${target.path}`);
175
+ return { text: lines.join('\n') };
176
+ }
@@ -0,0 +1,62 @@
1
+ // Read-only unified queue view command. Surfaces the central + per-area
2
+ // writeback queues via queue_model. Never writes any queue. Flag surface mirrors
3
+ // the vault tool so a vault shim is a pure passthrough.
4
+ import { absPath } from './fs.mjs';
5
+ import { vaultConfig } from './config.mjs';
6
+ import { loadUnifiedQueue, losslessCheck } from './queue_model.mjs';
7
+
8
+ export async function queueCommand(args) {
9
+ const root = absPath(args._[0] || args.root || process.cwd());
10
+ const vaultCfg = vaultConfig(root);
11
+ const unified = loadUnifiedQueue(root, vaultCfg);
12
+
13
+ let payload;
14
+ if (args['lossless-check']) {
15
+ payload = { ok: true, command: 'queue', durable_write: false, mode: 'lossless-check', ...losslessCheck(unified) };
16
+ } else if (args.stats) {
17
+ const byQueue = {};
18
+ const byStatus = {};
19
+ for (const r of unified.records) {
20
+ byQueue[r.queue] = (byQueue[r.queue] || 0) + 1;
21
+ byStatus[r.status] = (byStatus[r.status] || 0) + 1;
22
+ }
23
+ payload = { ok: true, command: 'queue', durable_write: false, mode: 'stats', total: unified.records.length, queues: unified.queues, by_queue: byQueue, by_status: byStatus };
24
+ } else {
25
+ let recs = unified.records;
26
+ if (args.queue) recs = recs.filter((r) => r.queue === String(args.queue));
27
+ if (args.status) recs = recs.filter((r) => r.status === String(args.status));
28
+ if (args.pending) recs = recs.filter((r) => r.status === 'pending');
29
+ payload = { ok: true, command: 'queue', durable_write: false, mode: 'list', count: recs.length, records: recs };
30
+ }
31
+
32
+ if (args.json) return { json: true, payload };
33
+ return { text: renderQueue(payload) };
34
+ }
35
+
36
+ function truncate(s, n) {
37
+ const value = String(s || '');
38
+ return value.length > n ? `${value.slice(0, n)}...` : value;
39
+ }
40
+
41
+ function renderQueue(res) {
42
+ if (res.mode === 'lossless-check') {
43
+ const lines = ['# Unified Queue Lossless Check', '', `- records: ${res.records}`, `- zero-loss (clean mapping): ${res.ok ? 'yes' : 'no'}`];
44
+ for (const l of res.leftovers) lines.push(`- UNMAPPED [${l.queue}] ${l.id}: ${l.unmapped_keys.join(', ')}`);
45
+ return lines.join('\n');
46
+ }
47
+ if (res.mode === 'stats') {
48
+ const lines = ['# Unified Queue Stats', '', `- total records: ${res.total}`];
49
+ for (const q of res.queues) {
50
+ lines.push(`- ${q.queue} (${q.format}): ${q.items} items from ${q.rows} rows${q.status_events != null ? `, ${q.status_events} status events` : ''}${q.metas != null ? `, ${q.metas} meta` : ''}`);
51
+ }
52
+ lines.push(`- by status: ${Object.entries(res.by_status).map(([k, v]) => `${k}=${v}`).join(', ')}`);
53
+ return lines.join('\n');
54
+ }
55
+ const lines = [`# Unified Queue (${res.count})`, ''];
56
+ if (!res.records.length) return `${lines.join('\n')}\nNo matching queue records.`;
57
+ for (const r of res.records) {
58
+ lines.push(`- [${r.queue}] ${r.id} (${r.status}) area=${r.area || '-'} | ${truncate(r.title || r.summary, 90)}`);
59
+ if (r.status_history && r.status_history.length > 1) lines.push(` history: ${r.status_history.map((h) => h.status).join(' -> ')}`);
60
+ }
61
+ return lines.join('\n');
62
+ }
@@ -0,0 +1,87 @@
1
+ // `queue-archive` command (W-B). Moves terminal writeback rows (compiled,
2
+ // superseded, flushed, obsolete) out of the hot queue into an append-only archive
3
+ // so the hot queue stays bounded to actionable (pending) work. Provenance is
4
+ // preserved: archived rows carry an archived_at stamp and each source still has
5
+ // its raw inbox envelope. The hot-queue rewrite re-reads inside the lock so a
6
+ // concurrent append is never lost; the archive is appended first so a crash
7
+ // leaves at worst a recoverable duplicate, never a lost row. Faithful port of the
8
+ // vault neurain-queue-archive tool.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { absPath, ensureDir, timestamp } from './fs.mjs';
12
+ import { vaultConfig } from './config.mjs';
13
+ import { atomicWriteText, readJsonlRows, withFileLock } from './durable.mjs';
14
+
15
+ const TERMINAL = new Set(['compiled', 'superseded', 'flushed', 'obsolete']);
16
+
17
+ function countBy(list) {
18
+ return list.reduce((acc, row) => { acc[row.status] = (acc[row.status] || 0) + 1; return acc; }, {});
19
+ }
20
+
21
+ export async function queueArchiveCommand(args) {
22
+ const root = absPath(args._[0] || args.root || process.cwd());
23
+ const vaultCfg = vaultConfig(root);
24
+ const queueAbs = path.join(root, vaultCfg.writeback_queue);
25
+ const archiveRel = vaultCfg.writeback_queue_archive;
26
+ const archiveAbs = path.join(root, archiveRel);
27
+ const dryRun = Boolean(args['dry-run']);
28
+ const olderThanDays = args['older-than-days'] !== undefined ? Number(args['older-than-days']) : null;
29
+ const now = Date.now();
30
+
31
+ if (!fs.existsSync(queueAbs)) {
32
+ return done(args, { ok: true, command: 'queue-archive', durable_write: false, note: 'No queue file.', archived: 0, kept: 0, archive_path: archiveRel });
33
+ }
34
+
35
+ function rowAgeDays(row) {
36
+ const parsed = Date.parse(row.compiled_at || row.queued_at || '');
37
+ if (Number.isNaN(parsed)) return Infinity;
38
+ return (now - parsed) / (1000 * 60 * 60 * 24);
39
+ }
40
+ function isArchivable(row) {
41
+ if (!TERMINAL.has(row.status)) return false;
42
+ if (olderThanDays === null) return true;
43
+ return rowAgeDays(row) >= olderThanDays;
44
+ }
45
+
46
+ const rows = readJsonlRows(queueAbs);
47
+ const toArchive = rows.filter(isArchivable);
48
+ const toKeep = rows.filter((row) => !isArchivable(row));
49
+ const willWrite = !dryRun && toArchive.length > 0;
50
+
51
+ if (willWrite) {
52
+ withFileLock(queueAbs, () => {
53
+ const freshRows = readJsonlRows(queueAbs);
54
+ const freshArchive = freshRows.filter(isArchivable);
55
+ const freshKeep = freshRows.filter((row) => !isArchivable(row));
56
+ if (freshArchive.length === 0) return;
57
+ const stamped = freshArchive.map((row) => JSON.stringify({ ...row, archived_at: timestamp() }));
58
+ ensureDir(path.dirname(archiveAbs));
59
+ fs.appendFileSync(archiveAbs, `${stamped.join('\n')}\n`);
60
+ atomicWriteText(queueAbs, freshKeep.length ? `${freshKeep.map((row) => JSON.stringify(row)).join('\n')}\n` : '');
61
+ });
62
+ }
63
+
64
+ const payload = {
65
+ ok: true,
66
+ command: 'queue-archive',
67
+ durable_write: willWrite,
68
+ dry_run: dryRun,
69
+ hot_queue_before: rows.length,
70
+ hot_queue_after: toKeep.length,
71
+ archived: toArchive.length,
72
+ archive_path: archiveRel,
73
+ older_than_days: olderThanDays,
74
+ kept_status_breakdown: countBy(toKeep),
75
+ archived_status_breakdown: countBy(toArchive),
76
+ };
77
+ return done(args, payload);
78
+ }
79
+
80
+ function done(args, payload) {
81
+ if (args.json) return { json: true, payload };
82
+ const lines = ['# Queue Archive', ''];
83
+ lines.push(`- Dry run: ${payload.dry_run ? 'yes' : 'no'}`);
84
+ lines.push(`- Hot queue: ${payload.hot_queue_before ?? 0} -> ${payload.hot_queue_after ?? 0}`);
85
+ lines.push(`- Archived: ${payload.archived} to ${payload.archive_path}`);
86
+ return { text: lines.join('\n') };
87
+ }
@@ -0,0 +1,161 @@
1
+ // Read-only unified queue model. Port of the vault queue-model: reads the central
2
+ // flat writeback queue plus every per-area queue registered in the search index
3
+ // registry (flat or event-sourced queue_item/queue_status/queue_meta) and folds
4
+ // both into one canonical record set. Losslessness is by construction: every
5
+ // source key is either mapped to a canonical field or retained in `extra`;
6
+ // losslessCheck reports any unmapped key so a future write cutover can be proven
7
+ // zero-loss. Strictly read-only; writes nothing.
8
+ import path from 'node:path';
9
+ import { readJsonl, readJsonSafe } from './vault_state.mjs';
10
+
11
+ function normalizeFlatRow(row, queue) {
12
+ const extra = { ...row };
13
+ const take = (key) => {
14
+ const value = extra[key];
15
+ delete extra[key];
16
+ return value;
17
+ };
18
+ const areaCandidates = take('area_candidates') || [];
19
+ return {
20
+ queue,
21
+ format: 'flat',
22
+ id: take('source_id') || take('queue_id') || '',
23
+ status: take('status') || 'pending',
24
+ recorded_at: take('queued_at') || take('recorded_at') || '',
25
+ title: take('title') || '',
26
+ summary: take('summary') || '',
27
+ area: (Array.isArray(areaCandidates) ? areaCandidates[0] : areaCandidates) || (queue === 'root' ? '' : queue),
28
+ area_candidates: areaCandidates,
29
+ raw_path: take('raw_path') || '',
30
+ source_docs: take('source_docs') || [],
31
+ raw_input: take('raw_input') || '',
32
+ input_sha256: take('input_sha256') || '',
33
+ speed_policy: take('speed_policy') || '',
34
+ capture_id: take('capture_id') || '',
35
+ capture_title: take('capture_title') || '',
36
+ capture_envelope: take('capture_envelope') || null,
37
+ plan_summary: take('plan_summary') || null,
38
+ write_intent: take('write_intent') || '',
39
+ sensitivity: take('sensitivity') || '',
40
+ flush_level: take('flush_level') || '',
41
+ target_layer: take('target_layer') || '',
42
+ target_path: take('target_path') || '',
43
+ conflict_policy: take('conflict_policy') || '',
44
+ session_id: take('session_id') || '',
45
+ scope: take('scope') || '',
46
+ handoff_path: take('handoff_path') || '',
47
+ requires_user_decision: take('requires_user_decision') ?? false,
48
+ compiled_at: take('compiled_at') || '',
49
+ compiled_to: take('compiled_to') || '',
50
+ completed_at: take('completed_at') || '',
51
+ superseded_by: take('superseded_by') || '',
52
+ status_history: [],
53
+ extra,
54
+ };
55
+ }
56
+
57
+ function normalizeEventSourced(rows, queue) {
58
+ const items = new Map();
59
+ const statuses = new Map();
60
+ const metas = [];
61
+ for (const row of rows) {
62
+ if (row.record_type === 'queue_item' && row.queue_id) items.set(row.queue_id, row);
63
+ else if (row.record_type === 'queue_status' && row.queue_id) {
64
+ if (!statuses.has(row.queue_id)) statuses.set(row.queue_id, []);
65
+ statuses.get(row.queue_id).push(row);
66
+ } else metas.push(row);
67
+ }
68
+ const records = [];
69
+ let statusEvents = 0;
70
+ for (const [qid, item] of items) {
71
+ const extra = { ...item };
72
+ const take = (key) => {
73
+ const value = extra[key];
74
+ delete extra[key];
75
+ return value;
76
+ };
77
+ take('record_type');
78
+ const initialStatus = take('status') || 'pending';
79
+ const hist = (statuses.get(qid) || []).slice().sort((a, b) => String(a.recorded_at).localeCompare(String(b.recorded_at)));
80
+ statusEvents += hist.length;
81
+ records.push({
82
+ queue,
83
+ format: 'event-sourced',
84
+ id: take('queue_id') || qid,
85
+ status: hist.length ? hist[hist.length - 1].status : initialStatus,
86
+ recorded_at: take('recorded_at') || '',
87
+ title: take('capture_title') || '',
88
+ summary: take('summary') || '',
89
+ area: queue,
90
+ area_candidates: [queue],
91
+ raw_path: take('raw_path') || '',
92
+ source_docs: take('source_docs') || [],
93
+ raw_input: take('raw_input') || '',
94
+ input_sha256: take('input_sha256') || '',
95
+ speed_policy: take('speed_policy') || '',
96
+ capture_id: take('capture_id') || '',
97
+ capture_title: '',
98
+ capture_envelope: take('capture_envelope') || null,
99
+ plan_summary: take('plan_summary') || null,
100
+ write_intent: '',
101
+ sensitivity: '',
102
+ flush_level: '',
103
+ target_layer: '',
104
+ target_path: '',
105
+ conflict_policy: '',
106
+ session_id: '',
107
+ scope: '',
108
+ handoff_path: '',
109
+ requires_user_decision: false,
110
+ initial_status: initialStatus,
111
+ status_history: [
112
+ { recorded_at: item.recorded_at || '', status: initialStatus, note: 'queued' },
113
+ ...hist.map((h) => ({ recorded_at: h.recorded_at || '', status: h.status || '', note: h.note || '' })),
114
+ ],
115
+ extra,
116
+ });
117
+ }
118
+ return { records, metas, statusEvents };
119
+ }
120
+
121
+ export function loadUnifiedQueue(root, vaultCfg) {
122
+ const registry = readJsonSafe(path.join(root, vaultCfg.search_index_registry), { areas: {} });
123
+ const records = [];
124
+ const queues = [];
125
+ const metas = [];
126
+
127
+ const rootRows = readJsonl(path.join(root, vaultCfg.writeback_queue));
128
+ for (const row of rootRows) records.push(normalizeFlatRow(row, 'root'));
129
+ queues.push({ queue: 'root', path: vaultCfg.writeback_queue, rows: rootRows.length, items: rootRows.length, format: 'flat' });
130
+
131
+ for (const [area, entry] of Object.entries(registry.areas || {})) {
132
+ if (!entry || !entry.writeback_queue || !entry.area_root) continue;
133
+ const relPath = `${entry.area_root}/${entry.writeback_queue}`;
134
+ const rows = readJsonl(path.join(root, relPath));
135
+ const eventSourced = rows.some((r) => r && r.record_type);
136
+ if (eventSourced) {
137
+ const folded = normalizeEventSourced(rows, area);
138
+ for (const rec of folded.records) records.push(rec);
139
+ for (const meta of folded.metas) metas.push({ queue: area, ...meta });
140
+ queues.push({ queue: area, path: relPath, rows: rows.length, items: folded.records.length, status_events: folded.statusEvents, metas: folded.metas.length, format: 'event-sourced' });
141
+ } else {
142
+ for (const row of rows) records.push(normalizeFlatRow(row, area));
143
+ queues.push({ queue: area, path: relPath, rows: rows.length, items: rows.length, format: 'flat' });
144
+ }
145
+ }
146
+ return { records, queues, metas };
147
+ }
148
+
149
+ export function losslessCheck(unified) {
150
+ const leftovers = [];
151
+ for (const rec of unified.records) {
152
+ const keys = Object.keys(rec.extra || {});
153
+ if (keys.length) leftovers.push({ queue: rec.queue, id: rec.id, unmapped_keys: keys });
154
+ }
155
+ return {
156
+ ok: leftovers.length === 0,
157
+ records: unified.records.length,
158
+ meta_records: (unified.metas || []).length,
159
+ leftovers,
160
+ };
161
+ }