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,335 @@
1
+ import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { persistenceEnabled } from './brand.js';
4
+ import { normalize, consolidate, loadStore, saveStore } from './memory-store.js';
5
+ import { search } from './search/engine.js';
6
+ const ARCHIVE_EXEMPT_PREFIXES = ['Shared/Core-Facts/', 'Shared/Archive/'];
7
+ const RETRIEVAL_CASES = [
8
+ { id: 'RET-01', query: 'memory write protocol merge dont append', expectedPath: 'Shared/Rules/memory-write-protocol.md' },
9
+ { id: 'RET-02', query: 'context assembly policy small context', expectedPath: 'Shared/Rules/context-assembly-policy.md' },
10
+ { id: 'RET-03', query: 'quality ledger retrieval hit grounded', expectedPath: 'Evals/quality-ledger.md' },
11
+ ];
12
+ export function parseBrainConsolidateArgs(args) {
13
+ let apply = false;
14
+ let archive = false;
15
+ let memory = false;
16
+ let runRetrieval = true;
17
+ for (const a of args) {
18
+ if (a === '--apply')
19
+ apply = true;
20
+ else if (a === '--archive')
21
+ archive = true;
22
+ else if (a === '--memory')
23
+ memory = true;
24
+ else if (a === '--no-retrieval')
25
+ runRetrieval = false;
26
+ else
27
+ return { ok: false, message: `ไม่รู้จัก option: ${a}` };
28
+ }
29
+ if (archive && !apply)
30
+ return { ok: false, message: '--archive ต้องใช้ร่วมกับ --apply (destructive move ถามก่อน — default เป็น dry-run)' };
31
+ return { ok: true, value: { apply, archive, memory, runRetrieval } };
32
+ }
33
+ function step(id, title, findings, message, fail = false, applied) {
34
+ const status = fail ? 'fail' : findings.length ? 'warn' : 'pass';
35
+ return { id, title, status, message, findings, applied };
36
+ }
37
+ function sectionBullets(content, heading) {
38
+ const lines = content.split('\n');
39
+ const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${heading.toLowerCase()}`);
40
+ if (start < 0)
41
+ return [];
42
+ const out = [];
43
+ for (const line of lines.slice(start + 1)) {
44
+ if (/^#{1,6}\s+/.test(line.trim()))
45
+ break;
46
+ const trimmed = line.trim();
47
+ if (trimmed.startsWith('- ') && !trimmed.includes('_('))
48
+ out.push(trimmed);
49
+ }
50
+ return out;
51
+ }
52
+ function parseFrontmatterField(content, field) {
53
+ const match = content.match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
54
+ return match?.[1]?.trim().replace(/^["']|["']$/g, '');
55
+ }
56
+ function parseIsoDate(value) {
57
+ if (!value)
58
+ return undefined;
59
+ const parsed = Date.parse(value);
60
+ return Number.isFinite(parsed) ? parsed : undefined;
61
+ }
62
+ async function readText(path) {
63
+ try {
64
+ return await readFile(path, 'utf8');
65
+ }
66
+ catch {
67
+ return '';
68
+ }
69
+ }
70
+ async function listMarkdown(root) {
71
+ const out = [];
72
+ async function walk(abs, rel) {
73
+ let entries;
74
+ try {
75
+ entries = await readdir(abs, { withFileTypes: true });
76
+ }
77
+ catch {
78
+ return;
79
+ }
80
+ for (const entry of entries) {
81
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
82
+ if (entry.isDirectory()) {
83
+ if (entry.name.startsWith('.') || entry.name === '.git' || entry.name === '.obsidian' || entry.name === 'node_modules')
84
+ continue;
85
+ await walk(join(abs, entry.name), childRel);
86
+ }
87
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
88
+ out.push(childRel);
89
+ }
90
+ }
91
+ }
92
+ await walk(root, '');
93
+ return out;
94
+ }
95
+ async function routeInboxStep(brainPath, apply) {
96
+ const path = join(brainPath, 'Shared', 'Memory-Inbox', 'memory-inbox.md');
97
+ const content = await readText(path);
98
+ if (!content) {
99
+ return step('consolidate.inbox-route', 'Route Memory-Inbox', [{ message: 'Memory-Inbox file is missing.', path }], 'Memory-Inbox is missing.', true);
100
+ }
101
+ const candidates = sectionBullets(content, 'New Candidates');
102
+ const needsMerge = sectionBullets(content, 'Needs Merge');
103
+ const findings = [];
104
+ const applied = [];
105
+ if (needsMerge.length) {
106
+ findings.push({
107
+ message: `${needsMerge.length} item(s) waiting in Needs Merge — promote to durable or discard.`,
108
+ path,
109
+ details: needsMerge.slice(0, 15),
110
+ });
111
+ }
112
+ const seen = new Map();
113
+ const duplicates = [];
114
+ for (const candidate of candidates) {
115
+ const key = normalize(candidate.replace(/^-\s*/, '').replace(/\s+/g, ' ')).trim();
116
+ if (!key)
117
+ continue;
118
+ if (seen.has(key))
119
+ duplicates.push(candidate);
120
+ else
121
+ seen.set(key, candidate);
122
+ }
123
+ if (duplicates.length) {
124
+ findings.push({ message: `${duplicates.length} duplicate candidate(s) in New Candidates.`, path, details: duplicates.slice(0, 15) });
125
+ if (apply) {
126
+ let next = content;
127
+ for (const dup of duplicates)
128
+ next = next.replace(`${dup}\n`, '');
129
+ if (next !== content) {
130
+ await writeFile(path, next);
131
+ applied.push(`removed ${duplicates.length} exact duplicate inbox candidate(s)`);
132
+ }
133
+ }
134
+ }
135
+ if (candidates.length && !needsMerge.length && !duplicates.length) {
136
+ findings.push({
137
+ message: `${candidates.length} candidate(s) ready for routing (ADD/UPDATE/DELETE/NOOP).`,
138
+ path,
139
+ details: candidates.slice(0, 10),
140
+ });
141
+ }
142
+ return step('consolidate.inbox-route', 'Route Memory-Inbox', findings, candidates.length ? `${candidates.length} inbox candidate(s) reviewed.` : 'Memory-Inbox has no active candidates.', false, applied.length ? applied : undefined);
143
+ }
144
+ async function dedupMergeStep(brainPath) {
145
+ const path = join(brainPath, 'Shared', 'Memory-Inbox', 'memory-inbox.md');
146
+ const content = await readText(path);
147
+ const candidates = [...sectionBullets(content, 'New Candidates'), ...sectionBullets(content, 'Needs Merge')];
148
+ const findings = [];
149
+ const buckets = new Map();
150
+ for (const candidate of candidates) {
151
+ const key = normalize(candidate.replace(/^-\s*/, '').replace(/\s+/g, ' ')).trim();
152
+ if (!key)
153
+ continue;
154
+ const bucket = buckets.get(key) ?? [];
155
+ bucket.push(candidate);
156
+ buckets.set(key, bucket);
157
+ }
158
+ const overlaps = [...buckets.entries()].filter(([, items]) => items.length > 1);
159
+ if (overlaps.length) {
160
+ findings.push({
161
+ message: `${overlaps.length} overlapping inbox group(s) need merge.`,
162
+ path,
163
+ details: overlaps.slice(0, 10).map(([key, items]) => `${key} (${items.length}x)`),
164
+ });
165
+ }
166
+ return step('consolidate.dedup-merge', 'Dedup + merge inbox', findings, 'Inbox overlap scan complete.');
167
+ }
168
+ async function staleArchiveStep(brainPath, nowMs, apply, archive) {
169
+ const findings = [];
170
+ const applied = [];
171
+ const candidates = [];
172
+ for (const rel of await listMarkdown(brainPath)) {
173
+ if (ARCHIVE_EXEMPT_PREFIXES.some((prefix) => rel.startsWith(prefix)))
174
+ continue;
175
+ const abs = join(brainPath, rel);
176
+ const content = await readText(abs);
177
+ const staleAfter = parseFrontmatterField(content, 'stale_after');
178
+ const staleMs = parseIsoDate(staleAfter);
179
+ if (staleMs === undefined || staleMs > nowMs)
180
+ continue;
181
+ candidates.push(rel);
182
+ findings.push({ message: `Stale candidate: stale_after=${staleAfter}`, path: abs });
183
+ }
184
+ if (archive && apply) {
185
+ await mkdir(join(brainPath, 'Shared', 'Archive'), { recursive: true });
186
+ for (const rel of candidates) {
187
+ const src = join(brainPath, rel);
188
+ const dest = join(brainPath, 'Shared', 'Archive', rel.split('/').pop());
189
+ await rename(src, dest);
190
+ applied.push(`archived ${rel} → Shared/Archive/${rel.split('/').pop()}`);
191
+ }
192
+ }
193
+ else if (candidates.length) {
194
+ findings.unshift({
195
+ message: `${candidates.length} note(s) past stale_after — dry-run only; rerun with --apply --archive to move into Shared/Archive.`,
196
+ });
197
+ }
198
+ return step('consolidate.stale-archive', 'Stale → Archive', findings, candidates.length ? `${candidates.length} stale note(s) flagged.` : 'No stale notes flagged.', false, applied.length ? applied : undefined);
199
+ }
200
+ async function patternPromoteStep(brainPath) {
201
+ const inboxPath = join(brainPath, 'Shared', 'Memory-Inbox', 'memory-inbox.md');
202
+ const candidates = sectionBullets(await readText(inboxPath), 'New Candidates');
203
+ const findings = [];
204
+ const markdown = await listMarkdown(brainPath);
205
+ const corpusParts = [];
206
+ for (const rel of markdown.slice(0, 200)) {
207
+ const text = await readText(join(brainPath, rel));
208
+ if (text)
209
+ corpusParts.push(normalize(text));
210
+ }
211
+ const corpus = corpusParts.join('\n');
212
+ for (const candidate of candidates) {
213
+ const phrase = normalize(candidate.replace(/^-\s*/, '').replace(/\s+/g, ' ')).trim();
214
+ if (phrase.length < 12)
215
+ continue;
216
+ const tokens = phrase.split(/\s+/).filter((t) => t.length > 3).slice(0, 4);
217
+ if (tokens.length < 2)
218
+ continue;
219
+ const hits = tokens.filter((token) => corpus.includes(token)).length;
220
+ if (hits >= 3 || (tokens.length >= 2 && hits === tokens.length && corpus.includes(phrase.slice(0, 20)))) {
221
+ findings.push({
222
+ message: `Possible recurring pattern (≥3 signals): "${phrase.slice(0, 80)}"`,
223
+ path: inboxPath,
224
+ details: ['Consider promoting to Playbooks/ or Distillations/ after review.'],
225
+ });
226
+ }
227
+ }
228
+ return step('consolidate.pattern-promote', 'Pattern → promote', findings, findings.length ? `${findings.length} pattern candidate(s) found.` : 'No recurring patterns detected in inbox.');
229
+ }
230
+ async function retrievalCheckStep(runRetrieval, searchImpl) {
231
+ if (!runRetrieval) {
232
+ return { id: 'consolidate.retrieval-check', title: 'Retrieval check', status: 'skipped', message: 'Skipped (--no-retrieval).', findings: [] };
233
+ }
234
+ const findings = [];
235
+ for (const item of RETRIEVAL_CASES) {
236
+ const res = await searchImpl(item.query);
237
+ const hit = res.hits.find((candidate) => candidate.path === item.expectedPath);
238
+ if (!hit) {
239
+ findings.push({
240
+ message: `${item.id} miss: query did not return ${item.expectedPath}`,
241
+ details: [`query="${item.query}"`, ...res.hits.slice(0, 3).map((h) => `got: ${h.path ?? h.snippet.slice(0, 60)}`)],
242
+ });
243
+ }
244
+ }
245
+ return step('consolidate.retrieval-check', 'Retrieval check', findings, findings.length ? `${findings.length} retrieval miss(es).` : 'Retrieval eval cases passed.', false);
246
+ }
247
+ async function memoryConsolidateStep(apply, memory, nowMs) {
248
+ if (!memory) {
249
+ return { id: 'consolidate.auto-memory', title: 'Auto-memory consolidate', status: 'skipped', message: 'Skipped (pass --memory to consolidate ~/.sanook memory store).', findings: [] };
250
+ }
251
+ if (!persistenceEnabled()) {
252
+ return step('consolidate.auto-memory', 'Auto-memory consolidate', [{ message: 'Persistence is disabled (SANOOK_DISABLE_PERSISTENCE).' }], 'Auto-memory consolidate skipped.', false);
253
+ }
254
+ const store = await loadStore();
255
+ const { store: next, report } = consolidate(store, nowMs);
256
+ const findings = [];
257
+ if (report.archived.length)
258
+ findings.push({ message: `Would archive ${report.archived.length} auto-memory fact(s).`, details: report.archived.slice(0, 10) });
259
+ if (report.merged.length)
260
+ findings.push({ message: `Merged ${report.merged.length} overlapping auto-memory fact(s).`, details: report.merged.slice(0, 10) });
261
+ if (report.needsReview.length)
262
+ findings.push({ message: `${report.needsReview.length} auto-memory fact(s) need review.`, details: report.needsReview.slice(0, 10) });
263
+ const applied = [];
264
+ if (apply) {
265
+ await saveStore(next);
266
+ applied.push('saved consolidated auto-memory store');
267
+ }
268
+ return step('consolidate.auto-memory', 'Auto-memory consolidate', findings, apply ? 'Auto-memory store consolidated.' : 'Auto-memory consolidate dry-run (pass --apply --memory to save).', false, applied.length ? applied : undefined);
269
+ }
270
+ export async function runBrainConsolidate(options = {}) {
271
+ const brainPath = options.brainPath;
272
+ const apply = options.apply ?? false;
273
+ const dryRun = !apply;
274
+ if (!brainPath) {
275
+ return {
276
+ ok: false,
277
+ dryRun,
278
+ steps: [step('consolidate.configured', 'Second-brain configured', [{ message: 'No second-brain path is configured.' }], 'Run `sanook brain init [path]` first.', true)],
279
+ };
280
+ }
281
+ try {
282
+ if (!(await stat(brainPath)).isDirectory()) {
283
+ return {
284
+ ok: false,
285
+ brainPath,
286
+ dryRun,
287
+ steps: [step('consolidate.path', 'Second-brain path', [{ message: 'Configured path is not a directory.', path: brainPath }], 'Configured brainPath is not usable.', true)],
288
+ };
289
+ }
290
+ }
291
+ catch {
292
+ return {
293
+ ok: false,
294
+ brainPath,
295
+ dryRun,
296
+ steps: [step('consolidate.path', 'Second-brain path', [{ message: 'Configured path does not exist.', path: brainPath }], 'Configured brainPath is not usable.', true)],
297
+ };
298
+ }
299
+ const nowMs = options.nowMs ?? Date.now();
300
+ const searchImpl = options.searchImpl ??
301
+ ((query) => search(query, { mode: 'fts', limit: 5, sources: ['vault'] }));
302
+ const steps = [
303
+ await routeInboxStep(brainPath, apply),
304
+ await dedupMergeStep(brainPath),
305
+ await staleArchiveStep(brainPath, nowMs, apply, options.archive ?? false),
306
+ await patternPromoteStep(brainPath),
307
+ await retrievalCheckStep(options.runRetrieval !== false, searchImpl),
308
+ await memoryConsolidateStep(apply, options.memory ?? false, nowMs),
309
+ ];
310
+ const ok = !steps.some((s) => s.status === 'fail');
311
+ return { ok, brainPath, dryRun, steps };
312
+ }
313
+ function statusLabel(status) {
314
+ return status.toUpperCase().padEnd(7);
315
+ }
316
+ export function formatBrainConsolidateReport(report) {
317
+ const lines = [
318
+ 'Sanook brain consolidate',
319
+ `vault: ${report.brainPath ?? '(not configured)'}`,
320
+ `mode: ${report.dryRun ? 'dry-run (pass --apply for safe fixes; --apply --archive for stale moves)' : 'apply'}`,
321
+ ];
322
+ for (const s of report.steps) {
323
+ lines.push(`[${statusLabel(s.status)}] ${s.id} — ${s.message}`);
324
+ for (const finding of s.findings) {
325
+ lines.push(` - ${finding.message}`);
326
+ if (finding.path)
327
+ lines.push(` ${finding.path}`);
328
+ for (const detail of finding.details ?? [])
329
+ lines.push(` · ${detail}`);
330
+ }
331
+ for (const action of s.applied ?? [])
332
+ lines.push(` ✓ ${action}`);
333
+ }
334
+ return lines.join('\n');
335
+ }
@@ -0,0 +1,262 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { resolveVaultProject } from './project-registry.js';
3
+ import { inlineValue, takeValue } from './cli-option-values.js';
4
+ import { buildBrainContextParts, renderBrainContext, } from './memory.js';
5
+ import { checkSearchIndexFreshness } from './brain-doctor.js';
6
+ import { search } from './search/engine.js';
7
+ import { SEARCH_SOURCES } from './search/index-core.js';
8
+ import { INDEX_PATH } from './search/store.js';
9
+ const SEARCH_MODES = ['auto', 'fts', 'semantic', 'hybrid'];
10
+ const DEFAULT_CONTEXT_WARNING_CHARS = 6500;
11
+ const DEFAULT_TASK_SOURCES = ['vault', 'session', 'skill'];
12
+ function isSearchMode(value) {
13
+ return SEARCH_MODES.includes(value);
14
+ }
15
+ function isSearchSource(value) {
16
+ return SEARCH_SOURCES.includes(value);
17
+ }
18
+ function parsePositiveInteger(raw) {
19
+ if (!raw || !/^[1-9]\d*$/.test(raw))
20
+ return undefined;
21
+ const n = Number(raw);
22
+ return Number.isSafeInteger(n) ? n : undefined;
23
+ }
24
+ function inlineSourceValue(value) {
25
+ return inlineValue('--source', value) ?? inlineValue('--sources', value);
26
+ }
27
+ export function parseBrainContextArgs(args) {
28
+ const taskParts = [];
29
+ let task;
30
+ let project;
31
+ let mode = 'auto';
32
+ let modeSet = false;
33
+ let limit = 5;
34
+ let limitSet = false;
35
+ let sources;
36
+ let showContent = true;
37
+ for (let i = 0; i < args.length; i++) {
38
+ const a = args[i];
39
+ if (a === '--') {
40
+ taskParts.push(...args.slice(i + 1));
41
+ break;
42
+ }
43
+ else if (a === '--task' || a.startsWith('--task=')) {
44
+ const next = a === '--task' ? takeValue(args, i) : undefined;
45
+ const raw = next ? next.value : inlineValue('--task', a);
46
+ if (next)
47
+ i = next.nextIndex;
48
+ if (!raw)
49
+ return { ok: false, message: '--task ต้องระบุข้อความ task' };
50
+ if (task !== undefined)
51
+ return { ok: false, message: 'ระบุ task ได้ครั้งเดียว: ใช้ --task เพียงครั้งเดียว' };
52
+ task = raw.trim();
53
+ if (!task)
54
+ return { ok: false, message: '--task ต้องระบุข้อความ task' };
55
+ }
56
+ else if (a === '--project' || a.startsWith('--project=')) {
57
+ const next = a === '--project' ? takeValue(args, i) : undefined;
58
+ const raw = next ? next.value : inlineValue('--project', a);
59
+ if (next)
60
+ i = next.nextIndex;
61
+ if (!raw?.trim())
62
+ return { ok: false, message: 'ต้องระบุค่าให้ --project' };
63
+ if (project !== undefined)
64
+ return { ok: false, message: 'ระบุ project ได้ครั้งเดียว' };
65
+ project = raw.trim();
66
+ }
67
+ else if (a === '--mode' || a.startsWith('--mode=')) {
68
+ const next = a === '--mode' ? takeValue(args, i) : undefined;
69
+ const raw = next ? next.value : inlineValue('--mode', a);
70
+ if (next)
71
+ i = next.nextIndex;
72
+ if (!raw)
73
+ return { ok: false, message: `--mode ต้องระบุค่าเป็น ${SEARCH_MODES.join('|')}` };
74
+ if (!isSearchMode(raw))
75
+ return { ok: false, message: `--mode ต้องเป็น ${SEARCH_MODES.join('|')}` };
76
+ if (modeSet)
77
+ return { ok: false, message: 'ใช้ --mode เพียงครั้งเดียว' };
78
+ mode = raw;
79
+ modeSet = true;
80
+ }
81
+ else if (a === '--limit' || a.startsWith('--limit=')) {
82
+ const next = a === '--limit' ? takeValue(args, i) : undefined;
83
+ const raw = next ? next.value : inlineValue('--limit', a);
84
+ if (next)
85
+ i = next.nextIndex;
86
+ const n = parsePositiveInteger(raw);
87
+ if (n === undefined)
88
+ return { ok: false, message: '--limit ต้องเป็น integer บวก เช่น 5' };
89
+ if (limitSet)
90
+ return { ok: false, message: 'ใช้ --limit เพียงครั้งเดียว' };
91
+ limit = n;
92
+ limitSet = true;
93
+ }
94
+ else if (a === '--source' || a === '--sources' || a.startsWith('--source=') || a.startsWith('--sources=')) {
95
+ const next = a === '--source' || a === '--sources' ? takeValue(args, i) : undefined;
96
+ const raw = next ? next.value : inlineSourceValue(a);
97
+ if (next)
98
+ i = next.nextIndex;
99
+ const requested = (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean);
100
+ if (!requested.length)
101
+ return { ok: false, message: `--source ต้องระบุค่าเป็น ${SEARCH_SOURCES.join(',')}` };
102
+ const bad = requested.filter((s) => !isSearchSource(s));
103
+ if (bad.length)
104
+ return { ok: false, message: `--source ต้องเป็น ${SEARCH_SOURCES.join(',')}` };
105
+ sources = [...new Set([...(sources ?? []), ...requested])];
106
+ }
107
+ else if (a === '--no-content' || a === '--summary') {
108
+ showContent = false;
109
+ }
110
+ else if (a === '--content') {
111
+ showContent = true;
112
+ }
113
+ else {
114
+ taskParts.push(a);
115
+ }
116
+ }
117
+ const positionalTask = taskParts.join(' ').trim();
118
+ if (task !== undefined && positionalTask)
119
+ return { ok: false, message: 'ระบุ task ได้ครั้งเดียว: ใช้ positional หรือ --task อย่างใดอย่างหนึ่ง' };
120
+ const finalTask = (task ?? positionalTask).trim();
121
+ return { ok: true, value: { task: finalTask || undefined, project, mode, limit, sources, showContent } };
122
+ }
123
+ export async function inspectBrainContext(options = {}) {
124
+ const brainPath = options.brainPath;
125
+ const warnings = [];
126
+ if (!brainPath) {
127
+ return {
128
+ ok: false,
129
+ context: '',
130
+ contextChars: 0,
131
+ sources: [],
132
+ warnings: ['No second-brain path is configured. Run `sanook brain init [path]` first.'],
133
+ };
134
+ }
135
+ try {
136
+ if (!(await stat(brainPath)).isDirectory()) {
137
+ return {
138
+ ok: false,
139
+ brainPath,
140
+ context: '',
141
+ contextChars: 0,
142
+ sources: [],
143
+ warnings: ['Configured second-brain path is not a directory.'],
144
+ };
145
+ }
146
+ }
147
+ catch {
148
+ return {
149
+ ok: false,
150
+ brainPath,
151
+ context: '',
152
+ contextChars: 0,
153
+ sources: [],
154
+ warnings: ['Configured second-brain path does not exist.'],
155
+ };
156
+ }
157
+ const parts = await buildBrainContextParts(brainPath, {
158
+ taskQuery: options.task,
159
+ cwd: options.cwd,
160
+ projectSlug: options.projectSlug,
161
+ });
162
+ const context = renderBrainContext(brainPath, parts);
163
+ const activeProject = await resolveVaultProject({
164
+ brainPath,
165
+ cwd: options.cwd,
166
+ slug: options.projectSlug,
167
+ });
168
+ const sourceReports = parts.map((part) => ({
169
+ id: part.id,
170
+ label: part.label,
171
+ relPath: part.relPath,
172
+ path: part.path,
173
+ status: part.status,
174
+ chars: part.chars,
175
+ maxChars: part.maxChars,
176
+ }));
177
+ for (const part of sourceReports) {
178
+ if (part.status === 'missing')
179
+ warnings.push(`Missing context source: ${part.relPath}`);
180
+ }
181
+ if (!context)
182
+ warnings.push('Brain context is empty; expected at least one hot context source to contain content.');
183
+ const maxContextChars = options.maxContextChars ?? DEFAULT_CONTEXT_WARNING_CHARS;
184
+ if (context.length > maxContextChars) {
185
+ warnings.push(`Brain context is ${context.length} chars, above the ${maxContextChars} char warning threshold.`);
186
+ }
187
+ const indexCheck = await checkSearchIndexFreshness(brainPath, options.indexPath ?? INDEX_PATH, options.indexFreshnessToleranceMs);
188
+ if (indexCheck.status !== 'pass')
189
+ warnings.push(indexCheck.message);
190
+ const taskQuery = options.task?.trim();
191
+ let taskReport;
192
+ if (taskQuery) {
193
+ const taskSources = options.sources?.length ? options.sources : [...DEFAULT_TASK_SOURCES];
194
+ const res = await (options.searchImpl ?? search)(taskQuery, {
195
+ mode: options.mode ?? 'auto',
196
+ limit: options.limit ?? 5,
197
+ sources: taskSources,
198
+ });
199
+ taskReport = {
200
+ query: taskQuery,
201
+ mode: res.mode,
202
+ degraded: res.degraded,
203
+ total: res.total,
204
+ hits: res.hits,
205
+ sources: taskSources,
206
+ };
207
+ }
208
+ return {
209
+ ok: true,
210
+ brainPath,
211
+ projectSlug: activeProject?.slug,
212
+ projectRepo: activeProject?.repoPath,
213
+ context,
214
+ contextChars: context.length,
215
+ sources: sourceReports,
216
+ warnings,
217
+ task: taskReport,
218
+ };
219
+ }
220
+ function statusLabel(status) {
221
+ return status.toUpperCase().padEnd(7);
222
+ }
223
+ export function formatBrainContextReport(report, showContent = true) {
224
+ const lines = ['Sanook brain context'];
225
+ lines.push(`vault: ${report.brainPath ?? '(not configured)'}`);
226
+ if (report.projectSlug) {
227
+ lines.push(`project: ${report.projectSlug}${report.projectRepo ? ` (${report.projectRepo})` : ''}`);
228
+ }
229
+ lines.push(`context: ${report.contextChars} chars`);
230
+ if (report.sources.length) {
231
+ lines.push('sources:');
232
+ for (const source of report.sources) {
233
+ lines.push(` [${statusLabel(source.status)}] ${source.relPath} (${source.chars} chars, cap ${source.maxChars})`);
234
+ }
235
+ }
236
+ if (report.warnings.length) {
237
+ lines.push('warnings:');
238
+ for (const warning of report.warnings)
239
+ lines.push(` - ${warning}`);
240
+ }
241
+ if (showContent) {
242
+ lines.push('--- context ---');
243
+ lines.push(report.context || '(empty)');
244
+ }
245
+ if (report.task) {
246
+ lines.push(`--- task retrieval: "${report.task.query}" ---`);
247
+ lines.push(`mode=${report.task.mode}${report.task.degraded ? ` degraded=${report.task.degraded}` : ''} ` +
248
+ `sources=${report.task.sources.join(',')} hits=${report.task.hits.length}/${report.task.total}`);
249
+ if (!report.task.hits.length) {
250
+ lines.push('(no task hits; run `sanook index` if the vault changed recently)');
251
+ }
252
+ else {
253
+ for (const hit of report.task.hits) {
254
+ const title = hit.title.trim();
255
+ const body = title ? `${title} — ${hit.snippet}` : hit.snippet;
256
+ const where = hit.path ? ` (${hit.path})` : '';
257
+ lines.push(`[${hit.source}] ${body}${where}`);
258
+ }
259
+ }
260
+ }
261
+ return lines.join('\n');
262
+ }