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,277 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { checkSearchIndexFreshness } from './brain-doctor.js';
4
+ import { runBrainEval } from './brain-eval.js';
5
+ import { INDEX_PATH, sanitizeManifest } from './search/store.js';
6
+ const DAY_MS = 24 * 60 * 60 * 1000;
7
+ const SKIP_DIRS = new Set(['.git', '.obsidian', 'node_modules', 'Shared/Context7-Docs']);
8
+ const ARCHIVE_EXEMPT_PREFIXES = ['Shared/Core-Facts/', 'Shared/Archive/'];
9
+ function sectionBullets(content, heading) {
10
+ const lines = content.split('\n');
11
+ const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${heading.toLowerCase()}`);
12
+ if (start < 0)
13
+ return [];
14
+ const out = [];
15
+ for (const line of lines.slice(start + 1)) {
16
+ if (/^#{1,6}\s+/.test(line.trim()))
17
+ break;
18
+ const trimmed = line.trim();
19
+ if (trimmed.startsWith('- ') && !trimmed.includes('_('))
20
+ out.push(trimmed);
21
+ }
22
+ return out;
23
+ }
24
+ function parseFrontmatterField(content, field) {
25
+ const match = content.match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
26
+ return match?.[1]?.trim().replace(/^["']|["']$/g, '');
27
+ }
28
+ function parseIsoDate(value) {
29
+ if (!value)
30
+ return undefined;
31
+ const ms = Date.parse(value);
32
+ return Number.isFinite(ms) ? ms : undefined;
33
+ }
34
+ function topFolder(relPath) {
35
+ const parts = relPath.split('/');
36
+ return parts.length > 1 ? parts[0] : relPath;
37
+ }
38
+ async function listMarkdown(root) {
39
+ const out = [];
40
+ async function walk(abs, rel) {
41
+ let entries;
42
+ try {
43
+ entries = await readdir(abs, { withFileTypes: true });
44
+ }
45
+ catch {
46
+ return;
47
+ }
48
+ for (const entry of entries) {
49
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
50
+ if (entry.isDirectory()) {
51
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name) || SKIP_DIRS.has(childRel))
52
+ continue;
53
+ await walk(join(abs, entry.name), childRel);
54
+ }
55
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
56
+ out.push(childRel);
57
+ }
58
+ }
59
+ }
60
+ await walk(root, '');
61
+ return out.sort();
62
+ }
63
+ async function readText(path) {
64
+ try {
65
+ return await readFile(path, 'utf8');
66
+ }
67
+ catch {
68
+ return '';
69
+ }
70
+ }
71
+ async function mtimeMs(path) {
72
+ try {
73
+ return (await stat(path)).mtimeMs;
74
+ }
75
+ catch {
76
+ return 0;
77
+ }
78
+ }
79
+ async function readManifest(indexPath) {
80
+ try {
81
+ const raw = JSON.parse(await readFile(indexPath, 'utf8'));
82
+ return sanitizeManifest(raw.manifest);
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ async function collectCounts(brainPath) {
89
+ const markdown = await listMarkdown(brainPath);
90
+ const byTopFolder = {};
91
+ for (const rel of markdown) {
92
+ const folder = topFolder(rel);
93
+ byTopFolder[folder] = (byTopFolder[folder] ?? 0) + 1;
94
+ }
95
+ const inboxPath = join(brainPath, 'Shared', 'Memory-Inbox', 'memory-inbox.md');
96
+ const inbox = await readText(inboxPath);
97
+ const inboxCandidates = sectionBullets(inbox, 'New Candidates').length;
98
+ const inboxNeedsMerge = sectionBullets(inbox, 'Needs Merge').length;
99
+ let sessionNotes = 0;
100
+ try {
101
+ sessionNotes = (await readdir(join(brainPath, 'Sessions'), { withFileTypes: true })).filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== '_Index.md').length;
102
+ }
103
+ catch {
104
+ sessionNotes = 0;
105
+ }
106
+ let contextPacks = 0;
107
+ try {
108
+ contextPacks = (await readdir(join(brainPath, 'Shared', 'Context-Packs'), { withFileTypes: true })).filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== '_Index.md').length;
109
+ }
110
+ catch {
111
+ contextPacks = 0;
112
+ }
113
+ const archivedNotes = markdown.filter((rel) => rel.startsWith('Shared/Archive/')).length;
114
+ return {
115
+ markdownTotal: markdown.length,
116
+ byTopFolder,
117
+ inboxCandidates,
118
+ inboxNeedsMerge,
119
+ sessionNotes,
120
+ contextPacks,
121
+ archivedNotes,
122
+ };
123
+ }
124
+ async function collectStaleNotes(brainPath, nowMs, touchGraceDays) {
125
+ const out = [];
126
+ for (const rel of await listMarkdown(brainPath)) {
127
+ if (ARCHIVE_EXEMPT_PREFIXES.some((prefix) => rel.startsWith(prefix)))
128
+ continue;
129
+ const path = join(brainPath, rel);
130
+ const content = await readText(path);
131
+ const staleAfter = parseFrontmatterField(content, 'stale_after');
132
+ const staleMs = parseIsoDate(staleAfter);
133
+ if (staleMs === undefined || staleMs > nowMs)
134
+ continue;
135
+ const touchMs = await mtimeMs(path);
136
+ const daysPastStale = Math.floor((nowMs - staleMs) / DAY_MS);
137
+ const daysSinceTouch = Math.floor((nowMs - touchMs) / DAY_MS);
138
+ if (daysSinceTouch < touchGraceDays)
139
+ continue;
140
+ out.push({ relPath: rel, staleAfter: staleAfter, daysPastStale, daysSinceTouch });
141
+ }
142
+ return out.sort((a, b) => b.daysPastStale - a.daysPastStale);
143
+ }
144
+ async function collectRetrievalCoverage(brainPath, indexPath, evalReport) {
145
+ let sessionTotal = 0;
146
+ let sessionIndexed = 0;
147
+ const sessionMissing = [];
148
+ try {
149
+ const sessions = (await readdir(join(brainPath, 'Sessions'), { withFileTypes: true }))
150
+ .filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== '_Index.md')
151
+ .map((e) => `Sessions/${e.name}`)
152
+ .sort();
153
+ sessionTotal = sessions.length;
154
+ const manifest = await readManifest(indexPath);
155
+ if (manifest) {
156
+ for (const rel of sessions) {
157
+ if (manifest[rel])
158
+ sessionIndexed++;
159
+ else
160
+ sessionMissing.push(rel);
161
+ }
162
+ }
163
+ }
164
+ catch {
165
+ sessionTotal = 0;
166
+ }
167
+ return {
168
+ sessionIndexed,
169
+ sessionTotal,
170
+ sessionMissing,
171
+ evalPercent: evalReport?.percent ?? 0,
172
+ evalOk: evalReport?.ok ?? false,
173
+ evalScore: evalReport?.score ?? 0,
174
+ evalMaxScore: evalReport?.maxScore ?? 0,
175
+ };
176
+ }
177
+ export async function collectBrainMetrics(options = {}) {
178
+ const brainPath = options.brainPath;
179
+ const emptyCounts = {
180
+ markdownTotal: 0,
181
+ byTopFolder: {},
182
+ inboxCandidates: 0,
183
+ inboxNeedsMerge: 0,
184
+ sessionNotes: 0,
185
+ contextPacks: 0,
186
+ archivedNotes: 0,
187
+ };
188
+ if (!brainPath) {
189
+ return {
190
+ ok: false,
191
+ counts: emptyCounts,
192
+ staleNotes: [],
193
+ indexFreshness: { status: 'fail', message: 'No second-brain path is configured.' },
194
+ retrieval: { sessionIndexed: 0, sessionTotal: 0, sessionMissing: [], evalPercent: 0, evalOk: false, evalScore: 0, evalMaxScore: 0 },
195
+ };
196
+ }
197
+ try {
198
+ if (!(await stat(brainPath)).isDirectory()) {
199
+ return {
200
+ ok: false,
201
+ brainPath,
202
+ counts: emptyCounts,
203
+ staleNotes: [],
204
+ indexFreshness: { status: 'fail', message: 'Configured second-brain path is not a directory.' },
205
+ retrieval: { sessionIndexed: 0, sessionTotal: 0, sessionMissing: [], evalPercent: 0, evalOk: false, evalScore: 0, evalMaxScore: 0 },
206
+ };
207
+ }
208
+ }
209
+ catch {
210
+ return {
211
+ ok: false,
212
+ brainPath,
213
+ counts: emptyCounts,
214
+ staleNotes: [],
215
+ indexFreshness: { status: 'fail', message: 'Configured second-brain path does not exist.' },
216
+ retrieval: { sessionIndexed: 0, sessionTotal: 0, sessionMissing: [], evalPercent: 0, evalOk: false, evalScore: 0, evalMaxScore: 0 },
217
+ };
218
+ }
219
+ const nowMs = options.nowMs ?? Date.now();
220
+ const indexPath = options.indexPath ?? INDEX_PATH;
221
+ const counts = await collectCounts(brainPath);
222
+ const staleNotes = await collectStaleNotes(brainPath, nowMs, options.staleTouchGraceDays ?? 14);
223
+ const indexCheck = await checkSearchIndexFreshness(brainPath, indexPath);
224
+ const evalReport = options.runRetrievalEval === false ? undefined : await runBrainEval({ brainPath, indexPath, runRetrieval: true });
225
+ const retrieval = await collectRetrievalCoverage(brainPath, indexPath, evalReport);
226
+ const indexFreshness = {
227
+ status: indexCheck.status,
228
+ message: indexCheck.message,
229
+ };
230
+ for (const detail of indexCheck.details ?? []) {
231
+ const m = detail.match(/^(\w+)_mtime_ms=(\d+)/);
232
+ if (!m)
233
+ continue;
234
+ const value = Number(m[2]);
235
+ if (m[1] === 'index')
236
+ indexFreshness.indexMtimeMs = value;
237
+ if (m[1] === 'vault_latest')
238
+ indexFreshness.vaultLatestMtimeMs = value;
239
+ }
240
+ const ok = indexCheck.status !== 'fail' &&
241
+ staleNotes.length === 0 &&
242
+ (retrieval.sessionTotal === 0 || retrieval.sessionMissing.length === 0) &&
243
+ (evalReport === undefined || evalReport.ok);
244
+ return { ok, brainPath, counts, staleNotes, indexFreshness, retrieval };
245
+ }
246
+ export function formatBrainMetricsReport(report) {
247
+ const lines = ['Sanook brain metrics', `vault: ${report.brainPath ?? '(not configured)'}`];
248
+ lines.push('counts:');
249
+ lines.push(` markdown: ${report.counts.markdownTotal}`);
250
+ lines.push(` inbox candidates: ${report.counts.inboxCandidates} · needs merge: ${report.counts.inboxNeedsMerge}`);
251
+ lines.push(` sessions: ${report.counts.sessionNotes} · context packs: ${report.counts.contextPacks} · archived: ${report.counts.archivedNotes}`);
252
+ const folders = Object.entries(report.counts.byTopFolder).sort((a, b) => b[1] - a[1]).slice(0, 8);
253
+ if (folders.length) {
254
+ lines.push(' top folders:');
255
+ for (const [folder, count] of folders)
256
+ lines.push(` ${folder}: ${count}`);
257
+ }
258
+ lines.push(`index freshness: [${report.indexFreshness.status.toUpperCase()}] ${report.indexFreshness.message}`);
259
+ lines.push(`retrieval coverage: sessions indexed ${report.retrieval.sessionIndexed}/${report.retrieval.sessionTotal}` +
260
+ (report.retrieval.evalMaxScore ? ` · eval ${report.retrieval.evalScore}/${report.retrieval.evalMaxScore} (${report.retrieval.evalPercent.toFixed(1)}%)` : ''));
261
+ if (report.retrieval.sessionMissing.length) {
262
+ lines.push(' missing from index:');
263
+ for (const rel of report.retrieval.sessionMissing.slice(0, 10))
264
+ lines.push(` - ${rel}`);
265
+ }
266
+ if (report.staleNotes.length) {
267
+ lines.push(`stale notes (${report.staleNotes.length}):`);
268
+ for (const note of report.staleNotes.slice(0, 15)) {
269
+ lines.push(` - ${note.relPath} (stale_after=${note.staleAfter}, +${note.daysPastStale}d, untouched ${note.daysSinceTouch}d)`);
270
+ }
271
+ }
272
+ else {
273
+ lines.push('stale notes: none flagged');
274
+ }
275
+ lines.push(`summary: ${report.ok ? 'healthy' : 'needs attention'}`);
276
+ return lines.join('\n');
277
+ }
@@ -0,0 +1,402 @@
1
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { inlineValue, takeValue } from './cli-option-values.js';
5
+ const TEMPLATE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'second-brain');
6
+ export const BRAIN_NOTE_TYPES = {
7
+ session: {
8
+ destDir: 'Sessions',
9
+ templateCandidates: ['Templates/session.md'],
10
+ defaultRelPath: ({ today, slug }) => `Sessions/${today}-${slug}.md`,
11
+ titlePlaceholders: ['<topic>', '<task/topic>'],
12
+ },
13
+ bug: {
14
+ destDir: 'Bugs',
15
+ templateCandidates: ['Templates/bug.md'],
16
+ defaultRelPath: ({ today, slug }) => `Bugs/${today}-${slug}.md`,
17
+ titlePlaceholders: ['<bug>'],
18
+ },
19
+ handoff: {
20
+ destDir: 'Handoffs',
21
+ templateCandidates: ['Templates/handoff.md'],
22
+ defaultRelPath: ({ slug }) => `Handoffs/${slug}-handoff.md`,
23
+ titlePlaceholders: ['<project>'],
24
+ },
25
+ project: {
26
+ destDir: 'Projects',
27
+ templateCandidates: ['Templates/project-workspace/overview.md', 'Templates/project.md'],
28
+ defaultRelPath: ({ slug }) => `Projects/${slug}/overview.md`,
29
+ titlePlaceholders: ['<Project Name>', '{{TITLE}}'],
30
+ },
31
+ 'golden-case': {
32
+ destDir: 'Acceptance',
33
+ templateCandidates: ['Templates/golden-case.md', 'Acceptance/golden-case-template.md'],
34
+ defaultRelPath: ({ slug }) => `Acceptance/${slug}.md`,
35
+ titlePlaceholders: ['Golden Case Template', '<input>', '<expected>'],
36
+ },
37
+ checklist: {
38
+ destDir: 'Checklists',
39
+ templateCandidates: ['Templates/checklist.md', 'Checklists/preflight-postflight-template.md'],
40
+ defaultRelPath: ({ slug }) => `Checklists/${slug}-checklist.md`,
41
+ titlePlaceholders: ['Preflight / Postflight Checklist Template'],
42
+ },
43
+ };
44
+ export function isBrainNoteType(value) {
45
+ return Object.hasOwn(BRAIN_NOTE_TYPES, value);
46
+ }
47
+ export function parseBrainNewArgs(args) {
48
+ const [typeArg, ...rest] = args;
49
+ if (!typeArg)
50
+ return { ok: false, message: 'ต้องระบุ note type' };
51
+ if (!isBrainNoteType(typeArg)) {
52
+ return { ok: false, message: `ไม่รู้จัก note type: ${typeArg}` };
53
+ }
54
+ const parsed = { type: typeArg, force: false };
55
+ const positional = [];
56
+ for (let i = 0; i < rest.length; i++) {
57
+ const arg = rest[i];
58
+ if (arg === '--force') {
59
+ parsed.force = true;
60
+ }
61
+ else if (arg === '--title' || arg.startsWith('--title=')) {
62
+ const next = arg === '--title' ? takeValue(rest, i) : undefined;
63
+ const value = next ? next.value : inlineValue('--title', arg);
64
+ if (next)
65
+ i = next.nextIndex;
66
+ if (!value?.trim())
67
+ return { ok: false, message: 'ต้องระบุค่าให้ --title' };
68
+ if (parsed.title)
69
+ return { ok: false, message: 'ระบุ title ได้ครั้งเดียว' };
70
+ parsed.title = value.trim();
71
+ }
72
+ else if (arg === '--repo' || arg.startsWith('--repo=')) {
73
+ const next = arg === '--repo' ? takeValue(rest, i) : undefined;
74
+ const value = next ? next.value : inlineValue('--repo', arg);
75
+ if (next)
76
+ i = next.nextIndex;
77
+ if (!value?.trim())
78
+ return { ok: false, message: 'ต้องระบุค่าให้ --repo' };
79
+ if (parsed.repo)
80
+ return { ok: false, message: 'ระบุ repo ได้ครั้งเดียว' };
81
+ parsed.repo = value.trim();
82
+ }
83
+ else if (arg === '--verify' || arg.startsWith('--verify=')) {
84
+ const next = arg === '--verify' ? takeValue(rest, i) : undefined;
85
+ const value = next ? next.value : inlineValue('--verify', arg);
86
+ if (next)
87
+ i = next.nextIndex;
88
+ if (!value?.trim())
89
+ return { ok: false, message: 'ต้องระบุค่าให้ --verify' };
90
+ if (parsed.verify)
91
+ return { ok: false, message: 'ระบุ verify ได้ครั้งเดียว' };
92
+ parsed.verify = value.trim();
93
+ }
94
+ else if (arg === '--default-branch' || arg.startsWith('--default-branch=')) {
95
+ const next = arg === '--default-branch' ? takeValue(rest, i) : undefined;
96
+ const value = next ? next.value : inlineValue('--default-branch', arg);
97
+ if (next)
98
+ i = next.nextIndex;
99
+ if (!value?.trim())
100
+ return { ok: false, message: 'ต้องระบุค่าให้ --default-branch' };
101
+ if (parsed.defaultBranch)
102
+ return { ok: false, message: 'ระบุ default-branch ได้ครั้งเดียว' };
103
+ parsed.defaultBranch = value.trim();
104
+ }
105
+ else if (arg === '--output') {
106
+ const next = takeValue(rest, i);
107
+ const value = next.value;
108
+ i = next.nextIndex;
109
+ if (!value?.trim())
110
+ return { ok: false, message: 'ต้องระบุค่าให้ --output' };
111
+ if (parsed.output !== undefined)
112
+ return { ok: false, message: 'ระบุ output ได้ครั้งเดียว' };
113
+ parsed.output = value.trim();
114
+ }
115
+ else if (arg.startsWith('--output=')) {
116
+ const value = arg.slice('--output='.length).trim();
117
+ if (!value)
118
+ return { ok: false, message: 'ต้องระบุค่าให้ --output' };
119
+ if (parsed.output !== undefined)
120
+ return { ok: false, message: 'ระบุ output ได้ครั้งเดียว' };
121
+ parsed.output = value;
122
+ }
123
+ else if (arg === '--') {
124
+ positional.push(...rest.slice(i + 1));
125
+ break;
126
+ }
127
+ else if (arg.startsWith('-')) {
128
+ return { ok: false, message: `ไม่รู้จัก option: ${arg}` };
129
+ }
130
+ else {
131
+ positional.push(arg);
132
+ }
133
+ }
134
+ const positionalTitle = positional.join(' ').trim();
135
+ if (parsed.title && positionalTitle)
136
+ return { ok: false, message: 'ระบุ title ได้ครั้งเดียว: ใช้ positional หรือ --title อย่างใดอย่างหนึ่ง' };
137
+ if (positionalTitle)
138
+ parsed.title = positionalTitle;
139
+ return { ok: true, value: parsed };
140
+ }
141
+ export function inferParentRelPath(relPath) {
142
+ const normalized = relPath.replace(/\\/g, '/');
143
+ const parts = normalized.split('/');
144
+ parts.pop();
145
+ if (!parts.length)
146
+ return 'Home';
147
+ return `${parts.join('/')}/_Index`;
148
+ }
149
+ export function destinationIndexRelPath(type, relPath) {
150
+ const config = BRAIN_NOTE_TYPES[type];
151
+ const normalized = relPath.replace(/\\/g, '/');
152
+ if (normalized.startsWith(`${config.destDir}/`) && normalized.includes('/')) {
153
+ const nested = normalized.slice(config.destDir.length + 1);
154
+ if (nested.includes('/')) {
155
+ const subDir = nested.split('/')[0];
156
+ return `${config.destDir}/${subDir}/_Index`;
157
+ }
158
+ }
159
+ return `${config.destDir}/_Index`;
160
+ }
161
+ export function validateNoteOutputPath(type, relPath) {
162
+ const config = BRAIN_NOTE_TYPES[type];
163
+ const normalized = relPath.replace(/\\/g, '/');
164
+ if (normalized.endsWith('/_Index.md') || normalized === '_Index.md') {
165
+ return { ok: false, message: 'Cannot create a note at an _Index.md path.' };
166
+ }
167
+ if (!normalized.startsWith(`${config.destDir}/`)) {
168
+ return { ok: false, message: `${type} notes must stay under ${config.destDir}/.` };
169
+ }
170
+ return { ok: true };
171
+ }
172
+ export function instantiateNoteTemplate(raw, options) {
173
+ let content = raw.replaceAll('YYYY-MM-DD', options.today).replaceAll('{{DATE}}', options.today);
174
+ const config = BRAIN_NOTE_TYPES[options.type];
175
+ for (const placeholder of config.titlePlaceholders) {
176
+ content = content.replaceAll(placeholder, options.title);
177
+ }
178
+ content = content.replace(/^# .+$/m, (heading) => {
179
+ if (heading.includes(options.title))
180
+ return heading;
181
+ if (options.type === 'session')
182
+ return `# ${options.today} — ${options.title}`;
183
+ if (options.type === 'bug')
184
+ return `# ${options.today} — ${options.title}`;
185
+ if (options.type === 'handoff')
186
+ return `# ${options.title} — Handoff`;
187
+ if (options.type === 'project')
188
+ return `# ${options.title}`;
189
+ if (options.type === 'golden-case')
190
+ return `# ${options.title}`;
191
+ if (options.type === 'checklist')
192
+ return `# ${options.title}`;
193
+ return heading;
194
+ });
195
+ const parentValue = `"[[${options.parent}]]"`;
196
+ if (/^parent:/m.test(content)) {
197
+ content = content.replace(/^parent:.*$/m, `parent: ${parentValue}`);
198
+ }
199
+ else if (/^---[\s\S]*?---/m.test(content)) {
200
+ content = content.replace(/^---\n/m, `---\nparent: ${parentValue}\n`);
201
+ }
202
+ const upLink = `up:: [[${options.parent}]]`;
203
+ if (content.includes('up:: [['))
204
+ content = content.replace(/^up:: \[\[[^\]]+\]\]\s*$/m, upLink);
205
+ else
206
+ content = `${content.trimEnd()}\n\n${upLink}\n`;
207
+ if (options.type === 'golden-case')
208
+ content = content.replace(/^note_type:\s*template/m, 'note_type: golden-case');
209
+ if (options.type === 'checklist')
210
+ content = content.replace(/^note_type:\s*template/m, 'note_type: checklist');
211
+ content = content.replace(/^tags: \[template,/m, 'tags: [');
212
+ return content;
213
+ }
214
+ export async function createBrainNote(options) {
215
+ const title = (options.title ?? defaultTitleForType(options.type)).trim() || defaultTitleForType(options.type);
216
+ const warnings = [];
217
+ if (!options.brainPath) {
218
+ return { ok: false, title, indexed: false, warnings: ['No second-brain path is configured.'] };
219
+ }
220
+ const brainPath = resolve(options.brainPath);
221
+ if (!(await pathExistsAsDir(brainPath))) {
222
+ return { ok: false, brainPath, title, indexed: false, warnings: ['Configured second-brain path does not exist or is not a directory.'] };
223
+ }
224
+ const today = options.today ?? new Date().toISOString().slice(0, 10);
225
+ if (options.type === 'project' && !options.output) {
226
+ const { scaffoldProjectWorkspace } = await import('./project-scaffold.js');
227
+ const scaffold = await scaffoldProjectWorkspace({
228
+ brainPath,
229
+ title,
230
+ repoPath: options.repo,
231
+ verify: options.verify,
232
+ defaultBranch: options.defaultBranch,
233
+ today,
234
+ force: options.force,
235
+ });
236
+ return {
237
+ ok: scaffold.ok,
238
+ brainPath,
239
+ type: 'project',
240
+ title: scaffold.title,
241
+ template: 'Templates/project-workspace/*',
242
+ path: join(brainPath, scaffold.relDir),
243
+ relPath: `${scaffold.relDir}/overview.md`,
244
+ indexed: scaffold.indexed,
245
+ warnings: [
246
+ ...scaffold.warnings,
247
+ ...(scaffold.created.length ? [`created ${scaffold.created.length} file(s) under ${scaffold.relDir}/`] : []),
248
+ ],
249
+ };
250
+ }
251
+ const slug = slugify(title);
252
+ const config = BRAIN_NOTE_TYPES[options.type];
253
+ const relPath = normalizeRelPath(options.output ?? config.defaultRelPath({ today, slug }));
254
+ const valid = validateNoteOutputPath(options.type, relPath);
255
+ if (!valid.ok) {
256
+ return { ok: false, brainPath, type: options.type, title, indexed: false, warnings: [valid.message] };
257
+ }
258
+ const destIndexRel = destinationIndexRelPath(options.type, relPath);
259
+ const indexPath = join(brainPath, `${destIndexRel}.md`);
260
+ if (!(await fileExists(indexPath))) {
261
+ warnings.push(`Destination index missing: ${destIndexRel}.md`);
262
+ }
263
+ else {
264
+ await readFile(indexPath, 'utf8');
265
+ }
266
+ const outputPath = resolve(isAbsolute(relPath) ? relPath : join(brainPath, relPath));
267
+ const insideVault = !relative(brainPath, outputPath).startsWith('..') && !isAbsolute(relative(brainPath, outputPath));
268
+ if (!insideVault) {
269
+ return { ok: false, brainPath, type: options.type, title, indexed: false, warnings: ['--output must stay inside the configured second-brain vault.'] };
270
+ }
271
+ if ((await fileExists(outputPath)) && !options.force) {
272
+ return {
273
+ ok: false,
274
+ brainPath,
275
+ type: options.type,
276
+ title,
277
+ path: outputPath,
278
+ relPath,
279
+ indexed: false,
280
+ warnings: ['Note already exists. Re-run with --force or choose --output.'],
281
+ };
282
+ }
283
+ const template = await readNoteTemplate(brainPath, options.type);
284
+ if (!template.path) {
285
+ return { ok: false, brainPath, type: options.type, title, indexed: false, warnings: [template.message] };
286
+ }
287
+ const parent = destinationIndexRelPath(options.type, relPath);
288
+ const content = instantiateNoteTemplate(template.content, { today, title, parent, type: options.type });
289
+ await mkdir(dirname(outputPath), { recursive: true });
290
+ await writeFile(outputPath, content, 'utf8');
291
+ const indexed = await maybeAppendDestinationIndex(brainPath, destIndexRel, relPath, title, options.type);
292
+ return {
293
+ ok: true,
294
+ brainPath,
295
+ type: options.type,
296
+ title,
297
+ template: template.path,
298
+ path: outputPath,
299
+ relPath,
300
+ indexed,
301
+ warnings,
302
+ };
303
+ }
304
+ export function formatBrainNewReport(report) {
305
+ const lines = ['Sanook brain new'];
306
+ lines.push(`vault: ${report.brainPath ?? '(not configured)'}`);
307
+ if (report.type)
308
+ lines.push(`type: ${report.type}`);
309
+ lines.push(`title: ${report.title}`);
310
+ if (report.template)
311
+ lines.push(`template: ${report.template}`);
312
+ if (report.path)
313
+ lines.push(`created: ${report.path}`);
314
+ if (report.relPath)
315
+ lines.push(`link: [[${report.relPath.replace(/\.md$/i, '')}]]`);
316
+ if (report.indexed)
317
+ lines.push('index: updated');
318
+ for (const warning of report.warnings)
319
+ lines.push(`warning: ${warning}`);
320
+ return lines.join('\n');
321
+ }
322
+ function defaultTitleForType(type) {
323
+ switch (type) {
324
+ case 'session':
325
+ return 'session note';
326
+ case 'bug':
327
+ return 'bug report';
328
+ case 'handoff':
329
+ return 'project handoff';
330
+ case 'project':
331
+ return 'project overview';
332
+ case 'golden-case':
333
+ return 'golden case';
334
+ case 'checklist':
335
+ return 'preflight postflight';
336
+ }
337
+ }
338
+ async function readNoteTemplate(brainPath, type) {
339
+ for (const candidate of BRAIN_NOTE_TYPES[type].templateCandidates) {
340
+ const vaultPath = join(brainPath, candidate);
341
+ const fromVault = await readText(vaultPath);
342
+ if (fromVault)
343
+ return { path: candidate, content: fromVault, message: '' };
344
+ const bundledPath = join(TEMPLATE_ROOT, candidate);
345
+ const fromBundled = await readText(bundledPath);
346
+ if (fromBundled)
347
+ return { path: candidate, content: fromBundled, message: '' };
348
+ }
349
+ return { content: '', message: `No template found for ${type}.` };
350
+ }
351
+ async function maybeAppendDestinationIndex(brainPath, indexRel, noteRel, title, type) {
352
+ const indexPath = join(brainPath, `${indexRel}.md`);
353
+ const content = await readText(indexPath);
354
+ if (!content)
355
+ return false;
356
+ const note = noteRel.replace(/\.md$/i, '');
357
+ const link = `[[${note}]]`;
358
+ if (content.includes(link))
359
+ return false;
360
+ const line = `- ${link} — ${type}: ${title}`;
361
+ const marker = '\nup:: [[Home]]';
362
+ const next = content.includes(marker) ? content.replace(marker, `\n${line}\n${marker}`) : `${content.trimEnd()}\n${line}\n`;
363
+ await writeFile(indexPath, next, 'utf8');
364
+ return true;
365
+ }
366
+ function slugify(value) {
367
+ const slug = value
368
+ .normalize('NFKD')
369
+ .toLowerCase()
370
+ .replace(/[^\p{Letter}\p{Number}]+/gu, '-')
371
+ .replace(/^-+|-+$/g, '')
372
+ .slice(0, 80)
373
+ .replace(/-+$/g, '');
374
+ return slug || 'note';
375
+ }
376
+ function normalizeRelPath(path) {
377
+ return path.replace(/\\/g, '/').replace(/^\/+/, '');
378
+ }
379
+ async function pathExistsAsDir(path) {
380
+ try {
381
+ return (await stat(path)).isDirectory();
382
+ }
383
+ catch {
384
+ return false;
385
+ }
386
+ }
387
+ async function fileExists(path) {
388
+ try {
389
+ return (await stat(path)).isFile();
390
+ }
391
+ catch {
392
+ return false;
393
+ }
394
+ }
395
+ async function readText(path) {
396
+ try {
397
+ return await readFile(path, 'utf8');
398
+ }
399
+ catch {
400
+ return '';
401
+ }
402
+ }