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
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { absPath, exists, walkFiles } from './fs.mjs';
|
|
4
|
+
import { listJournalEvents, verifyJournal } from './journal.mjs';
|
|
5
|
+
import { listLessons } from './lessons.mjs';
|
|
6
|
+
import { recallStatus } from './recall.mjs';
|
|
7
|
+
|
|
8
|
+
const required = [
|
|
9
|
+
'neurain.config.json',
|
|
10
|
+
'AGENTS.md',
|
|
11
|
+
'CLAUDE.md',
|
|
12
|
+
'00_system/neurain-startup.md',
|
|
13
|
+
'00_system/neurain/neurain-rules.md',
|
|
14
|
+
'00_system/neurain/lessons.md',
|
|
15
|
+
'00_system/neurain/events.ndjson',
|
|
16
|
+
'00_system/sessions/session-state.json',
|
|
17
|
+
'00_system/sessions/session-registry.md',
|
|
18
|
+
'wiki/index.md',
|
|
19
|
+
'wiki/log.md',
|
|
20
|
+
'raw/_inbox',
|
|
21
|
+
'output',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export async function doctorCommand(args) {
|
|
25
|
+
const root = absPath(args._[0] || args.root || process.cwd());
|
|
26
|
+
const missing = required.filter((rel) => !exists(path.join(root, rel)));
|
|
27
|
+
const config = readConfig(root);
|
|
28
|
+
const files = exists(root) ? walkFiles(root, { maxFiles: 20000 }) : [];
|
|
29
|
+
const lessons = exists(root) ? listLessons(root) : [];
|
|
30
|
+
const journal = exists(root) ? listJournalEvents(root, { limit: 5 }) : { count: 0 };
|
|
31
|
+
const journalIntegrity = exists(root) ? verifyJournal(root) : { ok: false };
|
|
32
|
+
const recall = exists(root) ? await recallStatus(root) : { sqlite_available: false, db_exists: false, row_count: 0, markdown_fallback: true };
|
|
33
|
+
const packageVersion = await version();
|
|
34
|
+
const payload = {
|
|
35
|
+
ok: exists(root) && fs.statSync(root).isDirectory() && missing.length === 0,
|
|
36
|
+
command: 'doctor',
|
|
37
|
+
root,
|
|
38
|
+
package_version: packageVersion,
|
|
39
|
+
missing,
|
|
40
|
+
file_count: files.length,
|
|
41
|
+
language_default: config.language_default || null,
|
|
42
|
+
host_connections: {
|
|
43
|
+
codex: 'run `neurain connect codex <folder>` to configure',
|
|
44
|
+
claude: 'run `neurain connect claude <folder>` to configure',
|
|
45
|
+
gemini: 'run `neurain connect gemini <folder>` to configure',
|
|
46
|
+
claude_lifecycle_hooks: 'preview with `neurain connect claude <folder> --lifecycle-hooks --dry-run`',
|
|
47
|
+
runtime: 'preview with `neurain connect runtime <folder> --dry-run`',
|
|
48
|
+
},
|
|
49
|
+
lesson_loop: {
|
|
50
|
+
registry_exists: exists(path.join(root, '00_system/neurain/lessons.md')),
|
|
51
|
+
active_lessons: lessons.length,
|
|
52
|
+
candidate_preview: 'run `neurain lessons candidates <folder>` or `neurain wrap <folder> --dry-run`',
|
|
53
|
+
promotion_available: 'cli_only_with_exact_confirmation',
|
|
54
|
+
rollback_available: true,
|
|
55
|
+
},
|
|
56
|
+
event_journal: {
|
|
57
|
+
path: '00_system/neurain/events.ndjson',
|
|
58
|
+
recent_events: journal.count || 0,
|
|
59
|
+
integrity_ok: journalIntegrity.ok,
|
|
60
|
+
write_policy: 'cli_only_with_exact_confirmation',
|
|
61
|
+
},
|
|
62
|
+
curator_loop: {
|
|
63
|
+
status_preview: 'run `neurain curator status <folder>`',
|
|
64
|
+
run_preview: 'run `neurain curator run <folder> --dry-run`',
|
|
65
|
+
write_policy: 'snapshot_then_exact_confirmation',
|
|
66
|
+
rollback_available: true,
|
|
67
|
+
},
|
|
68
|
+
recall_index: {
|
|
69
|
+
db_path: '00_system/neurain/recall.sqlite',
|
|
70
|
+
sqlite_available: recall.sqlite_available,
|
|
71
|
+
db_exists: recall.db_exists,
|
|
72
|
+
row_count: recall.row_count,
|
|
73
|
+
markdown_fallback: true,
|
|
74
|
+
rebuild: 'run `neurain recall rebuild <folder>`',
|
|
75
|
+
},
|
|
76
|
+
admin_surface: {
|
|
77
|
+
status: 'locked',
|
|
78
|
+
reason: 'The CLI alpha has no web admin page. Future admin UI must require authentication before exposing controls.',
|
|
79
|
+
},
|
|
80
|
+
backup: {
|
|
81
|
+
status: 'not_configured',
|
|
82
|
+
recommendation: 'Keep this folder in an external drive or cloud-synced backup before serious use.',
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
if (args.json) return { json: true, payload };
|
|
86
|
+
return {
|
|
87
|
+
text: [
|
|
88
|
+
'# Neurain doctor',
|
|
89
|
+
'',
|
|
90
|
+
`- OK: ${payload.ok ? 'yes' : 'no'}`,
|
|
91
|
+
`- Root: ${root}`,
|
|
92
|
+
`- Version: ${packageVersion}`,
|
|
93
|
+
`- Files scanned: ${files.length}`,
|
|
94
|
+
`- Missing required files: ${missing.length ? missing.join(', ') : 'none'}`,
|
|
95
|
+
`- Active lessons: ${lessons.length}`,
|
|
96
|
+
`- Journal integrity: ${payload.event_journal.integrity_ok ? 'ok' : 'not ready'}`,
|
|
97
|
+
`- Recall DB: ${payload.recall_index.db_exists ? `${payload.recall_index.row_count} rows` : 'not built, markdown fallback available'}`,
|
|
98
|
+
`- Admin surface: ${payload.admin_surface.status}`,
|
|
99
|
+
`- Backup: ${payload.backup.recommendation}`,
|
|
100
|
+
].join('\n'),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readConfig(root) {
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(fs.readFileSync(path.join(root, 'neurain.config.json'), 'utf8'));
|
|
107
|
+
} catch {
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function version() {
|
|
113
|
+
const pkg = await import('../../package.json', { with: { type: 'json' } });
|
|
114
|
+
return pkg.default.version;
|
|
115
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Shared durable-write primitives (W-B). The crash-safe + concurrency-safe write
|
|
2
|
+
// layer every W-B writer routes through: a temp+fsync+rename atomic write so a
|
|
3
|
+
// crash never leaves a torn file, an O_EXCL cross-process lock with stale-lock
|
|
4
|
+
// reclaim so concurrent read-modify-write callers serialize instead of clobbering
|
|
5
|
+
// each other, and a locked JSONL append (the engine FIXES the vault capture's
|
|
6
|
+
// non-atomic append here). Faithful port of the vault neurain-utils primitives.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { ensureDir, readText } from './fs.mjs';
|
|
10
|
+
|
|
11
|
+
export function atomicWriteText(filePath, text) {
|
|
12
|
+
ensureDir(path.dirname(filePath));
|
|
13
|
+
const tmp = `${filePath}.tmp-${process.pid}`;
|
|
14
|
+
const fd = fs.openSync(tmp, 'w');
|
|
15
|
+
try {
|
|
16
|
+
fs.writeSync(fd, text);
|
|
17
|
+
fs.fsyncSync(fd);
|
|
18
|
+
} finally {
|
|
19
|
+
fs.closeSync(fd);
|
|
20
|
+
}
|
|
21
|
+
fs.renameSync(tmp, filePath);
|
|
22
|
+
try { const dfd = fs.openSync(path.dirname(filePath), 'r'); fs.fsyncSync(dfd); fs.closeSync(dfd); } catch { /* dir fsync best-effort */ }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function atomicWriteJson(filePath, value) {
|
|
26
|
+
atomicWriteText(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Serialize callers on `abs` with an O_EXCL lock. Reclaims a lock older than 10
|
|
30
|
+
// minutes whose owning pid is gone (so a crashed holder never wedges the file).
|
|
31
|
+
export function withFileLock(abs, fn) {
|
|
32
|
+
const lockPath = `${abs}.lock`;
|
|
33
|
+
ensureDir(path.dirname(abs));
|
|
34
|
+
try {
|
|
35
|
+
const st = fs.statSync(lockPath);
|
|
36
|
+
if (Date.now() - st.mtimeMs > 600000) {
|
|
37
|
+
const pid = parseInt(String(fs.readFileSync(lockPath)).split(/\s/)[0], 10);
|
|
38
|
+
let alive = false;
|
|
39
|
+
try { process.kill(pid, 0); alive = true; } catch { /* not alive */ }
|
|
40
|
+
if (!alive) fs.unlinkSync(lockPath);
|
|
41
|
+
}
|
|
42
|
+
} catch { /* no stale lock */ }
|
|
43
|
+
let fd;
|
|
44
|
+
try {
|
|
45
|
+
fd = fs.openSync(lockPath, 'wx');
|
|
46
|
+
} catch (e) {
|
|
47
|
+
throw new Error(`locked: ${lockPath} (${e.code})`);
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
fs.writeSync(fd, `${process.pid} ${Date.now()}`);
|
|
51
|
+
return fn();
|
|
52
|
+
} finally {
|
|
53
|
+
fs.closeSync(fd);
|
|
54
|
+
try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Read a .jsonl file into rows, skipping blank and torn lines (one bad line never
|
|
59
|
+
// discards the whole file). Mirrors vault_state.readJsonl but lives here so the
|
|
60
|
+
// write primitives are self-contained for read-modify-write callers.
|
|
61
|
+
export function readJsonlRows(abs) {
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const line of readText(abs, '').split(/\r?\n/)) {
|
|
64
|
+
const trimmed = line.trim();
|
|
65
|
+
if (!trimmed) continue;
|
|
66
|
+
try { out.push(JSON.parse(trimmed)); } catch { /* skip torn line */ }
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Append one or more rows as JSONL under the file lock. The lock is what makes
|
|
72
|
+
// the append atomic against a concurrent appender or rewriter (the vault's plain
|
|
73
|
+
// appendText was not serialized; this is the fix).
|
|
74
|
+
export function appendJsonlLocked(abs, rows) {
|
|
75
|
+
const list = Array.isArray(rows) ? rows : [rows];
|
|
76
|
+
if (!list.length) return 0;
|
|
77
|
+
const text = `${list.map((row) => JSON.stringify(row)).join('\n')}\n`;
|
|
78
|
+
withFileLock(abs, () => {
|
|
79
|
+
ensureDir(path.dirname(abs));
|
|
80
|
+
fs.appendFileSync(abs, text, 'utf8');
|
|
81
|
+
});
|
|
82
|
+
return list.length;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Read-modify-write a JSONL file under one lock: the rows are re-read INSIDE the
|
|
86
|
+
// lock and the mutated set is written atomically, so a row appended between an
|
|
87
|
+
// earlier read and this rewrite is never lost. `mutate(rows)` returns the next
|
|
88
|
+
// row array.
|
|
89
|
+
export function rewriteJsonlLocked(abs, mutate) {
|
|
90
|
+
return withFileLock(abs, () => {
|
|
91
|
+
const rows = readJsonlRows(abs);
|
|
92
|
+
const next = mutate(rows) || [];
|
|
93
|
+
atomicWriteText(abs, next.length ? `${next.map((row) => JSON.stringify(row)).join('\n')}\n` : '');
|
|
94
|
+
return next;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Capture-envelope construction and source-input reading (W-B, read/pure half).
|
|
2
|
+
// `makeEnvelope` is the deterministic classification + envelope builder that route
|
|
3
|
+
// and plan-writeback share; it is pure (no writes) so it lives here ahead of the
|
|
4
|
+
// durable capture writer (B4), which will reuse it. `readArgInput` resolves the
|
|
5
|
+
// source text a command was given (--text, positional, --file, or piped stdin).
|
|
6
|
+
// Faithful port of the vault neurain-utils envelope helpers.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { readText, safeResolve, timestamp } from './fs.mjs';
|
|
10
|
+
import { vaultConfig } from './config.mjs';
|
|
11
|
+
import { classifyText, loadAreaIntel, loadAreaProfiles, normalizeAreaId } from './classify.mjs';
|
|
12
|
+
|
|
13
|
+
export function normalizeList(value) {
|
|
14
|
+
if (!value) return [];
|
|
15
|
+
const items = Array.isArray(value) ? value : String(value).split(',');
|
|
16
|
+
return items.map((item) => String(item).trim()).filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const SOURCE_TYPE_FOLDERS = {
|
|
20
|
+
chat: 'chat', file: 'files', files: 'files', link: 'links', meeting: 'meetings',
|
|
21
|
+
memo: 'memos', note: 'memos', screenshot: 'screenshots', transcript: 'transcripts', web: 'web',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function folderForSourceType(sourceType) {
|
|
25
|
+
return SOURCE_TYPE_FOLDERS[String(sourceType || 'memo').toLowerCase()] || 'memos';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function slugify(value, fallback = 'untitled') {
|
|
29
|
+
const out = String(value || '')
|
|
30
|
+
.trim()
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/['"]/g, '')
|
|
33
|
+
.replace(/[^a-z0-9가-힣]+/gi, '-')
|
|
34
|
+
.replace(/^-+|-+$/g, '')
|
|
35
|
+
.slice(0, 80);
|
|
36
|
+
return out || fallback;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function quoteYaml(value) {
|
|
40
|
+
return JSON.stringify(String(value || ''));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Render the raw-source markdown for a capture: frontmatter + the full envelope
|
|
44
|
+
// JSON block + the raw body. Byte-faithful to the vault renderCaptureMarkdown.
|
|
45
|
+
export function renderCaptureMarkdown(envelope, body) {
|
|
46
|
+
const safeBody = String(body || '').trimEnd();
|
|
47
|
+
return `---\nsource_id: ${envelope.source_id}\ntitle: ${quoteYaml(envelope.title)}\ntype: raw_source\nsource_type: ${envelope.source_type}\nareas: [${envelope.area_candidates.join(', ')}]\nsensitivity: ${envelope.sensitivity}\nstatus: ${envelope.status}\ncaptured: ${envelope.captured_at}\n---\n\n# ${envelope.title}\n\n## Capture Envelope\n\n\`\`\`json\n${JSON.stringify(envelope, null, 2)}\n\`\`\`\n\n## Raw Content\n\n${safeBody}\n`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Build a capture envelope from raw text. `root` anchors registry-driven routing;
|
|
51
|
+
// `overrides` may pin area(s), sensitivity, intent, or requiresUserDecision. Pure:
|
|
52
|
+
// classification reads the registry/area files but nothing is written.
|
|
53
|
+
export function makeEnvelope(root, { sourceId, sourceType, title, text, status = 'planned', overrides = {} }, opts = {}) {
|
|
54
|
+
const vaultCfg = opts.vaultCfg || vaultConfig(root);
|
|
55
|
+
const intel = opts.intel || loadAreaIntel(root, vaultCfg);
|
|
56
|
+
const profiles = opts.profiles || loadAreaProfiles(root, vaultCfg, intel);
|
|
57
|
+
const route = classifyText(root, text, overrides, { vaultCfg, intel, profiles });
|
|
58
|
+
const areaCandidates = normalizeList(overrides.area || overrides.areas || route.area_candidates).map((a) => normalizeAreaId(root, a, vaultCfg));
|
|
59
|
+
const domainCandidates = normalizeList(overrides.domain || overrides.domains || route.domain_candidates);
|
|
60
|
+
const entityCandidates = normalizeList(overrides.entity || overrides.entities || route.entity_candidates);
|
|
61
|
+
return {
|
|
62
|
+
source_id: sourceId,
|
|
63
|
+
title,
|
|
64
|
+
source_type: sourceType,
|
|
65
|
+
area_candidates: areaCandidates,
|
|
66
|
+
domain_candidates: domainCandidates,
|
|
67
|
+
entity_candidates: entityCandidates,
|
|
68
|
+
sensitivity: overrides.sensitivity || route.sensitivity,
|
|
69
|
+
write_intent: overrides.intent || route.write_intent,
|
|
70
|
+
status,
|
|
71
|
+
compiled_to: [],
|
|
72
|
+
requires_user_decision: Boolean(overrides.requiresUserDecision || route.requires_user_decision),
|
|
73
|
+
captured_at: timestamp(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Resolve the source text a command was handed. Precedence mirrors the vault:
|
|
78
|
+
// explicit --text, then positional tokens after the root, then --file (root-
|
|
79
|
+
// relative or absolute), then piped stdin. Stdin is only read when not a TTY, so
|
|
80
|
+
// an interactive CLI never blocks and spawned tests with a closed stdin get ''.
|
|
81
|
+
export function readArgInput(args, root) {
|
|
82
|
+
if (typeof args.text === 'string') return args.text;
|
|
83
|
+
const positional = args._.slice(1).join(' ').trim();
|
|
84
|
+
if (positional) return positional;
|
|
85
|
+
if (args.file) {
|
|
86
|
+
const target = path.isAbsolute(String(args.file)) ? String(args.file) : safeResolve(root, String(args.file));
|
|
87
|
+
return readText(target, '');
|
|
88
|
+
}
|
|
89
|
+
if (!process.stdin.isTTY) {
|
|
90
|
+
try {
|
|
91
|
+
return fs.readFileSync(0, 'utf8');
|
|
92
|
+
} catch {
|
|
93
|
+
return '';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// `flush` and `session-flush` commands (W-B, read-only). Both read the flat root
|
|
2
|
+
// writeback queue and produce a flush PLAN — never a write. `flush` groups all
|
|
3
|
+
// pending items by Light/Standard/Full scope; `session-flush` scopes to one
|
|
4
|
+
// session at a requested level, separating allowed vs blocked items and flagging
|
|
5
|
+
// target conflicts. Faithful port of the vault neurain-flush / neurain-session-
|
|
6
|
+
// flush tools (nondeterministic timing fields are intentionally dropped; parity
|
|
7
|
+
// normalizes them). The actual queue writes belong to a later increment.
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { absPath } from './fs.mjs';
|
|
10
|
+
import { vaultConfig } from './config.mjs';
|
|
11
|
+
import { loadSessionState, readJsonl } from './vault_state.mjs';
|
|
12
|
+
|
|
13
|
+
const FLUSH_GUIDANCE = [
|
|
14
|
+
'Flush only reviews pending candidates already in the writeback queue.',
|
|
15
|
+
'Direct file edits do not appear as Flush items because they are already applied.',
|
|
16
|
+
'Use Pulse when the goal is to let other sessions know about the latest context.',
|
|
17
|
+
'Use Save or Compile first when new material should become a queued canonical update.',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function classifyScope(item) {
|
|
21
|
+
if (item.requires_user_decision || item.sensitivity === 'private') return 'Full';
|
|
22
|
+
if (['remember', 'update_current', 'create_task', 'output_request'].includes(item.write_intent)) return 'Standard';
|
|
23
|
+
return 'Light';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function flushCommand(args) {
|
|
27
|
+
const root = absPath(args._[0] || args.root || process.cwd());
|
|
28
|
+
const vaultCfg = vaultConfig(root);
|
|
29
|
+
const queueRel = args.queue ? String(args.queue) : vaultCfg.writeback_queue;
|
|
30
|
+
const items = readJsonl(path.join(root, queueRel)).filter((item) => item.status === 'pending');
|
|
31
|
+
|
|
32
|
+
const groups = { Light: [], Standard: [], Full: [] };
|
|
33
|
+
for (const item of items) {
|
|
34
|
+
groups[classifyScope(item)].push({
|
|
35
|
+
source_id: item.source_id,
|
|
36
|
+
title: item.title,
|
|
37
|
+
raw_path: item.raw_path,
|
|
38
|
+
sensitivity: item.sensitivity,
|
|
39
|
+
write_intent: item.write_intent,
|
|
40
|
+
requires_user_decision: item.requires_user_decision,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const payload = {
|
|
45
|
+
ok: true,
|
|
46
|
+
command: 'flush',
|
|
47
|
+
durable_write: false,
|
|
48
|
+
queue: queueRel,
|
|
49
|
+
pending: items.length,
|
|
50
|
+
groups,
|
|
51
|
+
next_action:
|
|
52
|
+
groups.Full.length > 0
|
|
53
|
+
? 'Ask for confirmation on Full items before mutating wiki or memory.'
|
|
54
|
+
: groups.Standard.length > 0
|
|
55
|
+
? 'Compile Standard items into source summaries and indexes.'
|
|
56
|
+
: groups.Light.length > 0
|
|
57
|
+
? 'Apply Light metadata or log-only updates.'
|
|
58
|
+
: 'Nothing pending. Use Pulse for cross-session notice, or Save or Compile before Flush for new material.',
|
|
59
|
+
guidance: FLUSH_GUIDANCE,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (args.json) return { json: true, payload };
|
|
63
|
+
const lines = ['# Flush Plan', '', `- Pending: ${payload.pending}`];
|
|
64
|
+
for (const scope of ['Full', 'Standard', 'Light']) lines.push(`- ${scope}: ${payload.groups[scope].length}`);
|
|
65
|
+
lines.push(`- Next action: ${payload.next_action}`);
|
|
66
|
+
if (payload.pending === 0) {
|
|
67
|
+
lines.push('', '## Guidance', '');
|
|
68
|
+
for (const line of payload.guidance) lines.push(`- ${line}`);
|
|
69
|
+
}
|
|
70
|
+
return { text: lines.join('\n') };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function summarizeRow(row) {
|
|
74
|
+
return {
|
|
75
|
+
source_id: row.source_id || '',
|
|
76
|
+
title: row.title || '',
|
|
77
|
+
flush_level: row.flush_level || '',
|
|
78
|
+
target_layer: row.target_layer || '',
|
|
79
|
+
target_path: row.target_path || '',
|
|
80
|
+
raw_path: row.raw_path || '',
|
|
81
|
+
requires_user_decision: Boolean(row.requires_user_decision),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function inferRowLevel(row) {
|
|
86
|
+
if (row.requires_user_decision || row.sensitivity === 'private') return 'full';
|
|
87
|
+
if (row.write_intent === 'evidence_only') return 'light';
|
|
88
|
+
return 'standard';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isAllowedAtLevel(row, requestedLevel) {
|
|
92
|
+
if (row.requires_user_decision) return requestedLevel === 'full';
|
|
93
|
+
const rowLevel = String(row.flush_level || inferRowLevel(row)).toLowerCase();
|
|
94
|
+
if (requestedLevel === 'light') return rowLevel === 'light';
|
|
95
|
+
if (requestedLevel === 'standard') {
|
|
96
|
+
return ['light', 'standard'].includes(rowLevel) && !['current', 'fact_ledger', 'task_memory'].includes(row.target_layer);
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function blockReason(row, requestedLevel) {
|
|
102
|
+
if (row.requires_user_decision) return 'requires user decision';
|
|
103
|
+
if (requestedLevel === 'light') return 'needs standard or full flush';
|
|
104
|
+
if (['current', 'fact_ledger', 'task_memory'].includes(row.target_layer)) return 'canonical state layer requires Full Flush';
|
|
105
|
+
return 'blocked by flush level policy';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function detectConflicts(items) {
|
|
109
|
+
const byTarget = new Map();
|
|
110
|
+
for (const row of items) {
|
|
111
|
+
const key = `${row.target_layer || ''}:${row.target_path || ''}`;
|
|
112
|
+
if (!row.target_layer || !row.target_path) continue;
|
|
113
|
+
const current = byTarget.get(key) || [];
|
|
114
|
+
current.push(row);
|
|
115
|
+
byTarget.set(key, current);
|
|
116
|
+
}
|
|
117
|
+
return [...byTarget.entries()]
|
|
118
|
+
.filter(([, rowsForTarget]) => rowsForTarget.length > 1)
|
|
119
|
+
.map(([target, rowsForTarget]) => ({
|
|
120
|
+
target,
|
|
121
|
+
count: rowsForTarget.length,
|
|
122
|
+
source_ids: rowsForTarget.map((row) => row.source_id || row.queue_id || '').filter(Boolean),
|
|
123
|
+
policy: 'queue_first',
|
|
124
|
+
requires_user_decision: true,
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function sessionFlushCommand(args) {
|
|
129
|
+
const root = absPath(args._[0] || args.root || process.cwd());
|
|
130
|
+
const vaultCfg = vaultConfig(root);
|
|
131
|
+
const sessionId = String(args['session-id'] || '');
|
|
132
|
+
const level = String(args.level || 'standard').toLowerCase();
|
|
133
|
+
|
|
134
|
+
if (!sessionId) return done(args, { ok: false, command: 'session-flush', durable_write: false, error: 'Missing --session-id.' });
|
|
135
|
+
if (!['light', 'standard', 'full'].includes(level)) {
|
|
136
|
+
return done(args, { ok: false, command: 'session-flush', durable_write: false, error: 'Invalid --level. Use light, standard, or full.' });
|
|
137
|
+
}
|
|
138
|
+
let state = { sessions: {} };
|
|
139
|
+
try { state = loadSessionState(root, vaultCfg); } catch (error) {
|
|
140
|
+
return done(args, { ok: false, command: 'session-flush', durable_write: false, error: error.message });
|
|
141
|
+
}
|
|
142
|
+
if (!(state.sessions || {})[sessionId]) {
|
|
143
|
+
return done(args, { ok: false, command: 'session-flush', durable_write: false, error: `unknown session "${sessionId}"` });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const queueRel = args.queue ? String(args.queue) : vaultCfg.writeback_queue;
|
|
147
|
+
const rows = readJsonl(path.join(root, queueRel));
|
|
148
|
+
const pending = rows.filter((row) => row.session_id === sessionId && row.status === 'pending');
|
|
149
|
+
const conflicts = detectConflicts(pending);
|
|
150
|
+
const allowed = pending.filter((row) => isAllowedAtLevel(row, level));
|
|
151
|
+
const blocked = pending.filter((row) => !isAllowedAtLevel(row, level));
|
|
152
|
+
|
|
153
|
+
const payload = {
|
|
154
|
+
ok: true,
|
|
155
|
+
command: 'session-flush',
|
|
156
|
+
durable_write: false,
|
|
157
|
+
session_id: sessionId,
|
|
158
|
+
level,
|
|
159
|
+
queue_path: queueRel,
|
|
160
|
+
pending_count: pending.length,
|
|
161
|
+
allowed_count: allowed.length,
|
|
162
|
+
blocked_count: blocked.length,
|
|
163
|
+
conflicts,
|
|
164
|
+
allowed: allowed.map(summarizeRow),
|
|
165
|
+
blocked: blocked.map((row) => ({ ...summarizeRow(row), reason: blockReason(row, level) })),
|
|
166
|
+
mutates_canonical: false,
|
|
167
|
+
note:
|
|
168
|
+
level === 'full'
|
|
169
|
+
? 'Full Flush requires user confirmation before current, fact ledger, task memory, supersede, or conflict writes.'
|
|
170
|
+
: 'This v1 command prepares a session-scoped flush plan. The agent performs the compiled markdown write, then closes queue rows with complete.',
|
|
171
|
+
};
|
|
172
|
+
return done(args, payload);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function done(args, payload) {
|
|
176
|
+
if (args.json) return { json: true, payload };
|
|
177
|
+
if (!payload.ok) return { text: `# Session Flush Plan\n\n- ${payload.error}` };
|
|
178
|
+
const lines = [
|
|
179
|
+
'# Session Flush Plan',
|
|
180
|
+
'',
|
|
181
|
+
`- Session: ${payload.session_id}`,
|
|
182
|
+
`- Level: ${payload.level}`,
|
|
183
|
+
`- Pending: ${payload.pending_count}`,
|
|
184
|
+
`- Allowed: ${payload.allowed_count}`,
|
|
185
|
+
`- Blocked: ${payload.blocked_count}`,
|
|
186
|
+
`- Conflicts: ${payload.conflicts.length}`,
|
|
187
|
+
'- Mutates canonical: no',
|
|
188
|
+
];
|
|
189
|
+
return { text: lines.join('\n') };
|
|
190
|
+
}
|
package/src/core/fs.mjs
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const GENERATED_NAMES = new Set(['.git', 'node_modules', '.next', 'out', '.vercel', '.DS_Store', '.cache', '.neurain-staging']);
|
|
6
|
+
|
|
7
|
+
export function absPath(input, fallback = process.cwd()) {
|
|
8
|
+
return path.resolve(String(input || fallback));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ensureDir(dir) {
|
|
12
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function exists(file) {
|
|
16
|
+
return fs.existsSync(file);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readText(file, fallback = '') {
|
|
20
|
+
try {
|
|
21
|
+
return fs.readFileSync(file, 'utf8');
|
|
22
|
+
} catch {
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function writeFileNoOverwrite(file, content, { dryRun = false, created = [], skipped = [] } = {}) {
|
|
28
|
+
if (fs.existsSync(file)) {
|
|
29
|
+
skipped.push(file);
|
|
30
|
+
return { created, skipped };
|
|
31
|
+
}
|
|
32
|
+
if (!dryRun) {
|
|
33
|
+
ensureDir(path.dirname(file));
|
|
34
|
+
fs.writeFileSync(file, content, 'utf8');
|
|
35
|
+
}
|
|
36
|
+
created.push(file);
|
|
37
|
+
return { created, skipped };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function safeResolve(root, relPath) {
|
|
41
|
+
const rootAbs = path.resolve(root);
|
|
42
|
+
const target = path.resolve(rootAbs, String(relPath));
|
|
43
|
+
if (target !== rootAbs && !target.startsWith(`${rootAbs}${path.sep}`)) {
|
|
44
|
+
throw new Error(`Refusing path outside root: ${relPath}`);
|
|
45
|
+
}
|
|
46
|
+
return target;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function relPath(root, file) {
|
|
50
|
+
return path.relative(root, file).split(path.sep).join('/');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function sha256(value) {
|
|
54
|
+
return crypto.createHash('sha256').update(value).digest('hex');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function slug(value, fallback = 'general') {
|
|
58
|
+
return String(value || fallback)
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.replace(/^_+/, '')
|
|
61
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
62
|
+
.replace(/^-+|-+$/g, '')
|
|
63
|
+
.slice(0, 80) || fallback;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function timestamp() {
|
|
67
|
+
const now = new Date();
|
|
68
|
+
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
69
|
+
timeZone: 'Asia/Seoul',
|
|
70
|
+
year: 'numeric',
|
|
71
|
+
month: '2-digit',
|
|
72
|
+
day: '2-digit',
|
|
73
|
+
hour: '2-digit',
|
|
74
|
+
minute: '2-digit',
|
|
75
|
+
second: '2-digit',
|
|
76
|
+
hour12: false,
|
|
77
|
+
}).formatToParts(now);
|
|
78
|
+
const p = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
|
79
|
+
return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}+09:00`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function compactStamp() {
|
|
83
|
+
return timestamp().replace(/[:+]/g, '').replace('T', '-');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function walkFiles(root, { includeRaw = true, maxFiles = 10000 } = {}) {
|
|
87
|
+
const out = [];
|
|
88
|
+
function rec(dir) {
|
|
89
|
+
if (out.length >= maxFiles) return;
|
|
90
|
+
let entries = [];
|
|
91
|
+
try {
|
|
92
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
93
|
+
} catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
if (out.length >= maxFiles) return;
|
|
98
|
+
const abs = path.join(dir, entry.name);
|
|
99
|
+
const rel = relPath(root, abs);
|
|
100
|
+
if (GENERATED_NAMES.has(entry.name) || generatedPath(rel)) continue;
|
|
101
|
+
if (entry.isSymbolicLink()) continue;
|
|
102
|
+
if (entry.isDirectory()) {
|
|
103
|
+
rec(abs);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!entry.isFile()) continue;
|
|
107
|
+
if (!includeRaw && rel.startsWith('raw/')) continue;
|
|
108
|
+
out.push(abs);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
rec(root);
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function generatedPath(rel) {
|
|
116
|
+
return /(^|\/)(node_modules|\.git|\.next|out|\.vercel|\.cache)(\/|$)/.test(rel);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function isTextFile(file) {
|
|
120
|
+
return /\.(md|txt|json|yml|yaml|toml|env|js|mjs|ts|tsx|py|html|css)$/i.test(file);
|
|
121
|
+
}
|