sanook-cli 0.5.2 → 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 (119) hide show
  1. package/CHANGELOG.md +91 -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 +623 -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-metrics.js +277 -0
  10. package/dist/brain-new.js +402 -0
  11. package/dist/brain-pack.js +210 -0
  12. package/dist/brain-repair.js +280 -0
  13. package/dist/brain.js +3 -0
  14. package/dist/cli-args.js +47 -9
  15. package/dist/cli-option-values.js +1 -1
  16. package/dist/clipboard.js +65 -0
  17. package/dist/commands.js +94 -14
  18. package/dist/config.js +31 -5
  19. package/dist/context-pack.js +145 -0
  20. package/dist/dashboard/api-helpers.js +87 -0
  21. package/dist/dashboard/server.js +179 -0
  22. package/dist/dashboard/static/app.js +277 -0
  23. package/dist/dashboard/static/index.html +39 -0
  24. package/dist/dashboard/static/styles.css +85 -0
  25. package/dist/diff.js +10 -2
  26. package/dist/gateway/auth.js +14 -3
  27. package/dist/gateway/deliver.js +45 -3
  28. package/dist/gateway/doctor.js +456 -0
  29. package/dist/gateway/email.js +30 -1
  30. package/dist/gateway/ledger.js +20 -1
  31. package/dist/gateway/session.js +30 -11
  32. package/dist/hotkeys.js +21 -0
  33. package/dist/i18n/en.js +98 -0
  34. package/dist/i18n/index.js +19 -0
  35. package/dist/i18n/th.js +98 -0
  36. package/dist/i18n/types.js +1 -0
  37. package/dist/insights-args.js +24 -4
  38. package/dist/knowledge.js +55 -29
  39. package/dist/loop.js +34 -5
  40. package/dist/mcp-hub.js +33 -0
  41. package/dist/mcp-registry.js +153 -9
  42. package/dist/mcp-risk.js +71 -0
  43. package/dist/mcp.js +77 -5
  44. package/dist/memory-log.js +90 -0
  45. package/dist/memory-store.js +37 -1
  46. package/dist/memory.js +51 -7
  47. package/dist/model-picker.js +58 -0
  48. package/dist/orchestrate.js +7 -5
  49. package/dist/plan-handoff.js +17 -0
  50. package/dist/polyglot.js +162 -0
  51. package/dist/process-runner.js +96 -0
  52. package/dist/project-init.js +91 -0
  53. package/dist/project-registry.js +143 -0
  54. package/dist/project-scaffold.js +124 -0
  55. package/dist/prompt-size.js +155 -0
  56. package/dist/providers/codex-login.js +138 -0
  57. package/dist/providers/codex.js +20 -8
  58. package/dist/providers/keys.js +21 -0
  59. package/dist/providers/models.js +1 -1
  60. package/dist/search/cli.js +9 -1
  61. package/dist/search/embedding-config.js +22 -0
  62. package/dist/search/engine.js +2 -13
  63. package/dist/search/indexer.js +10 -10
  64. package/dist/session-distill.js +84 -0
  65. package/dist/session.js +1 -11
  66. package/dist/skill-install.js +24 -1
  67. package/dist/skills.js +33 -0
  68. package/dist/slash-completion.js +155 -0
  69. package/dist/support-dump.js +31 -0
  70. package/dist/tool-catalog.js +59 -0
  71. package/dist/tools/index.js +5 -0
  72. package/dist/tools/permission.js +82 -16
  73. package/dist/tools/polyglot.js +126 -0
  74. package/dist/tools/sandbox.js +38 -13
  75. package/dist/tools/search.js +9 -2
  76. package/dist/tools/task.js +22 -2
  77. package/dist/tools/timeout.js +7 -5
  78. package/dist/tools/web-fetch-tool.js +33 -0
  79. package/dist/turn-retrieval.js +83 -0
  80. package/dist/ui/app.js +835 -29
  81. package/dist/ui/banner.js +78 -4
  82. package/dist/ui/markdown.js +122 -0
  83. package/dist/ui/overlay.js +496 -0
  84. package/dist/ui/queue.js +23 -0
  85. package/dist/ui/render.js +20 -1
  86. package/dist/ui/session-panel.js +115 -0
  87. package/dist/ui/setup-providers.js +40 -0
  88. package/dist/ui/setup.js +163 -50
  89. package/dist/ui/status.js +142 -0
  90. package/dist/ui/thinking-panel.js +36 -0
  91. package/dist/ui/tool-trail.js +97 -0
  92. package/dist/ui/transcript.js +26 -0
  93. package/dist/ui/useBusyElapsed.js +19 -0
  94. package/dist/ui/useEditor.js +144 -5
  95. package/dist/ui/useGitBranch.js +57 -0
  96. package/dist/update.js +32 -6
  97. package/dist/web-fetch.js +637 -0
  98. package/dist/web-surface.js +190 -0
  99. package/package.json +2 -2
  100. package/second-brain/Projects/_Index.md +17 -4
  101. package/second-brain/Projects/sanook-cli/_Index.md +7 -3
  102. package/second-brain/Projects/sanook-cli/context.md +35 -0
  103. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  104. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  105. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  106. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +52 -11
  107. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  108. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  109. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  110. package/second-brain/Research/_Index.md +2 -0
  111. package/second-brain/Shared/Operating-State/current-state.md +14 -23
  112. package/second-brain/Shared/Tech-Standards/_Index.md +2 -0
  113. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  114. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  115. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  116. package/second-brain/Templates/project-workspace/context.md +28 -0
  117. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  118. package/second-brain/Templates/project-workspace/overview.md +39 -0
  119. package/second-brain/Templates/project-workspace/repo.md +33 -0
@@ -0,0 +1,210 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ const CONTEXT_PACKS_DIR = join('Shared', 'Context-Packs');
4
+ export function parseBrainPackArgs(args) {
5
+ const [action, ...rest] = args;
6
+ if (action === 'list') {
7
+ if (rest.length)
8
+ return { ok: false, message: `ไม่รู้จัก option: ${rest.join(' ')}` };
9
+ return { ok: true, action: 'list' };
10
+ }
11
+ if (action === 'show') {
12
+ const name = rest.join(' ').trim();
13
+ if (!name)
14
+ return { ok: false, message: 'ต้องระบุชื่อ context pack' };
15
+ return { ok: true, action: 'show', name };
16
+ }
17
+ if (!action)
18
+ return { ok: false, message: 'ต้องระบุ subcommand: list หรือ show <name>' };
19
+ return { ok: false, message: `ไม่รู้จัก subcommand: ${action}` };
20
+ }
21
+ export function normalizePackName(name) {
22
+ return name
23
+ .trim()
24
+ .replace(/^Shared\/Context-Packs\//i, '')
25
+ .replace(/\.md$/i, '');
26
+ }
27
+ export function extractPackDescription(indexContent, packName) {
28
+ const link = `[[Shared/Context-Packs/${packName}]]`;
29
+ for (const line of indexContent.split('\n')) {
30
+ const trimmed = line.trim();
31
+ if (!trimmed.startsWith('- ') || !trimmed.includes(link))
32
+ continue;
33
+ const dash = trimmed.indexOf('—');
34
+ if (dash >= 0)
35
+ return trimmed.slice(dash + 1).trim();
36
+ const hyphen = trimmed.indexOf(' - ');
37
+ if (hyphen >= 0)
38
+ return trimmed.slice(hyphen + 3).trim();
39
+ }
40
+ return undefined;
41
+ }
42
+ export function extractBlockquotePurpose(content) {
43
+ const body = content.replace(/^---[\s\S]*?---\n?/, '');
44
+ const match = body.match(/^>\s*(.+)$/m);
45
+ return match?.[1]?.trim() ?? '';
46
+ }
47
+ export function extractSectionBullets(content, heading) {
48
+ const lines = content.split('\n');
49
+ const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${heading.toLowerCase()}`);
50
+ if (start < 0)
51
+ return [];
52
+ const out = [];
53
+ for (const line of lines.slice(start + 1)) {
54
+ if (/^#{1,6}\s+/.test(line.trim()))
55
+ break;
56
+ const trimmed = line.trim();
57
+ if (trimmed.startsWith('- '))
58
+ out.push(trimmed.slice(2).trim());
59
+ else {
60
+ const numbered = trimmed.match(/^\d+\.\s+(.+)$/);
61
+ if (numbered)
62
+ out.push(numbered[1].trim());
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+ export function extractWikiLinks(value) {
68
+ return [...value.matchAll(/\[\[([^\]]+)\]\]/g)].map((match) => match[1].trim());
69
+ }
70
+ export function buildContextPackSummary(packName, content, indexContent) {
71
+ const relPath = `${CONTEXT_PACKS_DIR}/${packName}.md`;
72
+ const link = `[[Shared/Context-Packs/${packName}]]`;
73
+ return {
74
+ name: packName,
75
+ relPath,
76
+ description: extractPackDescription(indexContent, packName) || extractBlockquotePurpose(content),
77
+ indexed: indexContent.includes(link),
78
+ hasLoadOrder: content.includes('## Load Order'),
79
+ hasDoneCriteria: content.includes('## Done Criteria'),
80
+ };
81
+ }
82
+ export function buildContextPackDetail(packName, content, indexContent) {
83
+ const summary = buildContextPackSummary(packName, content, indexContent);
84
+ const loadOrder = extractSectionBullets(content, 'Load Order');
85
+ const doneCriteria = extractSectionBullets(content, 'Done Criteria');
86
+ const roleLines = extractSectionBullets(content, 'Required Role');
87
+ return {
88
+ ...summary,
89
+ useCase: extractBlockquotePurpose(content),
90
+ loadOrder,
91
+ doneCriteria,
92
+ requiredRole: roleLines.length ? roleLines.join(' · ') : undefined,
93
+ sources: uniqueSorted(loadOrder.flatMap(extractWikiLinks)),
94
+ };
95
+ }
96
+ export async function listContextPacks(brainPath) {
97
+ const warnings = [];
98
+ if (!(await pathExistsAsDir(brainPath))) {
99
+ return { ok: false, packs: [], warnings: ['Configured second-brain path does not exist or is not a directory.'] };
100
+ }
101
+ const dir = join(brainPath, CONTEXT_PACKS_DIR);
102
+ const indexPath = join(dir, '_Index.md');
103
+ const indexContent = await readText(indexPath);
104
+ if (!indexContent)
105
+ warnings.push('Context-Packs index is missing.');
106
+ let entries;
107
+ try {
108
+ entries = await readdir(dir, { withFileTypes: true });
109
+ }
110
+ catch {
111
+ return { ok: false, brainPath, packs: [], warnings: ['Context-Packs directory is missing.'] };
112
+ }
113
+ const packs = entries
114
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name !== '_Index.md')
115
+ .map((entry) => normalizePackName(entry.name))
116
+ .sort((a, b) => a.localeCompare(b));
117
+ const summaries = [];
118
+ for (const name of packs) {
119
+ const content = await readText(join(dir, `${name}.md`));
120
+ if (!content) {
121
+ warnings.push(`Unreadable context pack: ${name}.md`);
122
+ continue;
123
+ }
124
+ summaries.push(buildContextPackSummary(name, content, indexContent));
125
+ }
126
+ return { ok: true, brainPath, packs: summaries, warnings };
127
+ }
128
+ export async function showContextPack(brainPath, rawName) {
129
+ const warnings = [];
130
+ const name = normalizePackName(rawName);
131
+ if (!(await pathExistsAsDir(brainPath))) {
132
+ return { ok: false, warnings: ['Configured second-brain path does not exist or is not a directory.'] };
133
+ }
134
+ const dir = join(brainPath, CONTEXT_PACKS_DIR);
135
+ const packPath = join(dir, `${name}.md`);
136
+ const content = await readText(packPath);
137
+ if (!content) {
138
+ return { ok: false, brainPath, warnings: [`Context pack not found: ${name}`] };
139
+ }
140
+ const indexContent = await readText(join(dir, '_Index.md'));
141
+ if (!indexContent)
142
+ warnings.push('Context-Packs index is missing.');
143
+ const pack = buildContextPackDetail(name, content, indexContent);
144
+ if (!pack.indexed)
145
+ warnings.push(`${name} is not linked from Context-Packs/_Index.md.`);
146
+ return { ok: true, brainPath, pack, warnings };
147
+ }
148
+ export function formatBrainPackListReport(report) {
149
+ const lines = ['Sanook brain pack list', `vault: ${report.brainPath ?? '(not configured)'}`];
150
+ lines.push(`packs: ${report.packs.length}`);
151
+ for (const pack of report.packs) {
152
+ const flags = [
153
+ pack.indexed ? 'indexed' : 'missing-index-link',
154
+ pack.hasLoadOrder ? 'load-order' : 'no-load-order',
155
+ pack.hasDoneCriteria ? 'done-criteria' : 'no-done-criteria',
156
+ ].join(', ');
157
+ lines.push(`- ${pack.name} — ${pack.description || '(no description)'} [${flags}]`);
158
+ lines.push(` ${pack.relPath}`);
159
+ }
160
+ for (const warning of report.warnings)
161
+ lines.push(`warning: ${warning}`);
162
+ return lines.join('\n');
163
+ }
164
+ export function formatBrainPackShowReport(report) {
165
+ const lines = ['Sanook brain pack show', `vault: ${report.brainPath ?? '(not configured)'}`];
166
+ if (!report.pack) {
167
+ for (const warning of report.warnings)
168
+ lines.push(`warning: ${warning}`);
169
+ return lines.join('\n');
170
+ }
171
+ const pack = report.pack;
172
+ lines.push(`name: ${pack.name}`);
173
+ lines.push(`path: ${pack.relPath}`);
174
+ lines.push(`use-case: ${pack.useCase || '(none)'}`);
175
+ if (pack.requiredRole)
176
+ lines.push(`role: ${pack.requiredRole}`);
177
+ lines.push('load-order:');
178
+ for (const item of pack.loadOrder.length ? pack.loadOrder : ['(missing ## Load Order section)'])
179
+ lines.push(` - ${item}`);
180
+ lines.push('done-criteria:');
181
+ for (const item of pack.doneCriteria.length ? pack.doneCriteria : ['(missing ## Done Criteria section)'])
182
+ lines.push(` - ${item}`);
183
+ if (pack.sources.length) {
184
+ lines.push('sources:');
185
+ for (const source of pack.sources)
186
+ lines.push(` - [[${source}]]`);
187
+ }
188
+ for (const warning of report.warnings)
189
+ lines.push(`warning: ${warning}`);
190
+ return lines.join('\n');
191
+ }
192
+ async function pathExistsAsDir(path) {
193
+ try {
194
+ return (await stat(path)).isDirectory();
195
+ }
196
+ catch {
197
+ return false;
198
+ }
199
+ }
200
+ async function readText(path) {
201
+ try {
202
+ return await readFile(path, 'utf8');
203
+ }
204
+ catch {
205
+ return '';
206
+ }
207
+ }
208
+ function uniqueSorted(values) {
209
+ return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b));
210
+ }
@@ -0,0 +1,280 @@
1
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { FOLDERS } from './brain.js';
4
+ import { checkBrainFolders } from './brain-doctor.js';
5
+ import { inferParentRelPath } from './brain-new.js';
6
+ import { extractPackDescription, normalizePackName as normalizeContextPackName } from './brain-pack.js';
7
+ const ROOT_FILES_WITHOUT_PARENT = new Set(['Home.md', 'README.md', 'CLAUDE.md', 'GEMINI.md', 'AGENTS.md', 'SANOOK.md']);
8
+ const ROOT_FILES_WITHOUT_UP = ROOT_FILES_WITHOUT_PARENT;
9
+ const SKIP_DIRS = new Set(['.git', '.obsidian', 'node_modules', 'Shared/Context7-Docs']);
10
+ const PURPOSE_PLACEHOLDER = '> _(purpose pending — fill in)_\n\n';
11
+ export function parseBrainRepairArgs(args) {
12
+ for (const arg of args) {
13
+ if (arg === '--dry-run')
14
+ continue;
15
+ return { ok: false, message: `ไม่รู้จัก option: ${arg}` };
16
+ }
17
+ return { ok: true, dryRun: args.includes('--dry-run') };
18
+ }
19
+ export function planPurposeFix(relPath, content) {
20
+ if (/^>\s+/m.test(content))
21
+ return undefined;
22
+ return {
23
+ id: 'repair.purpose-blockquote',
24
+ relPath,
25
+ kind: 'markdown',
26
+ message: 'Add purpose blockquote after frontmatter.',
27
+ };
28
+ }
29
+ export function planParentFix(relPath, content) {
30
+ if (ROOT_FILES_WITHOUT_PARENT.has(relPath))
31
+ return undefined;
32
+ if (/^---[\s\S]*?^parent:/m.test(content))
33
+ return undefined;
34
+ const parent = inferParentRelPath(relPath);
35
+ return {
36
+ id: 'repair.parent-frontmatter',
37
+ relPath,
38
+ kind: 'markdown',
39
+ message: `Add parent frontmatter -> [[${parent}]]`,
40
+ };
41
+ }
42
+ export function planUpLinkFix(relPath, content) {
43
+ if (ROOT_FILES_WITHOUT_UP.has(relPath))
44
+ return undefined;
45
+ if (content.includes('up:: [['))
46
+ return undefined;
47
+ const parent = inferParentRelPath(relPath);
48
+ return {
49
+ id: 'repair.up-link',
50
+ relPath,
51
+ kind: 'markdown',
52
+ message: `Append up:: [[${parent}]]`,
53
+ };
54
+ }
55
+ export function applyPurposeFix(content) {
56
+ if (/^>\s+/m.test(content))
57
+ return content;
58
+ const match = content.match(/^---[\s\S]*?---\n?/);
59
+ if (match)
60
+ return content.replace(match[0], `${match[0]}${PURPOSE_PLACEHOLDER}`);
61
+ return `${PURPOSE_PLACEHOLDER}${content}`;
62
+ }
63
+ export function applyParentFix(content, relPath) {
64
+ if (/^---[\s\S]*?^parent:/m.test(content))
65
+ return content;
66
+ const parent = inferParentRelPath(relPath);
67
+ const parentLine = `parent: "[[${parent}]]"`;
68
+ const match = content.match(/^---\n([\s\S]*?)---\n/);
69
+ if (!match)
70
+ return `---\n${parentLine}\n---\n\n${content}`;
71
+ return content.replace(/^---\n/, `---\n${parentLine}\n`);
72
+ }
73
+ export function applyUpLinkFix(content, relPath) {
74
+ if (content.includes('up:: [['))
75
+ return content;
76
+ const parent = inferParentRelPath(relPath);
77
+ return `${content.trimEnd()}\n\nup:: [[${parent}]]\n`;
78
+ }
79
+ export function applyMarkdownRepairs(relPath, content) {
80
+ let next = content;
81
+ const applied = [];
82
+ if (planPurposeFix(relPath, next)) {
83
+ next = applyPurposeFix(next);
84
+ applied.push('purpose-blockquote');
85
+ }
86
+ if (planParentFix(relPath, next)) {
87
+ next = applyParentFix(next, relPath);
88
+ applied.push('parent-frontmatter');
89
+ }
90
+ if (planUpLinkFix(relPath, next)) {
91
+ next = applyUpLinkFix(next, relPath);
92
+ applied.push('up-link');
93
+ }
94
+ return { content: next, applied };
95
+ }
96
+ export function planContextPackIndexFix(packName, indexContent) {
97
+ const link = `[[Shared/Context-Packs/${packName}]]`;
98
+ if (indexContent.includes(link))
99
+ return undefined;
100
+ return {
101
+ id: 'repair.context-pack-index',
102
+ relPath: 'Shared/Context-Packs/_Index.md',
103
+ kind: 'index',
104
+ message: `Link ${packName} from Context-Packs/_Index.md`,
105
+ };
106
+ }
107
+ export function applyContextPackIndexFix(indexContent, packName, packContent) {
108
+ const link = `[[Shared/Context-Packs/${packName}]]`;
109
+ if (indexContent.includes(link))
110
+ return indexContent;
111
+ const description = extractBlockquotePurpose(packContent) || 'context pack';
112
+ const line = `- ${link} — ${description}`;
113
+ const marker = '\n## Use Rule';
114
+ if (indexContent.includes(marker))
115
+ return indexContent.replace(marker, `\n${line}\n${marker}`);
116
+ const upMarker = '\nup:: [[Shared/_Index]]';
117
+ if (indexContent.includes(upMarker))
118
+ return indexContent.replace(upMarker, `\n${line}\n${upMarker}`);
119
+ return `${indexContent.trimEnd()}\n${line}\n`;
120
+ }
121
+ export async function collectRepairActions(brainPath, expectedFolders = FOLDERS.map((folder) => folder.dir)) {
122
+ const actions = [];
123
+ const folderCheck = await checkBrainFolders(brainPath, expectedFolders);
124
+ for (const missing of folderCheck.details ?? []) {
125
+ actions.push({
126
+ id: 'repair.missing-folder',
127
+ relPath: missing,
128
+ kind: 'folder',
129
+ message: `Create missing folder: ${missing}`,
130
+ });
131
+ }
132
+ for (const relPath of await listMarkdown(brainPath)) {
133
+ const content = await readText(join(brainPath, relPath));
134
+ for (const plan of [planPurposeFix, planParentFix, planUpLinkFix]) {
135
+ const action = plan(relPath, content);
136
+ if (action)
137
+ actions.push(action);
138
+ }
139
+ }
140
+ const packsDir = join(brainPath, 'Shared', 'Context-Packs');
141
+ const indexPath = join(packsDir, '_Index.md');
142
+ const indexContent = await readText(indexPath);
143
+ let entries;
144
+ try {
145
+ entries = await readdir(packsDir, { withFileTypes: true });
146
+ }
147
+ catch {
148
+ return actions;
149
+ }
150
+ for (const entry of entries) {
151
+ if (!entry.isFile() || !entry.name.endsWith('.md') || entry.name === '_Index.md')
152
+ continue;
153
+ const packName = normalizeContextPackName(entry.name);
154
+ const action = planContextPackIndexFix(packName, indexContent);
155
+ if (action)
156
+ actions.push(action);
157
+ }
158
+ return actions;
159
+ }
160
+ export async function repairBrain(options = {}) {
161
+ const dryRun = options.dryRun ?? false;
162
+ const warnings = [];
163
+ const brainPath = options.brainPath;
164
+ if (!brainPath) {
165
+ return { ok: false, dryRun, actions: [], applied: [], warnings: ['No second-brain path is configured.'] };
166
+ }
167
+ if (!(await pathExistsAsDir(brainPath))) {
168
+ return { ok: false, brainPath, dryRun, actions: [], applied: [], warnings: ['Configured second-brain path does not exist or is not a directory.'] };
169
+ }
170
+ const actions = await collectRepairActions(brainPath, options.expectedFolders);
171
+ if (dryRun) {
172
+ return { ok: true, brainPath, dryRun, actions, applied: [], warnings };
173
+ }
174
+ const applied = [];
175
+ const markdownUpdates = new Map();
176
+ for (const action of actions) {
177
+ if (action.kind === 'folder') {
178
+ await mkdir(join(brainPath, action.relPath), { recursive: true });
179
+ applied.push(`${action.relPath}: created folder`);
180
+ continue;
181
+ }
182
+ if (action.kind === 'index' && action.id === 'repair.context-pack-index') {
183
+ const indexPath = join(brainPath, action.relPath);
184
+ const indexContent = await readText(indexPath);
185
+ if (!indexContent) {
186
+ warnings.push(`Skipped ${action.relPath}: index file missing.`);
187
+ continue;
188
+ }
189
+ const packName = action.message.match(/Link ([^\s]+) from/)?.[1];
190
+ if (!packName)
191
+ continue;
192
+ const packContent = await readText(join(brainPath, 'Shared', 'Context-Packs', `${packName}.md`));
193
+ const next = applyContextPackIndexFix(indexContent, packName, packContent);
194
+ if (next !== indexContent) {
195
+ await writeFile(indexPath, next, 'utf8');
196
+ applied.push(`${action.relPath}: linked ${packName}`);
197
+ }
198
+ continue;
199
+ }
200
+ if (action.kind === 'markdown') {
201
+ const current = markdownUpdates.get(action.relPath)?.content ?? (await readText(join(brainPath, action.relPath)));
202
+ const repaired = applyMarkdownRepairs(action.relPath, current);
203
+ if (repaired.applied.length) {
204
+ const prior = markdownUpdates.get(action.relPath)?.applied ?? [];
205
+ markdownUpdates.set(action.relPath, { content: repaired.content, applied: uniqueSorted([...prior, ...repaired.applied]) });
206
+ }
207
+ }
208
+ }
209
+ for (const [relPath, update] of markdownUpdates) {
210
+ await writeFile(join(brainPath, relPath), update.content, 'utf8');
211
+ applied.push(`${relPath}: ${update.applied.join(', ')}`);
212
+ }
213
+ return { ok: true, brainPath, dryRun, actions, applied, warnings };
214
+ }
215
+ export function formatBrainRepairReport(report) {
216
+ const lines = ['Sanook brain repair', `vault: ${report.brainPath ?? '(not configured)'}`];
217
+ lines.push(`mode: ${report.dryRun ? 'dry-run' : 'apply'}`);
218
+ lines.push(`planned: ${report.actions.length} fix(es)`);
219
+ for (const action of report.actions) {
220
+ lines.push(`- [${action.id}] ${action.relPath} — ${action.message}`);
221
+ }
222
+ if (!report.dryRun) {
223
+ lines.push(`applied: ${report.applied.length}`);
224
+ for (const item of report.applied)
225
+ lines.push(` ✓ ${item}`);
226
+ }
227
+ for (const warning of report.warnings)
228
+ lines.push(`warning: ${warning}`);
229
+ return lines.join('\n');
230
+ }
231
+ async function listMarkdown(root) {
232
+ const out = [];
233
+ async function walk(abs, rel) {
234
+ let entries;
235
+ try {
236
+ entries = await readdir(abs, { withFileTypes: true });
237
+ }
238
+ catch {
239
+ return;
240
+ }
241
+ for (const entry of entries) {
242
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
243
+ if (entry.isDirectory()) {
244
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name) || SKIP_DIRS.has(childRel))
245
+ continue;
246
+ await walk(join(abs, entry.name), childRel);
247
+ }
248
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
249
+ out.push(childRel);
250
+ }
251
+ }
252
+ }
253
+ await walk(root, '');
254
+ return out.sort();
255
+ }
256
+ async function pathExistsAsDir(path) {
257
+ try {
258
+ const { stat } = await import('node:fs/promises');
259
+ return (await stat(path)).isDirectory();
260
+ }
261
+ catch {
262
+ return false;
263
+ }
264
+ }
265
+ async function readText(path) {
266
+ try {
267
+ return await readFile(path, 'utf8');
268
+ }
269
+ catch {
270
+ return '';
271
+ }
272
+ }
273
+ function extractBlockquotePurpose(content) {
274
+ const body = content.replace(/^---[\s\S]*?---\n?/, '');
275
+ const match = body.match(/^>\s*(.+)$/m);
276
+ return match?.[1]?.trim() ?? '';
277
+ }
278
+ function uniqueSorted(values) {
279
+ return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b));
280
+ }
package/dist/brain.js CHANGED
@@ -196,6 +196,9 @@ export async function scaffoldBrain(targetPath, cfg) {
196
196
  for (const rel of await walk(TEMPLATE_DIR)) {
197
197
  if (rel.split('/').pop() === '_Index.md')
198
198
  continue; // generated จาก FOLDERS[] แล้ว ไม่ copy ซ้ำจาก template source
199
+ // Projects/<slug>/ are per-user workspaces — scaffold via `sanook brain new project`, not bundled copy
200
+ if (rel.startsWith('Projects/') && rel !== 'Projects/_Index.md')
201
+ continue;
199
202
  const raw = await readFile(join(TEMPLATE_DIR, rel), 'utf8');
200
203
  await writeIfMissing(join(targetPath, rel), substitute(raw, cfg), created, skipped);
201
204
  }
package/dist/cli-args.js CHANGED
@@ -13,9 +13,9 @@ export function parseBudgetUsd(value) {
13
13
  export function parseThinkingConfigValue(value) {
14
14
  const normalized = value.trim();
15
15
  const flag = normalized.toLowerCase();
16
- if (flag === 'on' || flag === 'true')
16
+ if (flag === 'on' || flag === 'true' || flag === 'yes')
17
17
  return true;
18
- if (flag === 'off' || flag === 'false')
18
+ if (flag === 'off' || flag === 'false' || flag === 'no')
19
19
  return false;
20
20
  if (!POSITIVE_INTEGER_RE.test(normalized))
21
21
  return undefined;
@@ -61,10 +61,20 @@ function parsePortValue(raw) {
61
61
  function portErrorValue(raw) {
62
62
  return raw === undefined || raw === '' ? 'ต้องระบุค่า' : raw;
63
63
  }
64
+ function modelErrorValue(raw) {
65
+ return cleanModelValue(raw) ? undefined : 'ต้องระบุค่า';
66
+ }
67
+ function cleanModelValue(raw) {
68
+ const clean = raw?.trim();
69
+ return clean ? clean : undefined;
70
+ }
64
71
  export function parseServeArgs(argv) {
65
72
  let port = 8787;
66
73
  let model;
67
74
  let portError;
75
+ let modelError;
76
+ let portSet = false;
77
+ let modelSet = false;
68
78
  for (let i = 0; i < argv.length; i++) {
69
79
  const a = argv[i];
70
80
  if (a === '--port' || a.startsWith('--port=')) {
@@ -75,25 +85,49 @@ export function parseServeArgs(argv) {
75
85
  const parsed = parsePortValue(raw);
76
86
  if (parsed === undefined)
77
87
  portError = portErrorValue(raw);
78
- else
88
+ else if (portSet)
89
+ portError = 'ใช้ --port เพียงครั้งเดียว';
90
+ else {
79
91
  port = parsed;
92
+ portSet = true;
93
+ }
80
94
  }
81
95
  else if (a === '--model' || a === '-m' || a.startsWith('--model=')) {
82
96
  if (a.startsWith('--model=')) {
83
- model = inlineValue('--model', a);
97
+ const raw = inlineValue('--model', a);
98
+ const clean = cleanModelValue(raw);
99
+ const error = modelErrorValue(raw);
100
+ if (error)
101
+ modelError = error;
102
+ else if (modelSet)
103
+ modelError = 'ใช้ --model เพียงครั้งเดียว';
104
+ else {
105
+ model = clean;
106
+ modelSet = true;
107
+ }
84
108
  }
85
109
  else {
86
110
  const next = takeValue(argv, i);
87
- model = next.value;
111
+ const clean = cleanModelValue(next.value);
112
+ const error = modelErrorValue(next.value);
113
+ if (error)
114
+ modelError = error;
115
+ else if (modelSet)
116
+ modelError = 'ใช้ --model เพียงครั้งเดียว';
117
+ else {
118
+ model = clean;
119
+ modelSet = true;
120
+ }
88
121
  i = next.nextIndex;
89
122
  }
90
123
  }
91
124
  }
92
- return { port, model, portError };
125
+ return { port, model, portError, modelError };
93
126
  }
94
127
  export function parseArgs(argv) {
95
128
  let model;
96
129
  let budget;
130
+ let budgetInvalid = false;
97
131
  let json = false;
98
132
  let quiet = false;
99
133
  let planMode = false;
@@ -113,14 +147,18 @@ export function parseArgs(argv) {
113
147
  break;
114
148
  }
115
149
  if (a.startsWith('--model='))
116
- model = inlineValue('--model', a);
150
+ model = cleanModelValue(inlineValue('--model', a));
117
151
  else if (a === '--model' || a === '-m')
118
- model = takeArgValue(i);
152
+ model = cleanModelValue(takeArgValue(i));
119
153
  else if (a.startsWith('--budget=')) {
120
154
  budget = parseBudgetUsd(inlineValue('--budget', a));
155
+ if (budget === undefined)
156
+ budgetInvalid = true;
121
157
  }
122
158
  else if (a === '--budget' || a === '-b') {
123
159
  budget = parseBudgetUsd(takeArgValue(i));
160
+ if (budget === undefined)
161
+ budgetInvalid = true;
124
162
  }
125
163
  else if (a === '--json')
126
164
  json = true;
@@ -148,5 +186,5 @@ export function parseArgs(argv) {
148
186
  else
149
187
  rest.push(a);
150
188
  }
151
- return { model, budget, json, quiet, prompt: rest.join(' ').trim(), planMode, yes, resume };
189
+ return { model, budget, budgetInvalid, json, quiet, prompt: rest.join(' ').trim(), planMode, yes, resume };
152
190
  }
@@ -10,7 +10,7 @@ export function inlineValue(flag, value) {
10
10
  }
11
11
  export function takeValue(argv, index) {
12
12
  const value = argv[index + 1];
13
- if (value === undefined || isFlagLike(value))
13
+ if (value === undefined || value === '' || isFlagLike(value))
14
14
  return { nextIndex: index };
15
15
  return { value, nextIndex: index + 1 };
16
16
  }
@@ -0,0 +1,65 @@
1
+ import { spawn } from 'node:child_process';
2
+ const OSC52_MAX_CHARS = 100_000;
3
+ function powershellSetClipboardScript(text) {
4
+ const b64 = Buffer.from(text, 'utf8').toString('base64');
5
+ return `Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${b64}')))`;
6
+ }
7
+ function clipboardWriteCommands(platform, env) {
8
+ if (platform === 'darwin')
9
+ return [{ args: [], command: 'pbcopy', stdin: true }];
10
+ if (platform === 'win32')
11
+ return [{ args: ['-NoProfile', '-NonInteractive'], command: 'powershell', stdin: false }];
12
+ const commands = [];
13
+ if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) {
14
+ commands.push({ args: ['-NoProfile', '-NonInteractive'], command: 'powershell.exe', stdin: false });
15
+ }
16
+ if (env.WAYLAND_DISPLAY)
17
+ commands.push({ args: ['--type', 'text/plain'], command: 'wl-copy', stdin: true });
18
+ commands.push({ args: ['-selection', 'clipboard', '-in'], command: 'xclip', stdin: true });
19
+ commands.push({ args: ['--clipboard', '--input'], command: 'xsel', stdin: true });
20
+ return commands;
21
+ }
22
+ function runClipboardCommand(command, text, start) {
23
+ return new Promise((resolve) => {
24
+ const args = command.stdin ? command.args : [...command.args, '-Command', powershellSetClipboardScript(text)];
25
+ let child;
26
+ try {
27
+ child = start(command.command, args, { stdio: command.stdin ? ['pipe', 'ignore', 'ignore'] : ['ignore', 'ignore', 'ignore'], windowsHide: true });
28
+ }
29
+ catch {
30
+ resolve(false);
31
+ return;
32
+ }
33
+ child.once('error', () => resolve(false));
34
+ child.once('close', (code) => resolve(code === 0));
35
+ if (command.stdin)
36
+ child.stdin?.end(text);
37
+ });
38
+ }
39
+ export async function writeSystemClipboard(text, options = {}) {
40
+ const env = options.env ?? process.env;
41
+ const platform = options.platform ?? process.platform;
42
+ const start = options.spawn ?? spawn;
43
+ for (const command of clipboardWriteCommands(platform, env)) {
44
+ if (await runClipboardCommand(command, text, start))
45
+ return command.command;
46
+ }
47
+ return null;
48
+ }
49
+ export function osc52Sequence(text) {
50
+ const safe = text.length > OSC52_MAX_CHARS ? text.slice(0, OSC52_MAX_CHARS) : text;
51
+ return `\u001b]52;c;${Buffer.from(safe, 'utf8').toString('base64')}\u0007`;
52
+ }
53
+ export async function copyTextToClipboard(text, options = {}) {
54
+ const payload = text.trimEnd();
55
+ if (!payload.trim())
56
+ throw new Error('ไม่มีข้อความให้ copy');
57
+ const backend = await writeSystemClipboard(payload, options);
58
+ if (backend)
59
+ return { detail: backend, method: 'system' };
60
+ if (options.writeOsc52) {
61
+ options.writeOsc52(osc52Sequence(payload));
62
+ return { detail: 'OSC52', method: 'osc52' };
63
+ }
64
+ throw new Error('ไม่พบ clipboard backend และไม่มี OSC52 output');
65
+ }