sanook-cli 0.5.2 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -2
- package/README.md +15 -3
- package/README.th.md +8 -1
- package/dist/approval.js +7 -0
- package/dist/bin.js +637 -56
- package/dist/brain-consolidate.js +335 -0
- package/dist/brain-context.js +42 -3
- package/dist/brain-final.js +15 -9
- package/dist/brain-link.js +73 -0
- package/dist/brain-metrics.js +277 -0
- package/dist/brain-new.js +402 -0
- package/dist/brain-pack.js +210 -0
- package/dist/brain-repair.js +280 -0
- package/dist/brain.js +3 -0
- package/dist/brand.js +4 -0
- package/dist/cli-args.js +47 -9
- package/dist/cli-option-values.js +1 -1
- package/dist/clipboard.js +65 -0
- package/dist/commands.js +98 -15
- package/dist/config.js +66 -34
- package/dist/context-pack.js +145 -0
- package/dist/cost.js +20 -0
- package/dist/dashboard/api-helpers.js +87 -0
- package/dist/dashboard/server.js +179 -0
- package/dist/dashboard/static/app.js +277 -0
- package/dist/dashboard/static/index.html +39 -0
- package/dist/dashboard/static/styles.css +85 -0
- package/dist/diff.js +10 -2
- package/dist/gateway/auth.js +14 -3
- package/dist/gateway/deliver.js +45 -3
- package/dist/gateway/doctor.js +456 -0
- package/dist/gateway/email.js +30 -1
- package/dist/gateway/ledger.js +20 -1
- package/dist/gateway/session.js +34 -11
- package/dist/hotkeys.js +21 -0
- package/dist/i18n/en.js +98 -0
- package/dist/i18n/index.js +19 -0
- package/dist/i18n/th.js +98 -0
- package/dist/i18n/types.js +1 -0
- package/dist/insights-args.js +24 -4
- package/dist/knowledge.js +55 -29
- package/dist/loop.js +65 -9
- package/dist/mcp-hub.js +33 -0
- package/dist/mcp-registry.js +153 -9
- package/dist/mcp-risk.js +71 -0
- package/dist/mcp.js +77 -5
- package/dist/memory-log.js +90 -0
- package/dist/memory-store.js +37 -1
- package/dist/memory.js +51 -7
- package/dist/model-picker.js +58 -0
- package/dist/orchestrate.js +7 -5
- package/dist/plan-handoff.js +17 -0
- package/dist/polyglot.js +162 -0
- package/dist/process-runner.js +96 -0
- package/dist/project-init.js +91 -0
- package/dist/project-registry.js +143 -0
- package/dist/project-scaffold.js +124 -0
- package/dist/prompt-size.js +155 -0
- package/dist/providers/codex-login.js +138 -0
- package/dist/providers/codex.js +20 -8
- package/dist/providers/keys.js +21 -0
- package/dist/providers/models.js +1 -1
- package/dist/providers/registry.js +11 -1
- package/dist/search/cli.js +9 -1
- package/dist/search/embedding-config.js +22 -0
- package/dist/search/engine.js +2 -13
- package/dist/search/indexer.js +10 -10
- package/dist/session-brain.js +103 -0
- package/dist/session-distill.js +84 -0
- package/dist/session.js +1 -11
- package/dist/skill-install.js +24 -1
- package/dist/skills.js +33 -0
- package/dist/slash-completion.js +155 -0
- package/dist/support-dump.js +31 -0
- package/dist/tool-catalog.js +59 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/permission.js +82 -16
- package/dist/tools/polyglot.js +126 -0
- package/dist/tools/sandbox.js +38 -13
- package/dist/tools/search.js +9 -2
- package/dist/tools/task.js +22 -2
- package/dist/tools/timeout.js +7 -5
- package/dist/tools/web-fetch-tool.js +33 -0
- package/dist/turn-retrieval.js +83 -0
- package/dist/ui/app.js +874 -35
- package/dist/ui/banner.js +78 -4
- package/dist/ui/markdown.js +122 -0
- package/dist/ui/overlay.js +496 -0
- package/dist/ui/queue.js +23 -0
- package/dist/ui/render.js +30 -2
- package/dist/ui/session-panel.js +115 -0
- package/dist/ui/setup-providers.js +40 -0
- package/dist/ui/setup.js +163 -50
- package/dist/ui/status.js +142 -0
- package/dist/ui/thinking-panel.js +36 -0
- package/dist/ui/tool-trail.js +97 -0
- package/dist/ui/transcript.js +26 -0
- package/dist/ui/useBusyElapsed.js +19 -0
- package/dist/ui/useEditor.js +144 -5
- package/dist/ui/useGitBranch.js +57 -0
- package/dist/update.js +32 -6
- package/dist/usage-cli.js +160 -0
- package/dist/usage-ledger.js +169 -0
- package/dist/web-fetch.js +637 -0
- package/dist/web-surface.js +190 -0
- package/package.json +4 -3
- package/scripts/postinstall.mjs +4 -4
- package/second-brain/Projects/_Index.md +17 -4
- package/second-brain/Projects/sanook-cli/_Index.md +7 -3
- package/second-brain/Projects/sanook-cli/context.md +35 -0
- package/second-brain/Projects/sanook-cli/current-state.md +32 -0
- package/second-brain/Projects/sanook-cli/overview.md +41 -0
- package/second-brain/Projects/sanook-cli/repo.md +34 -0
- package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +52 -11
- package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
- package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
- package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
- package/second-brain/Research/_Index.md +2 -0
- package/second-brain/Shared/Operating-State/current-state.md +14 -23
- package/second-brain/Shared/Tech-Standards/_Index.md +2 -0
- package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
- package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
- package/second-brain/Templates/project-workspace/_Index.md +31 -0
- package/second-brain/Templates/project-workspace/context.md +28 -0
- package/second-brain/Templates/project-workspace/current-state.md +29 -0
- package/second-brain/Templates/project-workspace/overview.md +39 -0
- package/second-brain/Templates/project-workspace/repo.md +33 -0
|
@@ -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
|
+
}
|
package/dist/support-dump.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -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 };
|
package/dist/tools/permission.js
CHANGED
|
@@ -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
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
|
|
936
|
-
|
|
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 (
|
|
939
|
-
return
|
|
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
|
-
|
|
942
|
-
|
|
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(
|
|
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
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { tool } from 'ai';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { findBinary } from '../lsp/servers.js';
|
|
7
|
+
import { runProcess, formatProcessResult } from '../process-runner.js';
|
|
8
|
+
import { agentCwd } from '../agentContext.js';
|
|
9
|
+
import { checkReadPath } from './permission.js';
|
|
10
|
+
import { maybeSandboxExec } from './sandbox.js';
|
|
11
|
+
import { resolveAgentPath } from './util.js';
|
|
12
|
+
const MAX_TIMEOUT_MS = 300_000;
|
|
13
|
+
// Run a runtime (python/rustc/compiled exe) under the same OS sandbox as run_bash so
|
|
14
|
+
// inline code cannot write outside the workspace (cwd + brain + tmp). No-shell: argv is
|
|
15
|
+
// wrapped directly, so there is no shell interpolation of the code/args.
|
|
16
|
+
async function runConfined(file, args, cwd, opts) {
|
|
17
|
+
const sb = await maybeSandboxExec(file, args, cwd);
|
|
18
|
+
return sb ? runProcess(sb.file, sb.args, { cwd, ...opts }) : runProcess(file, args, { cwd, ...opts });
|
|
19
|
+
}
|
|
20
|
+
const RuntimeScriptSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
code: z.string().optional().describe('source code to run as a temporary script'),
|
|
23
|
+
path: z.string().optional().describe('existing script/source file to run instead of code'),
|
|
24
|
+
args: z.array(z.string()).optional().describe('argv passed to the script/program'),
|
|
25
|
+
stdin: z.string().optional().describe('stdin content for the process'),
|
|
26
|
+
timeoutMs: z.number().int().positive().max(MAX_TIMEOUT_MS).optional().describe('timeout in ms (default 120000, max 300000)'),
|
|
27
|
+
})
|
|
28
|
+
.refine((v) => Boolean(v.code) !== Boolean(v.path), 'provide exactly one of code or path');
|
|
29
|
+
async function existingSourcePath(path) {
|
|
30
|
+
const full = resolveAgentPath(path);
|
|
31
|
+
const guard = await checkReadPath(full);
|
|
32
|
+
if (!guard.ok)
|
|
33
|
+
return { ok: false, reason: guard.reason };
|
|
34
|
+
return { ok: true, path: full };
|
|
35
|
+
}
|
|
36
|
+
async function tempSource(suffix, content) {
|
|
37
|
+
const dir = await mkdtemp(join(tmpdir(), 'sanook-polyglot-'));
|
|
38
|
+
const path = join(dir, `main${suffix}`);
|
|
39
|
+
await writeFile(path, content, 'utf8');
|
|
40
|
+
return { dir, path };
|
|
41
|
+
}
|
|
42
|
+
async function findRuntime(command) {
|
|
43
|
+
const bin = await findBinary(command, agentCwd());
|
|
44
|
+
return bin ?? findBinary(command, process.cwd()) ?? null;
|
|
45
|
+
}
|
|
46
|
+
async function runPython(input) {
|
|
47
|
+
const cwd = agentCwd();
|
|
48
|
+
const python = (await findRuntime('python3')) ?? (await findRuntime('python'));
|
|
49
|
+
if (!python)
|
|
50
|
+
return 'PYTHON: ยังไม่พบ python3/python — ติดตั้ง Python 3.11+ แล้วลองใหม่';
|
|
51
|
+
let tempDir;
|
|
52
|
+
let scriptPath;
|
|
53
|
+
try {
|
|
54
|
+
if (input.path) {
|
|
55
|
+
const source = await existingSourcePath(input.path);
|
|
56
|
+
if (!source.ok)
|
|
57
|
+
return `BLOCKED: ${source.reason}`;
|
|
58
|
+
scriptPath = source.path;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const temp = await tempSource('.py', input.code ?? '');
|
|
62
|
+
tempDir = temp.dir;
|
|
63
|
+
scriptPath = temp.path;
|
|
64
|
+
}
|
|
65
|
+
const result = await runConfined(python, [scriptPath, ...(input.args ?? [])], cwd, {
|
|
66
|
+
input: input.stdin,
|
|
67
|
+
timeoutMs: input.timeoutMs,
|
|
68
|
+
});
|
|
69
|
+
return formatProcessResult(result);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
if (tempDir)
|
|
73
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => { });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function runRust(input) {
|
|
77
|
+
const cwd = agentCwd();
|
|
78
|
+
const rustc = await findRuntime('rustc');
|
|
79
|
+
if (!rustc)
|
|
80
|
+
return 'RUST: ยังไม่พบ rustc — ติดตั้ง Rust ผ่าน rustup แล้วลองใหม่';
|
|
81
|
+
let tempDir;
|
|
82
|
+
try {
|
|
83
|
+
const temp = await mkdtemp(join(tmpdir(), 'sanook-rust-'));
|
|
84
|
+
tempDir = temp;
|
|
85
|
+
const sourcePath = input.path
|
|
86
|
+
? await (async () => {
|
|
87
|
+
const source = await existingSourcePath(input.path);
|
|
88
|
+
if (!source.ok)
|
|
89
|
+
return source;
|
|
90
|
+
return { ok: true, path: source.path };
|
|
91
|
+
})()
|
|
92
|
+
: { ok: true, path: join(temp, 'main.rs') };
|
|
93
|
+
if (!sourcePath.ok)
|
|
94
|
+
return `BLOCKED: ${sourcePath.reason}`;
|
|
95
|
+
if (!input.path)
|
|
96
|
+
await writeFile(sourcePath.path, input.code ?? '', 'utf8');
|
|
97
|
+
const exe = join(temp, process.platform === 'win32' ? 'sanook-rust-helper.exe' : 'sanook-rust-helper');
|
|
98
|
+
const compile = await runConfined(rustc, ['--edition=2021', sourcePath.path, '-o', exe], cwd, {
|
|
99
|
+
timeoutMs: input.timeoutMs,
|
|
100
|
+
});
|
|
101
|
+
if (!compile.ok)
|
|
102
|
+
return `RUST COMPILE ${formatProcessResult(compile)}`;
|
|
103
|
+
const run = await runConfined(exe, input.args ?? [], cwd, {
|
|
104
|
+
input: input.stdin,
|
|
105
|
+
timeoutMs: input.timeoutMs,
|
|
106
|
+
});
|
|
107
|
+
return formatProcessResult(run);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
if (tempDir)
|
|
111
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => { });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export const pythonTool = tool({
|
|
115
|
+
description: 'รัน Python แบบ no-shell สำหรับงานที่ Python ถนัด: data/JSON/CSV transform, document/text parsing, ML/OCR helper, research script. ' +
|
|
116
|
+
'ใช้ code สำหรับ snippet สั้น หรือ path สำหรับไฟล์ .py ใน workspace. ต้องมี python3/python ใน PATH.',
|
|
117
|
+
inputSchema: RuntimeScriptSchema,
|
|
118
|
+
execute: runPython,
|
|
119
|
+
});
|
|
120
|
+
export const rustTool = tool({
|
|
121
|
+
description: 'compile+run Rust single-file/snippet แบบ no-shell สำหรับงานที่ Rust ถนัด: parser/checker ที่เร็ว, algorithm ที่ต้อง type-safe, native helper prototype. ' +
|
|
122
|
+
'ใช้ code สำหรับ main.rs ชั่วคราว หรือ path สำหรับไฟล์ .rs เดี่ยวใน workspace. ต้องมี rustc ใน PATH; งาน Cargo project ให้ใช้ run_bash ตามปกติ.',
|
|
123
|
+
inputSchema: RuntimeScriptSchema,
|
|
124
|
+
execute: runRust,
|
|
125
|
+
});
|
|
126
|
+
export const runtimeScriptSchema = RuntimeScriptSchema;
|
package/dist/tools/sandbox.js
CHANGED
|
@@ -27,15 +27,12 @@ function seatbeltProfile(writable) {
|
|
|
27
27
|
' (literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr"))',
|
|
28
28
|
].join('\n');
|
|
29
29
|
}
|
|
30
|
-
function bwrapArgs(writable,
|
|
30
|
+
function bwrapArgs(writable, tail) {
|
|
31
31
|
const binds = writable.flatMap((w) => ['--bind', w, w]);
|
|
32
|
-
return ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', ...binds,
|
|
32
|
+
return ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', ...binds, ...tail];
|
|
33
33
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
* (caller รัน cmd ตรงๆ ตามเดิม). path ที่มี '"' → ข้าม sandbox (กัน profile พัง)
|
|
37
|
-
*/
|
|
38
|
-
export async function maybeSandbox(cmd, cwd = process.cwd()) {
|
|
34
|
+
// writable dirs (cwd + tmp + brain) — or null when sandbox is disabled / unusable
|
|
35
|
+
async function sandboxWritable(cwd) {
|
|
39
36
|
if (envFlag(BRAND_ENV.allowOutsideWorkspace) || envFlag('SANOOK_NO_SANDBOX'))
|
|
40
37
|
return null;
|
|
41
38
|
const writable = [canon(cwd), canon(tmpdir())];
|
|
@@ -44,18 +41,46 @@ export async function maybeSandbox(cmd, cwd = process.cwd()) {
|
|
|
44
41
|
writable.push(canon(brain));
|
|
45
42
|
if (writable.some((w) => w.includes('"')))
|
|
46
43
|
return null;
|
|
44
|
+
return writable;
|
|
45
|
+
}
|
|
46
|
+
function sandboxBin() {
|
|
47
47
|
const os = platform();
|
|
48
48
|
if (os === 'darwin') {
|
|
49
49
|
const bin = ['/usr/bin/sandbox-exec', '/usr/sbin/sandbox-exec'].find((p) => existsSync(p));
|
|
50
|
-
|
|
51
|
-
return null;
|
|
52
|
-
return { file: bin, args: ['-p', seatbeltProfile(writable), '/bin/sh', '-c', cmd] };
|
|
50
|
+
return bin ? { os, bin } : null;
|
|
53
51
|
}
|
|
54
52
|
if (os === 'linux') {
|
|
55
53
|
const bin = ['/usr/bin/bwrap', '/bin/bwrap'].find((p) => existsSync(p));
|
|
56
|
-
|
|
57
|
-
return null;
|
|
58
|
-
return { file: bin, args: bwrapArgs(writable, cmd) };
|
|
54
|
+
return bin ? { os, bin } : null;
|
|
59
55
|
}
|
|
60
56
|
return null;
|
|
61
57
|
}
|
|
58
|
+
// wrap an execFile-style argv (already split — no shell) under the sandbox tail
|
|
59
|
+
function wrap(writable, tail) {
|
|
60
|
+
const sb = sandboxBin();
|
|
61
|
+
if (!sb)
|
|
62
|
+
return null;
|
|
63
|
+
if (sb.os === 'darwin')
|
|
64
|
+
return { file: sb.bin, args: ['-p', seatbeltProfile(writable), ...tail] };
|
|
65
|
+
return { file: sb.bin, args: bwrapArgs(writable, tail) };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* คืน {file,args} สำหรับรัน cmd แบบ sandbox (ผ่าน execFile) — หรือ null ถ้าไม่มี sandbox/ปิดไว้
|
|
69
|
+
* (caller รัน cmd ตรงๆ ตามเดิม). path ที่มี '"' → ข้าม sandbox (กัน profile พัง)
|
|
70
|
+
*/
|
|
71
|
+
export async function maybeSandbox(cmd, cwd = process.cwd()) {
|
|
72
|
+
const writable = await sandboxWritable(cwd);
|
|
73
|
+
if (!writable)
|
|
74
|
+
return null;
|
|
75
|
+
return wrap(writable, ['/bin/sh', '-c', cmd]);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* เหมือน maybeSandbox แต่สำหรับ "no-shell" exec (run_python/run_rust): ห่อ argv โดยตรง
|
|
79
|
+
* ผ่าน execFile (ไม่มี shell interpolation) — confine write ให้อยู่ใน cwd+brain+tmp เท่ากับ run_bash.
|
|
80
|
+
*/
|
|
81
|
+
export async function maybeSandboxExec(file, argv, cwd = process.cwd()) {
|
|
82
|
+
const writable = await sandboxWritable(cwd);
|
|
83
|
+
if (!writable)
|
|
84
|
+
return null;
|
|
85
|
+
return wrap(writable, [file, ...argv]);
|
|
86
|
+
}
|
package/dist/tools/search.js
CHANGED
|
@@ -9,8 +9,15 @@ import { checkReadPath } from './permission.js';
|
|
|
9
9
|
import { agentCwd } from '../agentContext.js';
|
|
10
10
|
// pure-JS grep fallback — ใช้เมื่อ ripgrep (rg) ไม่ได้ติดตั้ง (เช่น Windows สะอาด) → grep ใช้ได้ทุกแพลตฟอร์ม
|
|
11
11
|
const FALLBACK_IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.cache', '.turbo', '.vercel', 'vendor']);
|
|
12
|
+
const FALLBACK_IGNORE_FILES = new Set(['.ds_store', '.localized', 'desktop.ini', 'thumbs.db']);
|
|
12
13
|
const FALLBACK_MAX_FILE = 2 * 1024 * 1024; // ข้ามไฟล์ใหญ่ (กันช้า/binary)
|
|
13
14
|
const PER_FILE_CAP = 50; // เหมือน rg --max-count 50
|
|
15
|
+
function isFallbackIgnoredFile(name) {
|
|
16
|
+
return FALLBACK_IGNORE_FILES.has(name.toLowerCase()) || name.startsWith('._');
|
|
17
|
+
}
|
|
18
|
+
function isFallbackIgnoredDir(name) {
|
|
19
|
+
return FALLBACK_IGNORE.has(name.toLowerCase()) || name.startsWith('.');
|
|
20
|
+
}
|
|
14
21
|
function otherAsciiCase(ch) {
|
|
15
22
|
const code = ch.charCodeAt(0);
|
|
16
23
|
if (code >= 65 && code <= 90)
|
|
@@ -250,10 +257,10 @@ export async function jsGrep(pattern, base, target) {
|
|
|
250
257
|
if (!guard.ok)
|
|
251
258
|
continue;
|
|
252
259
|
if (e.isDirectory()) {
|
|
253
|
-
if (!
|
|
260
|
+
if (!isFallbackIgnoredDir(e.name))
|
|
254
261
|
await walk(full);
|
|
255
262
|
}
|
|
256
|
-
else if (e.isFile()) {
|
|
263
|
+
else if (e.isFile() && !isFallbackIgnoredFile(e.name)) {
|
|
257
264
|
await scanFile(full);
|
|
258
265
|
}
|
|
259
266
|
}
|