sanook-cli 0.5.1 → 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 +57 -8
- package/README.md +240 -23
- package/README.th.md +87 -6
- package/dist/approval.js +6 -0
- package/dist/bin.js +3026 -196
- 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 +70 -36
- package/dist/providers/keys.js +1 -1
- package/dist/providers/models.js +1 -1
- package/dist/providers/registry.js +14 -47
- 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 +48 -8
- package/dist/ui/history.js +37 -5
- package/dist/ui/mentions.js +3 -2
- package/dist/ui/setup.js +17 -4
- 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,223 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { inlineValue, takeValue } from './cli-option-values.js';
|
|
3
|
+
import { buildBrainContextParts, renderBrainContext, } from './memory.js';
|
|
4
|
+
import { checkSearchIndexFreshness } from './brain-doctor.js';
|
|
5
|
+
import { search } from './search/engine.js';
|
|
6
|
+
import { SEARCH_SOURCES } from './search/index-core.js';
|
|
7
|
+
import { INDEX_PATH } from './search/store.js';
|
|
8
|
+
const SEARCH_MODES = ['auto', 'fts', 'semantic', 'hybrid'];
|
|
9
|
+
const DEFAULT_CONTEXT_WARNING_CHARS = 6500;
|
|
10
|
+
const DEFAULT_TASK_SOURCES = ['vault', 'session', 'skill'];
|
|
11
|
+
function isSearchMode(value) {
|
|
12
|
+
return SEARCH_MODES.includes(value);
|
|
13
|
+
}
|
|
14
|
+
function isSearchSource(value) {
|
|
15
|
+
return SEARCH_SOURCES.includes(value);
|
|
16
|
+
}
|
|
17
|
+
function parsePositiveInteger(raw) {
|
|
18
|
+
if (!raw || !/^[1-9]\d*$/.test(raw))
|
|
19
|
+
return undefined;
|
|
20
|
+
const n = Number(raw);
|
|
21
|
+
return Number.isSafeInteger(n) ? n : undefined;
|
|
22
|
+
}
|
|
23
|
+
function inlineSourceValue(value) {
|
|
24
|
+
return inlineValue('--source', value) ?? inlineValue('--sources', value);
|
|
25
|
+
}
|
|
26
|
+
export function parseBrainContextArgs(args) {
|
|
27
|
+
const taskParts = [];
|
|
28
|
+
let task;
|
|
29
|
+
let mode = 'auto';
|
|
30
|
+
let limit = 5;
|
|
31
|
+
let sources;
|
|
32
|
+
let showContent = true;
|
|
33
|
+
for (let i = 0; i < args.length; i++) {
|
|
34
|
+
const a = args[i];
|
|
35
|
+
if (a === '--') {
|
|
36
|
+
taskParts.push(...args.slice(i + 1));
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
else if (a === '--task' || a.startsWith('--task=')) {
|
|
40
|
+
const next = a === '--task' ? takeValue(args, i) : undefined;
|
|
41
|
+
const raw = next ? next.value : inlineValue('--task', a);
|
|
42
|
+
if (next)
|
|
43
|
+
i = next.nextIndex;
|
|
44
|
+
if (!raw)
|
|
45
|
+
return { ok: false, message: '--task ต้องระบุข้อความ task' };
|
|
46
|
+
task = raw.trim();
|
|
47
|
+
if (!task)
|
|
48
|
+
return { ok: false, message: '--task ต้องระบุข้อความ task' };
|
|
49
|
+
}
|
|
50
|
+
else if (a === '--mode' || a.startsWith('--mode=')) {
|
|
51
|
+
const next = a === '--mode' ? takeValue(args, i) : undefined;
|
|
52
|
+
const raw = next ? next.value : inlineValue('--mode', a);
|
|
53
|
+
if (next)
|
|
54
|
+
i = next.nextIndex;
|
|
55
|
+
if (!raw)
|
|
56
|
+
return { ok: false, message: `--mode ต้องระบุค่าเป็น ${SEARCH_MODES.join('|')}` };
|
|
57
|
+
if (!isSearchMode(raw))
|
|
58
|
+
return { ok: false, message: `--mode ต้องเป็น ${SEARCH_MODES.join('|')}` };
|
|
59
|
+
mode = raw;
|
|
60
|
+
}
|
|
61
|
+
else if (a === '--limit' || a.startsWith('--limit=')) {
|
|
62
|
+
const next = a === '--limit' ? takeValue(args, i) : undefined;
|
|
63
|
+
const raw = next ? next.value : inlineValue('--limit', a);
|
|
64
|
+
if (next)
|
|
65
|
+
i = next.nextIndex;
|
|
66
|
+
const n = parsePositiveInteger(raw);
|
|
67
|
+
if (n === undefined)
|
|
68
|
+
return { ok: false, message: '--limit ต้องเป็น integer บวก เช่น 5' };
|
|
69
|
+
limit = n;
|
|
70
|
+
}
|
|
71
|
+
else if (a === '--source' || a === '--sources' || a.startsWith('--source=') || a.startsWith('--sources=')) {
|
|
72
|
+
const next = a === '--source' || a === '--sources' ? takeValue(args, i) : undefined;
|
|
73
|
+
const raw = next ? next.value : inlineSourceValue(a);
|
|
74
|
+
if (next)
|
|
75
|
+
i = next.nextIndex;
|
|
76
|
+
const requested = (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
77
|
+
if (!requested.length)
|
|
78
|
+
return { ok: false, message: `--source ต้องระบุค่าเป็น ${SEARCH_SOURCES.join(',')}` };
|
|
79
|
+
const bad = requested.filter((s) => !isSearchSource(s));
|
|
80
|
+
if (bad.length)
|
|
81
|
+
return { ok: false, message: `--source ต้องเป็น ${SEARCH_SOURCES.join(',')}` };
|
|
82
|
+
sources = [...new Set(requested)];
|
|
83
|
+
}
|
|
84
|
+
else if (a === '--no-content' || a === '--summary') {
|
|
85
|
+
showContent = false;
|
|
86
|
+
}
|
|
87
|
+
else if (a === '--content') {
|
|
88
|
+
showContent = true;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
taskParts.push(a);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const positionalTask = taskParts.join(' ').trim();
|
|
95
|
+
const finalTask = (task ?? positionalTask).trim();
|
|
96
|
+
return { ok: true, value: { task: finalTask || undefined, mode, limit, sources, showContent } };
|
|
97
|
+
}
|
|
98
|
+
export async function inspectBrainContext(options = {}) {
|
|
99
|
+
const brainPath = options.brainPath;
|
|
100
|
+
const warnings = [];
|
|
101
|
+
if (!brainPath) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
context: '',
|
|
105
|
+
contextChars: 0,
|
|
106
|
+
sources: [],
|
|
107
|
+
warnings: ['No second-brain path is configured. Run `sanook brain init [path]` first.'],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
if (!(await stat(brainPath)).isDirectory()) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
brainPath,
|
|
115
|
+
context: '',
|
|
116
|
+
contextChars: 0,
|
|
117
|
+
sources: [],
|
|
118
|
+
warnings: ['Configured second-brain path is not a directory.'],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
brainPath,
|
|
126
|
+
context: '',
|
|
127
|
+
contextChars: 0,
|
|
128
|
+
sources: [],
|
|
129
|
+
warnings: ['Configured second-brain path does not exist.'],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const parts = await buildBrainContextParts(brainPath);
|
|
133
|
+
const context = renderBrainContext(brainPath, parts);
|
|
134
|
+
const sourceReports = parts.map((part) => ({
|
|
135
|
+
id: part.id,
|
|
136
|
+
label: part.label,
|
|
137
|
+
relPath: part.relPath,
|
|
138
|
+
path: part.path,
|
|
139
|
+
status: part.status,
|
|
140
|
+
chars: part.chars,
|
|
141
|
+
maxChars: part.maxChars,
|
|
142
|
+
}));
|
|
143
|
+
for (const part of sourceReports) {
|
|
144
|
+
if (part.status === 'missing')
|
|
145
|
+
warnings.push(`Missing context source: ${part.relPath}`);
|
|
146
|
+
}
|
|
147
|
+
if (!context)
|
|
148
|
+
warnings.push('Brain context is empty; expected at least one hot context source to contain content.');
|
|
149
|
+
const maxContextChars = options.maxContextChars ?? DEFAULT_CONTEXT_WARNING_CHARS;
|
|
150
|
+
if (context.length > maxContextChars) {
|
|
151
|
+
warnings.push(`Brain context is ${context.length} chars, above the ${maxContextChars} char warning threshold.`);
|
|
152
|
+
}
|
|
153
|
+
const indexCheck = await checkSearchIndexFreshness(brainPath, options.indexPath ?? INDEX_PATH, options.indexFreshnessToleranceMs);
|
|
154
|
+
if (indexCheck.status !== 'pass')
|
|
155
|
+
warnings.push(indexCheck.message);
|
|
156
|
+
const taskQuery = options.task?.trim();
|
|
157
|
+
let taskReport;
|
|
158
|
+
if (taskQuery) {
|
|
159
|
+
const taskSources = options.sources?.length ? options.sources : [...DEFAULT_TASK_SOURCES];
|
|
160
|
+
const res = await (options.searchImpl ?? search)(taskQuery, {
|
|
161
|
+
mode: options.mode ?? 'auto',
|
|
162
|
+
limit: options.limit ?? 5,
|
|
163
|
+
sources: taskSources,
|
|
164
|
+
});
|
|
165
|
+
taskReport = {
|
|
166
|
+
query: taskQuery,
|
|
167
|
+
mode: res.mode,
|
|
168
|
+
degraded: res.degraded,
|
|
169
|
+
total: res.total,
|
|
170
|
+
hits: res.hits,
|
|
171
|
+
sources: taskSources,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
ok: true,
|
|
176
|
+
brainPath,
|
|
177
|
+
context,
|
|
178
|
+
contextChars: context.length,
|
|
179
|
+
sources: sourceReports,
|
|
180
|
+
warnings,
|
|
181
|
+
task: taskReport,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function statusLabel(status) {
|
|
185
|
+
return status.toUpperCase().padEnd(7);
|
|
186
|
+
}
|
|
187
|
+
export function formatBrainContextReport(report, showContent = true) {
|
|
188
|
+
const lines = ['Sanook brain context'];
|
|
189
|
+
lines.push(`vault: ${report.brainPath ?? '(not configured)'}`);
|
|
190
|
+
lines.push(`context: ${report.contextChars} chars`);
|
|
191
|
+
if (report.sources.length) {
|
|
192
|
+
lines.push('sources:');
|
|
193
|
+
for (const source of report.sources) {
|
|
194
|
+
lines.push(` [${statusLabel(source.status)}] ${source.relPath} (${source.chars} chars, cap ${source.maxChars})`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (report.warnings.length) {
|
|
198
|
+
lines.push('warnings:');
|
|
199
|
+
for (const warning of report.warnings)
|
|
200
|
+
lines.push(` - ${warning}`);
|
|
201
|
+
}
|
|
202
|
+
if (showContent) {
|
|
203
|
+
lines.push('--- context ---');
|
|
204
|
+
lines.push(report.context || '(empty)');
|
|
205
|
+
}
|
|
206
|
+
if (report.task) {
|
|
207
|
+
lines.push(`--- task retrieval: "${report.task.query}" ---`);
|
|
208
|
+
lines.push(`mode=${report.task.mode}${report.task.degraded ? ` degraded=${report.task.degraded}` : ''} ` +
|
|
209
|
+
`sources=${report.task.sources.join(',')} hits=${report.task.hits.length}/${report.task.total}`);
|
|
210
|
+
if (!report.task.hits.length) {
|
|
211
|
+
lines.push('(no task hits; run `sanook index` if the vault changed recently)');
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
for (const hit of report.task.hits) {
|
|
215
|
+
const title = hit.title.trim();
|
|
216
|
+
const body = title ? `${title} — ${hit.snippet}` : hit.snippet;
|
|
217
|
+
const where = hit.path ? ` (${hit.path})` : '';
|
|
218
|
+
lines.push(`[${hit.source}] ${body}${where}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return lines.join('\n');
|
|
223
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, posix, win32 } from 'node:path';
|
|
3
|
+
import { appHomePath } from './brand.js';
|
|
4
|
+
import { FOLDERS } from './brain.js';
|
|
5
|
+
import { INDEX_PATH } from './search/store.js';
|
|
6
|
+
export const BRAIN_HOT_FILES = [
|
|
7
|
+
'SANOOK.md',
|
|
8
|
+
'Shared/AI-Context-Index.md',
|
|
9
|
+
'Vault Structure Map.md',
|
|
10
|
+
'Shared/Operating-State/current-state.md',
|
|
11
|
+
];
|
|
12
|
+
const DEFAULT_INDEX_FRESHNESS_TOLERANCE_MS = 1000;
|
|
13
|
+
const SKIP_DIRS = new Set(['.git', '.obsidian', 'node_modules']);
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function normalizeSlashes(path) {
|
|
18
|
+
return path.trim().replace(/\\/g, '/');
|
|
19
|
+
}
|
|
20
|
+
function normalizeFolderReference(path) {
|
|
21
|
+
const normalized = posix
|
|
22
|
+
.normalize(normalizeSlashes(path))
|
|
23
|
+
.replace(/^\/+/, '')
|
|
24
|
+
.replace(/\/+$/, '');
|
|
25
|
+
return normalized === '.' ? '' : normalized;
|
|
26
|
+
}
|
|
27
|
+
function isVaultRelativeFolderReference(path) {
|
|
28
|
+
const trimmed = path.trim();
|
|
29
|
+
const slashed = normalizeSlashes(trimmed);
|
|
30
|
+
const hasWindowsDriveSpecifier = /^[A-Za-z]:/.test(trimmed);
|
|
31
|
+
return (!posix.isAbsolute(slashed) &&
|
|
32
|
+
!win32.isAbsolute(trimmed) &&
|
|
33
|
+
!hasWindowsDriveSpecifier &&
|
|
34
|
+
!slashed.split('/').includes('..'));
|
|
35
|
+
}
|
|
36
|
+
function normalizeExpectedFolders(expectedFolders) {
|
|
37
|
+
const folders = new Set();
|
|
38
|
+
const invalid = new Set();
|
|
39
|
+
for (const dir of expectedFolders) {
|
|
40
|
+
const slashed = normalizeSlashes(dir);
|
|
41
|
+
if (!slashed)
|
|
42
|
+
continue;
|
|
43
|
+
const folder = normalizeFolderReference(dir);
|
|
44
|
+
if (!isVaultRelativeFolderReference(dir)) {
|
|
45
|
+
invalid.add(slashed.replace(/\/+$/, ''));
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!folder)
|
|
49
|
+
continue;
|
|
50
|
+
folders.add(folder);
|
|
51
|
+
}
|
|
52
|
+
return { folders: [...folders], invalid: [...invalid] };
|
|
53
|
+
}
|
|
54
|
+
function extractFolderReferences(map) {
|
|
55
|
+
const references = new Set();
|
|
56
|
+
for (const match of map.matchAll(/`([^`]+)`/g)) {
|
|
57
|
+
if (!isVaultRelativeFolderReference(match[1]))
|
|
58
|
+
continue;
|
|
59
|
+
const normalized = normalizeFolderReference(match[1]);
|
|
60
|
+
if (normalized)
|
|
61
|
+
references.add(normalized);
|
|
62
|
+
}
|
|
63
|
+
return references;
|
|
64
|
+
}
|
|
65
|
+
async function fileExists(path) {
|
|
66
|
+
try {
|
|
67
|
+
return (await stat(path)).isFile();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function directoryExists(path) {
|
|
74
|
+
try {
|
|
75
|
+
return (await stat(path)).isDirectory();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function latestMarkdownMtimeMs(root) {
|
|
82
|
+
let latest = 0;
|
|
83
|
+
async function walk(dir) {
|
|
84
|
+
let entries;
|
|
85
|
+
try {
|
|
86
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (entry.isDirectory()) {
|
|
93
|
+
if (!SKIP_DIRS.has(entry.name))
|
|
94
|
+
await walk(join(dir, entry.name));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!entry.isFile() || !entry.name.endsWith('.md'))
|
|
98
|
+
continue;
|
|
99
|
+
try {
|
|
100
|
+
latest = Math.max(latest, (await stat(join(dir, entry.name))).mtimeMs);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// File disappeared between readdir and stat; ignore the race.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
await walk(root);
|
|
108
|
+
return latest;
|
|
109
|
+
}
|
|
110
|
+
export async function checkBrainHotFiles(brainPath) {
|
|
111
|
+
const missing = [];
|
|
112
|
+
for (const rel of BRAIN_HOT_FILES) {
|
|
113
|
+
if (!(await fileExists(join(brainPath, rel))))
|
|
114
|
+
missing.push(rel);
|
|
115
|
+
}
|
|
116
|
+
if (missing.length) {
|
|
117
|
+
return {
|
|
118
|
+
id: 'brain.hot-files',
|
|
119
|
+
status: 'fail',
|
|
120
|
+
message: `Missing ${missing.length} required second-brain file${missing.length === 1 ? '' : 's'}.`,
|
|
121
|
+
path: brainPath,
|
|
122
|
+
details: missing,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
id: 'brain.hot-files',
|
|
127
|
+
status: 'pass',
|
|
128
|
+
message: 'Required second-brain hot files are present.',
|
|
129
|
+
path: brainPath,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export async function checkBrainFolders(brainPath, expectedFolders = FOLDERS.map((f) => f.dir)) {
|
|
133
|
+
const expected = normalizeExpectedFolders(expectedFolders);
|
|
134
|
+
if (expected.invalid.length) {
|
|
135
|
+
return {
|
|
136
|
+
id: 'brain.folders',
|
|
137
|
+
status: 'fail',
|
|
138
|
+
message: `Invalid expected second-brain folder reference${expected.invalid.length === 1 ? '' : 's'}.`,
|
|
139
|
+
path: brainPath,
|
|
140
|
+
details: expected.invalid,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const missing = [];
|
|
144
|
+
for (const dir of expected.folders) {
|
|
145
|
+
if (!(await directoryExists(join(brainPath, dir))))
|
|
146
|
+
missing.push(dir);
|
|
147
|
+
}
|
|
148
|
+
if (missing.length) {
|
|
149
|
+
return {
|
|
150
|
+
id: 'brain.folders',
|
|
151
|
+
status: 'fail',
|
|
152
|
+
message: `Missing ${missing.length} expected second-brain folder${missing.length === 1 ? '' : 's'}.`,
|
|
153
|
+
path: brainPath,
|
|
154
|
+
details: missing,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
id: 'brain.folders',
|
|
159
|
+
status: 'pass',
|
|
160
|
+
message: 'Expected second-brain folders are present.',
|
|
161
|
+
path: brainPath,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export async function checkVaultStructureMap(brainPath, expectedFolders = FOLDERS.map((f) => f.dir)) {
|
|
165
|
+
const mapPath = join(brainPath, 'Vault Structure Map.md');
|
|
166
|
+
let map;
|
|
167
|
+
try {
|
|
168
|
+
map = await readFile(mapPath, 'utf8');
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return {
|
|
172
|
+
id: 'brain.structure-map',
|
|
173
|
+
status: 'fail',
|
|
174
|
+
message: 'Vault Structure Map.md is missing or unreadable.',
|
|
175
|
+
path: mapPath,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const expected = normalizeExpectedFolders(expectedFolders);
|
|
179
|
+
if (expected.invalid.length) {
|
|
180
|
+
return {
|
|
181
|
+
id: 'brain.structure-map',
|
|
182
|
+
status: 'fail',
|
|
183
|
+
message: `Invalid expected second-brain folder reference${expected.invalid.length === 1 ? '' : 's'}.`,
|
|
184
|
+
path: mapPath,
|
|
185
|
+
details: expected.invalid,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const folderReferences = extractFolderReferences(map);
|
|
189
|
+
const missing = expected.folders.filter((dir) => !folderReferences.has(dir));
|
|
190
|
+
if (missing.length) {
|
|
191
|
+
return {
|
|
192
|
+
id: 'brain.structure-map',
|
|
193
|
+
status: 'fail',
|
|
194
|
+
message: `Vault Structure Map.md is missing ${missing.length} folder reference${missing.length === 1 ? '' : 's'}.`,
|
|
195
|
+
path: mapPath,
|
|
196
|
+
details: missing,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
id: 'brain.structure-map',
|
|
201
|
+
status: 'pass',
|
|
202
|
+
message: 'Vault Structure Map.md covers the expected folder manifest.',
|
|
203
|
+
path: mapPath,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
export async function checkSearchIndexFreshness(brainPath, indexPath = INDEX_PATH, toleranceMs = DEFAULT_INDEX_FRESHNESS_TOLERANCE_MS) {
|
|
207
|
+
const latestVaultMtimeMs = await latestMarkdownMtimeMs(brainPath);
|
|
208
|
+
if (latestVaultMtimeMs === 0) {
|
|
209
|
+
return {
|
|
210
|
+
id: 'brain.search-index',
|
|
211
|
+
status: 'warn',
|
|
212
|
+
message: 'No markdown files were found in the configured second-brain vault.',
|
|
213
|
+
path: brainPath,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
let indexMtimeMs = 0;
|
|
217
|
+
try {
|
|
218
|
+
indexMtimeMs = (await stat(indexPath)).mtimeMs;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return {
|
|
222
|
+
id: 'brain.search-index',
|
|
223
|
+
status: 'warn',
|
|
224
|
+
message: 'Search index is missing; run `sanook index` to build it.',
|
|
225
|
+
path: indexPath,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (indexMtimeMs + toleranceMs < latestVaultMtimeMs) {
|
|
229
|
+
return {
|
|
230
|
+
id: 'brain.search-index',
|
|
231
|
+
status: 'warn',
|
|
232
|
+
message: 'Search index is older than the second-brain markdown files.',
|
|
233
|
+
path: indexPath,
|
|
234
|
+
details: [`index_mtime_ms=${Math.round(indexMtimeMs)}`, `vault_latest_mtime_ms=${Math.round(latestVaultMtimeMs)}`],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
id: 'brain.search-index',
|
|
239
|
+
status: 'pass',
|
|
240
|
+
message: 'Search index is present and fresh enough for the vault.',
|
|
241
|
+
path: indexPath,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
export async function checkBrainMcpWiring(brainPath, mcpConfigPath = appHomePath('mcp.json')) {
|
|
245
|
+
let raw;
|
|
246
|
+
try {
|
|
247
|
+
raw = JSON.parse(await readFile(mcpConfigPath, 'utf8'));
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return {
|
|
251
|
+
id: 'brain.mcp',
|
|
252
|
+
status: 'warn',
|
|
253
|
+
message: 'MCP config is missing or unreadable; `sanook brain init` can wire the vault.',
|
|
254
|
+
path: mcpConfigPath,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const servers = isRecord(raw) && isRecord(raw.mcpServers) ? raw.mcpServers : undefined;
|
|
258
|
+
const server = servers && isRecord(servers['second-brain']) ? servers['second-brain'] : undefined;
|
|
259
|
+
const args = server && Array.isArray(server.args) ? server.args : [];
|
|
260
|
+
if (!args.includes(brainPath)) {
|
|
261
|
+
return {
|
|
262
|
+
id: 'brain.mcp',
|
|
263
|
+
status: 'warn',
|
|
264
|
+
message: 'MCP server `second-brain` is not wired to the configured vault path.',
|
|
265
|
+
path: mcpConfigPath,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
id: 'brain.mcp',
|
|
270
|
+
status: 'pass',
|
|
271
|
+
message: 'MCP server `second-brain` points at the configured vault path.',
|
|
272
|
+
path: mcpConfigPath,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
export async function checkBrain(options = {}) {
|
|
276
|
+
const checks = [];
|
|
277
|
+
const brainPath = options.brainPath;
|
|
278
|
+
if (!brainPath) {
|
|
279
|
+
checks.push({
|
|
280
|
+
id: 'brain.configured',
|
|
281
|
+
status: 'fail',
|
|
282
|
+
message: 'No second-brain path is configured.',
|
|
283
|
+
});
|
|
284
|
+
return { ok: false, checks };
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
if (!(await stat(brainPath)).isDirectory()) {
|
|
288
|
+
checks.push({
|
|
289
|
+
id: 'brain.path',
|
|
290
|
+
status: 'fail',
|
|
291
|
+
message: 'Configured second-brain path is not a directory.',
|
|
292
|
+
path: brainPath,
|
|
293
|
+
});
|
|
294
|
+
return { ok: false, checks };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
checks.push({
|
|
299
|
+
id: 'brain.path',
|
|
300
|
+
status: 'fail',
|
|
301
|
+
message: 'Configured second-brain path does not exist.',
|
|
302
|
+
path: brainPath,
|
|
303
|
+
});
|
|
304
|
+
return { ok: false, checks };
|
|
305
|
+
}
|
|
306
|
+
checks.push({
|
|
307
|
+
id: 'brain.path',
|
|
308
|
+
status: 'pass',
|
|
309
|
+
message: 'Configured second-brain path exists.',
|
|
310
|
+
path: brainPath,
|
|
311
|
+
});
|
|
312
|
+
checks.push(await checkBrainFolders(brainPath, options.expectedFolders));
|
|
313
|
+
checks.push(await checkBrainHotFiles(brainPath));
|
|
314
|
+
checks.push(await checkVaultStructureMap(brainPath, options.expectedFolders));
|
|
315
|
+
checks.push(await checkSearchIndexFreshness(brainPath, options.indexPath, options.indexFreshnessToleranceMs ?? DEFAULT_INDEX_FRESHNESS_TOLERANCE_MS));
|
|
316
|
+
checks.push(await checkBrainMcpWiring(brainPath, options.mcpConfigPath));
|
|
317
|
+
return { ok: !checks.some((check) => check.status === 'fail'), checks };
|
|
318
|
+
}
|