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,84 @@
1
+ // Session knowledge distiller — extracts DURABLE facts (decisions, gotchas, preferences,
2
+ // constraints) from a finished session transcript so they can be folded into the memory store
3
+ // WITHOUT the model voluntarily calling `remember`. Pure + deterministic (heuristic): the offline,
4
+ // zero-cost fallback. An LLM-based extractor can layer on top when a model is available.
5
+ // Signal patterns — a sentence is a candidate only if it matches one (keeps precision up).
6
+ const SIGNALS = [
7
+ { kind: 'decision', re: /\b(decided|we['’]?ll use|we will use|we use|going with|chose|switch(?:ed|ing)? to|standardiz(?:e|ed|ing) on|settled on|agreed to)\b/i },
8
+ { kind: 'preference', re: /\b(prefer(?:s|red)?|convention(?: is|:)|by convention|coding style|likes? to|always (?:use|run|prefer|name)|we name)\b/i },
9
+ { kind: 'constraint', re: /\b(must not|must|never|do ?n['’]?t|don['’]t|required to|is required|only (?:use|run|allow)|forbidden|not allowed|has to)\b/i },
10
+ { kind: 'gotcha', re: /\b(gotcha|caveat|watch out|the (?:bug|issue|problem|error) (?:was|is)|turned out|root cause|fix(?:ed)? (?:was|by|it by)|fails? (?:if|when|because)|breaks? (?:if|when)|broke because|because the|note that|important:|heads up)\b/i },
11
+ ];
12
+ // Strong "X not Y" / "X instead of Y" decision signal (e.g. "pnpm not npm", "tabs over spaces").
13
+ const X_NOT_Y = /\b[\w.@/+-]{2,}\s+(?:not|instead of|over|rather than)\s+[\w.@/+-]{2,}\b/i;
14
+ const MAX_CANDIDATES = 12;
15
+ const MIN_WORDS = 4;
16
+ const MAX_WORDS = 45;
17
+ function looksLikeCodeOrLog(s) {
18
+ if (/^\s*[$#>]/.test(s))
19
+ return true; // shell prompt / diff marker
20
+ if (/[{};=]\s*$/.test(s) && /[(){}\[\]=;]/.test(s))
21
+ return true; // code-ish line
22
+ if (/\b(at |Error:|Traceback|stack trace|node_modules\/)/.test(s) && /:\d+/.test(s))
23
+ return true; // stack trace
24
+ const symbolRatio = (s.replace(/[\w\s]/g, '').length || 0) / Math.max(1, s.length);
25
+ return symbolRatio > 0.3; // mostly punctuation/symbols
26
+ }
27
+ function splitSentences(text) {
28
+ return text
29
+ .split(/(?<=[.!?])\s+|\n+/)
30
+ .map((s) => s.trim())
31
+ .filter(Boolean);
32
+ }
33
+ function normalize(s) {
34
+ return s.replace(/\s+/g, ' ').replace(/^[-*•\d.)\s]+/, '').trim();
35
+ }
36
+ /**
37
+ * Extract durable-fact candidates from a transcript. Skips questions, chit-chat, code/log lines,
38
+ * and too-short/too-long sentences; requires a decision/gotcha/preference/constraint signal.
39
+ */
40
+ export function distillSession(messages) {
41
+ const out = [];
42
+ const seen = new Set();
43
+ for (const msg of messages) {
44
+ if (msg.role !== 'user' && msg.role !== 'assistant')
45
+ continue;
46
+ for (const raw of splitSentences(msg.text)) {
47
+ const s = normalize(raw);
48
+ const words = s.split(/\s+/).filter(Boolean);
49
+ if (words.length < MIN_WORDS || words.length > MAX_WORDS)
50
+ continue;
51
+ if (s.endsWith('?'))
52
+ continue; // questions aren't durable facts
53
+ if (looksLikeCodeOrLog(s))
54
+ continue;
55
+ const signal = SIGNALS.find((sig) => sig.re.test(s));
56
+ const kind = signal?.kind ?? (X_NOT_Y.test(s) ? 'decision' : undefined);
57
+ if (!kind)
58
+ continue;
59
+ const key = s.toLowerCase().replace(/[^a-z0-9 ]/g, '').trim();
60
+ if (!key || seen.has(key))
61
+ continue;
62
+ seen.add(key);
63
+ out.push({ text: s, kind });
64
+ if (out.length >= MAX_CANDIDATES)
65
+ return out;
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ /** flatten an AI-SDK ModelMessage content (string | parts[]) to its plain text. */
71
+ function messageText(content) {
72
+ if (typeof content === 'string')
73
+ return content;
74
+ if (!Array.isArray(content))
75
+ return '';
76
+ return content
77
+ .map((p) => (p && typeof p === 'object' && 'text' in p && typeof p.text === 'string' ? p.text : ''))
78
+ .join(' ')
79
+ .trim();
80
+ }
81
+ /** distill durable-fact texts from a finished conversation (ModelMessage[]-shaped). Pure. */
82
+ export function distilledFactsFromMessages(messages) {
83
+ return distillSession(messages.map((m) => ({ role: m.role, text: messageText(m.content) }))).map((c) => c.text);
84
+ }
package/dist/session.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { chmod, readFile, writeFile, mkdir, readdir, realpath, rm } from 'node:fs/promises';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { appHomePath, persistenceEnabled } from './brand.js';
4
- import { redactKey } from './providers/keys.js';
4
+ import { redactKey, redactUnknown } from './providers/keys.js';
5
5
  // session store — จำ conversation + ความคืบหน้า เพื่อ "ทำงานต่อได้" ไม่ลืมว่าทำถึงไหน
6
6
  const SESSION_DIR = appHomePath('sessions');
7
7
  function isRecord(value) {
@@ -45,16 +45,6 @@ function sessionFilePath(id) {
45
45
  }
46
46
  return join(SESSION_DIR, `${id}.json`);
47
47
  }
48
- function redactUnknown(value) {
49
- if (typeof value === 'string')
50
- return redactKey(value);
51
- if (Array.isArray(value))
52
- return value.map(redactUnknown);
53
- if (value && typeof value === 'object') {
54
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactUnknown(v)]));
55
- }
56
- return value;
57
- }
58
48
  function sanitizeSession(s) {
59
49
  return {
60
50
  ...s,
@@ -6,7 +6,7 @@ import { promisify } from 'node:util';
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { lookup } from 'node:dns/promises';
8
8
  import { isIP } from 'node:net';
9
- import { parseFrontmatter, isValidSkillName } from './skills.js';
9
+ import { parseFrontmatter, isValidSkillName, bundledSkillsDir, listBundledSkills } from './skills.js';
10
10
  import { appHomePath, BRAND } from './brand.js';
11
11
  const execFileAsync = promisify(execFile);
12
12
  const USER_SKILLS = appHomePath('skills');
@@ -172,6 +172,29 @@ async function fetchSkillMd(url) {
172
172
  throw new Error('SKILL.md ใหญ่เกิน 2MB');
173
173
  return text;
174
174
  }
175
+ function bundledCatalogHint(name) {
176
+ const sample = ['git-commit-pr', 'write-tests', 'debug-root-cause'];
177
+ return `ไม่เจอ bundled skill "${name}" — ลอง ${sample.join(', ')} หรือ ${BRAND.cliName} skill list`;
178
+ }
179
+ /**
180
+ * ติดตั้ง skill จาก bundled catalog (ชื่อ slug) · local path · URL · GitHub
181
+ * ⚠️ skill = instruction ที่ agent จะ trust (ไม่ใช่ data) — ติดตั้งจาก source ที่เชื่อถือเท่านั้น
182
+ */
183
+ export async function installNamedSkill(nameOrSource, onLog) {
184
+ if (await exists(nameOrSource))
185
+ return installFromLocal(nameOrSource, onLog);
186
+ if (isValidSkillName(nameOrSource)) {
187
+ const bundled = join(bundledSkillsDir(), nameOrSource);
188
+ if (await exists(join(bundled, 'SKILL.md')))
189
+ return [await installFromDir(bundled)];
190
+ const catalog = await listBundledSkills();
191
+ const match = catalog.find((skill) => skill.name === nameOrSource);
192
+ if (match)
193
+ return [await installFromDir(dirname(dirname(match.path)))];
194
+ throw new Error(bundledCatalogHint(nameOrSource));
195
+ }
196
+ return installSkill(nameOrSource, onLog);
197
+ }
175
198
  /**
176
199
  * ติดตั้ง skill จาก source — local path · URL ของ SKILL.md (https) · GitHub ("user/repo" หรือ "user/repo/sub/path")
177
200
  * ⚠️ skill = instruction ที่ agent จะ trust (ไม่ใช่ data) — ติดตั้งจาก source ที่เชื่อถือเท่านั้น
package/dist/skills.js CHANGED
@@ -9,6 +9,9 @@ import { projectConfigPathIfTrusted } from './trust.js';
9
9
  // 3 ชั้น: bundled (ship กับ CLI) → global (~/.sanook) → project (.sanook) — ชั้นหลัง override ชื่อซ้ำ
10
10
  const BUNDLED_SKILLS = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
11
11
  const GLOBAL_SKILLS = appHomePath('skills');
12
+ export function bundledSkillsDir() {
13
+ return BUNDLED_SKILLS;
14
+ }
12
15
  /** minimal frontmatter parser (key: value ใน --- block) — ไม่พึ่ง YAML dep */
13
16
  export function parseFrontmatter(content) {
14
17
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
@@ -33,6 +36,36 @@ export function parseFrontmatter(content) {
33
36
  export function isValidSkillName(name) {
34
37
  return /^[a-z0-9][a-z0-9-]{0,63}$/.test(name);
35
38
  }
39
+ /** list bundled skills only (sanook skill install <name> catalog) */
40
+ export async function listBundledSkills() {
41
+ const out = [];
42
+ let entries;
43
+ try {
44
+ entries = await readdir(BUNDLED_SKILLS, { withFileTypes: true });
45
+ }
46
+ catch {
47
+ return out;
48
+ }
49
+ for (const e of entries) {
50
+ if (!e.isDirectory() || !isValidSkillName(e.name))
51
+ continue;
52
+ const p = join(BUNDLED_SKILLS, e.name, 'SKILL.md');
53
+ try {
54
+ const { meta } = parseFrontmatter(await readFile(p, 'utf8'));
55
+ const name = meta.name && isValidSkillName(meta.name) ? meta.name : e.name;
56
+ out.push({
57
+ name,
58
+ description: meta.description ?? '',
59
+ whenToUse: meta.when_to_use,
60
+ path: p,
61
+ });
62
+ }
63
+ catch {
64
+ /* skip invalid entries */
65
+ }
66
+ }
67
+ return out.sort((a, b) => a.name.localeCompare(b.name));
68
+ }
36
69
  /** scan project + global skills → list (name+description เท่านั้น สำหรับ inject). project ทับ global ชื่อซ้ำ */
37
70
  export async function loadSkills(cwd = process.cwd()) {
38
71
  const out = new Map();
@@ -0,0 +1,155 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, isAbsolute, resolve } from 'node:path';
4
+ const PATH_TOKEN_RE = /((?:\.{1,2}\/|~\/?|\/|@|[^"'`\s]+\/)[^"'`\s]*)$/;
5
+ const MAX_PATH_COMPLETIONS = 40;
6
+ const DETAIL_SECTIONS = [
7
+ { text: 'thinking ', display: 'thinking', meta: 'details section' },
8
+ { text: 'tools ', display: 'tools', meta: 'details section' },
9
+ ];
10
+ const DETAIL_MODES = [
11
+ { text: 'hidden', display: 'hidden', meta: 'details mode' },
12
+ { text: 'collapsed', display: 'collapsed', meta: 'details mode' },
13
+ { text: 'expanded', display: 'expanded', meta: 'details mode' },
14
+ ];
15
+ const TRAIL_MODES = [
16
+ { text: 'compact', display: 'compact', meta: 'trail mode' },
17
+ { text: 'expanded', display: 'expanded', meta: 'trail mode' },
18
+ ];
19
+ const COPY_TARGETS = [{ text: 'last', display: 'last', meta: 'copy target' }];
20
+ const BUILTIN_SLASH_COMPLETIONS = [
21
+ { text: '/help', display: '/help', meta: 'command list + pager' },
22
+ { text: '/hotkeys', display: '/hotkeys', meta: 'keyboard shortcuts' },
23
+ { text: '/details', display: '/details', meta: 'thinking/tool trail visibility' },
24
+ { text: '/model', display: '/model', meta: 'pick provider then model' },
25
+ { text: '/setup', display: '/setup', meta: 'setup wizard sections' },
26
+ { text: '/dashboard', display: '/dashboard', meta: 'open web dashboard' },
27
+ { text: '/mcp', display: '/mcp', meta: 'browse MCP servers' },
28
+ { text: '/skills', display: '/skills', meta: 'browse loaded skills' },
29
+ { text: '/sessions', display: '/sessions', meta: 'resume saved sessions' },
30
+ { text: '/tasks', display: '/tasks', meta: 'background task_spawn jobs' },
31
+ { text: '/status', display: '/status', meta: 'session/model status' },
32
+ { text: '/platforms', display: '/platforms', meta: 'providers + gateways' },
33
+ { text: '/trail', display: '/trail', meta: 'toggle tool trail detail' },
34
+ { text: '/tools', display: '/tools', meta: 'agent tools' },
35
+ { text: '/diff', display: '/diff', meta: 'git diff stat' },
36
+ { text: '/copy', display: '/copy', meta: 'copy latest assistant response' },
37
+ { text: '/retry', display: '/retry', meta: 'rerun last prompt' },
38
+ { text: '/stop', display: '/stop', meta: 'stop current turn' },
39
+ { text: '/undo', display: '/undo', meta: 'stash recent file edits' },
40
+ { text: '/rewind', display: '/rewind', meta: 'restore previous turn' },
41
+ { text: '/cost', display: '/cost', meta: 'last usage/cost' },
42
+ { text: '/usage', display: '/usage', meta: 'last usage/cost' },
43
+ { text: '/insights', display: '/insights', meta: 'local usage insights' },
44
+ { text: '/personality', display: '/personality', meta: 'set response style' },
45
+ { text: '/compact', display: '/compact', meta: 'compress context' },
46
+ { text: '/compress', display: '/compress', meta: 'compress context' },
47
+ { text: '/new', display: '/new', meta: 'new conversation' },
48
+ { text: '/reset', display: '/reset', meta: 'new conversation' },
49
+ { text: '/clear', display: '/clear', meta: 'clear conversation' },
50
+ { text: '/quit', display: '/quit', meta: 'exit REPL' },
51
+ ];
52
+ export function slashCompletionItems(input) {
53
+ if (!/^\/[a-z0-9-?]*$/i.test(input))
54
+ return [];
55
+ const query = input.slice(1).toLowerCase();
56
+ return BUILTIN_SLASH_COMPLETIONS.filter((item) => item.text.slice(1).startsWith(query));
57
+ }
58
+ export function completionForInput(input, cwd = process.cwd()) {
59
+ const slash = slashCompletionItems(input);
60
+ if (slash.length)
61
+ return { items: slash, replaceFrom: 0 };
62
+ const slashArgs = slashArgumentCompletion(input);
63
+ if (slashArgs.items.length)
64
+ return slashArgs;
65
+ const path = pathCompletion(input, cwd);
66
+ if (path.items.length)
67
+ return path;
68
+ return { items: [], replaceFrom: 0 };
69
+ }
70
+ function slashArgumentCompletion(input) {
71
+ const commandMatch = /^\/([a-z0-9-?]+)\s+/i.exec(input);
72
+ if (!commandMatch)
73
+ return { items: [], replaceFrom: 0 };
74
+ const command = commandMatch[1].toLowerCase();
75
+ const rawArgs = input.slice(commandMatch[0].length);
76
+ const hasTrailingSpace = /\s$/.test(input);
77
+ const args = rawArgs.trim() ? rawArgs.trim().split(/\s+/) : [];
78
+ const activeIndex = hasTrailingSpace ? args.length : Math.max(0, args.length - 1);
79
+ const prefix = hasTrailingSpace ? '' : (args.at(-1) ?? '');
80
+ const replaceFrom = input.length - prefix.length;
81
+ if (command === 'trail' && activeIndex === 0) {
82
+ return { items: filterArgumentItems(TRAIL_MODES, prefix), replaceFrom };
83
+ }
84
+ if (command === 'copy' && activeIndex === 0) {
85
+ return { items: filterArgumentItems(COPY_TARGETS, prefix), replaceFrom };
86
+ }
87
+ if (command === 'details') {
88
+ if (activeIndex === 0)
89
+ return { items: filterArgumentItems(DETAIL_SECTIONS, prefix), replaceFrom };
90
+ const section = args[0]?.toLowerCase();
91
+ if (activeIndex === 1 && (section === 'thinking' || section === 'tools')) {
92
+ return { items: filterArgumentItems(DETAIL_MODES, prefix), replaceFrom };
93
+ }
94
+ }
95
+ return { items: [], replaceFrom: 0 };
96
+ }
97
+ function filterArgumentItems(items, prefix) {
98
+ const query = prefix.toLowerCase();
99
+ return items.filter((item) => item.text.toLowerCase().startsWith(query));
100
+ }
101
+ function pathCompletion(input, cwd) {
102
+ const match = PATH_TOKEN_RE.exec(input);
103
+ if (!match)
104
+ return { items: [], replaceFrom: 0 };
105
+ const token = match[1];
106
+ const replaceFrom = input.length - token.length;
107
+ const mention = token.startsWith('@');
108
+ const rawToken = mention ? token.slice(1) : token;
109
+ const raw = rawToken === '~' ? '~/' : rawToken;
110
+ const hasTrailingSlash = raw.endsWith('/');
111
+ const rawDir = hasTrailingSlash ? raw : dirname(raw);
112
+ const prefix = hasTrailingSlash ? '' : basename(raw);
113
+ const dirPart = rawDir === '.' ? '' : rawDir;
114
+ const absoluteDir = resolveInputPath(dirPart || '.', cwd);
115
+ if (!existsSync(absoluteDir))
116
+ return { items: [], replaceFrom };
117
+ let entries;
118
+ try {
119
+ entries = readdirSync(absoluteDir, { withFileTypes: true });
120
+ }
121
+ catch {
122
+ return { items: [], replaceFrom };
123
+ }
124
+ const head = `${mention ? '@' : ''}${dirPart ? `${dirPart.replace(/\/?$/, '/')}` : ''}`;
125
+ const items = entries
126
+ .filter((entry) => !entry.name.startsWith('.') && entry.name.startsWith(prefix))
127
+ .sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
128
+ .slice(0, MAX_PATH_COMPLETIONS)
129
+ .map((entry) => {
130
+ const suffix = entry.isDirectory() ? '/' : '';
131
+ const text = `${head}${entry.name}${suffix}`;
132
+ return { display: text, meta: entry.isDirectory() ? 'dir' : 'file', text };
133
+ });
134
+ return { items, replaceFrom };
135
+ }
136
+ function resolveInputPath(input, cwd) {
137
+ if (input === '~')
138
+ return homedir();
139
+ if (input.startsWith('~/'))
140
+ return resolve(homedir(), input.slice(2));
141
+ if (isAbsolute(input))
142
+ return input;
143
+ return resolve(cwd, input);
144
+ }
145
+ export function clampCompletionIndex(index, count) {
146
+ if (count <= 0)
147
+ return 0;
148
+ return ((index % count) + count) % count;
149
+ }
150
+ export function completionReplaceValue(input, item, replaceFrom = 0) {
151
+ if (!item)
152
+ return null;
153
+ const next = `${input.slice(0, replaceFrom)}${item.text}`;
154
+ return next === input ? null : next;
155
+ }
@@ -89,6 +89,12 @@ export async function buildSupportDump(options = {}) {
89
89
  const currentSessions = await listSessions({ cwd });
90
90
  const allSessions = await listSessions({ cwd: null });
91
91
  const { tools } = await import('./tools/index.js');
92
+ const polyglot = await import('./polyglot.js')
93
+ .then((m) => m.inspectPolyglotRuntimes({ cwd }))
94
+ .catch((e) => e);
95
+ const webSurface = await import('./web-surface.js')
96
+ .then((m) => m.inspectWebSurface({ cwd, loadConfig: async () => mcp }))
97
+ .catch((e) => e);
92
98
  lines.push(`${BRAND.productName} support dump`);
93
99
  lines.push(`version: ${options.version ?? '(dev)'}`);
94
100
  if (options.packageName)
@@ -123,6 +129,7 @@ export async function buildSupportDump(options = {}) {
123
129
  lines.push(` brainPath: ${valueOrUnset(loadedConfig.brainPath)}`);
124
130
  lines.push(` cacheTtl: ${loadedConfig.cacheTtl}`);
125
131
  lines.push(` compaction: ${loadedConfig.compaction}`);
132
+ lines.push(` contextCompression: ${loadedConfig.contextCompression}`);
126
133
  lines.push(` thinking: ${valueOrUnset(loadedConfig.thinking)}`);
127
134
  lines.push(` summaryModel: ${valueOrUnset(loadedConfig.summaryModel)}`);
128
135
  lines.push(` embeddingModel: ${valueOrUnset(loadedConfig.embeddingModel)}`);
@@ -161,6 +168,20 @@ export async function buildSupportDump(options = {}) {
161
168
  for (const log of mcpLogs)
162
169
  lines.push(` note: ${redactKey(log)}`);
163
170
  lines.push('');
171
+ lines.push('web search:');
172
+ if (webSurface instanceof Error) {
173
+ lines.push(` load error: ${redactKey(webSurface.message)}`);
174
+ }
175
+ else {
176
+ lines.push(` local search internet: ${yesNo(webSurface.localSearch.internet)}`);
177
+ lines.push(` web candidates: ${webSurface.webCandidates.length}`);
178
+ for (const candidate of webSurface.webCandidates.slice(0, 10)) {
179
+ lines.push(` ${candidate.name}: ${candidate.transport} ${candidate.reasons.join(' · ')}`);
180
+ }
181
+ if (webSurface.webCandidates.length > 10)
182
+ lines.push(` ... ${webSurface.webCandidates.length - 10} more`);
183
+ }
184
+ lines.push('');
164
185
  lines.push('inventory:');
165
186
  lines.push(` built-in tools: ${Object.keys(tools).length}`);
166
187
  lines.push(` skills: ${skills.length}`);
@@ -170,6 +191,16 @@ export async function buildSupportDump(options = {}) {
170
191
  if (latest)
171
192
  lines.push(` latest session: ${latest.id} updated ${latest.updated}`);
172
193
  lines.push('');
194
+ lines.push('runtimes:');
195
+ if (polyglot instanceof Error) {
196
+ lines.push(` load error: ${redactKey(polyglot.message)}`);
197
+ }
198
+ else {
199
+ for (const runtime of polyglot.runtimes) {
200
+ lines.push(` ${runtime.id}: ${runtime.status}${runtime.version ? ` (${runtime.version})` : ''}`);
201
+ }
202
+ }
203
+ lines.push('');
173
204
  lines.push(options.showKeys ? 'secrets: redacted prefixes/suffixes shown; raw keys are never printed' : 'secrets: hidden; use --show-keys to show redacted key fingerprints');
174
205
  return `${lines.join('\n')}\n`;
175
206
  }
@@ -0,0 +1,59 @@
1
+ export const TOOL_CATALOG = [
2
+ {
3
+ detail: 'Read, write, patch, list, glob, grep, and run bounded shell commands in the current workspace.',
4
+ group: 'Files',
5
+ name: 'workspace tools',
6
+ summary: 'read/write/edit/list/glob/grep/bash',
7
+ },
8
+ {
9
+ detail: 'Inspect diffs, status, logs, and create commits when the user explicitly wants a commit.',
10
+ group: 'Git',
11
+ name: 'git tools',
12
+ summary: 'status/diff/log/commit',
13
+ },
14
+ {
15
+ detail: 'Remember facts, recall local memory, discover skills, and create reusable skill workflows.',
16
+ group: 'Memory',
17
+ name: 'memory + skills',
18
+ summary: 'remember/recall/find_skills/create_skill',
19
+ },
20
+ {
21
+ detail: 'Fetch public pages via the ethical web ladder (robots.txt, SSRF guard, reader/Tavily/Wayback fallbacks).',
22
+ group: 'Research',
23
+ name: 'web fetch',
24
+ summary: 'web_fetch (ethical ladder)',
25
+ },
26
+ {
27
+ detail: 'Use local brain search for vault/session/skill retrieval, and configured MCP web/search/fetch servers for current external facts with citations.',
28
+ group: 'Research',
29
+ name: 'local + web grounding',
30
+ summary: 'sanook search + web MCP readiness',
31
+ },
32
+ {
33
+ detail: 'Schedule recurring or future tasks for the Sanook gateway service to run later.',
34
+ group: 'Gateway',
35
+ name: 'scheduled tasks',
36
+ summary: 'schedule/list/cancel',
37
+ },
38
+ {
39
+ detail: 'Fan work out to sub-agents, collect results, cancel background jobs, and inspect task status.',
40
+ group: 'Agents',
41
+ name: 'agent orchestration',
42
+ summary: 'task/task_parallel/task_spawn/task_collect',
43
+ },
44
+ {
45
+ detail: 'Ask the language server for type errors and lint-like diagnostics after code edits.',
46
+ group: 'Quality',
47
+ name: 'diagnostics',
48
+ summary: 'LSP diagnostics',
49
+ },
50
+ {
51
+ detail: 'Run optional Python or Rust snippets/files without shell strings for data analysis and native-helper prototypes.',
52
+ group: 'Polyglot',
53
+ name: 'python + rust runtime tools',
54
+ summary: 'run_python/run_rust',
55
+ },
56
+ ];
57
+ export function formatToolCatalog(tools = TOOL_CATALOG) {
58
+ return tools.map((tool) => `${tool.group}: ${tool.summary}`).join('\n ');
59
+ }
@@ -12,6 +12,8 @@ import { taskTool, taskParallelTool, taskSpawnTool, taskCollectTool, taskCancelT
12
12
  import { diagnosticsTool } from './diagnostics.js';
13
13
  import { gitStatusTool, gitDiffTool, gitLogTool, gitCommitTool } from './git.js';
14
14
  import { haCallServiceTool, haGetStateTool, haListEntitiesTool, haListServicesTool } from './homeassistant.js';
15
+ import { pythonTool, rustTool } from './polyglot.js';
16
+ import { webFetchTool } from './web-fetch-tool.js';
15
17
  /** tool registry ที่ส่งให้ agent loop */
16
18
  export const tools = {
17
19
  read_file: readFileTool,
@@ -21,6 +23,8 @@ export const tools = {
21
23
  glob: globTool,
22
24
  grep: grepTool,
23
25
  run_bash: bashTool,
26
+ run_python: pythonTool,
27
+ run_rust: rustTool,
24
28
  remember: rememberTool,
25
29
  recall: recallTool,
26
30
  skill: skillTool,
@@ -44,5 +48,6 @@ export const tools = {
44
48
  ha_get_state: haGetStateTool,
45
49
  ha_list_services: haListServicesTool,
46
50
  ha_call_service: haCallServiceTool,
51
+ web_fetch: webFetchTool,
47
52
  };
48
53
  export { readFileTool, writeFileTool, editFileTool, listDirTool, globTool, grepTool, bashTool };
@@ -1,5 +1,5 @@
1
1
  import { homedir } from 'node:os';
2
- import { realpath, stat } from 'node:fs/promises';
2
+ import { realpath, stat, lstat, readlink } from 'node:fs/promises';
3
3
  import { dirname, resolve, join, sep } from 'node:path';
4
4
  import { getBrainPath } from '../memory.js';
5
5
  import { BRAND_ENV, envFlag } from '../brand.js';
@@ -928,18 +928,60 @@ function readsProtectedEnvFile(cmd) {
928
928
  return true;
929
929
  return false;
930
930
  }
931
- export function checkBash(cmd, depth = 0) {
932
- if (hasRmRecursiveForce(cmd) || hasDangerousGitOperation(cmd) || hasDestructiveCommand(cmd)) {
933
- return { ok: false, reason: `คำสั่งทำลายล้าง/irreversible ถูกปฏิเสธ: "${cmd}"` };
931
+ // Rebuild `cmd` with each segment's COMMAND token de-quoted/de-escaped (e.g. `r\m`, `'rm'`, `g\it`
932
+ // `rm`, `git`), leaving every other token byte-identical so the literal-search-pattern exemption
933
+ // (e.g. `grep 'rm -rf'`) still holds. Lets the matchers below see the real command name.
934
+ function deobfuscateCommandTokens(cmd) {
935
+ const entries = shellishTokenEntries(cmd);
936
+ if (entries.length === 0)
937
+ return cmd;
938
+ const bySegment = new Map();
939
+ for (const entry of entries) {
940
+ const seg = bySegment.get(entry.segment);
941
+ if (seg)
942
+ seg.push(entry);
943
+ else
944
+ bySegment.set(entry.segment, [entry]);
934
945
  }
935
- if (PROTECTED_CMD_PATH.test(cmd) || mentionsProtectedEnvPath(cmd) || readsProtectedEnvFile(cmd)) {
936
- return { ok: false, reason: `คำสั่งที่อ่าน/แตะ path ลับถูกปฏิเสธ: "${cmd}"` };
946
+ const replacements = [];
947
+ for (const segEntries of bySegment.values()) {
948
+ const ci = shellSegmentCommandIndex(segEntries);
949
+ if (ci < 0)
950
+ continue;
951
+ const entry = segEntries[ci];
952
+ const cleaned = cleanShellToken(entry.raw);
953
+ if (cleaned && cleaned !== entry.raw)
954
+ replacements.push({ start: entry.start, end: entry.end, text: cleaned });
937
955
  }
938
- if (nestedShellCommandDenied(cmd, depth)) {
939
- return { ok: false, reason: `คำสั่ง nested shell ที่อันตรายถูกปฏิเสธ: "${cmd}"` };
956
+ if (replacements.length === 0)
957
+ return cmd;
958
+ replacements.sort((a, b) => b.start - a.start); // right-to-left so offsets stay valid
959
+ let out = cmd;
960
+ for (const r of replacements)
961
+ out = out.slice(0, r.start) + r.text + out.slice(r.end);
962
+ return out;
963
+ }
964
+ export function checkBash(cmd, depth = 0) {
965
+ // Also test a de-obfuscated copy so a backslash/quote-mangled command name can't slip past.
966
+ const deob = deobfuscateCommandTokens(cmd);
967
+ const variants = deob === cmd ? [cmd] : [cmd, deob];
968
+ for (const c of variants) {
969
+ if (hasRmRecursiveForce(c) || hasDangerousGitOperation(c) || hasDestructiveCommand(c)) {
970
+ return { ok: false, reason: `คำสั่งทำลายล้าง/irreversible ถูกปฏิเสธ: "${cmd}"` };
971
+ }
940
972
  }
941
- if (envWrappedCommandDenied(cmd, depth)) {
942
- return { ok: false, reason: `คำสั่ง env wrapper ที่อันตรายถูกปฏิเสธ: "${cmd}"` };
973
+ for (const c of variants) {
974
+ if (PROTECTED_CMD_PATH.test(c) || mentionsProtectedEnvPath(c) || readsProtectedEnvFile(c)) {
975
+ return { ok: false, reason: `คำสั่งที่อ่าน/แตะ path ลับถูกปฏิเสธ: "${cmd}"` };
976
+ }
977
+ }
978
+ for (const c of variants) {
979
+ if (nestedShellCommandDenied(c, depth)) {
980
+ return { ok: false, reason: `คำสั่ง nested shell ที่อันตรายถูกปฏิเสธ: "${cmd}"` };
981
+ }
982
+ if (envWrappedCommandDenied(c, depth)) {
983
+ return { ok: false, reason: `คำสั่ง env wrapper ที่อันตรายถูกปฏิเสธ: "${cmd}"` };
984
+ }
943
985
  }
944
986
  return { ok: true };
945
987
  }
@@ -951,6 +993,21 @@ async function canonicalExisting(path) {
951
993
  return resolve(path);
952
994
  }
953
995
  }
996
+ // If `path`'s leaf is a symlink, return where it actually points (resolved against its
997
+ // dir). A DANGLING leaf symlink (target missing, parent present) otherwise slips past
998
+ // existingAncestor (which stat()s through the link, fails, and falls back to the parent),
999
+ // letting a write follow the link outside the workspace.
1000
+ async function symlinkLeafTarget(path) {
1001
+ try {
1002
+ const st = await lstat(path);
1003
+ if (!st.isSymbolicLink())
1004
+ return null;
1005
+ return resolve(dirname(path), await readlink(path));
1006
+ }
1007
+ catch {
1008
+ return null;
1009
+ }
1010
+ }
954
1011
  async function existingAncestor(path) {
955
1012
  let dir = resolve(path);
956
1013
  for (;;) {
@@ -1009,17 +1066,26 @@ export async function checkReadPath(path) {
1009
1066
  export async function checkWritePath(path) {
1010
1067
  const abs = resolve(path);
1011
1068
  const canonical = await existingAncestor(path);
1069
+ // Where a symlinked leaf would actually write (closes the dangling-leaf-symlink escape).
1070
+ const linkTarget = await symlinkLeafTarget(path);
1071
+ const targetReal = linkTarget ? await existingAncestor(linkTarget) : null;
1072
+ const candidates = [abs, canonical, ...(linkTarget ? [linkTarget, targetReal] : [])];
1012
1073
  const inProtectedDir = (p) => PROTECTED_DIRS.some((d) => p === d || p.startsWith(d + sep));
1013
- if (PROTECTED_EXACT.has(abs) ||
1014
- PROTECTED_EXACT.has(canonical) ||
1015
- inProtectedDir(abs) ||
1016
- inProtectedDir(canonical) ||
1017
- protectedSegment(abs) ||
1018
- protectedSegment(canonical)) {
1074
+ if (candidates.some((p) => PROTECTED_EXACT.has(p) || inProtectedDir(p) || protectedSegment(p))) {
1019
1075
  return {
1020
1076
  ok: false,
1021
1077
  reason: `path ที่ป้องกันถูกปฏิเสธ: "${path}" (secrets / shell-rc / .sanook / .git / .env / node_modules)`,
1022
1078
  };
1023
1079
  }
1080
+ // A symlinked leaf must resolve INSIDE the workspace/brain, not just sit there as a link.
1081
+ if (linkTarget) {
1082
+ const roots = await allowedRoots();
1083
+ if (!roots.some((root) => inside(targetReal, root))) {
1084
+ return {
1085
+ ok: false,
1086
+ reason: `symlink ชี้ออกนอก workspace/brain ที่อนุญาต: "${path}" → "${linkTarget}" (ตั้ง ${BRAND_ENV.allowOutsideWorkspace}=1 เพื่อ opt-in)`,
1087
+ };
1088
+ }
1089
+ }
1024
1090
  return checkPathScope(path, 'write');
1025
1091
  }