sanook-cli 0.5.2 → 0.5.7

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 (127) hide show
  1. package/CHANGELOG.md +112 -2
  2. package/README.md +15 -3
  3. package/README.th.md +8 -1
  4. package/dist/approval.js +7 -0
  5. package/dist/bin.js +637 -56
  6. package/dist/brain-consolidate.js +335 -0
  7. package/dist/brain-context.js +42 -3
  8. package/dist/brain-final.js +15 -9
  9. package/dist/brain-link.js +73 -0
  10. package/dist/brain-metrics.js +277 -0
  11. package/dist/brain-new.js +402 -0
  12. package/dist/brain-pack.js +210 -0
  13. package/dist/brain-repair.js +280 -0
  14. package/dist/brain.js +3 -0
  15. package/dist/brand.js +4 -0
  16. package/dist/cli-args.js +47 -9
  17. package/dist/cli-option-values.js +1 -1
  18. package/dist/clipboard.js +65 -0
  19. package/dist/commands.js +98 -15
  20. package/dist/config.js +66 -34
  21. package/dist/context-pack.js +145 -0
  22. package/dist/cost.js +20 -0
  23. package/dist/dashboard/api-helpers.js +87 -0
  24. package/dist/dashboard/server.js +179 -0
  25. package/dist/dashboard/static/app.js +277 -0
  26. package/dist/dashboard/static/index.html +39 -0
  27. package/dist/dashboard/static/styles.css +85 -0
  28. package/dist/diff.js +10 -2
  29. package/dist/gateway/auth.js +14 -3
  30. package/dist/gateway/deliver.js +45 -3
  31. package/dist/gateway/doctor.js +456 -0
  32. package/dist/gateway/email.js +30 -1
  33. package/dist/gateway/ledger.js +20 -1
  34. package/dist/gateway/session.js +34 -11
  35. package/dist/hotkeys.js +21 -0
  36. package/dist/i18n/en.js +98 -0
  37. package/dist/i18n/index.js +19 -0
  38. package/dist/i18n/th.js +98 -0
  39. package/dist/i18n/types.js +1 -0
  40. package/dist/insights-args.js +24 -4
  41. package/dist/knowledge.js +55 -29
  42. package/dist/loop.js +65 -9
  43. package/dist/mcp-hub.js +33 -0
  44. package/dist/mcp-registry.js +153 -9
  45. package/dist/mcp-risk.js +71 -0
  46. package/dist/mcp.js +77 -5
  47. package/dist/memory-log.js +90 -0
  48. package/dist/memory-store.js +37 -1
  49. package/dist/memory.js +51 -7
  50. package/dist/model-picker.js +58 -0
  51. package/dist/orchestrate.js +7 -5
  52. package/dist/plan-handoff.js +17 -0
  53. package/dist/polyglot.js +162 -0
  54. package/dist/process-runner.js +96 -0
  55. package/dist/project-init.js +91 -0
  56. package/dist/project-registry.js +143 -0
  57. package/dist/project-scaffold.js +124 -0
  58. package/dist/prompt-size.js +155 -0
  59. package/dist/providers/codex-login.js +138 -0
  60. package/dist/providers/codex.js +20 -8
  61. package/dist/providers/keys.js +21 -0
  62. package/dist/providers/models.js +1 -1
  63. package/dist/providers/registry.js +11 -1
  64. package/dist/search/cli.js +9 -1
  65. package/dist/search/embedding-config.js +22 -0
  66. package/dist/search/engine.js +2 -13
  67. package/dist/search/indexer.js +10 -10
  68. package/dist/session-brain.js +103 -0
  69. package/dist/session-distill.js +84 -0
  70. package/dist/session.js +1 -11
  71. package/dist/skill-install.js +24 -1
  72. package/dist/skills.js +33 -0
  73. package/dist/slash-completion.js +155 -0
  74. package/dist/support-dump.js +31 -0
  75. package/dist/tool-catalog.js +59 -0
  76. package/dist/tools/index.js +5 -0
  77. package/dist/tools/permission.js +82 -16
  78. package/dist/tools/polyglot.js +126 -0
  79. package/dist/tools/sandbox.js +38 -13
  80. package/dist/tools/search.js +9 -2
  81. package/dist/tools/task.js +22 -2
  82. package/dist/tools/timeout.js +7 -5
  83. package/dist/tools/web-fetch-tool.js +33 -0
  84. package/dist/turn-retrieval.js +83 -0
  85. package/dist/ui/app.js +874 -35
  86. package/dist/ui/banner.js +78 -4
  87. package/dist/ui/markdown.js +122 -0
  88. package/dist/ui/overlay.js +496 -0
  89. package/dist/ui/queue.js +23 -0
  90. package/dist/ui/render.js +30 -2
  91. package/dist/ui/session-panel.js +115 -0
  92. package/dist/ui/setup-providers.js +40 -0
  93. package/dist/ui/setup.js +163 -50
  94. package/dist/ui/status.js +142 -0
  95. package/dist/ui/thinking-panel.js +36 -0
  96. package/dist/ui/tool-trail.js +97 -0
  97. package/dist/ui/transcript.js +26 -0
  98. package/dist/ui/useBusyElapsed.js +19 -0
  99. package/dist/ui/useEditor.js +144 -5
  100. package/dist/ui/useGitBranch.js +57 -0
  101. package/dist/update.js +32 -6
  102. package/dist/usage-cli.js +160 -0
  103. package/dist/usage-ledger.js +169 -0
  104. package/dist/web-fetch.js +637 -0
  105. package/dist/web-surface.js +190 -0
  106. package/package.json +4 -3
  107. package/scripts/postinstall.mjs +4 -4
  108. package/second-brain/Projects/_Index.md +17 -4
  109. package/second-brain/Projects/sanook-cli/_Index.md +7 -3
  110. package/second-brain/Projects/sanook-cli/context.md +35 -0
  111. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  112. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  113. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  114. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +52 -11
  115. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  116. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  117. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  118. package/second-brain/Research/_Index.md +2 -0
  119. package/second-brain/Shared/Operating-State/current-state.md +14 -23
  120. package/second-brain/Shared/Tech-Standards/_Index.md +2 -0
  121. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  122. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  123. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  124. package/second-brain/Templates/project-workspace/context.md +28 -0
  125. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  126. package/second-brain/Templates/project-workspace/overview.md +39 -0
  127. package/second-brain/Templates/project-workspace/repo.md +33 -0
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import { stat } from 'node:fs/promises';
2
+ import { resolveVaultProject } from './project-registry.js';
2
3
  import { inlineValue, takeValue } from './cli-option-values.js';
3
4
  import { buildBrainContextParts, renderBrainContext, } from './memory.js';
4
5
  import { checkSearchIndexFreshness } from './brain-doctor.js';
@@ -26,8 +27,11 @@ function inlineSourceValue(value) {
26
27
  export function parseBrainContextArgs(args) {
27
28
  const taskParts = [];
28
29
  let task;
30
+ let project;
29
31
  let mode = 'auto';
32
+ let modeSet = false;
30
33
  let limit = 5;
34
+ let limitSet = false;
31
35
  let sources;
32
36
  let showContent = true;
33
37
  for (let i = 0; i < args.length; i++) {
@@ -43,10 +47,23 @@ export function parseBrainContextArgs(args) {
43
47
  i = next.nextIndex;
44
48
  if (!raw)
45
49
  return { ok: false, message: '--task ต้องระบุข้อความ task' };
50
+ if (task !== undefined)
51
+ return { ok: false, message: 'ระบุ task ได้ครั้งเดียว: ใช้ --task เพียงครั้งเดียว' };
46
52
  task = raw.trim();
47
53
  if (!task)
48
54
  return { ok: false, message: '--task ต้องระบุข้อความ task' };
49
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
+ }
50
67
  else if (a === '--mode' || a.startsWith('--mode=')) {
51
68
  const next = a === '--mode' ? takeValue(args, i) : undefined;
52
69
  const raw = next ? next.value : inlineValue('--mode', a);
@@ -56,7 +73,10 @@ export function parseBrainContextArgs(args) {
56
73
  return { ok: false, message: `--mode ต้องระบุค่าเป็น ${SEARCH_MODES.join('|')}` };
57
74
  if (!isSearchMode(raw))
58
75
  return { ok: false, message: `--mode ต้องเป็น ${SEARCH_MODES.join('|')}` };
76
+ if (modeSet)
77
+ return { ok: false, message: 'ใช้ --mode เพียงครั้งเดียว' };
59
78
  mode = raw;
79
+ modeSet = true;
60
80
  }
61
81
  else if (a === '--limit' || a.startsWith('--limit=')) {
62
82
  const next = a === '--limit' ? takeValue(args, i) : undefined;
@@ -66,7 +86,10 @@ export function parseBrainContextArgs(args) {
66
86
  const n = parsePositiveInteger(raw);
67
87
  if (n === undefined)
68
88
  return { ok: false, message: '--limit ต้องเป็น integer บวก เช่น 5' };
89
+ if (limitSet)
90
+ return { ok: false, message: 'ใช้ --limit เพียงครั้งเดียว' };
69
91
  limit = n;
92
+ limitSet = true;
70
93
  }
71
94
  else if (a === '--source' || a === '--sources' || a.startsWith('--source=') || a.startsWith('--sources=')) {
72
95
  const next = a === '--source' || a === '--sources' ? takeValue(args, i) : undefined;
@@ -79,7 +102,7 @@ export function parseBrainContextArgs(args) {
79
102
  const bad = requested.filter((s) => !isSearchSource(s));
80
103
  if (bad.length)
81
104
  return { ok: false, message: `--source ต้องเป็น ${SEARCH_SOURCES.join(',')}` };
82
- sources = [...new Set(requested)];
105
+ sources = [...new Set([...(sources ?? []), ...requested])];
83
106
  }
84
107
  else if (a === '--no-content' || a === '--summary') {
85
108
  showContent = false;
@@ -92,8 +115,10 @@ export function parseBrainContextArgs(args) {
92
115
  }
93
116
  }
94
117
  const positionalTask = taskParts.join(' ').trim();
118
+ if (task !== undefined && positionalTask)
119
+ return { ok: false, message: 'ระบุ task ได้ครั้งเดียว: ใช้ positional หรือ --task อย่างใดอย่างหนึ่ง' };
95
120
  const finalTask = (task ?? positionalTask).trim();
96
- return { ok: true, value: { task: finalTask || undefined, mode, limit, sources, showContent } };
121
+ return { ok: true, value: { task: finalTask || undefined, project, mode, limit, sources, showContent } };
97
122
  }
98
123
  export async function inspectBrainContext(options = {}) {
99
124
  const brainPath = options.brainPath;
@@ -129,8 +154,17 @@ export async function inspectBrainContext(options = {}) {
129
154
  warnings: ['Configured second-brain path does not exist.'],
130
155
  };
131
156
  }
132
- const parts = await buildBrainContextParts(brainPath);
157
+ const parts = await buildBrainContextParts(brainPath, {
158
+ taskQuery: options.task,
159
+ cwd: options.cwd,
160
+ projectSlug: options.projectSlug,
161
+ });
133
162
  const context = renderBrainContext(brainPath, parts);
163
+ const activeProject = await resolveVaultProject({
164
+ brainPath,
165
+ cwd: options.cwd,
166
+ slug: options.projectSlug,
167
+ });
134
168
  const sourceReports = parts.map((part) => ({
135
169
  id: part.id,
136
170
  label: part.label,
@@ -174,6 +208,8 @@ export async function inspectBrainContext(options = {}) {
174
208
  return {
175
209
  ok: true,
176
210
  brainPath,
211
+ projectSlug: activeProject?.slug,
212
+ projectRepo: activeProject?.repoPath,
177
213
  context,
178
214
  contextChars: context.length,
179
215
  sources: sourceReports,
@@ -187,6 +223,9 @@ function statusLabel(status) {
187
223
  export function formatBrainContextReport(report, showContent = true) {
188
224
  const lines = ['Sanook brain context'];
189
225
  lines.push(`vault: ${report.brainPath ?? '(not configured)'}`);
226
+ if (report.projectSlug) {
227
+ lines.push(`project: ${report.projectSlug}${report.projectRepo ? ` (${report.projectRepo})` : ''}`);
228
+ }
190
229
  lines.push(`context: ${report.contextChars} chars`);
191
230
  if (report.sources.length) {
192
231
  lines.push('sources:');
@@ -3,6 +3,7 @@ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
3
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { promisify } from 'node:util';
6
+ import { inlineValue, takeValue } from './cli-option-values.js';
6
7
  const execFileAsync = promisify(execFile);
7
8
  const TEMPLATE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'second-brain', 'Templates');
8
9
  const FINAL_HEADINGS = [
@@ -31,28 +32,33 @@ export function parseBrainFinalArgs(args) {
31
32
  else if (arg === '--force') {
32
33
  parsed.force = true;
33
34
  }
34
- else if (arg === '--task') {
35
- const value = args[++i];
35
+ else if (arg === '--task' || arg.startsWith('--task=')) {
36
+ const next = arg === '--task' ? takeValue(args, i) : undefined;
37
+ const value = next ? next.value : inlineValue('--task', arg);
38
+ if (next)
39
+ i = next.nextIndex;
36
40
  if (!value?.trim())
37
41
  return { ok: false, message: 'ต้องระบุค่าให้ --task' };
42
+ if (parsed.task)
43
+ return { ok: false, message: 'ระบุ task ได้ครั้งเดียว: ใช้ --task เพียงครั้งเดียว' };
38
44
  parsed.task = value.trim();
39
45
  }
40
- else if (arg.startsWith('--task=')) {
41
- const value = arg.slice('--task='.length).trim();
42
- if (!value)
43
- return { ok: false, message: 'ต้องระบุค่าให้ --task' };
44
- parsed.task = value;
45
- }
46
46
  else if (arg === '--output') {
47
- const value = args[++i];
47
+ const next = takeValue(args, i);
48
+ const value = next.value;
49
+ i = next.nextIndex;
48
50
  if (!value?.trim())
49
51
  return { ok: false, message: 'ต้องระบุค่าให้ --output' };
52
+ if (parsed.output !== undefined)
53
+ return { ok: false, message: 'ระบุ output ได้ครั้งเดียว: ใช้ --output เพียงครั้งเดียว' };
50
54
  parsed.output = value.trim();
51
55
  }
52
56
  else if (arg.startsWith('--output=')) {
53
57
  const value = arg.slice('--output='.length).trim();
54
58
  if (!value)
55
59
  return { ok: false, message: 'ต้องระบุค่าให้ --output' };
60
+ if (parsed.output !== undefined)
61
+ return { ok: false, message: 'ระบุ output ได้ครั้งเดียว: ใช้ --output เพียงครั้งเดียว' };
56
62
  parsed.output = value;
57
63
  }
58
64
  else if (arg === '--') {
@@ -0,0 +1,73 @@
1
+ import { readFile, stat, writeFile } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { BRAND } from './brand.js';
4
+ import { scaffoldProjectWorkspace } from './project-scaffold.js';
5
+ async function exists(path) {
6
+ try {
7
+ await stat(path);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ /** Wire a freshly scaffolded vault to the current repo: Projects/<slug>/ + SANOOK.md memory stub. */
15
+ export async function linkBrainToProject(options) {
16
+ const cwd = options.cwd ?? process.cwd();
17
+ const brainPath = options.brainPath;
18
+ const title = options.title?.trim() || basename(cwd) || 'Project';
19
+ const today = options.today ?? new Date().toISOString().slice(0, 10);
20
+ const warnings = [];
21
+ const scaffold = await scaffoldProjectWorkspace({
22
+ brainPath,
23
+ title,
24
+ repoPath: cwd,
25
+ today,
26
+ });
27
+ if (!scaffold.ok && scaffold.skipped.length) {
28
+ warnings.push(...scaffold.warnings);
29
+ }
30
+ else if (scaffold.warnings.length) {
31
+ warnings.push(...scaffold.warnings);
32
+ }
33
+ const memoryFile = join(cwd, BRAND.memoryFileName);
34
+ let memoryCreated = false;
35
+ if (!(await exists(memoryFile))) {
36
+ const body = [
37
+ `# ${BRAND.productName} project memory`,
38
+ '',
39
+ `> Linked to second-brain vault: \`${brainPath}\``,
40
+ scaffold.ok || scaffold.slug ? `> Project workspace: \`Projects/${scaffold.slug}/\`` : '',
41
+ '',
42
+ '## Conventions',
43
+ '',
44
+ '- Decisions, gotchas, and preferences discovered in this repo belong here or in the vault.',
45
+ `- Session summaries are auto-written to \`Sessions/\` in the vault on exit (Ctrl+C / /quit).`,
46
+ '',
47
+ ]
48
+ .filter(Boolean)
49
+ .join('\n');
50
+ await writeFile(memoryFile, `${body}\n`, 'utf8');
51
+ memoryCreated = true;
52
+ }
53
+ else {
54
+ try {
55
+ const current = await readFile(memoryFile, 'utf8');
56
+ if (!current.includes(brainPath)) {
57
+ await writeFile(memoryFile, `${current.trimEnd()}\n\n<!-- ${BRAND.productName} -->\nsecond-brain: ${brainPath}\nproject: Projects/${scaffold.slug}/\n`, 'utf8');
58
+ }
59
+ }
60
+ catch {
61
+ warnings.push(`Could not update existing ${BRAND.memoryFileName}`);
62
+ }
63
+ }
64
+ return {
65
+ ok: scaffold.ok || scaffold.skipped.length > 0,
66
+ brainPath,
67
+ projectSlug: scaffold.slug,
68
+ projectRelDir: scaffold.relDir,
69
+ memoryFile,
70
+ memoryCreated,
71
+ warnings,
72
+ };
73
+ }