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,115 @@
|
|
|
1
|
+
// Read-only readers and helpers for the vault state formats the view tools
|
|
2
|
+
// consume (session-state.json, wrap-journal.jsonl, area briefs, handoffs). Every
|
|
3
|
+
// reader degrades gracefully: a missing file yields an empty result and never
|
|
4
|
+
// throws, so a generic vault with no sessions still works. These readers NEVER
|
|
5
|
+
// write; the one residual write in the legacy tools (the digest ack pointer)
|
|
6
|
+
// stays vault-side until the session-state writes move wholesale (W-D).
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { readText } from './fs.mjs';
|
|
10
|
+
|
|
11
|
+
export function readJsonSafe(absPath, fallback) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(fs.readFileSync(absPath, 'utf8'));
|
|
14
|
+
} catch {
|
|
15
|
+
return fallback;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Parse a .jsonl file into rows, skipping blank and torn lines (a single bad
|
|
20
|
+
// line never discards the whole file).
|
|
21
|
+
export function readJsonl(absPath) {
|
|
22
|
+
let text;
|
|
23
|
+
try {
|
|
24
|
+
text = fs.readFileSync(absPath, 'utf8');
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
const rows = [];
|
|
29
|
+
for (const line of text.split(/\r?\n/)) {
|
|
30
|
+
const trimmed = line.trim();
|
|
31
|
+
if (!trimmed) continue;
|
|
32
|
+
try {
|
|
33
|
+
rows.push(JSON.parse(trimmed));
|
|
34
|
+
} catch {
|
|
35
|
+
/* skip torn line */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return rows;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Load session-state. Mirrors the vault reader: a missing file is an empty
|
|
42
|
+
// state, but a CORRUPT file fails closed (never silently reset a registry the
|
|
43
|
+
// caller might overwrite). The view tools only read, so this just surfaces the
|
|
44
|
+
// corruption instead of pretending there are no sessions.
|
|
45
|
+
export function loadSessionState(root, vaultCfg) {
|
|
46
|
+
const abs = path.join(root, vaultCfg.session_state);
|
|
47
|
+
if (!fs.existsSync(abs)) return { version: 1, sessions: {} };
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(fs.readFileSync(abs, 'utf8'));
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new Error(`session-state is corrupt and was not read: ${error.message}`);
|
|
53
|
+
}
|
|
54
|
+
return { version: parsed.version || 1, updated_at: parsed.updated_at || '', sessions: parsed.sessions || {} };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Area-relative path -> area brief markdown path, mirroring the vault convention
|
|
58
|
+
// (10_areas/<area>/current/<area-without-underscore>-area-brief.md).
|
|
59
|
+
export function areaBriefPath(vaultCfg, area) {
|
|
60
|
+
const slug = String(area || '').trim();
|
|
61
|
+
if (!slug) return '';
|
|
62
|
+
const fileSlug = slug.replace(/^_+/, '') || slug;
|
|
63
|
+
return `${vaultCfg.areas_dir}/${slug}/current/${fileSlug}-area-brief.md`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Extract a "## Heading" section body from markdown (used for Current Focus,
|
|
67
|
+
// Latest Cross-Session Notes, etc.).
|
|
68
|
+
export function extractMarkdownSection(text, heading) {
|
|
69
|
+
const source = String(text || '');
|
|
70
|
+
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
71
|
+
const match = source.match(new RegExp(`(?:^|\\n)## ${escaped}\\n\\n([\\s\\S]*?)(?=\\n## |$)`));
|
|
72
|
+
return match ? match[1].trim() : '';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function frontmatterField(text, field) {
|
|
76
|
+
const match = String(text || '').match(/^---\n([\s\S]*?)\n---/);
|
|
77
|
+
if (!match) return '';
|
|
78
|
+
const fm = match[1].match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
|
|
79
|
+
return fm ? fm[1].trim() : '';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Pending writeback-queue rows for a session (flat root queue). Read-only.
|
|
83
|
+
export function pendingQueueRows(root, vaultCfg, { sessionId = '' } = {}) {
|
|
84
|
+
const rows = readJsonl(path.join(root, vaultCfg.writeback_queue));
|
|
85
|
+
return rows.filter((row) => row && row.status === 'pending' && (!sessionId || row.session_id === sessionId));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Count of pending flat-root-queue rows for one session. Mirrors the vault
|
|
89
|
+
// pendingQueueCountForSession exactly. Read-only; capture/complete read this
|
|
90
|
+
// AFTER their own queue write to fill the (advisory) session_state_delta.
|
|
91
|
+
export function pendingCountForSession(root, vaultCfg, sessionId) {
|
|
92
|
+
if (!sessionId) return 0;
|
|
93
|
+
return readJsonl(path.join(root, vaultCfg.writeback_queue))
|
|
94
|
+
.filter((row) => row && row.session_id === sessionId && row.status === 'pending').length;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Freshness of a session relative to a stale threshold (days). `now` is
|
|
98
|
+
// injectable for deterministic tests.
|
|
99
|
+
export function freshnessFor(lastPulse, staleDays, now = Date.now()) {
|
|
100
|
+
if (!lastPulse) return { freshness_status: 'missing_pulse', freshness_days: null, freshness_message: 'No pulse recorded yet.' };
|
|
101
|
+
const parsed = new Date(lastPulse);
|
|
102
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
103
|
+
return { freshness_status: 'invalid_pulse', freshness_days: null, freshness_message: `Last pulse timestamp is invalid: ${lastPulse}` };
|
|
104
|
+
}
|
|
105
|
+
const ageDays = Math.max(0, (now - parsed.getTime()) / 86400000);
|
|
106
|
+
const rounded = Math.round(ageDays * 10) / 10;
|
|
107
|
+
if (ageDays > staleDays) {
|
|
108
|
+
return { freshness_status: 'stale', freshness_days: rounded, freshness_message: `Last pulse is ${rounded} days old. Target is ${staleDays} days or fewer.` };
|
|
109
|
+
}
|
|
110
|
+
return { freshness_status: 'fresh', freshness_days: rounded, freshness_message: `Last pulse is ${rounded} days old.` };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function readTextAt(root, rel) {
|
|
114
|
+
return rel ? readText(path.join(root, rel), '') : '';
|
|
115
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { absPath, isTextFile, relPath, safeResolve, walkFiles } from './fs.mjs';
|
|
4
|
+
import { listJournalEvents, verifyJournal } from './journal.mjs';
|
|
5
|
+
import { lessonCandidates } from './lessons.mjs';
|
|
6
|
+
import { buildRecap } from './recap.mjs';
|
|
7
|
+
import { inferSensitivityFromPath } from './safety.mjs';
|
|
8
|
+
|
|
9
|
+
export async function watchCommand(args) {
|
|
10
|
+
const root = absPath(args._[0] || args.root || process.cwd());
|
|
11
|
+
if (args.follow || args.daemon) {
|
|
12
|
+
throw new Error('Public alpha supports watch report only. Use --poll-once or omit it for a read-only snapshot.');
|
|
13
|
+
}
|
|
14
|
+
const report = buildWatchReport(root, {
|
|
15
|
+
area: args.area || '',
|
|
16
|
+
top: Number(args.top || 10),
|
|
17
|
+
sinceMinutes: Number(args['since-minutes'] || 1440),
|
|
18
|
+
includePrivate: Boolean(args['include-private']),
|
|
19
|
+
pollOnce: Boolean(args['poll-once']),
|
|
20
|
+
});
|
|
21
|
+
if (args.json) return { json: true, payload: report };
|
|
22
|
+
return {
|
|
23
|
+
text: [
|
|
24
|
+
`# Neurain watch report${report.mode === 'poll_once' ? ' [poll-once]' : ''}`,
|
|
25
|
+
'',
|
|
26
|
+
`- Root: ${root}`,
|
|
27
|
+
`- Area: ${report.area || 'all'}`,
|
|
28
|
+
'- Durable write: no',
|
|
29
|
+
`- Recent files: ${report.recent_files.length}`,
|
|
30
|
+
`- Recent events: ${report.recent_events.length}`,
|
|
31
|
+
`- Review triggers: ${report.review_triggers.length}`,
|
|
32
|
+
'',
|
|
33
|
+
'## Review triggers',
|
|
34
|
+
...(report.review_triggers.length ? report.review_triggers.map((item) => `- [${item.priority}] ${item.reason}`) : ['- none']),
|
|
35
|
+
'',
|
|
36
|
+
'## Recent files',
|
|
37
|
+
...(report.recent_files.length ? report.recent_files.map((item) => `- ${item.path} (${item.changed_at || 'withheld'})`) : ['- none']),
|
|
38
|
+
'',
|
|
39
|
+
'## Recent events',
|
|
40
|
+
...(report.recent_events.length ? report.recent_events.map((item) => `- ${item.type}: ${item.summary}`) : ['- none']),
|
|
41
|
+
].join('\n'),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildWatchReport(root, {
|
|
46
|
+
area = '',
|
|
47
|
+
top = 10,
|
|
48
|
+
sinceMinutes = 1440,
|
|
49
|
+
includePrivate = false,
|
|
50
|
+
pollOnce = false,
|
|
51
|
+
} = {}) {
|
|
52
|
+
const normalizedArea = normalizeArea(area);
|
|
53
|
+
const numericTop = Math.max(1, Math.min(Number(top || 10), 50));
|
|
54
|
+
const numericSince = Math.max(1, Math.min(Number(sinceMinutes || 1440), 60 * 24 * 30));
|
|
55
|
+
const recentFiles = recentFileSignals(root, {
|
|
56
|
+
area: normalizedArea,
|
|
57
|
+
limit: numericTop,
|
|
58
|
+
sinceMinutes: numericSince,
|
|
59
|
+
includePrivate,
|
|
60
|
+
});
|
|
61
|
+
const journalIntegrity = verifyJournal(root);
|
|
62
|
+
const recentEvents = safeRecentEvents(root, {
|
|
63
|
+
area: normalizedArea,
|
|
64
|
+
limit: numericTop,
|
|
65
|
+
includePrivate,
|
|
66
|
+
});
|
|
67
|
+
const candidates = lessonCandidates(root, { limit: Math.min(numericTop, 10), area: normalizedArea });
|
|
68
|
+
const recap = buildRecap(root, { area: normalizedArea });
|
|
69
|
+
const reviewTriggers = buildReviewTriggers({
|
|
70
|
+
journalIntegrity,
|
|
71
|
+
recentEvents,
|
|
72
|
+
recentFiles,
|
|
73
|
+
candidates,
|
|
74
|
+
recap,
|
|
75
|
+
includePrivate,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
command: 'watch',
|
|
81
|
+
mode: pollOnce ? 'poll_once' : 'snapshot',
|
|
82
|
+
root,
|
|
83
|
+
area: normalizedArea || null,
|
|
84
|
+
durable_write: false,
|
|
85
|
+
write_policy: 'read_only_candidate_report',
|
|
86
|
+
since_minutes: numericSince,
|
|
87
|
+
recent_files: recentFiles,
|
|
88
|
+
recent_events: recentEvents,
|
|
89
|
+
lesson_candidate_count: candidates.length,
|
|
90
|
+
lesson_candidate_ids: candidates.map((candidate) => candidate.id),
|
|
91
|
+
recap_signal_count: recap.recent_signals.length,
|
|
92
|
+
open_work_hint_count: recap.open_work_hints.length,
|
|
93
|
+
journal_integrity: {
|
|
94
|
+
ok: journalIntegrity.ok,
|
|
95
|
+
line_count: journalIntegrity.line_count,
|
|
96
|
+
error_count: journalIntegrity.error_count,
|
|
97
|
+
},
|
|
98
|
+
review_triggers: reviewTriggers,
|
|
99
|
+
next_step: reviewTriggers.length
|
|
100
|
+
? 'Review the watch report. Run wrap dry-run or review worker next; do not promote lessons silently.'
|
|
101
|
+
: 'No strong watch trigger found. Continue work and run watch again after meaningful changes.',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function recentFileSignals(root, { area, limit, sinceMinutes, includePrivate }) {
|
|
106
|
+
const cutoff = Date.now() - sinceMinutes * 60 * 1000;
|
|
107
|
+
const files = walkFiles(root, { includeRaw: false, maxFiles: 50000 })
|
|
108
|
+
.map((abs) => fileSignal(root, abs, includePrivate))
|
|
109
|
+
.filter((item) => item)
|
|
110
|
+
.filter((item) => !area || item.area === area)
|
|
111
|
+
.filter((item) => item.mtime_ms >= cutoff)
|
|
112
|
+
.sort((a, b) => b.mtime_ms - a.mtime_ms)
|
|
113
|
+
.slice(0, limit);
|
|
114
|
+
return files.map(({ mtime_ms, ...item }) => item);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function fileSignal(root, abs, includePrivate) {
|
|
118
|
+
const rel = relPath(root, abs);
|
|
119
|
+
if (!isTextFile(rel)) return null;
|
|
120
|
+
if (ignoredWatchPath(rel)) return null;
|
|
121
|
+
let stat;
|
|
122
|
+
try {
|
|
123
|
+
stat = fs.statSync(abs);
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const sensitivity = inferSensitivityFromPath(rel);
|
|
128
|
+
const withheld = sensitivity === 'private' && !includePrivate;
|
|
129
|
+
return {
|
|
130
|
+
path: withheld ? '[private path withheld]' : rel,
|
|
131
|
+
path_withheld: withheld,
|
|
132
|
+
area: withheld ? '[withheld]' : areaFromRel(rel),
|
|
133
|
+
sensitivity,
|
|
134
|
+
changed_at: withheld ? null : new Date(stat.mtimeMs).toISOString(),
|
|
135
|
+
age_minutes: withheld ? null : Math.max(0, Math.round((Date.now() - stat.mtimeMs) / 60000)),
|
|
136
|
+
size_bytes: withheld ? null : stat.size,
|
|
137
|
+
mtime_ms: stat.mtimeMs,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function safeRecentEvents(root, { area, limit, includePrivate }) {
|
|
142
|
+
const events = listJournalEvents(root, { limit: Math.max(limit * 2, 20) }).events
|
|
143
|
+
.filter((event) => eventMatchesArea(event, area))
|
|
144
|
+
.slice(0, limit);
|
|
145
|
+
return events.map((event) => sanitizeEvent(event, includePrivate));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sanitizeEvent(event, includePrivate) {
|
|
149
|
+
const unsafe = event.sensitivity === 'private' || event.prompt_context_allowed === false || event.safety?.cross_host_allowed === false;
|
|
150
|
+
const hide = unsafe && !includePrivate;
|
|
151
|
+
return {
|
|
152
|
+
event_id: event.event_id,
|
|
153
|
+
type: event.type,
|
|
154
|
+
created_at: event.created_at,
|
|
155
|
+
sensitivity: event.sensitivity,
|
|
156
|
+
prompt_context_allowed: Boolean(event.prompt_context_allowed) && !hide,
|
|
157
|
+
source_ids: hide ? [] : event.source_ids || [],
|
|
158
|
+
source_ids_withheld: hide,
|
|
159
|
+
summary: hide && !event.redacted ? '[event summary withheld by safety policy]' : event.summary,
|
|
160
|
+
redacted: Boolean(event.redacted) || hide,
|
|
161
|
+
safety: {
|
|
162
|
+
secret_like: event.safety?.secret_like || null,
|
|
163
|
+
injection_like: event.safety?.injection_like || null,
|
|
164
|
+
indexing_allowed: Boolean(event.safety?.indexing_allowed) && !hide,
|
|
165
|
+
cross_host_allowed: Boolean(event.safety?.cross_host_allowed) && !hide,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function buildReviewTriggers({ journalIntegrity, recentEvents, recentFiles, candidates, recap, includePrivate }) {
|
|
171
|
+
const triggers = [];
|
|
172
|
+
if (!journalIntegrity.ok) {
|
|
173
|
+
triggers.push({
|
|
174
|
+
id: 'journal-integrity',
|
|
175
|
+
priority: 'high',
|
|
176
|
+
reason: `journal integrity has ${journalIntegrity.error_count} error(s)`,
|
|
177
|
+
source_event_ids: [],
|
|
178
|
+
source_candidate_ids: [],
|
|
179
|
+
source_paths: [],
|
|
180
|
+
source_paths_withheld: false,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const reviewEvents = recentEvents.filter((event) => ['correction', 'review', 'rollback'].includes(event.type));
|
|
184
|
+
if (reviewEvents.length) {
|
|
185
|
+
triggers.push({
|
|
186
|
+
id: 'review-events',
|
|
187
|
+
priority: 'high',
|
|
188
|
+
reason: `${reviewEvents.length} correction/review/rollback event(s) need review-worker attention`,
|
|
189
|
+
source_event_ids: reviewEvents.map((event) => event.event_id),
|
|
190
|
+
source_candidate_ids: [],
|
|
191
|
+
source_paths: [],
|
|
192
|
+
source_paths_withheld: false,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
const unsafeEvents = recentEvents.filter((event) => event.redacted || !event.safety.cross_host_allowed);
|
|
196
|
+
if (unsafeEvents.length) {
|
|
197
|
+
triggers.push({
|
|
198
|
+
id: 'unsafe-events',
|
|
199
|
+
priority: 'medium',
|
|
200
|
+
reason: `${unsafeEvents.length} unsafe or private event(s) were withheld from prompt context`,
|
|
201
|
+
source_event_ids: unsafeEvents.map((event) => event.event_id),
|
|
202
|
+
source_candidate_ids: [],
|
|
203
|
+
source_paths: [],
|
|
204
|
+
source_paths_withheld: false,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (candidates.length) {
|
|
208
|
+
const candidateSources = sanitizeTriggerPaths(candidates.flatMap((candidate) => candidate.source_ids || []), includePrivate);
|
|
209
|
+
triggers.push({
|
|
210
|
+
id: 'lesson-candidates',
|
|
211
|
+
priority: 'medium',
|
|
212
|
+
reason: `${candidates.length} lesson candidate(s) are available for manual review`,
|
|
213
|
+
source_event_ids: [],
|
|
214
|
+
source_candidate_ids: candidates.map((candidate) => candidate.id),
|
|
215
|
+
source_paths: candidateSources.paths,
|
|
216
|
+
source_paths_withheld: candidateSources.withheld,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (recap.open_work_hints.length) {
|
|
220
|
+
const recapSources = sanitizeTriggerPaths(recap.sources_checked || [], includePrivate);
|
|
221
|
+
triggers.push({
|
|
222
|
+
id: 'open-work',
|
|
223
|
+
priority: 'medium',
|
|
224
|
+
reason: `${recap.open_work_hints.length} open work hint(s) found in recap sources`,
|
|
225
|
+
source_event_ids: [],
|
|
226
|
+
source_candidate_ids: [],
|
|
227
|
+
source_paths: recapSources.paths,
|
|
228
|
+
source_paths_withheld: recapSources.withheld,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (recentFiles.length) {
|
|
232
|
+
triggers.push({
|
|
233
|
+
id: 'recent-files',
|
|
234
|
+
priority: 'low',
|
|
235
|
+
reason: `${recentFiles.length} recently changed text file(s) observed`,
|
|
236
|
+
source_event_ids: [],
|
|
237
|
+
source_candidate_ids: [],
|
|
238
|
+
source_paths: recentFiles.map((item) => item.path),
|
|
239
|
+
source_paths_withheld: recentFiles.some((item) => item.path_withheld),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return triggers;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function sanitizeTriggerPaths(paths, includePrivate) {
|
|
246
|
+
let withheld = false;
|
|
247
|
+
const safePaths = [];
|
|
248
|
+
for (const rel of paths) {
|
|
249
|
+
if (inferSensitivityFromPath(rel) === 'private' && !includePrivate) {
|
|
250
|
+
withheld = true;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
safePaths.push(rel);
|
|
254
|
+
}
|
|
255
|
+
return { paths: safePaths, withheld };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function ignoredWatchPath(rel) {
|
|
259
|
+
return [
|
|
260
|
+
/^output\/receipts\//,
|
|
261
|
+
/^output\/benchmarks\//,
|
|
262
|
+
/^output\/reviews\//,
|
|
263
|
+
/^00_system\/neurain\/events\.ndjson$/,
|
|
264
|
+
/^00_system\/sessions\/session-state\.json$/,
|
|
265
|
+
].some((pattern) => pattern.test(rel));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function eventMatchesArea(event, area) {
|
|
269
|
+
if (!area) return true;
|
|
270
|
+
const areaPrefix = `10_areas/_${area}/`;
|
|
271
|
+
return (event.source_ids || []).some((source) => String(source).startsWith(areaPrefix))
|
|
272
|
+
|| String(event.scope || '').replace(/^_/, '') === area;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function areaFromRel(rel) {
|
|
276
|
+
const match = String(rel).match(/^10_areas\/_?([^/]+)\//);
|
|
277
|
+
return match ? match[1] : null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function normalizeArea(area) {
|
|
281
|
+
return String(area || '').replace(/^_/, '').trim();
|
|
282
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Append-only wiki operations log (W-B). capture/complete record one operational
|
|
2
|
+
// line each (source id, paths, areas, status) here. This is an operational
|
|
3
|
+
// receipt, NOT session working memory or canonical knowledge, so the engine owns
|
|
4
|
+
// the append directly; the vault shuttle must NOT also append a duplicate line
|
|
5
|
+
// (single-owner rule from the B4 cross-review). Faithful port of the vault
|
|
6
|
+
// appendWikiLog.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { ensureDir, readText, timestamp } from './fs.mjs';
|
|
10
|
+
|
|
11
|
+
export function appendWikiLog(root, vaultCfg, kind, title, lines = []) {
|
|
12
|
+
const day = timestamp().slice(0, 10);
|
|
13
|
+
const logAbs = path.join(root, vaultCfg.wiki_dir, 'log.md');
|
|
14
|
+
ensureDir(path.dirname(logAbs));
|
|
15
|
+
if (!fs.existsSync(logAbs)) {
|
|
16
|
+
fs.writeFileSync(
|
|
17
|
+
logAbs,
|
|
18
|
+
`---\ntitle: Wiki Log\ntype: log\nareas: []\nsources: []\nstatus: active\nsensitivity: internal\nupdated: ${day}\n---\n\n# Wiki Log\n\nAppend-only operation log for root wiki maintenance.\n`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
const body = lines.length ? `\n${lines.map((line) => `- ${line}`).join('\n')}\n` : '';
|
|
22
|
+
fs.appendFileSync(logAbs, `\n## [${day}] ${kind} | ${title}\n${body}`);
|
|
23
|
+
return path.relative(root, logAbs).split(path.sep).join('/');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// best-effort: callers that prefer readText for existence can reuse this
|
|
27
|
+
export function wikiLogExists(root, vaultCfg) {
|
|
28
|
+
return Boolean(readText(path.join(root, vaultCfg.wiki_dir, 'log.md'), ''));
|
|
29
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { listCapabilities } from './capabilities.mjs';
|
|
2
|
+
import { doctorCommand } from './doctor.mjs';
|
|
3
|
+
import { lessonCandidates, listLessons } from './lessons.mjs';
|
|
4
|
+
import { buildRecap } from './recap.mjs';
|
|
5
|
+
import { absPath } from './fs.mjs';
|
|
6
|
+
|
|
7
|
+
export async function wrapCommand(args) {
|
|
8
|
+
const root = absPath(args._[0] || args.root || process.cwd());
|
|
9
|
+
const area = args.area || '';
|
|
10
|
+
const dryRun = args.apply ? false : true;
|
|
11
|
+
if (!dryRun) {
|
|
12
|
+
throw new Error('Public alpha supports wrap dry-run only. Durable lesson promotion is intentionally unavailable.');
|
|
13
|
+
}
|
|
14
|
+
const doctor = await doctorCommand({ _: [root], json: true });
|
|
15
|
+
const lessons = listLessons(root, { area });
|
|
16
|
+
const candidates = lessonCandidates(root, { limit: Number(args.top || 5), area });
|
|
17
|
+
const recap = buildRecap(root, { area });
|
|
18
|
+
const capabilities = listCapabilities({ query: 'wrap lesson recap rollback capability', limit: 5 });
|
|
19
|
+
const payload = {
|
|
20
|
+
ok: true,
|
|
21
|
+
command: 'wrap',
|
|
22
|
+
root,
|
|
23
|
+
dry_run: true,
|
|
24
|
+
durable_write: false,
|
|
25
|
+
doctor: doctor.payload,
|
|
26
|
+
lesson_state: {
|
|
27
|
+
active_lessons: lessons.length,
|
|
28
|
+
candidate_count: candidates.length,
|
|
29
|
+
promotion_available: candidates.some((candidate) => candidate.safety?.promotion_allowed),
|
|
30
|
+
promotion_note: candidates.some((candidate) => candidate.safety?.promotion_allowed)
|
|
31
|
+
? 'Promote one reviewed candidate with `neurain lessons promote <folder> --candidate-id <id> --confirm "1건 저장 진행"`.'
|
|
32
|
+
: 'No safe promotable candidate in this preview.',
|
|
33
|
+
},
|
|
34
|
+
recap,
|
|
35
|
+
candidates,
|
|
36
|
+
capabilities,
|
|
37
|
+
next_step: candidates.length
|
|
38
|
+
? 'Review lesson candidates. Do not promote until rollback proof exists.'
|
|
39
|
+
: 'No strong lesson candidates. Continue work and run wrap again after meaningful changes.',
|
|
40
|
+
};
|
|
41
|
+
if (args.json) return { json: true, payload };
|
|
42
|
+
return {
|
|
43
|
+
text: [
|
|
44
|
+
'# Neurain wrap dry-run',
|
|
45
|
+
'',
|
|
46
|
+
`- Root: ${root}`,
|
|
47
|
+
`- Doctor OK: ${doctor.payload.ok ? 'yes' : 'no'}`,
|
|
48
|
+
`- Active lessons: ${lessons.length}`,
|
|
49
|
+
`- Lesson candidates: ${candidates.length}`,
|
|
50
|
+
'- Durable write: no',
|
|
51
|
+
'',
|
|
52
|
+
'## Recap signals',
|
|
53
|
+
...(recap.recent_signals.length ? recap.recent_signals.map((line) => `- ${line}`) : ['- none']),
|
|
54
|
+
'',
|
|
55
|
+
'## Candidate preview',
|
|
56
|
+
...(candidates.length ? candidates.map((candidate) => `- ${candidate.id}: ${candidate.title} (${candidate.source_ids.join(', ')})`) : ['- none']),
|
|
57
|
+
'',
|
|
58
|
+
'## Useful capabilities',
|
|
59
|
+
...capabilities.map((capability) => `- ${capability.id}: ${capability.commands[0]}`),
|
|
60
|
+
].join('\n'),
|
|
61
|
+
};
|
|
62
|
+
}
|