sanook-cli 0.5.1 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +148 -10
  3. package/README.md +255 -26
  4. package/README.th.md +95 -7
  5. package/dist/approval.js +13 -0
  6. package/dist/bin.js +3552 -155
  7. package/dist/brain-consolidate.js +335 -0
  8. package/dist/brain-context.js +262 -0
  9. package/dist/brain-doctor.js +318 -0
  10. package/dist/brain-eval.js +186 -0
  11. package/dist/brain-final.js +377 -0
  12. package/dist/brain-metrics.js +277 -0
  13. package/dist/brain-new.js +402 -0
  14. package/dist/brain-pack.js +210 -0
  15. package/dist/brain-repair.js +280 -0
  16. package/dist/brain-review.js +382 -0
  17. package/dist/brain.js +15 -1
  18. package/dist/brand.js +1 -1
  19. package/dist/cli-args.js +190 -0
  20. package/dist/cli-option-values.js +16 -0
  21. package/dist/clipboard.js +65 -0
  22. package/dist/commands.js +266 -27
  23. package/dist/compaction.js +96 -11
  24. package/dist/config.js +149 -33
  25. package/dist/context-compression.js +191 -0
  26. package/dist/context-pack.js +145 -0
  27. package/dist/cost.js +49 -15
  28. package/dist/dashboard/api-helpers.js +87 -0
  29. package/dist/dashboard/server.js +179 -0
  30. package/dist/dashboard/static/app.js +277 -0
  31. package/dist/dashboard/static/index.html +39 -0
  32. package/dist/dashboard/static/styles.css +85 -0
  33. package/dist/diff.js +10 -2
  34. package/dist/first-run.js +21 -0
  35. package/dist/gateway/auth.js +49 -9
  36. package/dist/gateway/bluebubbles.js +205 -0
  37. package/dist/gateway/config.js +929 -0
  38. package/dist/gateway/deliver.js +399 -0
  39. package/dist/gateway/discord.js +124 -0
  40. package/dist/gateway/doctor.js +456 -0
  41. package/dist/gateway/email.js +501 -0
  42. package/dist/gateway/googlechat.js +207 -0
  43. package/dist/gateway/homeassistant.js +256 -0
  44. package/dist/gateway/ledger.js +38 -1
  45. package/dist/gateway/line.js +171 -0
  46. package/dist/gateway/lock.js +3 -1
  47. package/dist/gateway/matrix.js +366 -0
  48. package/dist/gateway/mattermost.js +322 -0
  49. package/dist/gateway/ntfy.js +218 -0
  50. package/dist/gateway/schedule.js +31 -4
  51. package/dist/gateway/serve.js +267 -7
  52. package/dist/gateway/server.js +253 -19
  53. package/dist/gateway/service.js +224 -0
  54. package/dist/gateway/session.js +362 -0
  55. package/dist/gateway/signal.js +351 -0
  56. package/dist/gateway/slack.js +124 -0
  57. package/dist/gateway/sms.js +169 -0
  58. package/dist/gateway/targets.js +576 -0
  59. package/dist/gateway/teams.js +106 -0
  60. package/dist/gateway/telegram.js +38 -15
  61. package/dist/gateway/webhooks.js +220 -0
  62. package/dist/gateway/whatsapp.js +230 -0
  63. package/dist/hooks.js +13 -2
  64. package/dist/hotkeys.js +21 -0
  65. package/dist/i18n/en.js +98 -0
  66. package/dist/i18n/index.js +19 -0
  67. package/dist/i18n/th.js +98 -0
  68. package/dist/i18n/types.js +1 -0
  69. package/dist/insights-args.js +55 -0
  70. package/dist/insights.js +86 -0
  71. package/dist/knowledge.js +55 -29
  72. package/dist/loop.js +157 -29
  73. package/dist/lsp/index.js +23 -5
  74. package/dist/mcp-hub.js +33 -0
  75. package/dist/mcp-registry.js +494 -0
  76. package/dist/mcp-risk.js +71 -0
  77. package/dist/mcp-server.js +1 -1
  78. package/dist/mcp.js +120 -10
  79. package/dist/memory-log.js +90 -0
  80. package/dist/memory-store.js +37 -1
  81. package/dist/memory.js +148 -37
  82. package/dist/model-picker.js +58 -0
  83. package/dist/orchestrate.js +51 -19
  84. package/dist/personality.js +58 -0
  85. package/dist/plan-handoff.js +17 -0
  86. package/dist/polyglot.js +162 -0
  87. package/dist/process-runner.js +96 -0
  88. package/dist/project-init.js +91 -0
  89. package/dist/project-registry.js +143 -0
  90. package/dist/project-scaffold.js +124 -0
  91. package/dist/prompt-size.js +155 -0
  92. package/dist/providers/codex-login.js +138 -0
  93. package/dist/providers/codex.js +89 -43
  94. package/dist/providers/keys.js +22 -1
  95. package/dist/providers/models.js +2 -2
  96. package/dist/providers/registry.js +14 -47
  97. package/dist/search/chunk.js +7 -8
  98. package/dist/search/cli.js +83 -0
  99. package/dist/search/embed-store.js +3 -0
  100. package/dist/search/embedding-config.js +22 -0
  101. package/dist/search/engine.js +2 -13
  102. package/dist/search/indexer.js +44 -1
  103. package/dist/search/store.js +23 -1
  104. package/dist/session-distill.js +84 -0
  105. package/dist/session.js +92 -16
  106. package/dist/skill-install.js +53 -13
  107. package/dist/skills.js +33 -0
  108. package/dist/slash-completion.js +155 -0
  109. package/dist/support-dump.js +206 -0
  110. package/dist/tool-catalog.js +59 -0
  111. package/dist/tools/edit.js +45 -15
  112. package/dist/tools/git.js +10 -5
  113. package/dist/tools/homeassistant.js +106 -0
  114. package/dist/tools/index.js +10 -0
  115. package/dist/tools/list.js +19 -6
  116. package/dist/tools/permission.js +992 -12
  117. package/dist/tools/polyglot.js +126 -0
  118. package/dist/tools/read.js +16 -4
  119. package/dist/tools/sandbox.js +38 -13
  120. package/dist/tools/schedule.js +19 -3
  121. package/dist/tools/search.js +226 -15
  122. package/dist/tools/task.js +40 -9
  123. package/dist/tools/timeout.js +23 -3
  124. package/dist/tools/web-fetch-tool.js +33 -0
  125. package/dist/trust.js +11 -1
  126. package/dist/turn-retrieval.js +83 -0
  127. package/dist/ui/app.js +878 -32
  128. package/dist/ui/banner.js +78 -4
  129. package/dist/ui/history.js +37 -5
  130. package/dist/ui/markdown.js +122 -0
  131. package/dist/ui/mentions.js +3 -2
  132. package/dist/ui/overlay.js +496 -0
  133. package/dist/ui/queue.js +23 -0
  134. package/dist/ui/render.js +20 -1
  135. package/dist/ui/session-panel.js +115 -0
  136. package/dist/ui/setup-providers.js +40 -0
  137. package/dist/ui/setup.js +172 -46
  138. package/dist/ui/status.js +142 -0
  139. package/dist/ui/thinking-panel.js +36 -0
  140. package/dist/ui/tool-trail.js +97 -0
  141. package/dist/ui/transcript.js +26 -0
  142. package/dist/ui/useBusyElapsed.js +19 -0
  143. package/dist/ui/useEditor.js +144 -5
  144. package/dist/ui/useGitBranch.js +57 -0
  145. package/dist/update.js +56 -17
  146. package/dist/web-fetch.js +637 -0
  147. package/dist/web-surface.js +190 -0
  148. package/dist/worktree.js +175 -4
  149. package/package.json +5 -5
  150. package/second-brain/AGENTS.md +6 -4
  151. package/second-brain/CLAUDE.md +7 -1
  152. package/second-brain/Evals/_Index.md +10 -2
  153. package/second-brain/Evals/quality-ledger.md +9 -1
  154. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  155. package/second-brain/GEMINI.md +5 -4
  156. package/second-brain/Home.md +1 -1
  157. package/second-brain/Projects/_Index.md +19 -4
  158. package/second-brain/Projects/sanook-cli/_Index.md +30 -0
  159. package/second-brain/Projects/sanook-cli/context.md +35 -0
  160. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  161. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  162. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  163. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +197 -0
  164. package/second-brain/README.md +1 -1
  165. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  166. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  167. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  168. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  169. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  170. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  171. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  172. package/second-brain/Research/_Index.md +8 -1
  173. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  174. package/second-brain/Reviews/_Index.md +1 -1
  175. package/second-brain/Runbooks/_Index.md +6 -1
  176. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  177. package/second-brain/SANOOK.md +45 -0
  178. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  179. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  180. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  181. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  182. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  183. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  184. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  185. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  186. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  187. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  188. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  189. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  190. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  191. package/second-brain/Sessions/_Index.md +15 -1
  192. package/second-brain/Shared/AI-Context-Index.md +22 -0
  193. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  194. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  195. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  196. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  197. package/second-brain/Shared/Operating-State/current-state.md +14 -4
  198. package/second-brain/Shared/Scripts/_Index.md +3 -1
  199. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  200. package/second-brain/Shared/Tech-Standards/_Index.md +6 -1
  201. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  202. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  203. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  204. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  205. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  206. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  207. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  208. package/second-brain/Templates/_Index.md +9 -0
  209. package/second-brain/Templates/final-lite.md +111 -0
  210. package/second-brain/Templates/final.md +231 -0
  211. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  212. package/second-brain/Templates/project-workspace/context.md +28 -0
  213. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  214. package/second-brain/Templates/project-workspace/overview.md +39 -0
  215. package/second-brain/Templates/project-workspace/repo.md +33 -0
  216. package/second-brain/Vault Structure Map.md +2 -1
  217. package/skills/structured-output-llm/SKILL.md +1 -1
@@ -0,0 +1,206 @@
1
+ import { arch, platform, release } from 'node:os';
2
+ import { appHomePath, appProjectPath, BRAND } from './brand.js';
3
+ import { authConfigPath, loadConfig, readGlobalConfigRaw, readStoredAuthRaw } from './config.js';
4
+ import { loadMcpConfig } from './mcp.js';
5
+ import { parseSpec, PROVIDERS } from './providers/registry.js';
6
+ import { redactKey } from './providers/keys.js';
7
+ import { listSessions, sessionStorePath } from './session.js';
8
+ import { loadSkills } from './skills.js';
9
+ import { projectRoot, projectTrustStatus } from './trust.js';
10
+ function yesNo(value) {
11
+ return value ? 'yes' : 'no';
12
+ }
13
+ function valueOrUnset(value) {
14
+ if (value === undefined || value === null || value === '')
15
+ return '(not set)';
16
+ return String(value);
17
+ }
18
+ function keySource(envVar, fallbacks, env) {
19
+ for (const name of [envVar, ...fallbacks]) {
20
+ const key = env[name]?.trim();
21
+ if (key)
22
+ return { name, key };
23
+ }
24
+ return null;
25
+ }
26
+ function providerStatusLines(stored, env, showKeys) {
27
+ const lines = ['provider auth:'];
28
+ for (const [id, cfg] of Object.entries(PROVIDERS)) {
29
+ if (!cfg.requiresKey) {
30
+ lines.push(` ${id.padEnd(10)} ${cfg.label}: no API key required`);
31
+ continue;
32
+ }
33
+ const runtime = keySource(cfg.envVar, cfg.envFallbacks ?? [], env);
34
+ const saved = stored[cfg.envVar];
35
+ const state = runtime ? `ready via ${runtime.name}` : saved ? `stored in auth.json` : `missing ${cfg.envVar}`;
36
+ const key = runtime?.key ?? saved;
37
+ const keySuffix = showKeys && key ? ` (${runtime?.name ?? cfg.envVar}=${redactKey(key)})` : '';
38
+ lines.push(` ${id.padEnd(10)} ${cfg.label}: ${state}${keySuffix}`);
39
+ }
40
+ return lines;
41
+ }
42
+ function mcpEndpointLabel(url) {
43
+ try {
44
+ const parsed = new URL(url);
45
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
46
+ }
47
+ catch {
48
+ return '(http endpoint)';
49
+ }
50
+ }
51
+ export async function buildSupportDump(options = {}) {
52
+ const cwd = options.cwd ?? process.cwd();
53
+ const env = options.env ?? process.env;
54
+ const lines = [];
55
+ const mcpLogs = [];
56
+ const rawConfig = await readGlobalConfigRaw();
57
+ const storedAuth = await readStoredAuthRaw();
58
+ const loadedConfig = await loadConfig({}, cwd).catch((e) => e);
59
+ const parsed = loadedConfig instanceof Error ? null : parseSpec(loadedConfig.model);
60
+ const provider = parsed ? PROVIDERS[parsed.provider] : undefined;
61
+ const root = await projectRoot(cwd);
62
+ const trust = await projectTrustStatus(root);
63
+ const { readGatewayConfig, resolveBlueBubblesConfig, resolveDiscordConfig, resolveEmailConfig, resolveGoogleChatConfig, resolveHomeAssistantConfig, resolveLineConfig, resolveMattermostConfig, resolveMatrixConfig, resolveNtfyConfig, resolveSignalConfig, resolveSlackConfig, resolveSmsConfig, resolveTelegramConfig, resolveTeamsConfig, resolveWhatsAppConfig, resolveWebhookConfig, gatewayConfigPath, } = await import('./gateway/config.js');
64
+ const { gatewayServiceStatus } = await import('./gateway/service.js');
65
+ const { listConfiguredTargets } = await import('./gateway/targets.js');
66
+ const { redactSignalId } = await import('./gateway/signal.js');
67
+ const { redactWhatsAppId } = await import('./gateway/whatsapp.js');
68
+ const gatewayConfig = await readGatewayConfig();
69
+ const telegram = resolveTelegramConfig(gatewayConfig, env);
70
+ const discord = resolveDiscordConfig(gatewayConfig, env);
71
+ const slack = resolveSlackConfig(gatewayConfig, env);
72
+ const email = resolveEmailConfig(gatewayConfig, env);
73
+ const homeassistant = resolveHomeAssistantConfig(gatewayConfig, env);
74
+ const line = resolveLineConfig(gatewayConfig, env);
75
+ const mattermost = resolveMattermostConfig(gatewayConfig, env);
76
+ const sms = resolveSmsConfig(gatewayConfig, env);
77
+ const ntfy = resolveNtfyConfig(gatewayConfig, env);
78
+ const signal = resolveSignalConfig(gatewayConfig, env);
79
+ const whatsapp = resolveWhatsAppConfig(gatewayConfig, env);
80
+ const matrix = resolveMatrixConfig(gatewayConfig, env);
81
+ const googleChat = resolveGoogleChatConfig(gatewayConfig, env);
82
+ const bluebubbles = resolveBlueBubblesConfig(gatewayConfig, env);
83
+ const teams = resolveTeamsConfig(gatewayConfig, env);
84
+ const webhooks = resolveWebhookConfig(gatewayConfig, env);
85
+ const service = await gatewayServiceStatus();
86
+ const targets = listConfiguredTargets(gatewayConfig, env);
87
+ const mcp = await loadMcpConfig((m) => mcpLogs.push(m), cwd);
88
+ const skills = await loadSkills(cwd);
89
+ const currentSessions = await listSessions({ cwd });
90
+ const allSessions = await listSessions({ cwd: null });
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);
98
+ lines.push(`${BRAND.productName} support dump`);
99
+ lines.push(`version: ${options.version ?? '(dev)'}`);
100
+ if (options.packageName)
101
+ lines.push(`package: ${options.packageName}`);
102
+ lines.push(`node: ${process.version}`);
103
+ lines.push(`platform: ${platform()} ${release()} ${arch()}`);
104
+ lines.push(`cwd: ${cwd}`);
105
+ lines.push(`project root: ${root}`);
106
+ lines.push(`project trust: ${trust.trusted ? 'trusted' : `untrusted (${trust.reason})`}`);
107
+ lines.push('');
108
+ lines.push('paths:');
109
+ lines.push(` config: ${appHomePath('config.json')}`);
110
+ lines.push(` auth: ${authConfigPath()}`);
111
+ lines.push(` gateway config: ${gatewayConfigPath()}`);
112
+ lines.push(` gateway service log: ${service.logPath}`);
113
+ lines.push(` sessions: ${sessionStorePath()}`);
114
+ lines.push(` mcp global: ${appHomePath('mcp.json')}`);
115
+ lines.push(` mcp project: ${appProjectPath(root, 'mcp.json')}`);
116
+ lines.push('');
117
+ lines.push('agent config:');
118
+ if (loadedConfig instanceof Error) {
119
+ lines.push(` load error: ${redactKey(loadedConfig.message)}`);
120
+ lines.push(` raw keys: ${Object.keys(rawConfig).sort().join(', ') || '(none)'}`);
121
+ }
122
+ else {
123
+ lines.push(` model: ${loadedConfig.model}`);
124
+ lines.push(` provider: ${provider?.label ?? parsed?.provider ?? '(unknown)'}`);
125
+ lines.push(` fallbackModel: ${valueOrUnset(loadedConfig.fallbackModel)}`);
126
+ lines.push(` permissionMode: ${loadedConfig.permissionMode}`);
127
+ lines.push(` maxSteps: ${loadedConfig.maxSteps}`);
128
+ lines.push(` budgetUsd: ${valueOrUnset(loadedConfig.budgetUsd)}`);
129
+ lines.push(` brainPath: ${valueOrUnset(loadedConfig.brainPath)}`);
130
+ lines.push(` cacheTtl: ${loadedConfig.cacheTtl}`);
131
+ lines.push(` compaction: ${loadedConfig.compaction}`);
132
+ lines.push(` contextCompression: ${loadedConfig.contextCompression}`);
133
+ lines.push(` thinking: ${valueOrUnset(loadedConfig.thinking)}`);
134
+ lines.push(` summaryModel: ${valueOrUnset(loadedConfig.summaryModel)}`);
135
+ lines.push(` embeddingModel: ${valueOrUnset(loadedConfig.embeddingModel)}`);
136
+ }
137
+ lines.push('');
138
+ lines.push(...providerStatusLines(storedAuth, env, options.showKeys === true));
139
+ lines.push('');
140
+ lines.push('gateway:');
141
+ lines.push(` service: ${service.running ? `running pid ${service.state?.pid}` : service.state ? `stopped last pid ${service.state.pid}` : 'not started'}`);
142
+ lines.push(` telegram: ${telegram.token ? `configured via ${telegram.source}` : 'not configured'}; enabled=${yesNo(telegram.enabled)}; allowed=${telegram.allowedChatIds.length}; write=${yesNo(telegram.allowWrite)}`);
143
+ lines.push(` discord: ${discord.token ? `configured via ${discord.source}` : 'not configured'}; enabled=${yesNo(discord.enabled)}; allowed=${discord.allowedChannelIds.length}; default=${valueOrUnset(discord.defaultChannelId)}; write=${yesNo(discord.allowWrite)}`);
144
+ lines.push(` slack: ${slack.botToken ? `configured via ${slack.source}` : 'not configured'}; enabled=${yesNo(slack.enabled)}; appToken=${yesNo(Boolean(slack.appToken))}; allowed=${slack.allowedChannelIds.length}; default=${valueOrUnset(slack.defaultChannelId)}; write=${yesNo(slack.allowWrite)}`);
145
+ lines.push(` mattermost: ${mattermost.serverUrl || mattermost.token ? `configured via ${mattermost.source}` : 'not configured'}; enabled=${yesNo(mattermost.enabled)}; url=${valueOrUnset(mattermost.serverUrl)}; token=${yesNo(Boolean(mattermost.token))}; allowedUsers=${mattermost.allowedUsers.length}; allowedChannels=${mattermost.allowedChannels.length}; home=${valueOrUnset(mattermost.homeChannel)}; requireMention=${yesNo(mattermost.requireMention)}; replyMode=${mattermost.replyMode}`);
146
+ lines.push(` homeassistant: ${homeassistant.token ? `configured via ${homeassistant.source}` : 'not configured'}; enabled=${yesNo(homeassistant.enabled)}; url=${valueOrUnset(homeassistant.url)}; token=${yesNo(Boolean(homeassistant.token))}; watchDomains=${homeassistant.watchDomains.length}; watchEntities=${homeassistant.watchEntities.length}; ignore=${homeassistant.ignoreEntities.length}; watchAll=${yesNo(homeassistant.watchAll)}; cooldown=${homeassistant.cooldownSeconds}s`);
147
+ lines.push(` email: ${email.address ? `configured via ${email.source}` : 'not configured'}; enabled=${yesNo(email.enabled)}; smtp=${valueOrUnset(email.smtpHost)}:${email.smtpPort}; imap=${valueOrUnset(email.imapHost)}:${email.imapPort}; allowed=${email.allowedUsers.length}; home=${valueOrUnset(email.homeAddress)}`);
148
+ lines.push(` line: ${line.channelAccessToken ? `configured via ${line.source}` : 'not configured'}; enabled=${yesNo(line.enabled)}; allowed=${line.allowedUsers.length + line.allowedGroups.length + line.allowedRooms.length}; home=${valueOrUnset(line.homeChannel)}; secret=${yesNo(Boolean(line.channelSecret))}`);
149
+ lines.push(` sms: ${sms.accountSid && sms.authToken && sms.phoneNumber ? `configured via ${sms.source}` : 'not configured'}; enabled=${yesNo(sms.enabled)}; allowed=${sms.allowedUsers.length}; home=${valueOrUnset(sms.homeChannel)}; webhook=${valueOrUnset(sms.webhookUrl)}; signature=${sms.insecureNoSignature ? 'disabled' : 'required'}`);
150
+ lines.push(` ntfy: ${ntfy.topic || ntfy.token ? `configured via ${ntfy.source}` : 'not configured'}; enabled=${yesNo(ntfy.enabled)}; server=${valueOrUnset(ntfy.serverUrl)}; topic=${valueOrUnset(ntfy.topic)}; publish=${valueOrUnset(ntfy.publishTopic)}; allowed=${ntfy.allowedUsers.length}; home=${valueOrUnset(ntfy.homeChannel)}; token=${yesNo(Boolean(ntfy.token))}; markdown=${yesNo(ntfy.markdown)}`);
151
+ lines.push(` signal: ${signal.account ? `configured via ${signal.source}` : 'not configured'}; enabled=${yesNo(signal.enabled)}; url=${valueOrUnset(signal.httpUrl)}; account=${redactSignalId(signal.account)}; allowed=${signal.allowedUsers.length}; groups=${signal.groupAllowedUsers.length}; home=${redactSignalId(signal.homeChannel)}; requireMention=${yesNo(signal.requireMention)}`);
152
+ lines.push(` whatsapp: ${whatsapp.phoneNumberId || whatsapp.accessToken ? `configured via ${whatsapp.source}` : 'not configured'}; enabled=${yesNo(whatsapp.enabled)}; phoneNumberId=${yesNo(Boolean(whatsapp.phoneNumberId))}; token=${yesNo(Boolean(whatsapp.accessToken))}; secret=${yesNo(Boolean(whatsapp.appSecret))}; verifyToken=${yesNo(Boolean(whatsapp.verifyToken))}; allowed=${whatsapp.allowedUsers.length}; home=${redactWhatsAppId(whatsapp.homeChannel)}; public=${valueOrUnset(whatsapp.publicUrl)}; api=${whatsapp.apiVersion}`);
153
+ lines.push(` matrix: ${matrix.homeserver || matrix.accessToken || matrix.userId ? `configured via ${matrix.source}` : 'not configured'}; enabled=${yesNo(matrix.enabled)}; homeserver=${valueOrUnset(matrix.homeserver)}; token=${yesNo(Boolean(matrix.accessToken))}; user=${valueOrUnset(matrix.userId)}; password=${yesNo(Boolean(matrix.password))}; allowedUsers=${matrix.allowedUsers.length}; allowedRooms=${matrix.allowedRooms.length}; home=${valueOrUnset(matrix.homeRoom)}; requireMention=${yesNo(matrix.requireMention)}; autoJoin=${yesNo(matrix.autoJoin)}`);
154
+ lines.push(` googlechat: ${googleChat.serviceAccountJson || googleChat.incomingWebhookUrl ? `configured via ${googleChat.source}` : 'not configured'}; enabled=${yesNo(googleChat.enabled)}; project=${valueOrUnset(googleChat.projectId)}; subscription=${yesNo(Boolean(googleChat.subscriptionName))}; serviceAccount=${yesNo(Boolean(googleChat.serviceAccountJson))}; webhook=${yesNo(Boolean(googleChat.incomingWebhookUrl))}; allowedUsers=${googleChat.allowedUsers.length}; allowedSpaces=${googleChat.allowedSpaces.length}; freeSpaces=${googleChat.freeResponseSpaces.length}; home=${valueOrUnset(googleChat.homeChannel)}; flow=${googleChat.maxMessages}/${googleChat.maxBytes}`);
155
+ lines.push(` bluebubbles: ${bluebubbles.serverUrl || bluebubbles.password ? `configured via ${bluebubbles.source}` : 'not configured'}; enabled=${yesNo(bluebubbles.enabled)}; server=${valueOrUnset(bluebubbles.serverUrl)}; password=${yesNo(Boolean(bluebubbles.password))}; webhook=${bluebubbles.webhookHost}:${bluebubbles.webhookPort}${bluebubbles.webhookPath}; allowed=${bluebubbles.allowedUsers.length}; home=${valueOrUnset(bluebubbles.homeChannel)}; requireMention=${yesNo(bluebubbles.requireMention)}`);
156
+ lines.push(` teams: ${teams.incomingWebhookUrl || teams.graphAccessToken || teams.clientId ? `configured via ${teams.source}` : 'not configured'}; enabled=${yesNo(teams.enabled)}; mode=${teams.deliveryMode}; webhook=${yesNo(Boolean(teams.incomingWebhookUrl))}; graphToken=${yesNo(Boolean(teams.graphAccessToken))}; chat=${valueOrUnset(teams.chatId)}; teamChannel=${teams.teamId && teams.channelId ? 'set' : '(not set)'}; home=${valueOrUnset(teams.homeChannel)}; botApp=${teams.clientId && teams.tenantId ? 'set' : '(not set)'}; allowed=${teams.allowedUsers.length}; port=${teams.port}`);
157
+ lines.push(` webhooks: ${webhooks.enabled ? `enabled via ${webhooks.source}` : 'not enabled'}; routes=${Object.keys(webhooks.routes).length}; secret=${yesNo(Boolean(webhooks.secret))}; public=${valueOrUnset(webhooks.publicUrl)}`);
158
+ lines.push(` send targets: ${targets.length}`);
159
+ lines.push('');
160
+ lines.push('mcp:');
161
+ const mcpEntries = Object.entries(mcp);
162
+ lines.push(` servers: ${mcpEntries.length}`);
163
+ for (const [name, cfg] of mcpEntries.slice(0, 20)) {
164
+ lines.push(` ${name}: ${cfg.url ? `http ${mcpEndpointLabel(cfg.url)}` : `stdio ${valueOrUnset(cfg.command)}`}`);
165
+ }
166
+ if (mcpEntries.length > 20)
167
+ lines.push(` ... ${mcpEntries.length - 20} more`);
168
+ for (const log of mcpLogs)
169
+ lines.push(` note: ${redactKey(log)}`);
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('');
185
+ lines.push('inventory:');
186
+ lines.push(` built-in tools: ${Object.keys(tools).length}`);
187
+ lines.push(` skills: ${skills.length}`);
188
+ lines.push(` sessions current project: ${currentSessions.length}`);
189
+ lines.push(` sessions all projects: ${allSessions.length}`);
190
+ const latest = currentSessions[0] ?? allSessions[0];
191
+ if (latest)
192
+ lines.push(` latest session: ${latest.id} updated ${latest.updated}`);
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('');
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');
205
+ return `${lines.join('\n')}\n`;
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
+ }
@@ -4,6 +4,19 @@ import { readFile, writeFile } from 'node:fs/promises';
4
4
  import { checkWritePath } from './permission.js';
5
5
  import { resolveAgentPath } from './util.js';
6
6
  import { renderEditDiff } from '../diff.js';
7
+ function detectLineEnding(content) {
8
+ if (content.includes('\r\n'))
9
+ return '\r\n';
10
+ if (content.includes('\r'))
11
+ return '\r';
12
+ return '\n';
13
+ }
14
+ function normalizeLineEndings(content) {
15
+ return content.replace(/\r\n|\r/g, '\n');
16
+ }
17
+ function restoreLineEndings(content, lineEnding) {
18
+ return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);
19
+ }
7
20
  /** tier 1: exact substring match + นับจำนวนครั้ง */
8
21
  export function exactMatch(content, needle) {
9
22
  if (needle.length === 0)
@@ -25,6 +38,8 @@ export function exactMatch(content, needle) {
25
38
  * คืน offset ของบล็อกที่ match ในไฟล์จริง (รวม indentation เดิม)
26
39
  */
27
40
  export function whitespaceFlexMatch(content, needle) {
41
+ if (needle.length === 0)
42
+ return null;
28
43
  const needleLines = needle.split('\n').map((l) => l.trim());
29
44
  const contentLines = content.split('\n');
30
45
  // offset อักขระของจุดเริ่มแต่ละบรรทัด
@@ -85,39 +100,54 @@ export const editFileTool = tool({
85
100
  catch (err) {
86
101
  return `ERROR: อ่านไฟล์ "${path}" ไม่ได้ — ${err.message}`;
87
102
  }
88
- // normalize CRLF→LF เพื่อให้ match/offset consistent แล้ว restore EOL เดิมตอนเขียน
89
- // (กัน flex match กิน \r แล้วทำ line ending พังบนไฟล์ Windows)
90
- const usesCRLF = raw.includes('\r\n');
91
- const content = usesCRLF ? raw.replace(/\r\n/g, '\n') : raw;
92
- const oldNorm = old_string.replace(/\r\n/g, '\n');
93
- const newNorm = new_string.replace(/\r\n/g, '\n');
103
+ // normalize CRLF/CR→LF เพื่อให้ match/offset consistent แล้ว restore EOL เดิมตอนเขียน
104
+ // (กัน flex match กิน \r แล้วทำ line ending พังบนไฟล์ Windows/legacy Mac)
105
+ const lineEnding = detectLineEnding(raw);
106
+ const content = normalizeLineEndings(raw);
107
+ const oldNorm = normalizeLineEndings(old_string);
108
+ const newNorm = normalizeLineEndings(new_string);
94
109
  // replace_all: แทนที่ทุกที่ที่ตรง "เป๊ะ" (exact เท่านั้น — flex หลายช่วงกำกวม) → old_string สั้นได้ ไม่ต้อง unique
95
110
  if (replace_all) {
96
111
  const exact = exactMatch(content, oldNorm);
97
112
  if (!exact) {
98
113
  return `ERROR: ไม่พบ old_string ในไฟล์ "${path}" — replace_all ใช้ match แบบตรงเป๊ะเท่านั้น (อ่านไฟล์ใหม่แล้วคัดข้อความที่ตรง)`;
99
114
  }
100
- let updated = content.split(oldNorm).join(newNorm); // split/join = แทนที่ทุกที่ (string literal, ไม่ใช่ regex)
101
- if (usesCRLF)
102
- updated = updated.replace(/\n/g, '\r\n');
115
+ const parts = content.split(oldNorm); // split/join = แทนที่ทุกที่ (string literal, ไม่ใช่ regex)
116
+ const updated = restoreLineEndings(parts.join(newNorm), lineEnding);
103
117
  try {
104
118
  await writeFile(full, updated, 'utf8');
105
119
  }
106
120
  catch (err) {
107
121
  return `ERROR: เขียนไฟล์ "${path}" ไม่ได้ — ${err.message}`;
108
122
  }
109
- return `OK: แก้ "${path}" (${exact.count} ที่)\n${renderEditDiff(oldNorm, newNorm)}`;
123
+ // นับจาก split (non-overlapping จริง) ไม่ใช่ exact.count ที่นับ overlapping → เลขตรงกับที่แทนจริง
124
+ return `OK: แก้ "${path}" (${parts.length - 1} ที่)\n${renderEditDiff(oldNorm, newNorm)}`;
110
125
  }
111
- const m = findMatch(content, oldNorm);
126
+ const exact = exactMatch(content, oldNorm);
127
+ const m = exact ?? whitespaceFlexMatch(content, oldNorm);
128
+ const isFlex = !exact && !!m; // match มาจาก tier whitespace-flex (old_string indentation ไม่ตรงไฟล์)
112
129
  if (!m) {
113
130
  return `ERROR: ไม่พบ old_string ในไฟล์ "${path}" — อ่านไฟล์ใหม่ด้วย read_file แล้วคัดข้อความที่ตรงเป๊ะมาใช้`;
114
131
  }
115
132
  if (m.count > 1) {
116
- return `ERROR: old_string พบ ${m.count} ที่ในไฟล์ "${path}" — ตั้ง replace_all:true เพื่อแก้ทุกที่ หรือใส่ context รอบๆ ให้พอ unique (ใช้เท่าที่จำเป็น ประหยัด token)`;
133
+ // flex tier: replace_all ใช้ไม่ได้ (exact-only) แนะให้ใส่ context อย่างเดียว กัน dead-end loop
134
+ return isFlex
135
+ ? `ERROR: old_string ตรง ${m.count} ที่ในไฟล์ "${path}" (แบบ flex) — ใส่ context รอบๆ ให้ unique แล้วลองใหม่`
136
+ : `ERROR: old_string พบ ${m.count} ที่ในไฟล์ "${path}" — ตั้ง replace_all:true เพื่อแก้ทุกที่ หรือใส่ context รอบๆ ให้พอ unique (ใช้เท่าที่จำเป็น ประหยัด token)`;
137
+ }
138
+ // flex match กิน indentation เดิมของไฟล์ (เทียบแบบ trim) — ต้อง re-apply indent ให้ replacement
139
+ // ไม่งั้น code โดน de-indent (พัง Python/YAML + เยื้องเพี้ยนทุกภาษา) แบบเงียบๆ
140
+ let replacement = newNorm;
141
+ if (isFlex) {
142
+ const baseIndent = content.slice(m.start).match(/^[ \t]*/)?.[0] ?? '';
143
+ if (baseIndent) {
144
+ const newLines = newNorm.split('\n');
145
+ const nonBlank = newLines.filter((l) => l.trim() !== '');
146
+ const commonNew = nonBlank.length ? Math.min(...nonBlank.map((l) => (l.match(/^[ \t]*/)?.[0].length ?? 0))) : 0;
147
+ replacement = newLines.map((l) => (l.trim() === '' ? l : baseIndent + l.slice(commonNew))).join('\n');
148
+ }
117
149
  }
118
- let updated = content.slice(0, m.start) + newNorm + content.slice(m.end);
119
- if (usesCRLF)
120
- updated = updated.replace(/\n/g, '\r\n');
150
+ const updated = restoreLineEndings(content.slice(0, m.start) + replacement + content.slice(m.end), lineEnding);
121
151
  try {
122
152
  await writeFile(full, updated, 'utf8');
123
153
  }
package/dist/tools/git.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import { tool } from 'ai';
2
2
  import { z } from 'zod';
3
3
  import { runGit } from '../git.js';
4
+ import { agentCwd } from '../agentContext.js';
4
5
  const gitErr = (e) => `git error: ${e.message}`;
6
+ // รัน git ใน cwd ของ agent (worktree ของ sub-agent ถ้ามี) — ไม่งั้น git_commit/status/diff ไปโดน MAIN repo
7
+ // แทนที่ของ sub-agent ที่ isolate ไว้ (worktree isolation พัง / commit ผิด tree)
8
+ const gitCwd = () => agentCwd();
5
9
  export const gitStatusTool = tool({
6
10
  description: 'ดู git status — ไฟล์ที่เปลี่ยน/staged/untracked + branch',
7
11
  inputSchema: z.object({
@@ -10,7 +14,7 @@ export const gitStatusTool = tool({
10
14
  execute: async ({ path }) => {
11
15
  try {
12
16
  const args = ['status', '--short', '--branch', ...(path ? ['--', path] : [])];
13
- return (await runGit(args)).trim() || '(clean)';
17
+ return (await runGit(args, gitCwd())).trim() || '(clean)';
14
18
  }
15
19
  catch (e) {
16
20
  return gitErr(e);
@@ -26,7 +30,7 @@ export const gitDiffTool = tool({
26
30
  execute: async ({ staged, path }) => {
27
31
  try {
28
32
  const args = ['diff', ...(staged ? ['--staged'] : []), ...(path ? ['--', path] : [])];
29
- const out = await runGit(args);
33
+ const out = await runGit(args, gitCwd());
30
34
  return out.length > 20000 ? `${out.slice(0, 20000)}\n... [diff ยาว, ตัด]` : out || '(no changes)';
31
35
  }
32
36
  catch (e) {
@@ -41,7 +45,7 @@ export const gitLogTool = tool({
41
45
  }),
42
46
  execute: async ({ count = 10 }) => {
43
47
  try {
44
- return (await runGit(['log', '--oneline', '-n', String(Math.min(Math.max(count, 1), 50))])) || '(no commits)';
48
+ return (await runGit(['log', '--oneline', '-n', String(Math.min(Math.max(count, 1), 50))], gitCwd())) || '(no commits)';
45
49
  }
46
50
  catch (e) {
47
51
  return gitErr(e);
@@ -57,9 +61,10 @@ export const gitCommitTool = tool({
57
61
  }),
58
62
  execute: async ({ message, addAll }) => {
59
63
  try {
64
+ const cwd = gitCwd();
60
65
  if (addAll)
61
- await runGit(['add', '-A']);
62
- return (await runGit(['commit', '-m', message])).trim();
66
+ await runGit(['add', '-A'], cwd);
67
+ return (await runGit(['commit', '-m', message], cwd)).trim();
63
68
  }
64
69
  catch (e) {
65
70
  return gitErr(e);
@@ -0,0 +1,106 @@
1
+ import { tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import { readGatewayConfig, resolveHomeAssistantConfig } from '../gateway/config.js';
4
+ import { homeAssistantApiUrl, homeAssistantAuthHeaders, readHomeAssistantJsonResponse } from '../gateway/homeassistant.js';
5
+ const BLOCKED_DOMAINS = new Set(['shell_command', 'command_line', 'python_script', 'pyscript', 'hassio', 'rest_command']);
6
+ const ENTITY_ID_RE = /^[a-z_][a-z0-9_]*\.[a-z0-9_]+$/;
7
+ async function loadHaConfig() {
8
+ const config = resolveHomeAssistantConfig(await readGatewayConfig());
9
+ if (!config.token)
10
+ throw new Error('ยังไม่ได้ตั้ง Home Assistant — รัน: sanook gateway setup homeassistant หรือ set HASS_TOKEN');
11
+ return config;
12
+ }
13
+ async function haFetch(config, path, init = {}) {
14
+ const r = await fetch(homeAssistantApiUrl(config, path), {
15
+ ...init,
16
+ headers: {
17
+ ...homeAssistantAuthHeaders(config.token, init.method ? { 'content-type': 'application/json' } : {}),
18
+ ...(init.headers ?? {}),
19
+ },
20
+ });
21
+ return readHomeAssistantJsonResponse(r, 'Home Assistant API');
22
+ }
23
+ function validateEntityId(entityId) {
24
+ const clean = entityId.trim();
25
+ if (!ENTITY_ID_RE.test(clean))
26
+ throw new Error(`entity_id ไม่ปลอดภัย/ไม่ถูกต้อง: ${entityId}`);
27
+ return clean;
28
+ }
29
+ function stateSummary(state) {
30
+ const name = typeof state.attributes?.friendly_name === 'string' ? state.attributes.friendly_name : state.entity_id;
31
+ return `${state.entity_id}: ${state.state ?? '(unknown)'}${name && name !== state.entity_id ? ` (${name})` : ''}`;
32
+ }
33
+ export const haListEntitiesTool = tool({
34
+ description: 'Home Assistant: list entities/states. Requires HASS_TOKEN or gateway setup homeassistant.',
35
+ inputSchema: z.object({
36
+ domain: z.string().optional().describe('Filter by entity domain เช่น light, switch, climate, sensor'),
37
+ area: z.string().optional().describe('Simple friendly-name substring filter เช่น living room, kitchen'),
38
+ limit: z.number().int().positive().max(200).optional().describe('Maximum rows to return (default 80)'),
39
+ }),
40
+ execute: async ({ domain, area, limit }) => {
41
+ const config = await loadHaConfig();
42
+ const states = await haFetch(config, '/states');
43
+ const cleanDomain = domain?.trim();
44
+ const areaNeedle = area?.trim().toLowerCase();
45
+ const rows = states
46
+ .filter((s) => !cleanDomain || s.entity_id?.startsWith(`${cleanDomain}.`))
47
+ .filter((s) => !areaNeedle || String(s.attributes?.friendly_name ?? s.entity_id ?? '').toLowerCase().includes(areaNeedle))
48
+ .slice(0, limit ?? 80)
49
+ .map(stateSummary);
50
+ return rows.length ? rows.join('\n') : 'ไม่พบ Home Assistant entity ที่ตรงเงื่อนไข';
51
+ },
52
+ });
53
+ export const haGetStateTool = tool({
54
+ description: 'Home Assistant: get detailed state and attributes for one entity.',
55
+ inputSchema: z.object({
56
+ entity_id: z.string().describe('Entity id เช่น light.living_room'),
57
+ }),
58
+ execute: async ({ entity_id }) => {
59
+ const config = await loadHaConfig();
60
+ const entityId = validateEntityId(entity_id);
61
+ const state = await haFetch(config, `/states/${encodeURIComponent(entityId)}`);
62
+ return JSON.stringify(state, null, 2);
63
+ },
64
+ });
65
+ export const haListServicesTool = tool({
66
+ description: 'Home Assistant: list available service domains/actions, optionally filtered by domain.',
67
+ inputSchema: z.object({
68
+ domain: z.string().optional().describe('Filter by service domain เช่น light, climate, switch'),
69
+ }),
70
+ execute: async ({ domain }) => {
71
+ const config = await loadHaConfig();
72
+ const services = await haFetch(config, '/services');
73
+ const cleanDomain = domain?.trim();
74
+ const rows = services
75
+ .filter((entry) => !cleanDomain || entry.domain === cleanDomain)
76
+ .map((entry) => `${entry.domain}: ${Object.keys(entry.services ?? {}).sort().join(', ') || '(none)'}`);
77
+ return rows.length ? rows.join('\n') : 'ไม่พบ Home Assistant service ที่ตรงเงื่อนไข';
78
+ },
79
+ });
80
+ export const haCallServiceTool = tool({
81
+ description: 'Home Assistant: call a service to control a device. Blocks unsafe domains such as shell_command, command_line, python_script, pyscript, hassio, and rest_command.',
82
+ inputSchema: z.object({
83
+ domain: z.string().describe('Service domain เช่น light, switch, climate, cover, media_player, scene, script'),
84
+ service: z.string().describe('Service name เช่น turn_on, turn_off, toggle, set_temperature'),
85
+ entity_id: z.string().optional().describe('Optional target entity id เช่น light.living_room'),
86
+ data: z.record(z.unknown()).optional().describe('Additional JSON service data'),
87
+ }),
88
+ execute: async ({ domain, service, entity_id, data }) => {
89
+ const config = await loadHaConfig();
90
+ const cleanDomain = domain.trim();
91
+ const cleanService = service.trim();
92
+ if (!/^[a-z_][a-z0-9_]*$/.test(cleanDomain))
93
+ throw new Error(`domain ไม่ถูกต้อง: ${domain}`);
94
+ if (!/^[a-z_][a-z0-9_]*$/.test(cleanService))
95
+ throw new Error(`service ไม่ถูกต้อง: ${service}`);
96
+ if (BLOCKED_DOMAINS.has(cleanDomain))
97
+ return `⛔ blocked Home Assistant domain: ${cleanDomain}`;
98
+ const entityId = entity_id ? validateEntityId(entity_id) : undefined;
99
+ const body = { ...(data ?? {}), ...(entityId ? { entity_id: entityId } : {}) };
100
+ const result = await haFetch(config, `/services/${encodeURIComponent(cleanDomain)}/${encodeURIComponent(cleanService)}`, {
101
+ method: 'POST',
102
+ body: JSON.stringify(body),
103
+ });
104
+ return `OK Home Assistant ${cleanDomain}.${cleanService}${entityId ? ` ${entityId}` : ''} (${Array.isArray(result) ? result.length : 0} state update(s))`;
105
+ },
106
+ });
@@ -11,6 +11,9 @@ import { scheduleTaskTool, listScheduledTool, cancelScheduledTool } from './sche
11
11
  import { taskTool, taskParallelTool, taskSpawnTool, taskCollectTool, taskCancelTool, taskStatusTool } from './task.js';
12
12
  import { diagnosticsTool } from './diagnostics.js';
13
13
  import { gitStatusTool, gitDiffTool, gitLogTool, gitCommitTool } from './git.js';
14
+ import { haCallServiceTool, haGetStateTool, haListEntitiesTool, haListServicesTool } from './homeassistant.js';
15
+ import { pythonTool, rustTool } from './polyglot.js';
16
+ import { webFetchTool } from './web-fetch-tool.js';
14
17
  /** tool registry ที่ส่งให้ agent loop */
15
18
  export const tools = {
16
19
  read_file: readFileTool,
@@ -20,6 +23,8 @@ export const tools = {
20
23
  glob: globTool,
21
24
  grep: grepTool,
22
25
  run_bash: bashTool,
26
+ run_python: pythonTool,
27
+ run_rust: rustTool,
23
28
  remember: rememberTool,
24
29
  recall: recallTool,
25
30
  skill: skillTool,
@@ -39,5 +44,10 @@ export const tools = {
39
44
  git_diff: gitDiffTool,
40
45
  git_log: gitLogTool,
41
46
  git_commit: gitCommitTool,
47
+ ha_list_entities: haListEntitiesTool,
48
+ ha_get_state: haGetStateTool,
49
+ ha_list_services: haListServicesTool,
50
+ ha_call_service: haCallServiceTool,
51
+ web_fetch: webFetchTool,
42
52
  };
43
53
  export { readFileTool, writeFileTool, editFileTool, listDirTool, globTool, grepTool, bashTool };
@@ -1,6 +1,7 @@
1
1
  import { tool } from 'ai';
2
2
  import { z } from 'zod';
3
- import { readdir } from 'node:fs/promises';
3
+ import { readdir, stat } from 'node:fs/promises';
4
+ import { join } from 'node:path';
4
5
  import { clamp, resolveAgentPath } from './util.js';
5
6
  import { checkReadPath } from './permission.js';
6
7
  export const listDirTool = tool({
@@ -15,11 +16,23 @@ export const listDirTool = tool({
15
16
  return `BLOCKED: ${guard.reason}`;
16
17
  try {
17
18
  const entries = await readdir(full, { withFileTypes: true });
18
- const out = entries
19
- .filter((e) => !e.name.startsWith('.') || e.name === '.env.example' || e.name === '.gitignore')
20
- .map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
21
- .sort()
22
- .join('\n');
19
+ const visible = [];
20
+ for (const e of entries) {
21
+ if (e.name.startsWith('.') && e.name !== '.env.example' && e.name !== '.gitignore')
22
+ continue;
23
+ const entryPath = join(full, e.name);
24
+ const entryGuard = await checkReadPath(entryPath);
25
+ if (!entryGuard.ok)
26
+ continue;
27
+ let isDirectory = e.isDirectory();
28
+ if (!isDirectory && e.isSymbolicLink()) {
29
+ isDirectory = await stat(entryPath)
30
+ .then((s) => s.isDirectory())
31
+ .catch(() => false);
32
+ }
33
+ visible.push(isDirectory ? `${e.name}/` : e.name);
34
+ }
35
+ const out = visible.sort().join('\n');
23
36
  return clamp(out) || '(empty)';
24
37
  }
25
38
  catch (err) {