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.
- package/CHANGELOG.md +91 -2
- package/README.md +15 -3
- package/README.th.md +8 -1
- package/dist/approval.js +7 -0
- package/dist/bin.js +623 -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-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/cli-args.js +47 -9
- package/dist/cli-option-values.js +1 -1
- package/dist/clipboard.js +65 -0
- package/dist/commands.js +94 -14
- package/dist/config.js +31 -5
- package/dist/context-pack.js +145 -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 +30 -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 +34 -5
- 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/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-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 +835 -29
- 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 +20 -1
- 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/web-fetch.js +637 -0
- package/dist/web-surface.js +190 -0
- package/package.json +2 -2
- 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,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,
|
package/dist/skill-install.js
CHANGED
|
@@ -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
|
+
}
|
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
|
}
|