sanook-cli 0.5.0 → 0.5.2
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/.env.example +161 -3
- package/CHANGELOG.md +83 -5
- package/README.md +240 -23
- package/README.th.md +87 -6
- package/dist/approval.js +6 -0
- package/dist/bin.js +3045 -210
- package/dist/brain-context.js +223 -0
- package/dist/brain-doctor.js +318 -0
- package/dist/brain-eval.js +186 -0
- package/dist/brain-final.js +371 -0
- package/dist/brain-review.js +382 -0
- package/dist/brain.js +12 -1
- package/dist/brand.js +1 -1
- package/dist/cli-args.js +152 -0
- package/dist/cli-option-values.js +16 -0
- package/dist/commands.js +172 -13
- package/dist/compaction.js +96 -11
- package/dist/config.js +118 -28
- package/dist/context-compression.js +191 -0
- package/dist/cost.js +49 -15
- package/dist/first-run.js +21 -0
- package/dist/gateway/auth.js +37 -8
- package/dist/gateway/bluebubbles.js +205 -0
- package/dist/gateway/config.js +929 -0
- package/dist/gateway/deliver.js +357 -0
- package/dist/gateway/discord.js +124 -0
- package/dist/gateway/email.js +472 -0
- package/dist/gateway/googlechat.js +207 -0
- package/dist/gateway/homeassistant.js +256 -0
- package/dist/gateway/ledger.js +18 -0
- package/dist/gateway/line.js +171 -0
- package/dist/gateway/lock.js +3 -1
- package/dist/gateway/matrix.js +366 -0
- package/dist/gateway/mattermost.js +322 -0
- package/dist/gateway/ntfy.js +218 -0
- package/dist/gateway/schedule.js +31 -4
- package/dist/gateway/serve.js +267 -7
- package/dist/gateway/server.js +253 -19
- package/dist/gateway/service.js +224 -0
- package/dist/gateway/session.js +343 -0
- package/dist/gateway/signal.js +351 -0
- package/dist/gateway/slack.js +124 -0
- package/dist/gateway/sms.js +169 -0
- package/dist/gateway/targets.js +576 -0
- package/dist/gateway/teams.js +106 -0
- package/dist/gateway/telegram.js +38 -15
- package/dist/gateway/webhooks.js +220 -0
- package/dist/gateway/whatsapp.js +230 -0
- package/dist/hooks.js +13 -2
- package/dist/insights-args.js +35 -0
- package/dist/insights.js +86 -0
- package/dist/loop.js +123 -24
- package/dist/lsp/index.js +23 -5
- package/dist/mcp-registry.js +350 -0
- package/dist/mcp-server.js +1 -1
- package/dist/mcp.js +44 -6
- package/dist/memory.js +100 -33
- package/dist/orchestrate.js +49 -19
- package/dist/personality.js +58 -0
- package/dist/providers/codex.js +86 -38
- package/dist/providers/keys.js +1 -1
- package/dist/providers/models.js +22 -6
- package/dist/providers/registry.js +38 -49
- package/dist/search/chunk.js +7 -8
- package/dist/search/cli.js +75 -0
- package/dist/search/embed-store.js +3 -0
- package/dist/search/indexer.js +44 -1
- package/dist/search/store.js +23 -1
- package/dist/session.js +93 -7
- package/dist/skill-install.js +29 -12
- package/dist/support-dump.js +175 -0
- package/dist/tools/edit.js +45 -15
- package/dist/tools/git.js +10 -5
- package/dist/tools/homeassistant.js +106 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/list.js +19 -6
- package/dist/tools/permission.js +923 -9
- package/dist/tools/read.js +16 -4
- package/dist/tools/schedule.js +19 -3
- package/dist/tools/search.js +217 -13
- package/dist/tools/task.js +18 -7
- package/dist/tools/timeout.js +21 -3
- package/dist/trust.js +11 -1
- package/dist/ui/app.js +57 -11
- package/dist/ui/brain-wizard.js +2 -2
- package/dist/ui/history.js +37 -5
- package/dist/ui/mentions.js +3 -2
- package/dist/ui/render.js +55 -15
- package/dist/ui/setup.js +107 -10
- package/dist/update.js +24 -11
- package/dist/worktree.js +175 -4
- package/package.json +4 -4
- package/second-brain/AGENTS.md +6 -4
- package/second-brain/CLAUDE.md +7 -1
- package/second-brain/Evals/_Index.md +10 -2
- package/second-brain/Evals/quality-ledger.md +9 -1
- package/second-brain/Evals/second-brain-benchmarks.md +62 -0
- package/second-brain/GEMINI.md +5 -4
- package/second-brain/Home.md +1 -1
- package/second-brain/Projects/_Index.md +3 -1
- package/second-brain/Projects/sanook-cli/_Index.md +26 -0
- package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +156 -0
- package/second-brain/README.md +1 -1
- package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
- package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
- package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
- package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
- package/second-brain/Research/_Index.md +6 -1
- package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
- package/second-brain/Reviews/_Index.md +1 -1
- package/second-brain/Runbooks/_Index.md +6 -1
- package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
- package/second-brain/SANOOK.md +45 -0
- package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
- package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
- package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
- package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
- package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
- package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
- package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
- package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
- package/second-brain/Sessions/_Index.md +15 -1
- package/second-brain/Shared/AI-Context-Index.md +22 -0
- package/second-brain/Shared/Context-Packs/_Index.md +9 -1
- package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
- package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
- package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
- package/second-brain/Shared/Operating-State/current-state.md +22 -3
- package/second-brain/Shared/Scripts/_Index.md +3 -1
- package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
- package/second-brain/Shared/Tech-Standards/_Index.md +4 -1
- package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
- package/second-brain/Shared/User-Memory/_Index.md +4 -1
- package/second-brain/Shared/User-Memory/response-examples.md +98 -0
- package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
- package/second-brain/Templates/_Index.md +9 -0
- package/second-brain/Templates/final-lite.md +111 -0
- package/second-brain/Templates/final.md +231 -0
- package/second-brain/Vault Structure Map.md +2 -1
- package/skills/structured-output-llm/SKILL.md +1 -1
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { listFinalGateFiles, validateFinalGateContent } from './brain-final.js';
|
|
4
|
+
import { normalize } from './memory-store.js';
|
|
5
|
+
import { INDEX_PATH, sanitizeManifest } from './search/store.js';
|
|
6
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
const DEFAULT_MEMORY_INBOX_MAX_AGE_DAYS = 14;
|
|
8
|
+
const DEFAULT_CONTEXT_PACK_MAX_AGE_DAYS = 90;
|
|
9
|
+
const DEFAULT_EVAL_FRESHNESS_TOLERANCE_MS = 60 * 1000;
|
|
10
|
+
const DETAIL_LIMIT = 25;
|
|
11
|
+
const ROOT_FILES_WITHOUT_UP = new Set(['Home.md', 'README.md', 'CLAUDE.md', 'GEMINI.md', 'AGENTS.md', 'SANOOK.md']);
|
|
12
|
+
const ROOT_FILES_WITHOUT_PARENT = ROOT_FILES_WITHOUT_UP;
|
|
13
|
+
const SKIP_DIRS = new Set(['.git', '.obsidian', 'node_modules', 'Shared/Context7-Docs']);
|
|
14
|
+
const NEGATION_TOKENS = new Set([
|
|
15
|
+
'no',
|
|
16
|
+
'not',
|
|
17
|
+
'never',
|
|
18
|
+
'without',
|
|
19
|
+
'disable',
|
|
20
|
+
'disabled',
|
|
21
|
+
'false',
|
|
22
|
+
'ไม่',
|
|
23
|
+
'ห้าม',
|
|
24
|
+
'เลิก',
|
|
25
|
+
'ปิด',
|
|
26
|
+
'ไม่ได้',
|
|
27
|
+
'ไม่ชอบ',
|
|
28
|
+
]);
|
|
29
|
+
export function parseBrainReviewArgs(args) {
|
|
30
|
+
let scanMarkdownHygiene = true;
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
const a = args[i];
|
|
33
|
+
if (a === '--no-hygiene') {
|
|
34
|
+
scanMarkdownHygiene = false;
|
|
35
|
+
}
|
|
36
|
+
else if (a === '--hygiene') {
|
|
37
|
+
scanMarkdownHygiene = true;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
return { ok: false, message: `ไม่รู้จัก option: ${a}` };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { ok: true, value: { scanMarkdownHygiene } };
|
|
44
|
+
}
|
|
45
|
+
function result(id, title, findings, message, fail = false, path) {
|
|
46
|
+
const status = fail ? 'fail' : findings.length ? 'warn' : 'pass';
|
|
47
|
+
return { id, title, status, message, path, findings: findings.length ? findings : undefined };
|
|
48
|
+
}
|
|
49
|
+
function normalizeCandidate(line) {
|
|
50
|
+
return normalize(line.replace(/^-\s*/, '').replace(/\s+/g, ' ')).trim();
|
|
51
|
+
}
|
|
52
|
+
function tokensForCandidate(line) {
|
|
53
|
+
return new Set(normalizeCandidate(line)
|
|
54
|
+
.split(/\s+/)
|
|
55
|
+
.filter((token) => token.length > 1 && !NEGATION_TOKENS.has(token)));
|
|
56
|
+
}
|
|
57
|
+
function hasNegation(line) {
|
|
58
|
+
const normalized = normalizeCandidate(line);
|
|
59
|
+
return [...NEGATION_TOKENS].some((token) => normalized.includes(token));
|
|
60
|
+
}
|
|
61
|
+
function sectionBullets(content, heading) {
|
|
62
|
+
const lines = content.split('\n');
|
|
63
|
+
const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${heading.toLowerCase()}`);
|
|
64
|
+
if (start < 0)
|
|
65
|
+
return [];
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const line of lines.slice(start + 1)) {
|
|
68
|
+
if (/^#{1,6}\s+/.test(line.trim()))
|
|
69
|
+
break;
|
|
70
|
+
const trimmed = line.trim();
|
|
71
|
+
if (trimmed.startsWith('- ') && !trimmed.includes('_('))
|
|
72
|
+
out.push(trimmed);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
function overlap(a, b) {
|
|
77
|
+
if (!a.size || !b.size)
|
|
78
|
+
return 0;
|
|
79
|
+
let shared = 0;
|
|
80
|
+
for (const token of a)
|
|
81
|
+
if (b.has(token))
|
|
82
|
+
shared++;
|
|
83
|
+
return shared / Math.min(a.size, b.size);
|
|
84
|
+
}
|
|
85
|
+
async function pathExistsAsDir(path) {
|
|
86
|
+
try {
|
|
87
|
+
return (await stat(path)).isDirectory();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function readText(path) {
|
|
94
|
+
try {
|
|
95
|
+
return await readFile(path, 'utf8');
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return '';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function mtimeMs(path) {
|
|
102
|
+
try {
|
|
103
|
+
return (await stat(path)).mtimeMs;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function listMarkdown(root) {
|
|
110
|
+
const out = [];
|
|
111
|
+
async function walk(abs, rel) {
|
|
112
|
+
let entries;
|
|
113
|
+
try {
|
|
114
|
+
entries = await readdir(abs, { withFileTypes: true });
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
121
|
+
if (entry.isDirectory()) {
|
|
122
|
+
if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name) || SKIP_DIRS.has(childRel))
|
|
123
|
+
continue;
|
|
124
|
+
await walk(join(abs, entry.name), childRel);
|
|
125
|
+
}
|
|
126
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
127
|
+
out.push(childRel);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
await walk(root, '');
|
|
132
|
+
return out.sort();
|
|
133
|
+
}
|
|
134
|
+
async function readManifest(indexPath) {
|
|
135
|
+
try {
|
|
136
|
+
const raw = JSON.parse(await readFile(indexPath, 'utf8'));
|
|
137
|
+
return sanitizeManifest(raw.manifest);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function checkMemoryInbox(brainPath, nowMs, maxAgeDays) {
|
|
144
|
+
const path = join(brainPath, 'Shared', 'Memory-Inbox', 'memory-inbox.md');
|
|
145
|
+
const content = await readText(path);
|
|
146
|
+
if (!content) {
|
|
147
|
+
return result('review.memory-inbox', 'Memory-Inbox curation', [{ message: 'Memory-Inbox file is missing.', path }], 'Memory-Inbox is missing.', true, path);
|
|
148
|
+
}
|
|
149
|
+
const candidates = [...sectionBullets(content, 'New Candidates'), ...sectionBullets(content, 'Needs Merge')];
|
|
150
|
+
const findings = [];
|
|
151
|
+
const seen = new Map();
|
|
152
|
+
const duplicates = [];
|
|
153
|
+
for (const candidate of candidates) {
|
|
154
|
+
const key = normalizeCandidate(candidate);
|
|
155
|
+
if (!key)
|
|
156
|
+
continue;
|
|
157
|
+
if (seen.has(key))
|
|
158
|
+
duplicates.push(candidate);
|
|
159
|
+
else
|
|
160
|
+
seen.set(key, candidate);
|
|
161
|
+
}
|
|
162
|
+
if (duplicates.length) {
|
|
163
|
+
findings.push({ message: `${duplicates.length} duplicate Memory-Inbox candidate(s).`, path, details: duplicates.slice(0, DETAIL_LIMIT) });
|
|
164
|
+
}
|
|
165
|
+
const needsMerge = sectionBullets(content, 'Needs Merge');
|
|
166
|
+
if (needsMerge.length) {
|
|
167
|
+
findings.push({ message: `${needsMerge.length} Memory-Inbox item(s) are waiting in Needs Merge.`, path, details: needsMerge.slice(0, DETAIL_LIMIT) });
|
|
168
|
+
}
|
|
169
|
+
const possibleContradictions = [];
|
|
170
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
171
|
+
for (let j = i + 1; j < candidates.length; j++) {
|
|
172
|
+
if (hasNegation(candidates[i]) === hasNegation(candidates[j]))
|
|
173
|
+
continue;
|
|
174
|
+
const score = overlap(tokensForCandidate(candidates[i]), tokensForCandidate(candidates[j]));
|
|
175
|
+
if (score >= 0.6)
|
|
176
|
+
possibleContradictions.push(`${candidates[i]} <> ${candidates[j]}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (possibleContradictions.length) {
|
|
180
|
+
findings.push({
|
|
181
|
+
message: `${possibleContradictions.length} possible contradictory Memory-Inbox pair(s).`,
|
|
182
|
+
path,
|
|
183
|
+
details: possibleContradictions.slice(0, DETAIL_LIMIT),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
const ageDays = Math.floor((nowMs - (await mtimeMs(path))) / DAY_MS);
|
|
187
|
+
if (candidates.length && ageDays > maxAgeDays) {
|
|
188
|
+
findings.push({ message: `Memory-Inbox has ${candidates.length} candidate(s) and was last touched ${ageDays} days ago.`, path });
|
|
189
|
+
}
|
|
190
|
+
return result('review.memory-inbox', 'Memory-Inbox curation', findings, candidates.length ? `${candidates.length} candidate(s) reviewed.` : 'Memory-Inbox has no active candidates.', false, path);
|
|
191
|
+
}
|
|
192
|
+
async function checkContextPacks(brainPath, nowMs, maxAgeDays) {
|
|
193
|
+
const dir = join(brainPath, 'Shared', 'Context-Packs');
|
|
194
|
+
const indexPath = join(dir, '_Index.md');
|
|
195
|
+
const index = await readText(indexPath);
|
|
196
|
+
const findings = [];
|
|
197
|
+
let entries;
|
|
198
|
+
try {
|
|
199
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return result('review.context-packs', 'Context pack curation', [{ message: 'Context-Packs directory is missing.', path: dir }], 'Context-Packs directory is missing.', true, dir);
|
|
203
|
+
}
|
|
204
|
+
if (!index)
|
|
205
|
+
findings.push({ message: 'Context-Packs index is missing.', path: indexPath });
|
|
206
|
+
const packs = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name !== '_Index.md');
|
|
207
|
+
for (const pack of packs) {
|
|
208
|
+
const path = join(dir, pack.name);
|
|
209
|
+
const content = await readText(path);
|
|
210
|
+
const missingSections = ['## Load Order', '## Done Criteria'].filter((section) => !content.includes(section));
|
|
211
|
+
if (missingSections.length) {
|
|
212
|
+
findings.push({ message: `${pack.name} is missing expected section(s).`, path, details: missingSections });
|
|
213
|
+
}
|
|
214
|
+
const link = `[[Shared/Context-Packs/${pack.name.replace(/\.md$/i, '')}]]`;
|
|
215
|
+
if (index && !index.includes(link))
|
|
216
|
+
findings.push({ message: `${pack.name} is not linked from Context-Packs index.`, path: indexPath });
|
|
217
|
+
const ageDays = Math.floor((nowMs - (await mtimeMs(path))) / DAY_MS);
|
|
218
|
+
if (ageDays > maxAgeDays)
|
|
219
|
+
findings.push({ message: `${pack.name} has not been touched for ${ageDays} days.`, path });
|
|
220
|
+
}
|
|
221
|
+
return result('review.context-packs', 'Context pack curation', findings, packs.length ? `${packs.length} context pack(s) reviewed.` : 'No context packs found.', false, dir);
|
|
222
|
+
}
|
|
223
|
+
async function checkSessionIndexCoverage(brainPath, indexPath) {
|
|
224
|
+
const sessionsDir = join(brainPath, 'Sessions');
|
|
225
|
+
let entries;
|
|
226
|
+
try {
|
|
227
|
+
entries = await readdir(sessionsDir, { withFileTypes: true });
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return result('review.session-index', 'Session index coverage', [{ message: 'Sessions directory is missing.', path: sessionsDir }], 'Sessions directory is missing.', true, sessionsDir);
|
|
231
|
+
}
|
|
232
|
+
const sessions = entries
|
|
233
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name !== '_Index.md')
|
|
234
|
+
.map((entry) => `Sessions/${entry.name}`)
|
|
235
|
+
.sort();
|
|
236
|
+
if (!sessions.length)
|
|
237
|
+
return result('review.session-index', 'Session index coverage', [], 'No session notes to check.', false, sessionsDir);
|
|
238
|
+
const manifest = await readManifest(indexPath);
|
|
239
|
+
if (!manifest) {
|
|
240
|
+
return result('review.session-index', 'Session index coverage', [{ message: 'Search index is missing or unreadable; run `sanook index`.', path: indexPath }], `${sessions.length} session note(s) found, but no readable index manifest exists.`, false, indexPath);
|
|
241
|
+
}
|
|
242
|
+
const missing = sessions.filter((session) => !manifest[session]);
|
|
243
|
+
return result('review.session-index', 'Session index coverage', missing.length ? [{ message: `${missing.length} session note(s) are missing from the search manifest.`, path: indexPath, details: missing.slice(0, DETAIL_LIMIT) }] : [], `${sessions.length} session note(s) checked against the search manifest.`, false, indexPath);
|
|
244
|
+
}
|
|
245
|
+
async function frameworkFiles(brainPath) {
|
|
246
|
+
const paths = ['SANOOK.md', 'AGENTS.md', 'CLAUDE.md', 'GEMINI.md', 'Vault Structure Map.md', 'Shared/AI-Context-Index.md'];
|
|
247
|
+
for (const rel of await listMarkdown(join(brainPath, 'Shared', 'Rules')))
|
|
248
|
+
paths.push(`Shared/Rules/${rel}`);
|
|
249
|
+
for (const rel of await listMarkdown(join(brainPath, 'Shared', 'Context-Packs')))
|
|
250
|
+
paths.push(`Shared/Context-Packs/${rel}`);
|
|
251
|
+
return paths;
|
|
252
|
+
}
|
|
253
|
+
async function newest(paths, brainPath) {
|
|
254
|
+
let best = { rel: '', mtime: 0 };
|
|
255
|
+
for (const rel of paths) {
|
|
256
|
+
const mt = await mtimeMs(join(brainPath, rel));
|
|
257
|
+
if (mt > best.mtime)
|
|
258
|
+
best = { rel, mtime: mt };
|
|
259
|
+
}
|
|
260
|
+
return best;
|
|
261
|
+
}
|
|
262
|
+
async function checkEvalFreshness(brainPath, toleranceMs) {
|
|
263
|
+
const evalFiles = ['Evals/second-brain-benchmarks.md', 'Evals/retrieval-eval.md', 'Evals/quality-ledger.md'];
|
|
264
|
+
const findings = [];
|
|
265
|
+
for (const rel of evalFiles) {
|
|
266
|
+
if (!(await mtimeMs(join(brainPath, rel))))
|
|
267
|
+
findings.push({ message: `Missing eval file: ${rel}`, path: join(brainPath, rel) });
|
|
268
|
+
}
|
|
269
|
+
if (findings.length) {
|
|
270
|
+
return result('review.eval-freshness', 'Eval freshness after framework changes', findings, 'Eval files are incomplete.', true, join(brainPath, 'Evals'));
|
|
271
|
+
}
|
|
272
|
+
const newestFramework = await newest(await frameworkFiles(brainPath), brainPath);
|
|
273
|
+
const newestEval = await newest(evalFiles, brainPath);
|
|
274
|
+
if (newestFramework.mtime > newestEval.mtime + toleranceMs) {
|
|
275
|
+
findings.push({
|
|
276
|
+
message: 'Framework/context files are newer than eval evidence; rerun `sanook brain eval` and update the ledger if behavior changed.',
|
|
277
|
+
details: [`newest framework: ${newestFramework.rel}`, `newest eval: ${newestEval.rel}`],
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return result('review.eval-freshness', 'Eval freshness after framework changes', findings, 'Framework files and eval evidence compared.', false, join(brainPath, 'Evals'));
|
|
281
|
+
}
|
|
282
|
+
async function checkFinalGates(brainPath) {
|
|
283
|
+
const findings = [];
|
|
284
|
+
for (const rel of ['Templates/final.md', 'Templates/final-lite.md']) {
|
|
285
|
+
const content = await readText(join(brainPath, rel));
|
|
286
|
+
if (!content) {
|
|
287
|
+
findings.push({ message: `Missing final gate template: ${rel}`, path: join(brainPath, rel) });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const missing = ['## 1. Objective / DoD Lock', '## 4. Evidence Matrix', '## Final Verdict'].filter((section) => !content.includes(section));
|
|
291
|
+
if (missing.length)
|
|
292
|
+
findings.push({ message: `${rel} is missing expected final gate section(s).`, path: join(brainPath, rel), details: missing });
|
|
293
|
+
}
|
|
294
|
+
const gates = await listFinalGateFiles(brainPath);
|
|
295
|
+
for (const gate of gates) {
|
|
296
|
+
const validation = validateFinalGateContent(gate.content);
|
|
297
|
+
if (!validation.ok) {
|
|
298
|
+
findings.push({
|
|
299
|
+
message: `${gate.relPath} has incomplete final gate evidence.`,
|
|
300
|
+
path: gate.path,
|
|
301
|
+
details: validation.warnings.slice(0, DETAIL_LIMIT),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return result('review.final-gates', 'Final gate evidence', findings, gates.length ? `${gates.length} final gate note(s) checked.` : 'Final gate templates checked; no session final gates found.', false, join(brainPath, 'Templates'));
|
|
306
|
+
}
|
|
307
|
+
async function checkMarkdownHygiene(brainPath) {
|
|
308
|
+
const markdown = await listMarkdown(brainPath);
|
|
309
|
+
const missingPurpose = [];
|
|
310
|
+
const missingParent = [];
|
|
311
|
+
const missingUp = [];
|
|
312
|
+
for (const rel of markdown) {
|
|
313
|
+
const content = await readText(join(brainPath, rel));
|
|
314
|
+
if (!/^>\s+/m.test(content))
|
|
315
|
+
missingPurpose.push(rel);
|
|
316
|
+
if (!ROOT_FILES_WITHOUT_PARENT.has(rel) && !/^---[\s\S]*?^parent:/m.test(content))
|
|
317
|
+
missingParent.push(rel);
|
|
318
|
+
if (!ROOT_FILES_WITHOUT_UP.has(rel) && !content.includes('up:: [['))
|
|
319
|
+
missingUp.push(rel);
|
|
320
|
+
}
|
|
321
|
+
const findings = [];
|
|
322
|
+
if (missingPurpose.length)
|
|
323
|
+
findings.push({ message: `${missingPurpose.length} markdown file(s) have no purpose blockquote.`, details: missingPurpose.slice(0, DETAIL_LIMIT) });
|
|
324
|
+
if (missingParent.length)
|
|
325
|
+
findings.push({ message: `${missingParent.length} markdown file(s) have no parent frontmatter.`, details: missingParent.slice(0, DETAIL_LIMIT) });
|
|
326
|
+
if (missingUp.length)
|
|
327
|
+
findings.push({ message: `${missingUp.length} markdown file(s) have no up:: graph link.`, details: missingUp.slice(0, DETAIL_LIMIT) });
|
|
328
|
+
return result('review.markdown-hygiene', 'Markdown routing hygiene', findings, `${markdown.length} markdown file(s) scanned.`, false, brainPath);
|
|
329
|
+
}
|
|
330
|
+
export async function reviewBrain(options = {}) {
|
|
331
|
+
const brainPath = options.brainPath;
|
|
332
|
+
if (!brainPath) {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
checks: [
|
|
336
|
+
result('review.configured', 'Second-brain path configured', [{ message: 'No second-brain path is configured.' }], 'Run `sanook brain init [path]` first.', true),
|
|
337
|
+
],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (!(await pathExistsAsDir(brainPath))) {
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
brainPath,
|
|
344
|
+
checks: [
|
|
345
|
+
result('review.path', 'Second-brain path exists', [{ message: 'Configured second-brain path does not exist or is not a directory.', path: brainPath }], 'Configured brainPath is not usable.', true, brainPath),
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
350
|
+
const checks = [
|
|
351
|
+
await checkMemoryInbox(brainPath, nowMs, options.memoryInboxMaxAgeDays ?? DEFAULT_MEMORY_INBOX_MAX_AGE_DAYS),
|
|
352
|
+
await checkContextPacks(brainPath, nowMs, options.contextPackMaxAgeDays ?? DEFAULT_CONTEXT_PACK_MAX_AGE_DAYS),
|
|
353
|
+
await checkSessionIndexCoverage(brainPath, options.indexPath ?? INDEX_PATH),
|
|
354
|
+
await checkEvalFreshness(brainPath, options.evalFreshnessToleranceMs ?? DEFAULT_EVAL_FRESHNESS_TOLERANCE_MS),
|
|
355
|
+
await checkFinalGates(brainPath),
|
|
356
|
+
];
|
|
357
|
+
if (options.scanMarkdownHygiene !== false)
|
|
358
|
+
checks.push(await checkMarkdownHygiene(brainPath));
|
|
359
|
+
return { ok: !checks.some((check) => check.status === 'fail'), brainPath, checks };
|
|
360
|
+
}
|
|
361
|
+
function statusLabel(status) {
|
|
362
|
+
return status.toUpperCase().padEnd(4);
|
|
363
|
+
}
|
|
364
|
+
export function formatBrainReviewReport(report) {
|
|
365
|
+
const lines = ['Sanook brain review', `vault: ${report.brainPath ?? '(not configured)'}`];
|
|
366
|
+
const warnCount = report.checks.filter((check) => check.status === 'warn').length;
|
|
367
|
+
const failCount = report.checks.filter((check) => check.status === 'fail').length;
|
|
368
|
+
lines.push(`summary: ${report.checks.length} check(s), ${warnCount} warning(s), ${failCount} failure(s)`);
|
|
369
|
+
for (const check of report.checks) {
|
|
370
|
+
lines.push(`[${statusLabel(check.status)}] ${check.id} — ${check.message}`);
|
|
371
|
+
if (check.path)
|
|
372
|
+
lines.push(` ${check.path}`);
|
|
373
|
+
for (const finding of check.findings ?? []) {
|
|
374
|
+
lines.push(` - ${finding.message}`);
|
|
375
|
+
if (finding.path && finding.path !== check.path)
|
|
376
|
+
lines.push(` ${finding.path}`);
|
|
377
|
+
for (const detail of finding.details ?? [])
|
|
378
|
+
lines.push(` · ${detail}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return lines.join('\n');
|
|
382
|
+
}
|
package/dist/brain.js
CHANGED
|
@@ -67,7 +67,17 @@ export const FOLDERS = [
|
|
|
67
67
|
{ dir: 'Shared/Scripts', role: 'automation maintenance (lint/graph audit/metrics)', put: 'สคริปต์ maintenance ที่รันจริง', avoid: 'one-off ที่ retired (→Scripts-Archive)' },
|
|
68
68
|
{ dir: 'Shared/Scripts-Archive', role: 'สคริปต์ one-off ที่ retired', put: 'script เก่าเก็บเป็นประวัติ', avoid: 'script ที่ยังใช้ (→Scripts)' },
|
|
69
69
|
{ dir: 'Shared/mcp-servers', role: 'vendored local MCP server bundle (code/README)', put: 'โค้ด/README ของ MCP server (config อยู่ Tech-Standards)', avoid: 'config การต่อ (→Tech-Standards/mcp.json)' },
|
|
70
|
-
{
|
|
70
|
+
{
|
|
71
|
+
dir: 'Shared/Context-Packs',
|
|
72
|
+
role: 'full-context bundle ต่อ domain/task-type',
|
|
73
|
+
put: 'pack รวม context พร้อมโหลด',
|
|
74
|
+
avoid: 'โน้ตเดี่ยว (→ปลายทางปกติ)',
|
|
75
|
+
links: [
|
|
76
|
+
'- [[Shared/Context-Packs/second-brain-maintenance]] — แก้ vault structure, routing, memory policy, indexes, runbooks, agent adapters',
|
|
77
|
+
'- [[Shared/Context-Packs/coding-release]] — แก้ code/tests/build/release/CLI scripts',
|
|
78
|
+
'- [[Shared/Context-Packs/research-to-framework]] — research/experiment → framework update',
|
|
79
|
+
],
|
|
80
|
+
},
|
|
71
81
|
{ dir: 'Shared/Context7-Docs', role: 'cached external lib doc (regenerable — gitignore)', put: 'cache ของ context7/lib doc', avoid: 'durable knowledge (→Learning/Research)' },
|
|
72
82
|
{ dir: 'Shared/AI-Threads', role: 'saved AI reasoning/conversation trail (ไม่ใช่ source of truth)', put: 'thread ที่เก็บไว้ review/resume/promote', avoid: 'durable decision (promote → Decision-Memory)' },
|
|
73
83
|
{ dir: 'Shared/Prompting', role: 'prompt-engineering pattern (style/structure)', put: 'pattern การเขียน prompt ที่ reuse', avoid: 'prompt asset ต่อ task (→Prompts)' },
|
|
@@ -132,6 +142,7 @@ ${f.avoid ?? '_(—)_'}
|
|
|
132
142
|
|
|
133
143
|
_(ยังว่าง — โน้ตในโฟลเดอร์นี้จะถูกลิงก์ที่นี่)_
|
|
134
144
|
|
|
145
|
+
${f.links?.length ? `## Seed Notes\n\n${f.links.join('\n')}\n\n` : ''}
|
|
135
146
|
up:: [[${parent}]]
|
|
136
147
|
`;
|
|
137
148
|
}
|
package/dist/brand.js
CHANGED
|
@@ -36,7 +36,7 @@ export function appTempPath(name) {
|
|
|
36
36
|
return join(tmpdir(), name);
|
|
37
37
|
}
|
|
38
38
|
export function envFlag(name) {
|
|
39
|
-
const v = process.env[name];
|
|
39
|
+
const v = process.env[name]?.trim();
|
|
40
40
|
return v === '1' || v?.toLowerCase() === 'true' || v?.toLowerCase() === 'yes';
|
|
41
41
|
}
|
|
42
42
|
export function persistenceEnabled() {
|
package/dist/cli-args.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { inlineValue, takeValue } from './cli-option-values.js';
|
|
2
|
+
const DECIMAL_BUDGET_RE = /^\+?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
|
|
3
|
+
const POSITIVE_INTEGER_RE = /^\d+$/;
|
|
4
|
+
export function parseBudgetUsd(value) {
|
|
5
|
+
if (value === undefined)
|
|
6
|
+
return undefined;
|
|
7
|
+
const normalized = value.trim();
|
|
8
|
+
if (!DECIMAL_BUDGET_RE.test(normalized))
|
|
9
|
+
return undefined;
|
|
10
|
+
const parsed = Number(normalized);
|
|
11
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
12
|
+
}
|
|
13
|
+
export function parseThinkingConfigValue(value) {
|
|
14
|
+
const normalized = value.trim();
|
|
15
|
+
const flag = normalized.toLowerCase();
|
|
16
|
+
if (flag === 'on' || flag === 'true')
|
|
17
|
+
return true;
|
|
18
|
+
if (flag === 'off' || flag === 'false')
|
|
19
|
+
return false;
|
|
20
|
+
if (!POSITIVE_INTEGER_RE.test(normalized))
|
|
21
|
+
return undefined;
|
|
22
|
+
const parsed = Number(normalized);
|
|
23
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
24
|
+
}
|
|
25
|
+
function optionArgs(argv) {
|
|
26
|
+
const end = argv.indexOf('--');
|
|
27
|
+
return end === -1 ? argv : argv.slice(0, end);
|
|
28
|
+
}
|
|
29
|
+
export function hasResumeRequest(argv) {
|
|
30
|
+
return optionArgs(argv).some((arg) => arg === '--resume' || arg === '-r' || arg.startsWith('--resume='));
|
|
31
|
+
}
|
|
32
|
+
export function hasContinueRequest(argv) {
|
|
33
|
+
return optionArgs(argv).some((arg) => arg === '--continue' || arg === '-c' || arg === '--continue-any');
|
|
34
|
+
}
|
|
35
|
+
export function hasContinueAnyRequest(argv) {
|
|
36
|
+
return optionArgs(argv).includes('--continue-any');
|
|
37
|
+
}
|
|
38
|
+
export function hasServeCommandRequest(argv) {
|
|
39
|
+
if (argv[0] !== 'serve')
|
|
40
|
+
return false;
|
|
41
|
+
for (let i = 1; i < argv.length; i++) {
|
|
42
|
+
const arg = argv[i];
|
|
43
|
+
if (arg === '--')
|
|
44
|
+
return false;
|
|
45
|
+
if (arg === '--port' || arg === '--model' || arg === '-m') {
|
|
46
|
+
i = takeValue(argv, i).nextIndex;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (arg.startsWith('--port=') || arg.startsWith('--model='))
|
|
50
|
+
continue;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
function parsePortValue(raw) {
|
|
56
|
+
if (raw === undefined || !/^\d+$/.test(raw))
|
|
57
|
+
return undefined;
|
|
58
|
+
const port = Number(raw);
|
|
59
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : undefined;
|
|
60
|
+
}
|
|
61
|
+
function portErrorValue(raw) {
|
|
62
|
+
return raw === undefined || raw === '' ? 'ต้องระบุค่า' : raw;
|
|
63
|
+
}
|
|
64
|
+
export function parseServeArgs(argv) {
|
|
65
|
+
let port = 8787;
|
|
66
|
+
let model;
|
|
67
|
+
let portError;
|
|
68
|
+
for (let i = 0; i < argv.length; i++) {
|
|
69
|
+
const a = argv[i];
|
|
70
|
+
if (a === '--port' || a.startsWith('--port=')) {
|
|
71
|
+
const next = a === '--port' ? takeValue(argv, i) : undefined;
|
|
72
|
+
const raw = next ? next.value : inlineValue('--port', a);
|
|
73
|
+
if (next)
|
|
74
|
+
i = next.nextIndex;
|
|
75
|
+
const parsed = parsePortValue(raw);
|
|
76
|
+
if (parsed === undefined)
|
|
77
|
+
portError = portErrorValue(raw);
|
|
78
|
+
else
|
|
79
|
+
port = parsed;
|
|
80
|
+
}
|
|
81
|
+
else if (a === '--model' || a === '-m' || a.startsWith('--model=')) {
|
|
82
|
+
if (a.startsWith('--model=')) {
|
|
83
|
+
model = inlineValue('--model', a);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const next = takeValue(argv, i);
|
|
87
|
+
model = next.value;
|
|
88
|
+
i = next.nextIndex;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { port, model, portError };
|
|
93
|
+
}
|
|
94
|
+
export function parseArgs(argv) {
|
|
95
|
+
let model;
|
|
96
|
+
let budget;
|
|
97
|
+
let json = false;
|
|
98
|
+
let quiet = false;
|
|
99
|
+
let planMode = false;
|
|
100
|
+
let yes = false;
|
|
101
|
+
let resume;
|
|
102
|
+
const rest = [];
|
|
103
|
+
let i = 0;
|
|
104
|
+
const takeArgValue = (index) => {
|
|
105
|
+
const next = takeValue(argv, index);
|
|
106
|
+
i = next.nextIndex;
|
|
107
|
+
return next.value;
|
|
108
|
+
};
|
|
109
|
+
for (; i < argv.length; i++) {
|
|
110
|
+
const a = argv[i];
|
|
111
|
+
if (a === '--') {
|
|
112
|
+
rest.push(...argv.slice(i + 1));
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
if (a.startsWith('--model='))
|
|
116
|
+
model = inlineValue('--model', a);
|
|
117
|
+
else if (a === '--model' || a === '-m')
|
|
118
|
+
model = takeArgValue(i);
|
|
119
|
+
else if (a.startsWith('--budget=')) {
|
|
120
|
+
budget = parseBudgetUsd(inlineValue('--budget', a));
|
|
121
|
+
}
|
|
122
|
+
else if (a === '--budget' || a === '-b') {
|
|
123
|
+
budget = parseBudgetUsd(takeArgValue(i));
|
|
124
|
+
}
|
|
125
|
+
else if (a === '--json')
|
|
126
|
+
json = true;
|
|
127
|
+
else if (a === '-q' || a === '--quiet')
|
|
128
|
+
quiet = true;
|
|
129
|
+
else if (a.startsWith('--output-format=') || a === '--output-format') {
|
|
130
|
+
const v = a.startsWith('--output-format=') ? inlineValue('--output-format', a) : takeArgValue(i);
|
|
131
|
+
if (v === 'json')
|
|
132
|
+
json = true;
|
|
133
|
+
else if (v === 'final' || v === 'quiet')
|
|
134
|
+
quiet = true;
|
|
135
|
+
/* 'text' = default */
|
|
136
|
+
}
|
|
137
|
+
else if (a === '--plan')
|
|
138
|
+
planMode = true;
|
|
139
|
+
else if (a === '--yes' || a === '-y' || a === '--yolo' || a === '--dangerously-skip-permissions')
|
|
140
|
+
yes = true;
|
|
141
|
+
else if (a.startsWith('--resume='))
|
|
142
|
+
resume = inlineValue('--resume', a);
|
|
143
|
+
else if (a === '--resume' || a === '-r')
|
|
144
|
+
resume = takeArgValue(i);
|
|
145
|
+
else if (a === '-p' || a === '--print' || a === '-c' || a === '--continue' || a === '--continue-any') {
|
|
146
|
+
/* -p headless flag · -c/--continue/--continue-any resume (handled in main) */
|
|
147
|
+
}
|
|
148
|
+
else
|
|
149
|
+
rest.push(a);
|
|
150
|
+
}
|
|
151
|
+
return { model, budget, json, quiet, prompt: rest.join(' ').trim(), planMode, yes, resume };
|
|
152
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function isFlagLike(value) {
|
|
2
|
+
return value.startsWith('--') || /^-[A-Za-z]/.test(value);
|
|
3
|
+
}
|
|
4
|
+
export function inlineValue(flag, value) {
|
|
5
|
+
const prefix = `${flag}=`;
|
|
6
|
+
if (!value.startsWith(prefix))
|
|
7
|
+
return undefined;
|
|
8
|
+
const parsed = value.slice(prefix.length);
|
|
9
|
+
return parsed === '' ? undefined : parsed;
|
|
10
|
+
}
|
|
11
|
+
export function takeValue(argv, index) {
|
|
12
|
+
const value = argv[index + 1];
|
|
13
|
+
if (value === undefined || isFlagLike(value))
|
|
14
|
+
return { nextIndex: index };
|
|
15
|
+
return { value, nextIndex: index + 1 };
|
|
16
|
+
}
|