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,194 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { absPath, safeResolve, writeFileNoOverwrite } from './fs.mjs';
4
+
5
+ const dirs = [
6
+ '00_system/neurain',
7
+ '00_system/sessions/handoffs',
8
+ '10_areas',
9
+ '20_hubs',
10
+ '90_archive',
11
+ 'raw/_inbox',
12
+ 'wiki',
13
+ 'output',
14
+ 'output/receipts/events',
15
+ 'output/receipts/recall',
16
+ ];
17
+
18
+ export async function initCommand(args) {
19
+ const root = absPath(args._[0]);
20
+ const dryRun = Boolean(args['dry-run']);
21
+ const lang = args.lang === 'en' ? 'en' : 'ko';
22
+ const area = String(args.area || 'general').replace(/^_+/, '') || 'general';
23
+ const created = [];
24
+ const skipped = [];
25
+
26
+ for (const rel of dirs) {
27
+ const dir = safeResolve(root, rel);
28
+ if (fs.existsSync(dir)) skipped.push(`${rel}/`);
29
+ else {
30
+ if (!dryRun) fs.mkdirSync(dir, { recursive: true });
31
+ created.push(`${rel}/`);
32
+ }
33
+ }
34
+
35
+ const files = starterFiles(area, lang);
36
+ for (const [rel, content] of Object.entries(files)) {
37
+ writeFileNoOverwrite(safeResolve(root, rel), content, { dryRun, created, skipped });
38
+ }
39
+
40
+ const summary = {
41
+ ok: true,
42
+ command: 'init',
43
+ root,
44
+ dry_run: dryRun,
45
+ created: created.map((file) => path.relative(root, file).split(path.sep).join('/')).map((v) => v || '.'),
46
+ skipped: skipped.map((file) => (path.isAbsolute(file) ? path.relative(root, file).split(path.sep).join('/') : file)),
47
+ next_steps: [
48
+ `Run: neurain doctor "${root}"`,
49
+ `Run: neurain connect codex "${root}" --dry-run`,
50
+ `Ask your agent to use Neurain status before work and Neurain capture after important work.`,
51
+ ],
52
+ };
53
+
54
+ if (args.json) return { json: true, payload: summary };
55
+ return {
56
+ text: [
57
+ `# Neurain init${dryRun ? ' [dry-run]' : ''}`,
58
+ '',
59
+ `- Root: ${root}`,
60
+ `- Created: ${summary.created.length}`,
61
+ `- Skipped existing: ${summary.skipped.length}`,
62
+ '',
63
+ 'Next:',
64
+ ...summary.next_steps.map((step) => `- ${step}`),
65
+ ].join('\n'),
66
+ };
67
+ }
68
+
69
+ export function starterFiles(area, lang = 'ko') {
70
+ const areaRoot = `10_areas/${area}`;
71
+ const areaTitle = area.replace(/[-_]+/g, ' ');
72
+ return {
73
+ 'neurain.config.json': `${JSON.stringify({
74
+ version: 1,
75
+ language_default: lang,
76
+ areas_dir: '10_areas',
77
+ wiki_dir: 'wiki',
78
+ raw_dir: 'raw',
79
+ output_dir: 'output',
80
+ created_by: 'neurain-cli',
81
+ }, null, 2)}\n`,
82
+ 'AGENTS.md': lang === 'en' ? agentsEn() : agentsKo(),
83
+ 'CLAUDE.md': lang === 'en' ? agentsEn('CLAUDE') : agentsKo('CLAUDE'),
84
+ 'README.md': readme(lang),
85
+ 'index.md': `# Neurain Vault\n\n## Areas\n\n- [${areaTitle}](${areaRoot}/index.md)\n`,
86
+ 'log.md': '# Vault Log\n\n',
87
+ '00_system/neurain-startup.md': startup(lang),
88
+ '00_system/neurain/neurain-rules.md': rules(lang),
89
+ '00_system/neurain/lessons.md': lessons(lang),
90
+ '00_system/neurain/events.ndjson': '',
91
+ '00_system/neurain/search-index-registry.json': '{\n "areas": {}\n}\n',
92
+ '00_system/neurain/memory-write-registry.json': '{\n "version": 1,\n "areas": {}\n}\n',
93
+ '00_system/sessions/session-state.json': '{\n "version": 1,\n "sessions": {}\n}\n',
94
+ '00_system/sessions/session-registry.md': '# Session Registry\n\n',
95
+ '00_system/sessions/handoffs/.gitkeep': '',
96
+ 'wiki/index.md': '# Wiki Index\n\n',
97
+ 'wiki/log.md': '# Wiki Log\n\n',
98
+ [`${areaRoot}/_area.md`]: `# ${areaTitle}\n\n- purpose: describe this work area\n- sensitivity: internal\n- source status: native\n`,
99
+ [`${areaRoot}/index.md`]: `# ${areaTitle} Index\n\nAdd key documents here.\n`,
100
+ [`${areaRoot}/log.md`]: `# ${areaTitle} Log\n\n`,
101
+ [`${areaRoot}/sources_map.md`]: `# ${areaTitle} Sources Map\n\nMap source files to Neurain layers here.\n`,
102
+ [`${areaRoot}/current/${area}-area-brief.md`]: `# ${areaTitle} Area Brief\n\nCurrent working context will appear here.\n`,
103
+ };
104
+ }
105
+
106
+ function readme(lang) {
107
+ if (lang === 'en') {
108
+ return '# Neurain Vault\n\nThis folder is a local-first AI Knowledge OS vault. Your files stay local unless your AI host reads them with your approval.\n\nStart with `neurain doctor .`.\n';
109
+ }
110
+ return '# Neurain Vault\n\n이 폴더는 local-first AI Knowledge OS vault입니다. 사용자의 파일은 사용자가 연결한 AI host가 읽기 전까지 로컬에 남습니다.\n\n먼저 `neurain doctor .`를 실행하세요.\n';
111
+ }
112
+
113
+ function startup(lang) {
114
+ return lang === 'en'
115
+ ? '# Neurain Startup\n\nRead this before using Neurain. Check status, search source-grounded knowledge, capture important work, and run wrap dry-run after meaningful work so future sessions can learn from lesson candidates.\n'
116
+ : '# Neurain Startup\n\nNeurain을 쓰기 전에 이 문서를 읽으세요. 상태를 확인하고, 근거 기반으로 검색하고, 중요한 작업을 캡처합니다. 의미 있는 작업 후에는 wrap dry-run을 실행해 다음 세션이 lesson 후보를 보고 더 잘 이어받게 합니다.\n';
117
+ }
118
+
119
+ function rules(lang) {
120
+ return lang === 'en'
121
+ ? '# Neurain Rules\n\n- Preserve source evidence before durable interpretation.\n- Ask before risky writes.\n- Keep private material local.\n- Lessons are previewed before promotion. Public alpha does not silently promote lessons.\n'
122
+ : '# Neurain Rules\n\n- 오래 남길 해석 전에 원본 근거를 보존합니다.\n- 위험한 쓰기는 먼저 확인합니다.\n- private 자료는 로컬에 둡니다.\n- lesson은 승격 전에 먼저 미리보기로 확인합니다. public alpha는 lesson을 조용히 승격하지 않습니다.\n';
123
+ }
124
+
125
+ function lessons(lang) {
126
+ if (lang === 'en') {
127
+ return `# Neurain Lessons
128
+
129
+ Durable lessons for recurring agent mistakes and corrections.
130
+
131
+ ## Rules
132
+
133
+ - Add a lesson only when a mistake or correction is likely to recur.
134
+ - Keep each lesson short and source-grounded.
135
+ - Include trigger, correction, scope, status, and sensitivity.
136
+ - Do not store private source details here.
137
+ - Public alpha can list and preview lesson candidates, but cannot silently promote lessons.
138
+
139
+ ## Template
140
+
141
+ \`\`\`markdown
142
+ ## YYYY-MM-DD | short lesson title
143
+
144
+ - Trigger:
145
+ - Correction:
146
+ - Scope: global | area | session
147
+ - Status: active | stale | archived | obsolete
148
+ - Pinned: true | false
149
+ - Sensitivity: internal
150
+ \`\`\`
151
+
152
+ ## Active Lessons
153
+
154
+ No durable lessons recorded yet.
155
+ `;
156
+ }
157
+ return `# Neurain Lessons
158
+
159
+ 반복되는 agent 실수와 correction을 위한 durable lesson registry입니다.
160
+
161
+ ## Rules
162
+
163
+ - 반복될 가능성이 큰 실수나 correction만 lesson으로 남깁니다.
164
+ - 각 lesson은 짧고 source-grounded해야 합니다.
165
+ - trigger, correction, scope, status, sensitivity를 포함합니다.
166
+ - private source details는 여기에 저장하지 않습니다.
167
+ - public alpha는 lesson list와 candidate preview만 제공하며, lesson을 조용히 승격하지 않습니다.
168
+
169
+ ## Template
170
+
171
+ \`\`\`markdown
172
+ ## YYYY-MM-DD | short lesson title
173
+
174
+ - Trigger:
175
+ - Correction:
176
+ - Scope: global | area | session
177
+ - Status: active | stale | archived | obsolete
178
+ - Pinned: true | false
179
+ - Sensitivity: internal
180
+ \`\`\`
181
+
182
+ ## Active Lessons
183
+
184
+ No durable lessons recorded yet.
185
+ `;
186
+ }
187
+
188
+ function agentsEn(name = 'AGENTS') {
189
+ return `# ${name}.md\n\nNeurain is local-first. Use the local CLI or MCP server to inspect status, search, and capture. Do not expose private files unless the user asks.\n`;
190
+ }
191
+
192
+ function agentsKo(name = 'AGENTS') {
193
+ return `# ${name}.md\n\nNeurain은 local-first입니다. 로컬 CLI 또는 MCP server로 상태 확인, 검색, 캡처를 수행합니다. 사용자가 요청하지 않은 private 파일은 노출하지 않습니다.\n`;
194
+ }
@@ -0,0 +1,269 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { absPath, compactStamp, ensureDir, readText, safeResolve, sha256, timestamp } from './fs.mjs';
5
+ import { inferSensitivityFromPath, maxSensitivity, redactedPreview } from './safety.mjs';
6
+
7
+ export const journalRel = '00_system/neurain/events.ndjson';
8
+ const receiptDirRel = 'output/receipts/events';
9
+ const allowedTypes = new Set([
10
+ 'wrap',
11
+ 'review',
12
+ 'test',
13
+ 'correction',
14
+ 'adoption',
15
+ 'rollback',
16
+ 'sync',
17
+ 'lesson',
18
+ 'recall',
19
+ 'curator',
20
+ 'watch',
21
+ 'lifecycle',
22
+ 'note',
23
+ ]);
24
+
25
+ export async function journalCommand(args) {
26
+ const [subcommand, ...rest] = args._;
27
+ const root = absPath(rest[0] || args.root || process.cwd());
28
+ if (!subcommand || subcommand === 'list') return renderJournalList(root, args);
29
+ if (subcommand === 'add') return renderJournalAdd(root, args);
30
+ if (subcommand === 'verify') return renderJournalVerify(root, args);
31
+ throw new Error(`Unknown journal command: ${subcommand}. Use "journal list", "journal add", or "journal verify".`);
32
+ }
33
+
34
+ export function appendJournalEvent(root, {
35
+ type = 'note',
36
+ summary = '',
37
+ source = '',
38
+ host = '',
39
+ scope = 'global',
40
+ confirm = '',
41
+ dryRun = false,
42
+ metadata = null,
43
+ } = {}) {
44
+ const normalizedType = normalizeType(type);
45
+ const rawSummary = String(summary || '');
46
+ if (!rawSummary.trim()) throw new Error('Journal event requires --summary <text>.');
47
+ if (rawSummary.length > 20000) throw new Error('Journal summary is too large. Keep it under 20000 characters.');
48
+ if (!dryRun && String(confirm || '') !== '1건 저장 진행') {
49
+ throw new Error('Journal add requires --confirm "1건 저장 진행".');
50
+ }
51
+ const sourceIds = normalizeSourceIds(source);
52
+ const preview = redactedPreview(rawSummary, 500);
53
+ const sensitivity = maxSensitivity([
54
+ ...sourceIds.map((item) => inferSensitivityFromPath(item)),
55
+ preview.secret_like ? 'private' : 'internal',
56
+ String(scope || '').includes('private') ? 'private' : 'internal',
57
+ ]);
58
+ const eventId = `event-${crypto.randomUUID()}`;
59
+ const receiptRel = `${receiptDirRel}/${compactStamp()}-${normalizedType}-${eventId.slice(-12)}.json`;
60
+ const event = {
61
+ version: 1,
62
+ event_id: eventId,
63
+ type: normalizedType,
64
+ created_at: timestamp(),
65
+ scope: String(scope || 'global'),
66
+ host: String(host || 'cli'),
67
+ source_ids: sourceIds,
68
+ receipt_path: receiptRel,
69
+ sensitivity,
70
+ prompt_context_allowed: !preview.secret_like && !preview.injection_like && sensitivity !== 'private',
71
+ summary: preview.text,
72
+ redacted: preview.redacted,
73
+ safety: {
74
+ secret_like: preview.secret_like,
75
+ injection_like: preview.injection_like,
76
+ indexing_allowed: !preview.secret_like && !preview.injection_like && sensitivity !== 'private',
77
+ cross_host_allowed: !preview.secret_like && !preview.injection_like && sensitivity !== 'private',
78
+ },
79
+ };
80
+ if (metadata && typeof metadata === 'object') event.metadata = metadata;
81
+ event.event_hash = hashEvent(event);
82
+
83
+ const line = `${JSON.stringify(event)}\n`;
84
+ const receipt = {
85
+ ok: true,
86
+ command: 'journal add',
87
+ created_at: timestamp(),
88
+ dry_run: Boolean(dryRun),
89
+ durable_write: !dryRun,
90
+ event_id: event.event_id,
91
+ event_hash: event.event_hash,
92
+ line_hash: sha256(line),
93
+ line_hash_status: dryRun ? 'planned' : 'observed_after_append',
94
+ event_log_path: journalRel,
95
+ source_ids: sourceIds,
96
+ sensitivity,
97
+ redacted: event.redacted,
98
+ safety: event.safety,
99
+ writes: dryRun ? [] : [{ path: journalRel, action: 'append' }],
100
+ };
101
+ receipt.receipt_path = receiptRel;
102
+
103
+ if (!dryRun) {
104
+ const journalAbs = safeResolve(root, journalRel);
105
+ const receiptAbs = safeResolve(root, receiptRel);
106
+ ensureDir(path.dirname(journalAbs));
107
+ ensureDir(path.dirname(receiptAbs));
108
+ fs.writeFileSync(receiptAbs, `${JSON.stringify({
109
+ ...receipt,
110
+ ok: false,
111
+ pending: true,
112
+ line_hash_status: 'expected_pending_append',
113
+ }, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
114
+ fs.appendFileSync(journalAbs, line, 'utf8');
115
+ fs.writeFileSync(receiptAbs, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
116
+ }
117
+
118
+ return {
119
+ ok: true,
120
+ command: 'journal add',
121
+ dry_run: Boolean(dryRun),
122
+ durable_write: !dryRun,
123
+ event,
124
+ receipt_path: dryRun ? null : receiptRel,
125
+ receipt,
126
+ };
127
+ }
128
+
129
+ export function listJournalEvents(root, { limit = 20, type = '' } = {}) {
130
+ const rows = readJournalRows(root);
131
+ const normalizedType = type ? normalizeType(type) : '';
132
+ const events = rows
133
+ .filter((row) => row.ok)
134
+ .map((row) => row.event)
135
+ .filter((event) => !normalizedType || event.type === normalizedType)
136
+ .slice(-Number(limit || 20))
137
+ .reverse();
138
+ return {
139
+ ok: true,
140
+ command: 'journal list',
141
+ durable_write: false,
142
+ event_log_path: journalRel,
143
+ count: events.length,
144
+ events,
145
+ };
146
+ }
147
+
148
+ export function verifyJournal(root) {
149
+ const rows = readJournalRows(root);
150
+ const errors = [];
151
+ for (const row of rows) {
152
+ if (!row.ok) {
153
+ errors.push({ line: row.line, reason: row.reason });
154
+ continue;
155
+ }
156
+ const expected = hashEvent(row.event);
157
+ if (expected !== row.event.event_hash) {
158
+ errors.push({ line: row.line, event_id: row.event.event_id, reason: 'event_hash mismatch' });
159
+ }
160
+ }
161
+ return {
162
+ ok: errors.length === 0,
163
+ command: 'journal verify',
164
+ durable_write: false,
165
+ event_log_path: journalRel,
166
+ line_count: rows.length,
167
+ error_count: errors.length,
168
+ errors,
169
+ };
170
+ }
171
+
172
+ function renderJournalAdd(root, args) {
173
+ const result = appendJournalEvent(root, {
174
+ type: args.type || 'note',
175
+ summary: args.summary || args.message || '',
176
+ source: args.source || '',
177
+ host: args.host || 'cli',
178
+ scope: args.scope || 'global',
179
+ confirm: args.confirm || '',
180
+ dryRun: Boolean(args['dry-run']),
181
+ });
182
+ if (args.json) return { json: true, payload: result };
183
+ return {
184
+ text: [
185
+ `# Neurain journal add${result.dry_run ? ' [dry-run]' : ''}`,
186
+ '',
187
+ `- OK: ${result.ok ? 'yes' : 'no'}`,
188
+ `- Event: ${result.event.event_id}`,
189
+ `- Type: ${result.event.type}`,
190
+ `- Durable write: ${result.durable_write ? 'yes' : 'no'}`,
191
+ `- Redacted: ${result.event.redacted ? 'yes' : 'no'}`,
192
+ result.receipt_path ? `- Receipt: ${result.receipt_path}` : '',
193
+ ].filter(Boolean).join('\n'),
194
+ };
195
+ }
196
+
197
+ function renderJournalList(root, args) {
198
+ const result = listJournalEvents(root, { limit: Number(args.top || args.limit || 20), type: args.type || '' });
199
+ if (args.json) return { json: true, payload: result };
200
+ return {
201
+ text: [
202
+ '# Neurain journal',
203
+ '',
204
+ `- Events: ${result.count}`,
205
+ '- Durable write: no',
206
+ '',
207
+ ...result.events.map((event) => [
208
+ `## ${event.event_id}`,
209
+ `- Type: ${event.type}`,
210
+ `- Created: ${event.created_at}`,
211
+ `- Sensitivity: ${event.sensitivity}`,
212
+ `- Prompt context allowed: ${event.prompt_context_allowed ? 'yes' : 'no'}`,
213
+ `- Summary: ${event.summary}`,
214
+ ].join('\n')),
215
+ ].join('\n'),
216
+ };
217
+ }
218
+
219
+ function renderJournalVerify(root, args) {
220
+ const result = verifyJournal(root);
221
+ if (args.json) return { json: true, payload: result };
222
+ return {
223
+ text: [
224
+ '# Neurain journal verify',
225
+ '',
226
+ `- OK: ${result.ok ? 'yes' : 'no'}`,
227
+ `- Lines: ${result.line_count}`,
228
+ `- Errors: ${result.error_count}`,
229
+ ].join('\n'),
230
+ };
231
+ }
232
+
233
+ function readJournalRows(root) {
234
+ const text = readText(safeResolve(root, journalRel), '');
235
+ return text.split(/\r?\n/).map((line, index) => ({ line, index })).filter((row) => row.line.trim()).map((row) => {
236
+ try {
237
+ const event = JSON.parse(row.line);
238
+ return { ok: true, line: row.index + 1, event };
239
+ } catch {
240
+ return { ok: false, line: row.index + 1, reason: 'invalid json' };
241
+ }
242
+ });
243
+ }
244
+
245
+ function normalizeType(value) {
246
+ const type = String(value || 'note').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
247
+ if (!allowedTypes.has(type)) throw new Error(`Unsupported journal event type: ${value}`);
248
+ return type;
249
+ }
250
+
251
+ function normalizeSourceIds(source) {
252
+ if (!source) return [];
253
+ if (Array.isArray(source)) return source.flatMap((item) => normalizeSourceIds(item));
254
+ return String(source).split(',').map((item) => item.trim()).filter(Boolean).map((item) => item.replace(/\\/g, '/'));
255
+ }
256
+
257
+ function hashEvent(event) {
258
+ const copy = { ...event };
259
+ delete copy.event_hash;
260
+ return sha256(stableJson(copy));
261
+ }
262
+
263
+ function stableJson(value) {
264
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
265
+ if (value && typeof value === 'object') {
266
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
267
+ }
268
+ return JSON.stringify(value);
269
+ }
@@ -0,0 +1,117 @@
1
+ import path from 'node:path';
2
+ import { readText } from './fs.mjs';
3
+ import { maxSensitivity } from './safety.mjs';
4
+
5
+ // Default private path markers for the MARKDOWN recall corpus. Matching uses a
6
+ // word-ish boundary (any non letter/digit bounds a marker), so `_private`,
7
+ // `private-area-brief`, and `personal_records` are caught while a marker that
8
+ // merely appears as a SUBSTRING of a longer word (e.g. `credential` inside
9
+ // `credentialing-notes`) is NOT. The old engine gate used a plain substring test
10
+ // on safety.inferSensitivityFromPath, whose markers could exclude a whole
11
+ // legitimate knowledge folder; that is the bug this module fixes. `token` and
12
+ // `recovery` are intentionally omitted from the markdown vocabulary (too many
13
+ // false positives in real knowledge folders);
14
+ // event/receipt metadata still uses safety.inferSensitivityFromPath unchanged,
15
+ // where stricter is harmless.
16
+ export const DEFAULT_PATH_MARKERS = [
17
+ 'private', 'secrets?', 'credentials?', 'passwords?', 'keychain',
18
+ 'personal[_-]?records', '\\.env',
19
+ ];
20
+
21
+ const SCALAR_LINE = /^([A-Za-z0-9_-]+):\s*(.*)$/;
22
+
23
+ // Parse the leading `--- ... ---` frontmatter into a flat map of scalar fields,
24
+ // without a YAML dependency (engine has none). Only top-of-file scalars are
25
+ // read; list and nested values are ignored because the only field we need is a
26
+ // scalar (`sensitivity:`). Stricter than a whole-file grep, so a body that
27
+ // merely mentions `sensitivity:` mid-document is never misread as a label.
28
+ export function parseFrontmatter(text) {
29
+ const value = String(text || '');
30
+ if (!/^---\r?\n/.test(value)) return {};
31
+ const lines = value.split(/\r?\n/);
32
+ const out = {};
33
+ for (let i = 1; i < lines.length && i < 200; i += 1) {
34
+ if (lines[i].trim() === '---') break;
35
+ const match = lines[i].match(SCALAR_LINE);
36
+ if (match) out[match[1].toLowerCase()] = match[2].trim();
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function normalizeSensitivity(value) {
42
+ const text = String(value || '').toLowerCase();
43
+ if (/\bprivate\b/.test(text)) return 'private';
44
+ if (/\bpublic\b/.test(text)) return 'public';
45
+ return 'internal';
46
+ }
47
+
48
+ // Compile marker source strings (trusted regex fragments from defaults or
49
+ // config) into boundary-wrapped regexes. An unparseable fragment is skipped, not
50
+ // thrown, so one bad config entry never disables sensitivity gating wholesale.
51
+ export function compileMarkers(markers) {
52
+ const compiled = [];
53
+ for (const marker of markers || []) {
54
+ try {
55
+ compiled.push(new RegExp(`(^|[^\\p{L}\\p{N}])(${String(marker)})($|[^\\p{L}\\p{N}])`, 'iu'));
56
+ } catch {
57
+ /* skip invalid marker */
58
+ }
59
+ }
60
+ return compiled;
61
+ }
62
+
63
+ export function matchesPathMarker(rel, compiled) {
64
+ return (compiled || []).some((re) => re.test(String(rel || '')));
65
+ }
66
+
67
+ // Build a resolver that decides per-path sensitivity by STRONGEST-WINS over four
68
+ // signals: per-file frontmatter `sensitivity:`, the area baseline from the
69
+ // area's `_area.md` (private but not "mixed" -> whole area private, mirroring the
70
+ // vault), configurable path markers, and a default (internal). Area baselines are
71
+ // cached per area dir. The resolver reads files lazily; pass the already-read
72
+ // text into sensitivityFor so the corpus walk reads each file once.
73
+ export function createSensitivityResolver(root, recallCfg = {}) {
74
+ const areasDir = recallCfg.areas_dir || '10_areas';
75
+ const markerSources = Array.isArray(recallCfg.labels?.path_markers) && recallCfg.labels.path_markers.length
76
+ ? recallCfg.labels.path_markers
77
+ : DEFAULT_PATH_MARKERS;
78
+ const compiled = compileMarkers(markerSources);
79
+ const overrides = (recallCfg.labels && recallCfg.labels.areas) || {};
80
+ const def = normalizeSensitivity(recallCfg.labels?.default_sensitivity || 'internal');
81
+ const areaCache = new Map();
82
+ const areaRe = new RegExp(`^${areasDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([^/]+)/`);
83
+
84
+ function areaBaseline(dir) {
85
+ if (areaCache.has(dir)) return areaCache.get(dir);
86
+ let result = '';
87
+ const override = overrides[dir];
88
+ if (override !== undefined) {
89
+ if (/\bprivate\b/i.test(String(override)) && !/\bmixed\b/i.test(String(override))) result = 'private';
90
+ } else {
91
+ const text = readText(path.join(root, areasDir, dir, '_area.md'), '');
92
+ const match = text.match(/^sensitivity:\s*(.+)$/im);
93
+ if (match && /\bprivate\b/i.test(match[1]) && !/\bmixed\b/i.test(match[1])) result = 'private';
94
+ }
95
+ areaCache.set(dir, result);
96
+ return result;
97
+ }
98
+
99
+ function sensitivityFor(rel, text) {
100
+ const signals = [def];
101
+ const fm = parseFrontmatter(text);
102
+ if (fm.sensitivity) signals.push(normalizeSensitivity(fm.sensitivity));
103
+ const areaMatch = String(rel).match(areaRe);
104
+ if (areaMatch) {
105
+ const baseline = areaBaseline(areaMatch[1]);
106
+ if (baseline) signals.push(baseline);
107
+ }
108
+ if (matchesPathMarker(rel, compiled)) signals.push('private');
109
+ return maxSensitivity(signals);
110
+ }
111
+
112
+ return {
113
+ sensitivityFor,
114
+ isPrivateArea: (dir) => areaBaseline(dir) === 'private',
115
+ markers: compiled,
116
+ };
117
+ }