sanook-cli 0.5.1 → 0.5.5

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.
Files changed (217) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +148 -10
  3. package/README.md +255 -26
  4. package/README.th.md +95 -7
  5. package/dist/approval.js +13 -0
  6. package/dist/bin.js +3552 -155
  7. package/dist/brain-consolidate.js +335 -0
  8. package/dist/brain-context.js +262 -0
  9. package/dist/brain-doctor.js +318 -0
  10. package/dist/brain-eval.js +186 -0
  11. package/dist/brain-final.js +377 -0
  12. package/dist/brain-metrics.js +277 -0
  13. package/dist/brain-new.js +402 -0
  14. package/dist/brain-pack.js +210 -0
  15. package/dist/brain-repair.js +280 -0
  16. package/dist/brain-review.js +382 -0
  17. package/dist/brain.js +15 -1
  18. package/dist/brand.js +1 -1
  19. package/dist/cli-args.js +190 -0
  20. package/dist/cli-option-values.js +16 -0
  21. package/dist/clipboard.js +65 -0
  22. package/dist/commands.js +266 -27
  23. package/dist/compaction.js +96 -11
  24. package/dist/config.js +149 -33
  25. package/dist/context-compression.js +191 -0
  26. package/dist/context-pack.js +145 -0
  27. package/dist/cost.js +49 -15
  28. package/dist/dashboard/api-helpers.js +87 -0
  29. package/dist/dashboard/server.js +179 -0
  30. package/dist/dashboard/static/app.js +277 -0
  31. package/dist/dashboard/static/index.html +39 -0
  32. package/dist/dashboard/static/styles.css +85 -0
  33. package/dist/diff.js +10 -2
  34. package/dist/first-run.js +21 -0
  35. package/dist/gateway/auth.js +49 -9
  36. package/dist/gateway/bluebubbles.js +205 -0
  37. package/dist/gateway/config.js +929 -0
  38. package/dist/gateway/deliver.js +399 -0
  39. package/dist/gateway/discord.js +124 -0
  40. package/dist/gateway/doctor.js +456 -0
  41. package/dist/gateway/email.js +501 -0
  42. package/dist/gateway/googlechat.js +207 -0
  43. package/dist/gateway/homeassistant.js +256 -0
  44. package/dist/gateway/ledger.js +38 -1
  45. package/dist/gateway/line.js +171 -0
  46. package/dist/gateway/lock.js +3 -1
  47. package/dist/gateway/matrix.js +366 -0
  48. package/dist/gateway/mattermost.js +322 -0
  49. package/dist/gateway/ntfy.js +218 -0
  50. package/dist/gateway/schedule.js +31 -4
  51. package/dist/gateway/serve.js +267 -7
  52. package/dist/gateway/server.js +253 -19
  53. package/dist/gateway/service.js +224 -0
  54. package/dist/gateway/session.js +362 -0
  55. package/dist/gateway/signal.js +351 -0
  56. package/dist/gateway/slack.js +124 -0
  57. package/dist/gateway/sms.js +169 -0
  58. package/dist/gateway/targets.js +576 -0
  59. package/dist/gateway/teams.js +106 -0
  60. package/dist/gateway/telegram.js +38 -15
  61. package/dist/gateway/webhooks.js +220 -0
  62. package/dist/gateway/whatsapp.js +230 -0
  63. package/dist/hooks.js +13 -2
  64. package/dist/hotkeys.js +21 -0
  65. package/dist/i18n/en.js +98 -0
  66. package/dist/i18n/index.js +19 -0
  67. package/dist/i18n/th.js +98 -0
  68. package/dist/i18n/types.js +1 -0
  69. package/dist/insights-args.js +55 -0
  70. package/dist/insights.js +86 -0
  71. package/dist/knowledge.js +55 -29
  72. package/dist/loop.js +157 -29
  73. package/dist/lsp/index.js +23 -5
  74. package/dist/mcp-hub.js +33 -0
  75. package/dist/mcp-registry.js +494 -0
  76. package/dist/mcp-risk.js +71 -0
  77. package/dist/mcp-server.js +1 -1
  78. package/dist/mcp.js +120 -10
  79. package/dist/memory-log.js +90 -0
  80. package/dist/memory-store.js +37 -1
  81. package/dist/memory.js +148 -37
  82. package/dist/model-picker.js +58 -0
  83. package/dist/orchestrate.js +51 -19
  84. package/dist/personality.js +58 -0
  85. package/dist/plan-handoff.js +17 -0
  86. package/dist/polyglot.js +162 -0
  87. package/dist/process-runner.js +96 -0
  88. package/dist/project-init.js +91 -0
  89. package/dist/project-registry.js +143 -0
  90. package/dist/project-scaffold.js +124 -0
  91. package/dist/prompt-size.js +155 -0
  92. package/dist/providers/codex-login.js +138 -0
  93. package/dist/providers/codex.js +89 -43
  94. package/dist/providers/keys.js +22 -1
  95. package/dist/providers/models.js +2 -2
  96. package/dist/providers/registry.js +14 -47
  97. package/dist/search/chunk.js +7 -8
  98. package/dist/search/cli.js +83 -0
  99. package/dist/search/embed-store.js +3 -0
  100. package/dist/search/embedding-config.js +22 -0
  101. package/dist/search/engine.js +2 -13
  102. package/dist/search/indexer.js +44 -1
  103. package/dist/search/store.js +23 -1
  104. package/dist/session-distill.js +84 -0
  105. package/dist/session.js +92 -16
  106. package/dist/skill-install.js +53 -13
  107. package/dist/skills.js +33 -0
  108. package/dist/slash-completion.js +155 -0
  109. package/dist/support-dump.js +206 -0
  110. package/dist/tool-catalog.js +59 -0
  111. package/dist/tools/edit.js +45 -15
  112. package/dist/tools/git.js +10 -5
  113. package/dist/tools/homeassistant.js +106 -0
  114. package/dist/tools/index.js +10 -0
  115. package/dist/tools/list.js +19 -6
  116. package/dist/tools/permission.js +992 -12
  117. package/dist/tools/polyglot.js +126 -0
  118. package/dist/tools/read.js +16 -4
  119. package/dist/tools/sandbox.js +38 -13
  120. package/dist/tools/schedule.js +19 -3
  121. package/dist/tools/search.js +226 -15
  122. package/dist/tools/task.js +40 -9
  123. package/dist/tools/timeout.js +23 -3
  124. package/dist/tools/web-fetch-tool.js +33 -0
  125. package/dist/trust.js +11 -1
  126. package/dist/turn-retrieval.js +83 -0
  127. package/dist/ui/app.js +878 -32
  128. package/dist/ui/banner.js +78 -4
  129. package/dist/ui/history.js +37 -5
  130. package/dist/ui/markdown.js +122 -0
  131. package/dist/ui/mentions.js +3 -2
  132. package/dist/ui/overlay.js +496 -0
  133. package/dist/ui/queue.js +23 -0
  134. package/dist/ui/render.js +20 -1
  135. package/dist/ui/session-panel.js +115 -0
  136. package/dist/ui/setup-providers.js +40 -0
  137. package/dist/ui/setup.js +172 -46
  138. package/dist/ui/status.js +142 -0
  139. package/dist/ui/thinking-panel.js +36 -0
  140. package/dist/ui/tool-trail.js +97 -0
  141. package/dist/ui/transcript.js +26 -0
  142. package/dist/ui/useBusyElapsed.js +19 -0
  143. package/dist/ui/useEditor.js +144 -5
  144. package/dist/ui/useGitBranch.js +57 -0
  145. package/dist/update.js +56 -17
  146. package/dist/web-fetch.js +637 -0
  147. package/dist/web-surface.js +190 -0
  148. package/dist/worktree.js +175 -4
  149. package/package.json +5 -5
  150. package/second-brain/AGENTS.md +6 -4
  151. package/second-brain/CLAUDE.md +7 -1
  152. package/second-brain/Evals/_Index.md +10 -2
  153. package/second-brain/Evals/quality-ledger.md +9 -1
  154. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  155. package/second-brain/GEMINI.md +5 -4
  156. package/second-brain/Home.md +1 -1
  157. package/second-brain/Projects/_Index.md +19 -4
  158. package/second-brain/Projects/sanook-cli/_Index.md +30 -0
  159. package/second-brain/Projects/sanook-cli/context.md +35 -0
  160. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  161. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  162. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  163. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +197 -0
  164. package/second-brain/README.md +1 -1
  165. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  166. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  167. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  168. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  169. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  170. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  171. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  172. package/second-brain/Research/_Index.md +8 -1
  173. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  174. package/second-brain/Reviews/_Index.md +1 -1
  175. package/second-brain/Runbooks/_Index.md +6 -1
  176. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  177. package/second-brain/SANOOK.md +45 -0
  178. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  179. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  180. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  181. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  182. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  183. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  184. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  185. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  186. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  187. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  188. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  189. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  190. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  191. package/second-brain/Sessions/_Index.md +15 -1
  192. package/second-brain/Shared/AI-Context-Index.md +22 -0
  193. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  194. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  195. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  196. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  197. package/second-brain/Shared/Operating-State/current-state.md +14 -4
  198. package/second-brain/Shared/Scripts/_Index.md +3 -1
  199. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  200. package/second-brain/Shared/Tech-Standards/_Index.md +6 -1
  201. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  202. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  203. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  204. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  205. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  206. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  207. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  208. package/second-brain/Templates/_Index.md +9 -0
  209. package/second-brain/Templates/final-lite.md +111 -0
  210. package/second-brain/Templates/final.md +231 -0
  211. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  212. package/second-brain/Templates/project-workspace/context.md +28 -0
  213. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  214. package/second-brain/Templates/project-workspace/overview.md +39 -0
  215. package/second-brain/Templates/project-workspace/repo.md +33 -0
  216. package/second-brain/Vault Structure Map.md +2 -1
  217. package/skills/structured-output-llm/SKILL.md +1 -1
@@ -0,0 +1,280 @@
1
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { FOLDERS } from './brain.js';
4
+ import { checkBrainFolders } from './brain-doctor.js';
5
+ import { inferParentRelPath } from './brain-new.js';
6
+ import { extractPackDescription, normalizePackName as normalizeContextPackName } from './brain-pack.js';
7
+ const ROOT_FILES_WITHOUT_PARENT = new Set(['Home.md', 'README.md', 'CLAUDE.md', 'GEMINI.md', 'AGENTS.md', 'SANOOK.md']);
8
+ const ROOT_FILES_WITHOUT_UP = ROOT_FILES_WITHOUT_PARENT;
9
+ const SKIP_DIRS = new Set(['.git', '.obsidian', 'node_modules', 'Shared/Context7-Docs']);
10
+ const PURPOSE_PLACEHOLDER = '> _(purpose pending — fill in)_\n\n';
11
+ export function parseBrainRepairArgs(args) {
12
+ for (const arg of args) {
13
+ if (arg === '--dry-run')
14
+ continue;
15
+ return { ok: false, message: `ไม่รู้จัก option: ${arg}` };
16
+ }
17
+ return { ok: true, dryRun: args.includes('--dry-run') };
18
+ }
19
+ export function planPurposeFix(relPath, content) {
20
+ if (/^>\s+/m.test(content))
21
+ return undefined;
22
+ return {
23
+ id: 'repair.purpose-blockquote',
24
+ relPath,
25
+ kind: 'markdown',
26
+ message: 'Add purpose blockquote after frontmatter.',
27
+ };
28
+ }
29
+ export function planParentFix(relPath, content) {
30
+ if (ROOT_FILES_WITHOUT_PARENT.has(relPath))
31
+ return undefined;
32
+ if (/^---[\s\S]*?^parent:/m.test(content))
33
+ return undefined;
34
+ const parent = inferParentRelPath(relPath);
35
+ return {
36
+ id: 'repair.parent-frontmatter',
37
+ relPath,
38
+ kind: 'markdown',
39
+ message: `Add parent frontmatter -> [[${parent}]]`,
40
+ };
41
+ }
42
+ export function planUpLinkFix(relPath, content) {
43
+ if (ROOT_FILES_WITHOUT_UP.has(relPath))
44
+ return undefined;
45
+ if (content.includes('up:: [['))
46
+ return undefined;
47
+ const parent = inferParentRelPath(relPath);
48
+ return {
49
+ id: 'repair.up-link',
50
+ relPath,
51
+ kind: 'markdown',
52
+ message: `Append up:: [[${parent}]]`,
53
+ };
54
+ }
55
+ export function applyPurposeFix(content) {
56
+ if (/^>\s+/m.test(content))
57
+ return content;
58
+ const match = content.match(/^---[\s\S]*?---\n?/);
59
+ if (match)
60
+ return content.replace(match[0], `${match[0]}${PURPOSE_PLACEHOLDER}`);
61
+ return `${PURPOSE_PLACEHOLDER}${content}`;
62
+ }
63
+ export function applyParentFix(content, relPath) {
64
+ if (/^---[\s\S]*?^parent:/m.test(content))
65
+ return content;
66
+ const parent = inferParentRelPath(relPath);
67
+ const parentLine = `parent: "[[${parent}]]"`;
68
+ const match = content.match(/^---\n([\s\S]*?)---\n/);
69
+ if (!match)
70
+ return `---\n${parentLine}\n---\n\n${content}`;
71
+ return content.replace(/^---\n/, `---\n${parentLine}\n`);
72
+ }
73
+ export function applyUpLinkFix(content, relPath) {
74
+ if (content.includes('up:: [['))
75
+ return content;
76
+ const parent = inferParentRelPath(relPath);
77
+ return `${content.trimEnd()}\n\nup:: [[${parent}]]\n`;
78
+ }
79
+ export function applyMarkdownRepairs(relPath, content) {
80
+ let next = content;
81
+ const applied = [];
82
+ if (planPurposeFix(relPath, next)) {
83
+ next = applyPurposeFix(next);
84
+ applied.push('purpose-blockquote');
85
+ }
86
+ if (planParentFix(relPath, next)) {
87
+ next = applyParentFix(next, relPath);
88
+ applied.push('parent-frontmatter');
89
+ }
90
+ if (planUpLinkFix(relPath, next)) {
91
+ next = applyUpLinkFix(next, relPath);
92
+ applied.push('up-link');
93
+ }
94
+ return { content: next, applied };
95
+ }
96
+ export function planContextPackIndexFix(packName, indexContent) {
97
+ const link = `[[Shared/Context-Packs/${packName}]]`;
98
+ if (indexContent.includes(link))
99
+ return undefined;
100
+ return {
101
+ id: 'repair.context-pack-index',
102
+ relPath: 'Shared/Context-Packs/_Index.md',
103
+ kind: 'index',
104
+ message: `Link ${packName} from Context-Packs/_Index.md`,
105
+ };
106
+ }
107
+ export function applyContextPackIndexFix(indexContent, packName, packContent) {
108
+ const link = `[[Shared/Context-Packs/${packName}]]`;
109
+ if (indexContent.includes(link))
110
+ return indexContent;
111
+ const description = extractBlockquotePurpose(packContent) || 'context pack';
112
+ const line = `- ${link} — ${description}`;
113
+ const marker = '\n## Use Rule';
114
+ if (indexContent.includes(marker))
115
+ return indexContent.replace(marker, `\n${line}\n${marker}`);
116
+ const upMarker = '\nup:: [[Shared/_Index]]';
117
+ if (indexContent.includes(upMarker))
118
+ return indexContent.replace(upMarker, `\n${line}\n${upMarker}`);
119
+ return `${indexContent.trimEnd()}\n${line}\n`;
120
+ }
121
+ export async function collectRepairActions(brainPath, expectedFolders = FOLDERS.map((folder) => folder.dir)) {
122
+ const actions = [];
123
+ const folderCheck = await checkBrainFolders(brainPath, expectedFolders);
124
+ for (const missing of folderCheck.details ?? []) {
125
+ actions.push({
126
+ id: 'repair.missing-folder',
127
+ relPath: missing,
128
+ kind: 'folder',
129
+ message: `Create missing folder: ${missing}`,
130
+ });
131
+ }
132
+ for (const relPath of await listMarkdown(brainPath)) {
133
+ const content = await readText(join(brainPath, relPath));
134
+ for (const plan of [planPurposeFix, planParentFix, planUpLinkFix]) {
135
+ const action = plan(relPath, content);
136
+ if (action)
137
+ actions.push(action);
138
+ }
139
+ }
140
+ const packsDir = join(brainPath, 'Shared', 'Context-Packs');
141
+ const indexPath = join(packsDir, '_Index.md');
142
+ const indexContent = await readText(indexPath);
143
+ let entries;
144
+ try {
145
+ entries = await readdir(packsDir, { withFileTypes: true });
146
+ }
147
+ catch {
148
+ return actions;
149
+ }
150
+ for (const entry of entries) {
151
+ if (!entry.isFile() || !entry.name.endsWith('.md') || entry.name === '_Index.md')
152
+ continue;
153
+ const packName = normalizeContextPackName(entry.name);
154
+ const action = planContextPackIndexFix(packName, indexContent);
155
+ if (action)
156
+ actions.push(action);
157
+ }
158
+ return actions;
159
+ }
160
+ export async function repairBrain(options = {}) {
161
+ const dryRun = options.dryRun ?? false;
162
+ const warnings = [];
163
+ const brainPath = options.brainPath;
164
+ if (!brainPath) {
165
+ return { ok: false, dryRun, actions: [], applied: [], warnings: ['No second-brain path is configured.'] };
166
+ }
167
+ if (!(await pathExistsAsDir(brainPath))) {
168
+ return { ok: false, brainPath, dryRun, actions: [], applied: [], warnings: ['Configured second-brain path does not exist or is not a directory.'] };
169
+ }
170
+ const actions = await collectRepairActions(brainPath, options.expectedFolders);
171
+ if (dryRun) {
172
+ return { ok: true, brainPath, dryRun, actions, applied: [], warnings };
173
+ }
174
+ const applied = [];
175
+ const markdownUpdates = new Map();
176
+ for (const action of actions) {
177
+ if (action.kind === 'folder') {
178
+ await mkdir(join(brainPath, action.relPath), { recursive: true });
179
+ applied.push(`${action.relPath}: created folder`);
180
+ continue;
181
+ }
182
+ if (action.kind === 'index' && action.id === 'repair.context-pack-index') {
183
+ const indexPath = join(brainPath, action.relPath);
184
+ const indexContent = await readText(indexPath);
185
+ if (!indexContent) {
186
+ warnings.push(`Skipped ${action.relPath}: index file missing.`);
187
+ continue;
188
+ }
189
+ const packName = action.message.match(/Link ([^\s]+) from/)?.[1];
190
+ if (!packName)
191
+ continue;
192
+ const packContent = await readText(join(brainPath, 'Shared', 'Context-Packs', `${packName}.md`));
193
+ const next = applyContextPackIndexFix(indexContent, packName, packContent);
194
+ if (next !== indexContent) {
195
+ await writeFile(indexPath, next, 'utf8');
196
+ applied.push(`${action.relPath}: linked ${packName}`);
197
+ }
198
+ continue;
199
+ }
200
+ if (action.kind === 'markdown') {
201
+ const current = markdownUpdates.get(action.relPath)?.content ?? (await readText(join(brainPath, action.relPath)));
202
+ const repaired = applyMarkdownRepairs(action.relPath, current);
203
+ if (repaired.applied.length) {
204
+ const prior = markdownUpdates.get(action.relPath)?.applied ?? [];
205
+ markdownUpdates.set(action.relPath, { content: repaired.content, applied: uniqueSorted([...prior, ...repaired.applied]) });
206
+ }
207
+ }
208
+ }
209
+ for (const [relPath, update] of markdownUpdates) {
210
+ await writeFile(join(brainPath, relPath), update.content, 'utf8');
211
+ applied.push(`${relPath}: ${update.applied.join(', ')}`);
212
+ }
213
+ return { ok: true, brainPath, dryRun, actions, applied, warnings };
214
+ }
215
+ export function formatBrainRepairReport(report) {
216
+ const lines = ['Sanook brain repair', `vault: ${report.brainPath ?? '(not configured)'}`];
217
+ lines.push(`mode: ${report.dryRun ? 'dry-run' : 'apply'}`);
218
+ lines.push(`planned: ${report.actions.length} fix(es)`);
219
+ for (const action of report.actions) {
220
+ lines.push(`- [${action.id}] ${action.relPath} — ${action.message}`);
221
+ }
222
+ if (!report.dryRun) {
223
+ lines.push(`applied: ${report.applied.length}`);
224
+ for (const item of report.applied)
225
+ lines.push(` ✓ ${item}`);
226
+ }
227
+ for (const warning of report.warnings)
228
+ lines.push(`warning: ${warning}`);
229
+ return lines.join('\n');
230
+ }
231
+ async function listMarkdown(root) {
232
+ const out = [];
233
+ async function walk(abs, rel) {
234
+ let entries;
235
+ try {
236
+ entries = await readdir(abs, { withFileTypes: true });
237
+ }
238
+ catch {
239
+ return;
240
+ }
241
+ for (const entry of entries) {
242
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
243
+ if (entry.isDirectory()) {
244
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name) || SKIP_DIRS.has(childRel))
245
+ continue;
246
+ await walk(join(abs, entry.name), childRel);
247
+ }
248
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
249
+ out.push(childRel);
250
+ }
251
+ }
252
+ }
253
+ await walk(root, '');
254
+ return out.sort();
255
+ }
256
+ async function pathExistsAsDir(path) {
257
+ try {
258
+ const { stat } = await import('node:fs/promises');
259
+ return (await stat(path)).isDirectory();
260
+ }
261
+ catch {
262
+ return false;
263
+ }
264
+ }
265
+ async function readText(path) {
266
+ try {
267
+ return await readFile(path, 'utf8');
268
+ }
269
+ catch {
270
+ return '';
271
+ }
272
+ }
273
+ function extractBlockquotePurpose(content) {
274
+ const body = content.replace(/^---[\s\S]*?---\n?/, '');
275
+ const match = body.match(/^>\s*(.+)$/m);
276
+ return match?.[1]?.trim() ?? '';
277
+ }
278
+ function uniqueSorted(values) {
279
+ return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b));
280
+ }
@@ -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
+ }