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,351 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { absPath, compactStamp, ensureDir, safeResolve, sha256, timestamp } from './fs.mjs';
|
|
4
|
+
|
|
5
|
+
const registryRel = '00_system/neurain/lessons.md';
|
|
6
|
+
const confirmPhrase = '1건 저장 진행';
|
|
7
|
+
|
|
8
|
+
export async function curatorCommand(args) {
|
|
9
|
+
const [subcommand, ...rest] = args._;
|
|
10
|
+
const root = absPath(rest[0] || args.root || process.cwd());
|
|
11
|
+
if (!subcommand || subcommand === 'status') return renderCuratorStatus(root, args);
|
|
12
|
+
if (subcommand === 'run') return renderCuratorRun(root, args);
|
|
13
|
+
if (subcommand === 'rollback') return renderCuratorRollback(root, args);
|
|
14
|
+
throw new Error(`Unknown curator command: ${subcommand}. Use "curator status", "curator run", or "curator rollback".`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function curatorStatus(root, {
|
|
18
|
+
staleDays = 30,
|
|
19
|
+
archiveDays = 90,
|
|
20
|
+
} = {}) {
|
|
21
|
+
const registryAbs = safeResolve(root, registryRel);
|
|
22
|
+
const registryText = fs.existsSync(registryAbs) ? fs.readFileSync(registryAbs, 'utf8') : '';
|
|
23
|
+
const plan = curatorPlan(registryText, {
|
|
24
|
+
staleDays: Number(staleDays || 30),
|
|
25
|
+
archiveDays: Number(archiveDays || 90),
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
command: 'curator status',
|
|
30
|
+
root,
|
|
31
|
+
durable_write: false,
|
|
32
|
+
registry_path: registryRel,
|
|
33
|
+
policy: curatorPolicy(staleDays, archiveDays),
|
|
34
|
+
counts: plan.counts,
|
|
35
|
+
planned_changes: plan.planned_changes,
|
|
36
|
+
protected_items: plan.protected_items,
|
|
37
|
+
next_step: plan.planned_changes.length
|
|
38
|
+
? 'Run curator run --dry-run first, then apply only with the exact confirmation phrase.'
|
|
39
|
+
: 'No curator lifecycle changes are currently recommended.',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function runCurator(root, {
|
|
44
|
+
confirm = '',
|
|
45
|
+
dryRun = false,
|
|
46
|
+
staleDays = 30,
|
|
47
|
+
archiveDays = 90,
|
|
48
|
+
} = {}) {
|
|
49
|
+
if (!dryRun && String(confirm || '') !== confirmPhrase) {
|
|
50
|
+
throw new Error(`Curator run requires --dry-run or --confirm "${confirmPhrase}".`);
|
|
51
|
+
}
|
|
52
|
+
const registryAbs = safeResolve(root, registryRel);
|
|
53
|
+
const beforeText = fs.existsSync(registryAbs) ? fs.readFileSync(registryAbs, 'utf8') : '';
|
|
54
|
+
const plan = curatorPlan(beforeText, {
|
|
55
|
+
staleDays: Number(staleDays || 30),
|
|
56
|
+
archiveDays: Number(archiveDays || 90),
|
|
57
|
+
});
|
|
58
|
+
const afterText = applyCuratorPlan(beforeText, plan.planned_changes);
|
|
59
|
+
const changed = beforeText !== afterText;
|
|
60
|
+
const stamp = compactStamp();
|
|
61
|
+
const receiptRel = changed
|
|
62
|
+
? `output/receipts/curator/${stamp}-${sha256(`${beforeText}:${afterText}`).slice(0, 12)}-curator-run.json`
|
|
63
|
+
: null;
|
|
64
|
+
const result = {
|
|
65
|
+
ok: true,
|
|
66
|
+
command: 'curator run',
|
|
67
|
+
generated_at: timestamp(),
|
|
68
|
+
dry_run: Boolean(dryRun),
|
|
69
|
+
applied: Boolean(!dryRun && changed),
|
|
70
|
+
durable_write: Boolean(!dryRun && changed),
|
|
71
|
+
registry_path: registryRel,
|
|
72
|
+
receipt_path: !dryRun && changed ? receiptRel : null,
|
|
73
|
+
policy: curatorPolicy(staleDays, archiveDays),
|
|
74
|
+
counts: plan.counts,
|
|
75
|
+
planned_changes: plan.planned_changes,
|
|
76
|
+
protected_items: plan.protected_items,
|
|
77
|
+
snapshot: {
|
|
78
|
+
before_sha256: sha256(beforeText),
|
|
79
|
+
after_sha256: sha256(afterText),
|
|
80
|
+
before_text: dryRun ? null : beforeText,
|
|
81
|
+
},
|
|
82
|
+
receipt_status: dryRun || !changed ? 'not_written' : 'pending_registry_write',
|
|
83
|
+
write_policy: 'snapshot_then_lifecycle_status_update_only',
|
|
84
|
+
no_delete: true,
|
|
85
|
+
};
|
|
86
|
+
if (!dryRun && changed) {
|
|
87
|
+
ensureDir(path.dirname(registryAbs));
|
|
88
|
+
ensureDir(path.dirname(safeResolve(root, receiptRel)));
|
|
89
|
+
const receiptAbs = safeResolve(root, receiptRel);
|
|
90
|
+
fs.writeFileSync(receiptAbs, `${JSON.stringify(result, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
91
|
+
atomicWriteFile(registryAbs, afterText);
|
|
92
|
+
result.applied = true;
|
|
93
|
+
result.durable_write = true;
|
|
94
|
+
result.receipt_status = 'observed_after_registry_write';
|
|
95
|
+
fs.writeFileSync(receiptAbs, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function rollbackCurator(root, { receiptPath } = {}) {
|
|
101
|
+
if (!receiptPath) throw new Error('Curator rollback requires --receipt <path>.');
|
|
102
|
+
const receiptAbs = safeResolve(root, receiptPath);
|
|
103
|
+
if (!fs.existsSync(receiptAbs)) throw new Error(`Receipt does not exist: ${receiptPath}`);
|
|
104
|
+
const receipt = JSON.parse(fs.readFileSync(receiptAbs, 'utf8'));
|
|
105
|
+
if (receipt.command !== 'curator run') throw new Error(`Not a curator run receipt: ${receiptPath}`);
|
|
106
|
+
const registryAbs = safeResolve(root, receipt.registry_path || registryRel);
|
|
107
|
+
const beforeText = receipt.snapshot?.before_text;
|
|
108
|
+
if (typeof beforeText !== 'string') throw new Error('Curator receipt is missing snapshot.before_text.');
|
|
109
|
+
const currentText = fs.existsSync(registryAbs) ? fs.readFileSync(registryAbs, 'utf8') : '';
|
|
110
|
+
const currentHash = sha256(currentText);
|
|
111
|
+
const beforeHash = receipt.snapshot.before_sha256;
|
|
112
|
+
const afterHash = receipt.snapshot.after_sha256;
|
|
113
|
+
const stamp = compactStamp();
|
|
114
|
+
const rollbackRel = `output/receipts/curator/${stamp}-${sha256(receiptPath).slice(0, 12)}-curator-rollback.json`;
|
|
115
|
+
const result = {
|
|
116
|
+
ok: false,
|
|
117
|
+
command: 'curator rollback',
|
|
118
|
+
generated_at: timestamp(),
|
|
119
|
+
source_receipt_path: receiptPath,
|
|
120
|
+
registry_path: receipt.registry_path || registryRel,
|
|
121
|
+
restored: false,
|
|
122
|
+
idempotent: false,
|
|
123
|
+
reason: '',
|
|
124
|
+
};
|
|
125
|
+
if (currentHash === beforeHash) {
|
|
126
|
+
result.ok = true;
|
|
127
|
+
result.idempotent = true;
|
|
128
|
+
result.reason = 'registry already matches snapshot before state';
|
|
129
|
+
} else if (currentHash !== afterHash) {
|
|
130
|
+
result.reason = 'registry changed since curator run; refusing rollback';
|
|
131
|
+
} else {
|
|
132
|
+
atomicWriteFile(registryAbs, beforeText);
|
|
133
|
+
result.ok = true;
|
|
134
|
+
result.restored = true;
|
|
135
|
+
result.before_sha256 = currentHash;
|
|
136
|
+
result.after_sha256 = beforeHash;
|
|
137
|
+
}
|
|
138
|
+
ensureDir(path.dirname(safeResolve(root, rollbackRel)));
|
|
139
|
+
fs.writeFileSync(safeResolve(root, rollbackRel), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
|
140
|
+
return { ...result, receipt_path: rollbackRel };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function renderCuratorStatus(root, args) {
|
|
144
|
+
const payload = curatorStatus(root, {
|
|
145
|
+
staleDays: args['stale-days'] || 30,
|
|
146
|
+
archiveDays: args['archive-days'] || 90,
|
|
147
|
+
});
|
|
148
|
+
if (args.json) return { json: true, payload };
|
|
149
|
+
return {
|
|
150
|
+
text: [
|
|
151
|
+
'# Neurain curator status',
|
|
152
|
+
'',
|
|
153
|
+
`- Root: ${root}`,
|
|
154
|
+
'- Durable write: no',
|
|
155
|
+
`- Planned changes: ${payload.planned_changes.length}`,
|
|
156
|
+
`- Protected items: ${payload.protected_items.length}`,
|
|
157
|
+
].join('\n'),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function renderCuratorRun(root, args) {
|
|
162
|
+
const payload = runCurator(root, {
|
|
163
|
+
confirm: args.confirm,
|
|
164
|
+
dryRun: Boolean(args['dry-run']),
|
|
165
|
+
staleDays: args['stale-days'] || 30,
|
|
166
|
+
archiveDays: args['archive-days'] || 90,
|
|
167
|
+
});
|
|
168
|
+
if (args.json) return { json: true, payload };
|
|
169
|
+
return {
|
|
170
|
+
text: [
|
|
171
|
+
`# Neurain curator run${payload.dry_run ? ' [dry-run]' : ''}`,
|
|
172
|
+
'',
|
|
173
|
+
`- Root: ${root}`,
|
|
174
|
+
`- Applied: ${payload.applied ? 'yes' : 'no'}`,
|
|
175
|
+
`- Planned changes: ${payload.planned_changes.length}`,
|
|
176
|
+
`- Protected items: ${payload.protected_items.length}`,
|
|
177
|
+
payload.receipt_path ? `- Receipt: ${payload.receipt_path}` : '',
|
|
178
|
+
].filter(Boolean).join('\n'),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function renderCuratorRollback(root, args) {
|
|
183
|
+
const payload = rollbackCurator(root, { receiptPath: args.receipt || args._[1] || args.path });
|
|
184
|
+
if (args.json) return { json: true, payload };
|
|
185
|
+
return {
|
|
186
|
+
text: [
|
|
187
|
+
'# Neurain curator rollback',
|
|
188
|
+
'',
|
|
189
|
+
`- OK: ${payload.ok ? 'yes' : 'no'}`,
|
|
190
|
+
`- Restored: ${payload.restored ? 'yes' : 'no'}`,
|
|
191
|
+
payload.reason ? `- Reason: ${payload.reason}` : '',
|
|
192
|
+
payload.receipt_path ? `- Receipt: ${payload.receipt_path}` : '',
|
|
193
|
+
].filter(Boolean).join('\n'),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function curatorPolicy(staleDays, archiveDays) {
|
|
198
|
+
return {
|
|
199
|
+
stale_after_days: Number(staleDays || 30),
|
|
200
|
+
archive_after_days: Number(archiveDays || 90),
|
|
201
|
+
mutates: ['Status field only'],
|
|
202
|
+
never_deletes: true,
|
|
203
|
+
protects: ['pinned lessons', 'human-authored lessons', 'private lessons', 'non-agent-created lessons'],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function curatorPlan(registryText, { staleDays, archiveDays }) {
|
|
208
|
+
const lessons = registryLessons(registryText);
|
|
209
|
+
const planned = [];
|
|
210
|
+
const protectedItems = [];
|
|
211
|
+
const counts = { active: 0, stale: 0, archived: 0, protected: 0, agent_created: 0 };
|
|
212
|
+
for (const lesson of lessons) {
|
|
213
|
+
if (lesson.status === 'active') counts.active += 1;
|
|
214
|
+
if (lesson.status === 'stale') counts.stale += 1;
|
|
215
|
+
if (lesson.status === 'archived') counts.archived += 1;
|
|
216
|
+
if (lesson.agent_created) counts.agent_created += 1;
|
|
217
|
+
const protectedReason = protectedReasonFor(lesson);
|
|
218
|
+
if (protectedReason) {
|
|
219
|
+
counts.protected += 1;
|
|
220
|
+
protectedItems.push(publicLessonItem(lesson, protectedReason));
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const age = ageDays(lesson.last_used || lesson.last_verified);
|
|
224
|
+
if (age == null) continue;
|
|
225
|
+
if (lesson.status === 'active' && age >= staleDays) {
|
|
226
|
+
planned.push(changeFor(lesson, 'stale', age, `last use is ${age} day(s) old`));
|
|
227
|
+
} else if (lesson.status === 'stale' && age >= archiveDays) {
|
|
228
|
+
planned.push(changeFor(lesson, 'archived', age, `stale lesson last use is ${age} day(s) old`));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { lessons, planned_changes: planned, protected_items: protectedItems, counts };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function registryLessons(text) {
|
|
235
|
+
const chunks = String(text || '').split(/(?=\n##\s+)/g);
|
|
236
|
+
return chunks.map((block, index) => parseRegistryBlock(block, index)).filter(Boolean);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parseRegistryBlock(block, index = 0) {
|
|
240
|
+
const heading = block.match(/^\n?##\s+(.+)$/m)?.[1]?.trim();
|
|
241
|
+
if (!heading || /Rules|Registration Criteria|Template|Active Lessons/i.test(heading)) return null;
|
|
242
|
+
if (/No durable lessons recorded yet/i.test(block)) return null;
|
|
243
|
+
const rawStatus = field(block, 'Status') || 'active';
|
|
244
|
+
const status = normalizeStatus(rawStatus);
|
|
245
|
+
const candidateHash = field(block, 'Candidate hash');
|
|
246
|
+
const reviewReceipt = field(block, 'Review receipt');
|
|
247
|
+
const author = field(block, 'Author') || field(block, 'Created by');
|
|
248
|
+
const sensitivity = normalizeSensitivity(field(block, 'Sensitivity') || 'internal');
|
|
249
|
+
return {
|
|
250
|
+
id: `lesson-${sha256(`${index}:${heading}:${candidateHash}:${reviewReceipt}`).slice(0, 12)}`,
|
|
251
|
+
title: heading,
|
|
252
|
+
block,
|
|
253
|
+
status,
|
|
254
|
+
sensitivity,
|
|
255
|
+
scope: field(block, 'Scope') || 'global',
|
|
256
|
+
pinned: truthy(field(block, 'Pinned')) || /^pinned$/i.test(rawStatus),
|
|
257
|
+
human_authored: truthy(field(block, 'Human authored')) || /human|user|manual/i.test(author),
|
|
258
|
+
agent_created: Boolean(candidateHash || reviewReceipt || /neurain|agent|cli/i.test(author)),
|
|
259
|
+
last_used: field(block, 'Last used'),
|
|
260
|
+
last_verified: field(block, 'Last verified'),
|
|
261
|
+
candidate_hash: candidateHash || null,
|
|
262
|
+
review_receipt: reviewReceipt || null,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function publicLessonItem(lesson, reason) {
|
|
267
|
+
const privateItem = lesson.sensitivity === 'private';
|
|
268
|
+
return {
|
|
269
|
+
lesson_id: lesson.id,
|
|
270
|
+
title: privateItem ? '[private title withheld]' : lesson.title,
|
|
271
|
+
status: lesson.status,
|
|
272
|
+
sensitivity: lesson.sensitivity,
|
|
273
|
+
scope: privateItem ? '[withheld]' : lesson.scope,
|
|
274
|
+
reason,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function changeFor(lesson, toStatus, age, reason) {
|
|
279
|
+
return {
|
|
280
|
+
lesson_id: lesson.id,
|
|
281
|
+
title: lesson.title,
|
|
282
|
+
from_status: lesson.status,
|
|
283
|
+
to_status: toStatus,
|
|
284
|
+
age_days: age,
|
|
285
|
+
reason,
|
|
286
|
+
source: lesson.review_receipt || lesson.candidate_hash || null,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function applyCuratorPlan(text, changes) {
|
|
291
|
+
if (!changes.length) return text;
|
|
292
|
+
const changeById = new Map(changes.map((change) => [change.lesson_id, change]));
|
|
293
|
+
return String(text || '').split(/(?=\n##\s+)/g).map((block, index) => {
|
|
294
|
+
const lesson = parseRegistryBlock(block, index);
|
|
295
|
+
if (!lesson) return block;
|
|
296
|
+
const change = changeById.get(lesson.id);
|
|
297
|
+
if (!change) return block;
|
|
298
|
+
return updateStatusLine(block, change.to_status);
|
|
299
|
+
}).join('');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function updateStatusLine(block, status) {
|
|
303
|
+
if (/^- Status:\s*.+$/im.test(block)) return block.replace(/^- Status:\s*.+$/im, `- Status: ${status}`);
|
|
304
|
+
return block.replace(/(\n\s*)$/, `\n- Status: ${status}$1`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function protectedReasonFor(lesson) {
|
|
308
|
+
if (lesson.pinned) return 'pinned lesson is protected';
|
|
309
|
+
if (lesson.human_authored) return 'human-authored lesson is protected';
|
|
310
|
+
if (lesson.sensitivity === 'private') return 'private lesson is protected';
|
|
311
|
+
if (!lesson.agent_created) return 'non-agent-created lesson is protected';
|
|
312
|
+
return '';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function normalizeStatus(value) {
|
|
316
|
+
const status = String(value || 'active').trim().toLowerCase();
|
|
317
|
+
if (status === 'archive') return 'archived';
|
|
318
|
+
if (['active', 'stale', 'archived', 'obsolete'].includes(status)) return status;
|
|
319
|
+
return 'active';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function normalizeSensitivity(value) {
|
|
323
|
+
const sensitivity = String(value || 'internal').trim().toLowerCase();
|
|
324
|
+
if (sensitivity.includes('private')) return 'private';
|
|
325
|
+
if (sensitivity.includes('public')) return 'public';
|
|
326
|
+
if (sensitivity.includes('mixed')) return 'mixed';
|
|
327
|
+
return 'internal';
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function truthy(value) {
|
|
331
|
+
return /^(true|yes|1|pinned)$/i.test(String(value || '').trim());
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function ageDays(value) {
|
|
335
|
+
const match = String(value || '').match(/\d{4}-\d{2}-\d{2}/);
|
|
336
|
+
if (!match) return null;
|
|
337
|
+
const at = Date.parse(`${match[0]}T00:00:00Z`);
|
|
338
|
+
if (!Number.isFinite(at)) return null;
|
|
339
|
+
return Math.max(0, Math.floor((Date.now() - at) / 86400000));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function atomicWriteFile(file, content) {
|
|
343
|
+
ensureDir(path.dirname(file));
|
|
344
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
345
|
+
fs.writeFileSync(tmp, content, { encoding: 'utf8', flag: 'wx' });
|
|
346
|
+
fs.renameSync(tmp, file);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function field(text, name) {
|
|
350
|
+
return text.match(new RegExp(`^-\\s*${name}:\\s*(.+)$`, 'im'))?.[1]?.trim() || '';
|
|
351
|
+
}
|