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.
- package/CHANGELOG.md +19 -0
- package/LICENSE +57 -0
- package/README.md +205 -0
- package/SECURITY.md +22 -0
- package/bin/neurain.mjs +7 -0
- package/docs/comparison-mem0.en.md +22 -0
- package/docs/connect-claude.en.md +48 -0
- package/docs/connect-claude.kr.md +51 -0
- package/docs/connect-codex.en.md +38 -0
- package/docs/connect-codex.kr.md +40 -0
- package/docs/connect-gemini.en.md +71 -0
- package/docs/connect-gemini.kr.md +71 -0
- package/docs/connect-runtime.en.md +61 -0
- package/docs/connect-runtime.kr.md +61 -0
- package/docs/development-status.en.md +157 -0
- package/docs/development-status.kr.md +157 -0
- package/docs/knowledge-os.en.md +105 -0
- package/docs/knowledge-os.kr.md +106 -0
- package/docs/pricing.en.md +14 -0
- package/docs/privacy-and-data-flow.en.md +25 -0
- package/docs/public-saas-readiness.en.md +39 -0
- package/docs/quickstart.en.md +64 -0
- package/docs/quickstart.kr.md +64 -0
- package/docs/release-checklist.en.md +38 -0
- package/docs/safety.en.md +36 -0
- package/docs/self-improvement-90-roadmap.en.md +429 -0
- package/docs/self-improvement-90-roadmap.kr.md +429 -0
- package/docs/self-improving-workflows.en.md +163 -0
- package/docs/self-improving-workflows.kr.md +163 -0
- package/docs/support.en.md +17 -0
- package/docs/troubleshooting.en.md +35 -0
- package/package.json +36 -0
- package/src/cli.mjs +261 -0
- package/src/core/adopt.mjs +304 -0
- package/src/core/answer_eval.mjs +450 -0
- package/src/core/capabilities.mjs +217 -0
- package/src/core/capture_durable.mjs +181 -0
- package/src/core/classify.mjs +237 -0
- package/src/core/compile_desk.mjs +324 -0
- package/src/core/complete.mjs +108 -0
- package/src/core/config.mjs +142 -0
- package/src/core/connect.mjs +355 -0
- package/src/core/curator.mjs +351 -0
- package/src/core/daemon.mjs +536 -0
- package/src/core/digest.mjs +155 -0
- package/src/core/doctor.mjs +115 -0
- package/src/core/durable.mjs +96 -0
- package/src/core/envelope.mjs +97 -0
- package/src/core/flush.mjs +190 -0
- package/src/core/fs.mjs +121 -0
- package/src/core/init.mjs +194 -0
- package/src/core/journal.mjs +269 -0
- package/src/core/labels.mjs +117 -0
- package/src/core/lessons.mjs +793 -0
- package/src/core/lifecycle.mjs +1138 -0
- package/src/core/link_check.mjs +180 -0
- package/src/core/live_cases.mjs +221 -0
- package/src/core/onboard.mjs +175 -0
- package/src/core/plan_receipt.mjs +177 -0
- package/src/core/plan_writeback.mjs +176 -0
- package/src/core/queue.mjs +62 -0
- package/src/core/queue_archive.mjs +87 -0
- package/src/core/queue_model.mjs +161 -0
- package/src/core/queue_write.mjs +28 -0
- package/src/core/recall.mjs +1802 -0
- package/src/core/recall_bench.mjs +275 -0
- package/src/core/recall_corpus.mjs +152 -0
- package/src/core/recall_facts.mjs +233 -0
- package/src/core/recall_intel.mjs +233 -0
- package/src/core/recall_lexical.mjs +269 -0
- package/src/core/recap.mjs +78 -0
- package/src/core/review_queue.mjs +131 -0
- package/src/core/review_worker.mjs +284 -0
- package/src/core/route.mjs +73 -0
- package/src/core/safety.mjs +57 -0
- package/src/core/scheduler.mjs +697 -0
- package/src/core/search.mjs +54 -0
- package/src/core/secret_scan.mjs +143 -0
- package/src/core/semantic.mjs +187 -0
- package/src/core/source_digest.mjs +56 -0
- package/src/core/source_digest_gen.mjs +311 -0
- package/src/core/stage.mjs +105 -0
- package/src/core/status.mjs +175 -0
- package/src/core/vault_state.mjs +115 -0
- package/src/core/watch.mjs +282 -0
- package/src/core/wiki_log.mjs +29 -0
- package/src/core/wrap.mjs +62 -0
- package/src/mcp/server.mjs +865 -0
- package/templates/starter-vault/README.md +9 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { absPath, ensureDir, safeResolve, sha256, timestamp } from './fs.mjs';
|
|
4
|
+
import { buildSchedulerTick } from './scheduler.mjs';
|
|
5
|
+
|
|
6
|
+
const MODES = new Set(['run', 'status', 'stop']);
|
|
7
|
+
const STATE_REL = '00_system/neurain/daemon-state.json';
|
|
8
|
+
const STOP_REL = '00_system/neurain/daemon-stop.json';
|
|
9
|
+
const FORBIDDEN_FLAGS = [
|
|
10
|
+
'install',
|
|
11
|
+
'apply',
|
|
12
|
+
'write',
|
|
13
|
+
'promote',
|
|
14
|
+
'model-call',
|
|
15
|
+
'external',
|
|
16
|
+
'include-private',
|
|
17
|
+
'mcp',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export async function daemonCommand(args) {
|
|
21
|
+
const { mode, root } = parseDaemonTarget(args);
|
|
22
|
+
rejectUnsafeFlags(args);
|
|
23
|
+
if (mode === 'status') {
|
|
24
|
+
const payload = buildDaemonStatus(root);
|
|
25
|
+
if (args.json) return { json: true, payload };
|
|
26
|
+
return renderStatus(payload);
|
|
27
|
+
}
|
|
28
|
+
if (mode === 'stop') {
|
|
29
|
+
const payload = requestDaemonStop(root);
|
|
30
|
+
if (args.json) return { json: true, payload };
|
|
31
|
+
return renderStop(payload);
|
|
32
|
+
}
|
|
33
|
+
const payload = await runDaemon(root, {
|
|
34
|
+
area: args.area || '',
|
|
35
|
+
top: Number(args.top || 10),
|
|
36
|
+
sinceMinutes: Number(args['since-minutes'] || 1440),
|
|
37
|
+
minTriggers: Number(args['min-triggers'] || 1),
|
|
38
|
+
minEvents: Number(args['min-events'] || 1),
|
|
39
|
+
intervalSeconds: numberOrDefault(args['interval-seconds'], 300),
|
|
40
|
+
maxTicks: numberOrDefault(args['max-ticks'], 0),
|
|
41
|
+
});
|
|
42
|
+
if (args.json) return { json: true, payload };
|
|
43
|
+
return renderRun(payload);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runDaemon(root, {
|
|
47
|
+
area = '',
|
|
48
|
+
top = 10,
|
|
49
|
+
sinceMinutes = 1440,
|
|
50
|
+
minTriggers = 1,
|
|
51
|
+
minEvents = 1,
|
|
52
|
+
intervalSeconds = 300,
|
|
53
|
+
maxTicks = 0,
|
|
54
|
+
} = {}) {
|
|
55
|
+
const runId = sha256(`${process.pid}:${Date.now()}:${Math.random()}`).slice(0, 16);
|
|
56
|
+
const startedAt = timestamp();
|
|
57
|
+
const numericInterval = clampNumber(intervalSeconds, 300, 0, 3600);
|
|
58
|
+
const numericMaxTicks = normalizeMaxTicks(maxTicks);
|
|
59
|
+
if (numericMaxTicks === 0 && numericInterval === 0) {
|
|
60
|
+
throw new Error('Refusing unbounded daemon with interval 0. Use --max-ticks for tests or set --interval-seconds above 0.');
|
|
61
|
+
}
|
|
62
|
+
const existing = readState(root);
|
|
63
|
+
if (existing && ['running', 'stop_requested'].includes(existing.status) && isPidAlive(existing.pid)) {
|
|
64
|
+
throw new Error(`Daemon already running for this root: pid ${existing.pid}, run ${existing.run_id}. Run: neurain daemon stop "${root}"`);
|
|
65
|
+
}
|
|
66
|
+
clearStopRequest(root);
|
|
67
|
+
const ticks = [];
|
|
68
|
+
let stopReason = 'max_ticks_reached';
|
|
69
|
+
let state = baseState(root, {
|
|
70
|
+
runId,
|
|
71
|
+
startedAt,
|
|
72
|
+
status: 'running',
|
|
73
|
+
area,
|
|
74
|
+
intervalSeconds: numericInterval,
|
|
75
|
+
maxTicks: numericMaxTicks,
|
|
76
|
+
});
|
|
77
|
+
writeState(root, state);
|
|
78
|
+
|
|
79
|
+
for (let index = 0; numericMaxTicks === 0 || index < numericMaxTicks; index += 1) {
|
|
80
|
+
if (stopRequested(root, runId)) {
|
|
81
|
+
stopReason = 'stop_requested';
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const tick = buildSchedulerTick(root, {
|
|
86
|
+
command: 'daemon scheduler tick',
|
|
87
|
+
area,
|
|
88
|
+
top,
|
|
89
|
+
sinceMinutes,
|
|
90
|
+
minTriggers,
|
|
91
|
+
minEvents,
|
|
92
|
+
includeReview: true,
|
|
93
|
+
});
|
|
94
|
+
const summary = summarizeTick(tick, index + 1, numericInterval);
|
|
95
|
+
ticks.push(summary);
|
|
96
|
+
state = {
|
|
97
|
+
...state,
|
|
98
|
+
status: 'running',
|
|
99
|
+
updated_at: timestamp(),
|
|
100
|
+
tick_count: ticks.length,
|
|
101
|
+
review_due_count: ticks.filter((item) => item.review_due).length,
|
|
102
|
+
last_tick: summary,
|
|
103
|
+
stop_requested: stopRequested(root, runId),
|
|
104
|
+
};
|
|
105
|
+
writeState(root, state);
|
|
106
|
+
|
|
107
|
+
if (state.stop_requested) {
|
|
108
|
+
stopReason = 'stop_requested';
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
if (numericMaxTicks > 0 && ticks.length >= numericMaxTicks) break;
|
|
112
|
+
if (stopRequested(root, runId)) {
|
|
113
|
+
stopReason = 'stop_requested';
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
if (numericInterval > 0) {
|
|
117
|
+
const stoppedDuringSleep = await interruptibleSleep(root, runId, numericInterval * 1000);
|
|
118
|
+
if (stoppedDuringSleep) {
|
|
119
|
+
stopReason = 'stop_requested';
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const finalState = {
|
|
126
|
+
...state,
|
|
127
|
+
status: 'stopped',
|
|
128
|
+
stop_reason: stopReason,
|
|
129
|
+
stop_requested: stopReason === 'stop_requested',
|
|
130
|
+
stopped_at: timestamp(),
|
|
131
|
+
updated_at: timestamp(),
|
|
132
|
+
tick_count: ticks.length,
|
|
133
|
+
review_due_count: ticks.filter((item) => item.review_due).length,
|
|
134
|
+
};
|
|
135
|
+
writeState(root, finalState);
|
|
136
|
+
clearStopRequest(root, runId);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
command: 'daemon run',
|
|
141
|
+
root,
|
|
142
|
+
area: area || null,
|
|
143
|
+
durable_write: false,
|
|
144
|
+
durable_knowledge_write: false,
|
|
145
|
+
operational_state_write: true,
|
|
146
|
+
model_calls: false,
|
|
147
|
+
external_tool_calls: false,
|
|
148
|
+
writes_wiki: false,
|
|
149
|
+
promotes_lessons: false,
|
|
150
|
+
daemon_started: true,
|
|
151
|
+
mcp_exposed: false,
|
|
152
|
+
write_policy: 'operational_state_only',
|
|
153
|
+
state_path: STATE_REL,
|
|
154
|
+
daemon: {
|
|
155
|
+
mode: 'foreground_continuous',
|
|
156
|
+
process_id: process.pid,
|
|
157
|
+
run_id: runId,
|
|
158
|
+
interval_seconds: numericInterval,
|
|
159
|
+
max_ticks: numericMaxTicks,
|
|
160
|
+
completed_ticks: ticks.length,
|
|
161
|
+
review_due_count: finalState.review_due_count,
|
|
162
|
+
stop_reason: stopReason,
|
|
163
|
+
background_job_started: false,
|
|
164
|
+
install_supported: false,
|
|
165
|
+
mcp_exposed: false,
|
|
166
|
+
stop_supported: true,
|
|
167
|
+
},
|
|
168
|
+
ticks,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function buildDaemonStatus(root) {
|
|
173
|
+
const state = readState(root);
|
|
174
|
+
const pidAlive = state?.pid ? isPidAlive(state.pid) : false;
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
command: 'daemon status',
|
|
178
|
+
root,
|
|
179
|
+
durable_write: false,
|
|
180
|
+
durable_knowledge_write: false,
|
|
181
|
+
operational_state_write: false,
|
|
182
|
+
model_calls: false,
|
|
183
|
+
external_tool_calls: false,
|
|
184
|
+
mcp_exposed: false,
|
|
185
|
+
state_path: STATE_REL,
|
|
186
|
+
state_exists: Boolean(state),
|
|
187
|
+
daemon: state
|
|
188
|
+
? {
|
|
189
|
+
status: state.status,
|
|
190
|
+
run_id: state.run_id,
|
|
191
|
+
process_id: state.pid,
|
|
192
|
+
pid_alive: ['running', 'stop_requested'].includes(state.status) ? pidAlive : false,
|
|
193
|
+
started_at: state.started_at,
|
|
194
|
+
updated_at: state.updated_at,
|
|
195
|
+
stopped_at: state.stopped_at || null,
|
|
196
|
+
tick_count: state.tick_count || 0,
|
|
197
|
+
review_due_count: state.review_due_count || 0,
|
|
198
|
+
stop_requested: Boolean(state.stop_requested),
|
|
199
|
+
last_tick: state.last_tick || null,
|
|
200
|
+
safety: state.safety,
|
|
201
|
+
}
|
|
202
|
+
: null,
|
|
203
|
+
next_suggested_command: ['running', 'stop_requested'].includes(state?.status)
|
|
204
|
+
? `neurain daemon stop "${root}"`
|
|
205
|
+
: `neurain daemon run "${root}" --interval-seconds 300`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function requestDaemonStop(root) {
|
|
210
|
+
const state = readState(root);
|
|
211
|
+
if (!state) {
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
command: 'daemon stop',
|
|
215
|
+
root,
|
|
216
|
+
durable_write: false,
|
|
217
|
+
durable_knowledge_write: false,
|
|
218
|
+
operational_state_write: false,
|
|
219
|
+
model_calls: false,
|
|
220
|
+
external_tool_calls: false,
|
|
221
|
+
mcp_exposed: false,
|
|
222
|
+
stop_requested: false,
|
|
223
|
+
reason: 'no running daemon state found',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (['running', 'stop_requested'].includes(state.status) && !isPidAlive(state.pid)) {
|
|
227
|
+
const stale = {
|
|
228
|
+
...state,
|
|
229
|
+
status: 'stopped',
|
|
230
|
+
stop_reason: 'stale_pid',
|
|
231
|
+
stop_requested: false,
|
|
232
|
+
stopped_at: timestamp(),
|
|
233
|
+
updated_at: timestamp(),
|
|
234
|
+
};
|
|
235
|
+
writeState(root, stale);
|
|
236
|
+
clearStopRequest(root, state.run_id);
|
|
237
|
+
return {
|
|
238
|
+
ok: true,
|
|
239
|
+
command: 'daemon stop',
|
|
240
|
+
root,
|
|
241
|
+
durable_write: false,
|
|
242
|
+
durable_knowledge_write: false,
|
|
243
|
+
operational_state_write: true,
|
|
244
|
+
model_calls: false,
|
|
245
|
+
external_tool_calls: false,
|
|
246
|
+
mcp_exposed: false,
|
|
247
|
+
stop_requested: false,
|
|
248
|
+
state_path: STATE_REL,
|
|
249
|
+
reason: 'stale running state found; marked stopped',
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (state.status === 'stop_requested') {
|
|
253
|
+
return {
|
|
254
|
+
ok: true,
|
|
255
|
+
command: 'daemon stop',
|
|
256
|
+
root,
|
|
257
|
+
durable_write: false,
|
|
258
|
+
durable_knowledge_write: false,
|
|
259
|
+
operational_state_write: false,
|
|
260
|
+
model_calls: false,
|
|
261
|
+
external_tool_calls: false,
|
|
262
|
+
mcp_exposed: false,
|
|
263
|
+
stop_requested: true,
|
|
264
|
+
state_path: STATE_REL,
|
|
265
|
+
daemon: {
|
|
266
|
+
run_id: state.run_id,
|
|
267
|
+
process_id: state.pid,
|
|
268
|
+
pid_alive: isPidAlive(state.pid),
|
|
269
|
+
status: state.status,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (state.status !== 'running') {
|
|
274
|
+
return {
|
|
275
|
+
ok: true,
|
|
276
|
+
command: 'daemon stop',
|
|
277
|
+
root,
|
|
278
|
+
durable_write: false,
|
|
279
|
+
durable_knowledge_write: false,
|
|
280
|
+
operational_state_write: false,
|
|
281
|
+
model_calls: false,
|
|
282
|
+
external_tool_calls: false,
|
|
283
|
+
mcp_exposed: false,
|
|
284
|
+
stop_requested: false,
|
|
285
|
+
reason: 'no running daemon state found',
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const next = {
|
|
289
|
+
...state,
|
|
290
|
+
status: 'stop_requested',
|
|
291
|
+
stop_requested: true,
|
|
292
|
+
updated_at: timestamp(),
|
|
293
|
+
};
|
|
294
|
+
writeStopRequest(root, next.run_id);
|
|
295
|
+
writeState(root, next);
|
|
296
|
+
return {
|
|
297
|
+
ok: true,
|
|
298
|
+
command: 'daemon stop',
|
|
299
|
+
root,
|
|
300
|
+
durable_write: false,
|
|
301
|
+
durable_knowledge_write: false,
|
|
302
|
+
operational_state_write: true,
|
|
303
|
+
model_calls: false,
|
|
304
|
+
external_tool_calls: false,
|
|
305
|
+
mcp_exposed: false,
|
|
306
|
+
stop_requested: true,
|
|
307
|
+
state_path: STATE_REL,
|
|
308
|
+
daemon: {
|
|
309
|
+
run_id: next.run_id,
|
|
310
|
+
process_id: next.pid,
|
|
311
|
+
pid_alive: isPidAlive(next.pid),
|
|
312
|
+
status: next.status,
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function parseDaemonTarget(args) {
|
|
318
|
+
const parts = args._ || [];
|
|
319
|
+
const first = parts[0] || 'status';
|
|
320
|
+
if (['install', 'launchd', 'service', 'background', 'apply', 'write', 'promote'].includes(first)) {
|
|
321
|
+
throw new Error('Public alpha daemon supports foreground run, status, and cooperative stop only.');
|
|
322
|
+
}
|
|
323
|
+
const mode = MODES.has(first) ? first : 'status';
|
|
324
|
+
const rootArg = MODES.has(first) ? parts[1] : first;
|
|
325
|
+
return {
|
|
326
|
+
mode,
|
|
327
|
+
root: absPath(rootArg || args.root || process.cwd()),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function rejectUnsafeFlags(args) {
|
|
332
|
+
for (const flag of FORBIDDEN_FLAGS) {
|
|
333
|
+
if (args[flag]) {
|
|
334
|
+
throw new Error('Public alpha daemon cannot install jobs, write knowledge, promote lessons, call models, expose MCP write tools, or include private material.');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function baseState(root, { runId, startedAt, status, area, intervalSeconds, maxTicks }) {
|
|
340
|
+
return {
|
|
341
|
+
version: 1,
|
|
342
|
+
kind: 'neurain_daemon_state',
|
|
343
|
+
run_id: runId,
|
|
344
|
+
root,
|
|
345
|
+
area: area || null,
|
|
346
|
+
pid: process.pid,
|
|
347
|
+
status,
|
|
348
|
+
started_at: startedAt,
|
|
349
|
+
updated_at: startedAt,
|
|
350
|
+
stopped_at: null,
|
|
351
|
+
tick_count: 0,
|
|
352
|
+
review_due_count: 0,
|
|
353
|
+
interval_seconds: intervalSeconds,
|
|
354
|
+
max_ticks: maxTicks,
|
|
355
|
+
stop_requested: false,
|
|
356
|
+
safety: {
|
|
357
|
+
durable_knowledge_write: false,
|
|
358
|
+
operational_state_write: true,
|
|
359
|
+
model_calls: false,
|
|
360
|
+
external_tool_calls: false,
|
|
361
|
+
writes_wiki: false,
|
|
362
|
+
promotes_lessons: false,
|
|
363
|
+
private_paths_stored: false,
|
|
364
|
+
private_descendant_paths_stored: false,
|
|
365
|
+
mcp_exposed: false,
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function summarizeTick(tick, index, intervalSeconds) {
|
|
371
|
+
return {
|
|
372
|
+
index,
|
|
373
|
+
at: timestamp(),
|
|
374
|
+
interval_seconds: intervalSeconds,
|
|
375
|
+
review_due: Boolean(tick.review_fork.should_run),
|
|
376
|
+
reason: sanitizeReason(tick.review_fork.reason),
|
|
377
|
+
trigger_count: tick.review_fork.trigger_count,
|
|
378
|
+
meaningful_event_count: tick.review_fork.meaningful_event_count,
|
|
379
|
+
includes_review_report: Boolean(tick.review_fork.includes_review_report),
|
|
380
|
+
journal_integrity_ok: Boolean(tick.watch_report?.journal_integrity?.ok),
|
|
381
|
+
lesson_candidate_count: Number(tick.watch_report?.lesson_candidate_count || 0),
|
|
382
|
+
recent_event_count: Number(tick.watch_report?.recent_events?.length || 0),
|
|
383
|
+
recent_file_count: Number(tick.watch_report?.recent_files?.length || 0),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function readState(root) {
|
|
388
|
+
try {
|
|
389
|
+
const file = safeResolve(root, STATE_REL);
|
|
390
|
+
if (!fs.existsSync(file)) return null;
|
|
391
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
392
|
+
} catch {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function readStopRequest(root) {
|
|
398
|
+
try {
|
|
399
|
+
const file = safeResolve(root, STOP_REL);
|
|
400
|
+
if (!fs.existsSync(file)) return null;
|
|
401
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function writeState(root, state) {
|
|
408
|
+
const file = safeResolve(root, STATE_REL);
|
|
409
|
+
ensureDir(path.dirname(file));
|
|
410
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
411
|
+
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
412
|
+
fs.renameSync(tmp, file);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function writeStopRequest(root, runId) {
|
|
416
|
+
const file = safeResolve(root, STOP_REL);
|
|
417
|
+
ensureDir(path.dirname(file));
|
|
418
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
419
|
+
fs.writeFileSync(tmp, `${JSON.stringify({
|
|
420
|
+
version: 1,
|
|
421
|
+
kind: 'neurain_daemon_stop_request',
|
|
422
|
+
run_id: runId,
|
|
423
|
+
requested_at: timestamp(),
|
|
424
|
+
}, null, 2)}\n`, 'utf8');
|
|
425
|
+
fs.renameSync(tmp, file);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function clearStopRequest(root, runId = null) {
|
|
429
|
+
const request = readStopRequest(root);
|
|
430
|
+
if (runId && request?.run_id !== runId) return;
|
|
431
|
+
try {
|
|
432
|
+
fs.rmSync(safeResolve(root, STOP_REL), { force: true });
|
|
433
|
+
} catch {
|
|
434
|
+
/* ignore cleanup failures */
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function stopRequested(root, runId) {
|
|
439
|
+
const request = readStopRequest(root);
|
|
440
|
+
return request?.run_id === runId;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function sanitizeReason(reason) {
|
|
444
|
+
const text = String(reason || 'review threshold reached');
|
|
445
|
+
if (/(^|\s)([A-Za-z0-9._-]+\/)+[A-Za-z0-9._-]+/.test(text)) return 'review threshold reached';
|
|
446
|
+
return text.length > 240 ? `${text.slice(0, 237)}...` : text;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function renderRun(payload) {
|
|
450
|
+
return {
|
|
451
|
+
text: [
|
|
452
|
+
'# Neurain daemon run',
|
|
453
|
+
'',
|
|
454
|
+
`- Root: ${payload.root}`,
|
|
455
|
+
`- Area: ${payload.area || 'all'}`,
|
|
456
|
+
'- Durable knowledge write: no',
|
|
457
|
+
'- Operational state write: yes',
|
|
458
|
+
'- Model calls: no',
|
|
459
|
+
'- External tool calls: no',
|
|
460
|
+
'- MCP exposed: no',
|
|
461
|
+
`- Ticks completed: ${payload.daemon.completed_ticks}`,
|
|
462
|
+
`- Review due ticks: ${payload.daemon.review_due_count}`,
|
|
463
|
+
`- State: ${payload.state_path}`,
|
|
464
|
+
].join('\n'),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function renderStatus(payload) {
|
|
469
|
+
return {
|
|
470
|
+
text: [
|
|
471
|
+
'# Neurain daemon status',
|
|
472
|
+
'',
|
|
473
|
+
`- Root: ${payload.root}`,
|
|
474
|
+
'- Durable knowledge write: no',
|
|
475
|
+
'- Operational state write: no',
|
|
476
|
+
`- State exists: ${payload.state_exists ? 'yes' : 'no'}`,
|
|
477
|
+
`- Status: ${payload.daemon?.status || 'not running'}`,
|
|
478
|
+
`- Stop requested: ${payload.daemon?.stop_requested ? 'yes' : 'no'}`,
|
|
479
|
+
`- Ticks: ${payload.daemon?.tick_count || 0}`,
|
|
480
|
+
`- Next: ${payload.next_suggested_command}`,
|
|
481
|
+
].join('\n'),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function renderStop(payload) {
|
|
486
|
+
return {
|
|
487
|
+
text: [
|
|
488
|
+
'# Neurain daemon stop',
|
|
489
|
+
'',
|
|
490
|
+
`- Root: ${payload.root}`,
|
|
491
|
+
'- Durable knowledge write: no',
|
|
492
|
+
`- Stop requested: ${payload.stop_requested ? 'yes' : 'no'}`,
|
|
493
|
+
payload.reason ? `- Reason: ${payload.reason}` : `- State: ${payload.state_path}`,
|
|
494
|
+
].join('\n'),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function isPidAlive(pid) {
|
|
499
|
+
try {
|
|
500
|
+
process.kill(Number(pid), 0);
|
|
501
|
+
return true;
|
|
502
|
+
} catch {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function normalizeMaxTicks(value) {
|
|
508
|
+
const parsed = Number(value);
|
|
509
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return 0;
|
|
510
|
+
return Math.max(1, Math.min(Math.trunc(parsed), 1000));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function interruptibleSleep(root, runId, ms) {
|
|
514
|
+
const deadline = Date.now() + ms;
|
|
515
|
+
while (Date.now() < deadline) {
|
|
516
|
+
if (stopRequested(root, runId)) return true;
|
|
517
|
+
await sleep(Math.min(1000, deadline - Date.now()));
|
|
518
|
+
}
|
|
519
|
+
return stopRequested(root, runId);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function clampNumber(value, fallback, min, max) {
|
|
523
|
+
const parsed = Number(value);
|
|
524
|
+
const selected = Number.isFinite(parsed) ? parsed : fallback;
|
|
525
|
+
return Math.max(min, Math.min(Math.trunc(selected), max));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function numberOrDefault(value, fallback) {
|
|
529
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
530
|
+
const parsed = Number(value);
|
|
531
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function sleep(ms) {
|
|
535
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
536
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// "Since your last visit" cross-session digest. Read-only port of the vault
|
|
2
|
+
// digest-intel compute + render. The engine NEVER advances the ack pointer:
|
|
3
|
+
// session-state writes are owned by a later wave (W-D), and introducing a second
|
|
4
|
+
// lock protocol on session-state.json during the soak is the data-loss class the
|
|
5
|
+
// cutover machinery exists to avoid. The legacy ack therefore stays vault-side;
|
|
6
|
+
// the engine returns `through_ms` so a vault shim can ack after delivering the
|
|
7
|
+
// rendered digest to a human.
|
|
8
|
+
//
|
|
9
|
+
// Privacy (kept identical to the vault): current-area only, foreign-area
|
|
10
|
+
// references redacted at output time, conservative dedupe, a 72h window floor.
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { absPath } from './fs.mjs';
|
|
13
|
+
import { vaultConfig } from './config.mjs';
|
|
14
|
+
import { areaBriefPath, frontmatterField, loadSessionState, readJsonl, readTextAt } from './vault_state.mjs';
|
|
15
|
+
|
|
16
|
+
const WINDOW_HOURS_DEFAULT = 72;
|
|
17
|
+
const MAX_ITEMS = 12;
|
|
18
|
+
const MAX_TEXT = 140;
|
|
19
|
+
|
|
20
|
+
function toMs(ts) {
|
|
21
|
+
if (!ts) return NaN;
|
|
22
|
+
const ms = new Date(String(ts).trim().replace(' ', 'T')).getTime();
|
|
23
|
+
return Number.isNaN(ms) ? NaN : ms;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function agoLabel(ms, now) {
|
|
27
|
+
const d = Math.max(0, now - ms);
|
|
28
|
+
const h = d / 3600000;
|
|
29
|
+
if (h < 1) return `${Math.max(1, Math.round(d / 60000))}m ago`;
|
|
30
|
+
if (h < 48) return `${Math.round(h)}h ago`;
|
|
31
|
+
return `${Math.round(h / 24)}d ago`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function clip(s) {
|
|
35
|
+
const t = String(s || '').replace(/\s+/g, ' ').trim();
|
|
36
|
+
return t.length > MAX_TEXT ? `${t.slice(0, MAX_TEXT - 1)}…` : t;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Pure read. now/windowHours/sources injectable; never throws on missing data.
|
|
40
|
+
export function computeDigest(root, sessionId, opts = {}) {
|
|
41
|
+
const vaultCfg = opts.vaultCfg || vaultConfig(root);
|
|
42
|
+
const now = opts.now || Date.now();
|
|
43
|
+
const windowHours = opts.windowHours || WINDOW_HOURS_DEFAULT;
|
|
44
|
+
const sources = new Set(opts.sources || ['area-brief', 'wrap-journal']);
|
|
45
|
+
|
|
46
|
+
let state = { sessions: {} };
|
|
47
|
+
try { state = loadSessionState(root, vaultCfg); } catch { state = { sessions: {} }; }
|
|
48
|
+
const sessions = state.sessions || {};
|
|
49
|
+
const me = sessions[sessionId];
|
|
50
|
+
if (!me) return { ok: false, error: `unknown session "${sessionId}"`, known: Object.keys(sessions) };
|
|
51
|
+
const area = me.area || '';
|
|
52
|
+
if (!area) return { ok: false, error: `session "${sessionId}" has no area (digest is per-area)` };
|
|
53
|
+
|
|
54
|
+
const floorMs = now - windowHours * 3600000;
|
|
55
|
+
const ackedMs = toMs(me.last_acked_at);
|
|
56
|
+
const lowerMs = Math.max(Number.isNaN(ackedMs) ? 0 : ackedMs, floorMs);
|
|
57
|
+
const engineOf = (sid) => (sessions[sid] && sessions[sid].engine) || 'unknown';
|
|
58
|
+
const areaSensitivity = frontmatterField(readTextAt(root, `${vaultCfg.areas_dir}/${area}/_area.md`), 'sensitivity') || 'internal';
|
|
59
|
+
|
|
60
|
+
// Redact notes that reference ANOTHER area (cross-area text may carry private
|
|
61
|
+
// content). Match the underscore area-id form, applied at output (after dedupe).
|
|
62
|
+
const foreignAreas = [...new Set(Object.values(sessions).map((s) => s && s.area).filter((a) => a && a !== area))];
|
|
63
|
+
const foreignRe = foreignAreas.length
|
|
64
|
+
? new RegExp(`(?:^|[^A-Za-z0-9_])(${foreignAreas.map((a) => a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?![A-Za-z0-9])`)
|
|
65
|
+
: null;
|
|
66
|
+
const redact = (t) => (foreignRe && foreignRe.test(String(t)) ? '[redacted: references another area]' : String(t));
|
|
67
|
+
|
|
68
|
+
const items = [];
|
|
69
|
+
if (sources.has('area-brief')) {
|
|
70
|
+
const brief = readTextAt(root, areaBriefPath(vaultCfg, area));
|
|
71
|
+
if (brief) {
|
|
72
|
+
let inNotes = false;
|
|
73
|
+
for (const ln of brief.split(/\r?\n/)) {
|
|
74
|
+
if (/^#{1,6}\s/.test(ln)) { inNotes = /Latest Cross-Session Notes/i.test(ln); continue; }
|
|
75
|
+
if (!inNotes) continue;
|
|
76
|
+
const m = ln.match(/^-\s+(\d{4}-\d{2}-\d{2}[T ][0-9:+\-Z.]+)\s+([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
77
|
+
if (!m) continue;
|
|
78
|
+
const ms = toMs(m[1]);
|
|
79
|
+
if (Number.isNaN(ms)) continue;
|
|
80
|
+
items.push({ ms, session: m[2], text: m[3], source: 'area-brief' });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (sources.has('wrap-journal')) {
|
|
85
|
+
for (const r of readJsonl(path.join(root, vaultCfg.wrap_journal))) {
|
|
86
|
+
if (!r || r.area !== area) continue;
|
|
87
|
+
if (r.committed === false || r.dry_run) continue;
|
|
88
|
+
const ms = toMs(r.wrapped_at);
|
|
89
|
+
if (Number.isNaN(ms)) continue;
|
|
90
|
+
items.push({ ms, session: r.session_id || '?', text: r.headline || r.boundary || 'wrap', source: 'wrap-journal' });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const kept = items.filter((it) => it.ms > lowerMs && it.ms <= now && it.session !== sessionId);
|
|
95
|
+
const seen = new Map();
|
|
96
|
+
for (const it of kept.sort((a, b) => (a.source === b.source ? 0 : a.source === 'wrap-journal' ? -1 : 1))) {
|
|
97
|
+
const key = `${it.session}|${clip(it.text).toLowerCase()}`;
|
|
98
|
+
if (!seen.has(key)) seen.set(key, it);
|
|
99
|
+
}
|
|
100
|
+
const deduped = [...seen.values()].sort((a, b) => b.ms - a.ms);
|
|
101
|
+
const total = deduped.length;
|
|
102
|
+
const shown = deduped.slice(0, MAX_ITEMS);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
area,
|
|
107
|
+
area_sensitivity: areaSensitivity,
|
|
108
|
+
window_hours: windowHours,
|
|
109
|
+
lower_ms: lowerMs,
|
|
110
|
+
through_ms: now,
|
|
111
|
+
total,
|
|
112
|
+
shown: shown.length,
|
|
113
|
+
truncated: total - shown.length,
|
|
114
|
+
items: shown.map((it) => ({ ago: agoLabel(it.ms, now), engine: engineOf(it.session), session: it.session, source: it.source, text: clip(redact(it.text)) })),
|
|
115
|
+
coverage_note: 'From wrap/sync records only; work ended without a command is not shown.',
|
|
116
|
+
privacy_note: "This area's command-mediated work only; other areas (including private ones) are never gathered, and cross-area references are redacted.",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function renderDigest(d) {
|
|
121
|
+
if (!d || !d.ok) return '';
|
|
122
|
+
const lines = [];
|
|
123
|
+
lines.push(`## Since your last visit (last ${d.window_hours}h in ${d.area}; from wrap/sync records, work ended without a command is not shown)`);
|
|
124
|
+
if (!d.items.length) {
|
|
125
|
+
lines.push('- Nothing new from other sessions in this window.');
|
|
126
|
+
} else {
|
|
127
|
+
for (const it of d.items) {
|
|
128
|
+
const who = it.engine && it.engine !== 'unknown' ? `${it.engine}/${it.session}` : it.session;
|
|
129
|
+
lines.push(`- [${who}] ${it.text} (${it.ago})`);
|
|
130
|
+
}
|
|
131
|
+
if (d.truncated > 0) lines.push(`- ... and ${d.truncated} more (see area brief + wrap-journal)`);
|
|
132
|
+
}
|
|
133
|
+
lines.push('');
|
|
134
|
+
lines.push(`_${d.privacy_note}_`);
|
|
135
|
+
return lines.join('\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function digestCommand(args) {
|
|
139
|
+
const root = absPath(args._[0] || args.root || process.cwd());
|
|
140
|
+
const sessionId = String(args.session || args['session-id'] || '');
|
|
141
|
+
if (!sessionId) throw new Error('digest requires --session <id>.');
|
|
142
|
+
const opts = {
|
|
143
|
+
now: args.now !== undefined ? Number(args.now) : undefined,
|
|
144
|
+
windowHours: args['window-hours'] !== undefined ? Number(args['window-hours']) : undefined,
|
|
145
|
+
sources: args.sources ? String(args.sources).split(',').map((s) => s.trim()).filter(Boolean) : undefined,
|
|
146
|
+
};
|
|
147
|
+
const payload = computeDigest(root, sessionId, opts);
|
|
148
|
+
// The engine never acks; a vault shim acks after delivering `rendered`.
|
|
149
|
+
payload.command = 'digest';
|
|
150
|
+
payload.durable_write = false;
|
|
151
|
+
payload.rendered = renderDigest(payload);
|
|
152
|
+
if (args.json) return { json: true, payload };
|
|
153
|
+
if (!payload.ok) return { text: `# Neurain digest\n\n- ${payload.error}` };
|
|
154
|
+
return { text: payload.rendered || '# Neurain digest\n\n- Nothing new.' };
|
|
155
|
+
}
|