sanook-cli 0.4.0 → 0.5.1
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/.env.example +19 -0
- package/CHANGELOG.md +173 -0
- package/README.md +153 -20
- package/README.th.md +136 -0
- package/dist/agentContext.js +4 -0
- package/dist/approval.js +6 -0
- package/dist/bin.js +405 -57
- package/dist/brain.js +92 -59
- package/dist/brand.js +47 -0
- package/dist/checkpoint.js +37 -0
- package/dist/commands.js +86 -6
- package/dist/compaction.js +76 -5
- package/dist/config.js +100 -12
- package/dist/cost.js +60 -3
- package/dist/doctor.js +92 -0
- package/dist/gateway/auth.js +2 -2
- package/dist/gateway/ledger.js +2 -2
- package/dist/gateway/scheduler.js +1 -0
- package/dist/gateway/serve.js +6 -4
- package/dist/gateway/server.js +10 -2
- package/dist/git.js +11 -2
- package/dist/hooks.js +43 -17
- package/dist/knowledge.js +48 -49
- package/dist/loop.js +182 -66
- package/dist/lsp/client.js +173 -0
- package/dist/lsp/framing.js +56 -0
- package/dist/lsp/index.js +138 -0
- package/dist/lsp/servers.js +82 -0
- package/dist/mcp-server.js +244 -0
- package/dist/mcp.js +184 -29
- package/dist/memory-store.js +559 -0
- package/dist/memory.js +143 -29
- package/dist/orchestrate.js +150 -0
- package/dist/providers/codex.js +21 -7
- package/dist/providers/keys.js +3 -2
- package/dist/providers/models.js +22 -6
- package/dist/providers/registry.js +155 -1
- package/dist/repomap.js +93 -0
- package/dist/search/chunk.js +158 -0
- package/dist/search/embed-store.js +187 -0
- package/dist/search/engine.js +203 -0
- package/dist/search/fuse.js +35 -0
- package/dist/search/index-core.js +187 -0
- package/dist/search/indexer.js +241 -0
- package/dist/search/store.js +77 -0
- package/dist/session.js +42 -8
- package/dist/skill-install.js +10 -10
- package/dist/skills.js +12 -9
- package/dist/summarize.js +31 -0
- package/dist/tools/bash.js +21 -2
- package/dist/tools/diagnostics.js +41 -0
- package/dist/tools/edit.js +29 -7
- package/dist/tools/index.js +8 -1
- package/dist/tools/list.js +7 -2
- package/dist/tools/permission.js +90 -9
- package/dist/tools/read.js +23 -4
- package/dist/tools/remember.js +1 -1
- package/dist/tools/sandbox.js +61 -0
- package/dist/tools/search.js +105 -4
- package/dist/tools/task.js +195 -29
- package/dist/tools/timeout.js +35 -0
- package/dist/tools/util.js +10 -0
- package/dist/tools/write.js +6 -4
- package/dist/trust.js +89 -0
- package/dist/ui/app.js +228 -31
- package/dist/ui/banner.js +4 -9
- package/dist/ui/brain-wizard.js +2 -2
- package/dist/ui/history.js +30 -0
- package/dist/ui/mentions.js +44 -0
- package/dist/ui/render.js +55 -15
- package/dist/ui/setup.js +97 -12
- package/dist/ui/useEditor.js +83 -0
- package/dist/update.js +114 -0
- package/dist/worktree.js +173 -0
- package/package.json +11 -5
- package/scripts/postinstall.mjs +33 -0
- package/second-brain/.agents/_Index.md +30 -0
- package/second-brain/.agents/skills/_Index.md +30 -0
- package/second-brain/.agents/workflows/_Index.md +30 -0
- package/second-brain/AGENTS.md +4 -4
- package/second-brain/Acceptance/_Index.md +30 -0
- package/second-brain/Acceptance/golden-case-template.md +39 -0
- package/second-brain/Areas/_Index.md +30 -0
- package/second-brain/Bugs/System-OS/_Index.md +30 -0
- package/second-brain/Bugs/_Index.md +30 -0
- package/second-brain/CLAUDE.md +4 -1
- package/second-brain/Checklists/_Index.md +30 -0
- package/second-brain/Checklists/preflight-postflight-template.md +29 -0
- package/second-brain/Distillations/_Index.md +30 -0
- package/second-brain/Entities/_Index.md +30 -0
- package/second-brain/Entities/entity-template.md +33 -0
- package/second-brain/Evals/_Index.md +30 -0
- package/second-brain/Evals/correction-pairs.md +24 -0
- package/second-brain/Evals/failure-taxonomy.md +24 -0
- package/second-brain/Evals/golden-set.md +25 -0
- package/second-brain/Evals/quality-ledger.md +23 -0
- package/second-brain/Evals/self-eval-rubric.md +23 -0
- package/second-brain/GEMINI.md +4 -4
- package/second-brain/Goals/_Index.md +30 -0
- package/second-brain/Handoffs/_Index.md +30 -0
- package/second-brain/Home.md +7 -0
- package/second-brain/Intake/Raw Sources/_Index.md +30 -0
- package/second-brain/Intake/_Index.md +30 -0
- package/second-brain/Intake/_Quarantine/_Index.md +30 -0
- package/second-brain/Learning/_Index.md +30 -0
- package/second-brain/Playbooks/_Index.md +30 -0
- package/second-brain/Playbooks/playbook-template.md +23 -0
- package/second-brain/Projects/_Index.md +30 -0
- package/second-brain/Prompts/_Index.md +30 -0
- package/second-brain/README.md +2 -1
- package/second-brain/Research/_Index.md +30 -0
- package/second-brain/Retrospectives/_Index.md +30 -0
- package/second-brain/Reviews/_Index.md +30 -0
- package/second-brain/Runbooks/_Index.md +30 -0
- package/second-brain/Runbooks/eval-loop.md +24 -0
- package/second-brain/Sessions/_Index.md +30 -0
- package/second-brain/Shared/AI-Context-Index.md +20 -0
- package/second-brain/Shared/AI-Threads/_Index.md +30 -0
- package/second-brain/Shared/Archive/_Index.md +30 -0
- package/second-brain/Shared/Assets/_Index.md +30 -0
- package/second-brain/Shared/Context-Packs/_Index.md +30 -0
- package/second-brain/Shared/Context7-Docs/_Index.md +30 -0
- package/second-brain/Shared/Coordination/NOW.md +28 -0
- package/second-brain/Shared/Coordination/_Index.md +30 -0
- package/second-brain/Shared/Coordination/agent-registry.md +24 -0
- package/second-brain/Shared/Coordination/task-board/_Index.md +30 -0
- package/second-brain/Shared/Coordination/task-board/task-template.md +43 -0
- package/second-brain/Shared/Coordination/task-board.md +32 -0
- package/second-brain/Shared/Core-Facts/_Index.md +30 -0
- package/second-brain/Shared/Decision-Memory/_Index.md +30 -0
- package/second-brain/Shared/Glossary/_Index.md +30 -0
- package/second-brain/Shared/Memory-Inbox/_Index.md +30 -0
- package/second-brain/Shared/Operating-State/_Index.md +30 -0
- package/second-brain/Shared/Prompting/_Index.md +30 -0
- package/second-brain/Shared/Provenance/_Index.md +30 -0
- package/second-brain/Shared/Rules/_Index.md +30 -0
- package/second-brain/Shared/Rules/contextual-note-rule.md +30 -0
- package/second-brain/Shared/Rules/frontmatter-standard.md +10 -0
- package/second-brain/Shared/Rules/memory-write-protocol.md +28 -0
- package/second-brain/Shared/Rules/procedural-runbook-header.md +40 -0
- package/second-brain/Shared/Rules/review-and-staleness-policy.md +22 -0
- package/second-brain/Shared/Rules/rules-formatting.md +34 -0
- package/second-brain/Shared/Scripts/_Index.md +30 -0
- package/second-brain/Shared/Scripts-Archive/_Index.md +30 -0
- package/second-brain/Shared/Tech-Standards/_Index.md +30 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +40 -0
- package/second-brain/Shared/User-Memory/_Index.md +30 -0
- package/second-brain/Shared/User-Persona/_Index.md +30 -0
- package/second-brain/Shared/User-Persona/owner-profile.md +25 -0
- package/second-brain/Shared/Working-Memory/_Index.md +30 -0
- package/second-brain/Shared/_Index.md +30 -0
- package/second-brain/Shared/mcp-servers/_Index.md +30 -0
- package/second-brain/Skills/_Index.md +30 -0
- package/second-brain/Templates/_Index.md +30 -0
- package/second-brain/Templates/bug.md +2 -0
- package/second-brain/Templates/handoff.md +2 -0
- package/second-brain/Templates/session.md +2 -0
- package/second-brain/Tools/_Index.md +30 -0
- package/second-brain/Traces/_Index.md +30 -0
- package/second-brain/Vault Structure Map.md +33 -1
- package/second-brain/copilot/_Index.md +30 -0
- package/skills/audit-license-compliance/SKILL.md +117 -0
- package/skills/author-codemod/SKILL.md +110 -0
- package/skills/build-audit-logging/SKILL.md +112 -0
- package/skills/build-cdc-streaming-pipeline/SKILL.md +123 -0
- package/skills/build-cli-tool/SKILL.md +108 -0
- package/skills/build-data-table/SKILL.md +141 -0
- package/skills/build-native-mobile-ui/SKILL.md +154 -0
- package/skills/build-offline-first-sync/SKILL.md +118 -0
- package/skills/build-realtime-channel/SKILL.md +122 -0
- package/skills/build-vector-search/SKILL.md +131 -0
- package/skills/compose-local-dev-stack/SKILL.md +149 -0
- package/skills/configure-bundler-build/SKILL.md +166 -0
- package/skills/configure-dns-tls/SKILL.md +142 -0
- package/skills/configure-reverse-proxy-lb/SKILL.md +129 -0
- package/skills/configure-security-headers-csp/SKILL.md +122 -0
- package/skills/contract-testing/SKILL.md +140 -0
- package/skills/datetime-timezone-correctness/SKILL.md +125 -0
- package/skills/debug-ci-pipeline-failure/SKILL.md +134 -0
- package/skills/debug-flaky-tests/SKILL.md +128 -0
- package/skills/defend-llm-prompt-injection/SKILL.md +110 -0
- package/skills/deliver-webhooks/SKILL.md +116 -0
- package/skills/design-api-pagination/SKILL.md +144 -0
- package/skills/design-authorization-model/SKILL.md +119 -0
- package/skills/design-backup-dr-recovery/SKILL.md +113 -0
- package/skills/design-event-sourcing-cqrs/SKILL.md +143 -0
- package/skills/design-multi-tenancy/SKILL.md +100 -0
- package/skills/design-protobuf-grpc-service/SKILL.md +146 -0
- package/skills/design-relational-schema/SKILL.md +129 -0
- package/skills/design-search-index-infra/SKILL.md +151 -0
- package/skills/design-state-machine/SKILL.md +108 -0
- package/skills/design-token-system/SKILL.md +109 -0
- package/skills/distributed-locks-leases/SKILL.md +120 -0
- package/skills/encrypt-sensitive-data/SKILL.md +148 -0
- package/skills/feature-flags-rollout/SKILL.md +130 -0
- package/skills/file-upload-object-storage/SKILL.md +107 -0
- package/skills/fuzz-dynamic-security-test/SKILL.md +111 -0
- package/skills/harden-llm-app-reliability/SKILL.md +126 -0
- package/skills/i18n-localization-setup/SKILL.md +113 -0
- package/skills/idempotency-keys/SKILL.md +107 -0
- package/skills/implement-push-notifications/SKILL.md +142 -0
- package/skills/ingest-webhook-secure/SKILL.md +120 -0
- package/skills/integrate-oauth-oidc/SKILL.md +126 -0
- package/skills/load-stress-test/SKILL.md +129 -0
- package/skills/map-privacy-data-gdpr/SKILL.md +146 -0
- package/skills/model-nosql-data/SKILL.md +118 -0
- package/skills/money-decimal-arithmetic/SKILL.md +123 -0
- package/skills/monitor-ml-drift/SKILL.md +109 -0
- package/skills/numeric-precision-units/SKILL.md +144 -0
- package/skills/optimize-llm-cost-latency/SKILL.md +103 -0
- package/skills/optimize-react-rerenders/SKILL.md +124 -0
- package/skills/orchestrate-agent-workflow/SKILL.md +100 -0
- package/skills/payments-billing-integration/SKILL.md +114 -0
- package/skills/pin-toolchain-versions/SKILL.md +116 -0
- package/skills/plan-strangler-migration/SKILL.md +95 -0
- package/skills/property-based-testing/SKILL.md +108 -0
- package/skills/publish-package-registry/SKILL.md +130 -0
- package/skills/recover-git-state/SKILL.md +119 -0
- package/skills/remediate-web-vulnerabilities/SKILL.md +125 -0
- package/skills/resilience-timeouts-retries/SKILL.md +104 -0
- package/skills/resolve-merge-rebase-conflict/SKILL.md +97 -0
- package/skills/rewrite-git-history/SKILL.md +109 -0
- package/skills/scaffold-cross-platform-app/SKILL.md +137 -0
- package/skills/schema-evolution-compatibility/SKILL.md +121 -0
- package/skills/send-transactional-email/SKILL.md +126 -0
- package/skills/serve-deploy-ml-model/SKILL.md +107 -0
- package/skills/setup-cdn-edge-waf/SKILL.md +107 -0
- package/skills/setup-devcontainer-env/SKILL.md +131 -0
- package/skills/setup-lint-format-precommit/SKILL.md +140 -0
- package/skills/setup-monorepo-tooling/SKILL.md +125 -0
- package/skills/ship-mobile-app-store-release/SKILL.md +137 -0
- package/skills/structured-output-llm/SKILL.md +86 -0
- package/skills/supply-chain-sbom-provenance/SKILL.md +120 -0
- package/skills/test-data-factories/SKILL.md +158 -0
- package/skills/threat-model-stride/SKILL.md +123 -0
- package/skills/train-evaluate-ml-model/SKILL.md +109 -0
- package/skills/unicode-text-correctness/SKILL.md +109 -0
- package/skills/visual-regression-testing/SKILL.md +120 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// src/search/store.ts — the ONLY disk-touching module of the search subsystem.
|
|
3
|
+
//
|
|
4
|
+
// Mirrors memory-store.ts's FS discipline exactly: atomic tmp+rename writes,
|
|
5
|
+
// 0o600 permissions, honors persistenceEnabled(), and loadIndex() NEVER writes
|
|
6
|
+
// (read paths stay pure). The persisted payload is one JSON file next to
|
|
7
|
+
// memory.json under ~/.sanook/search/index.json — no SQLite file, no native db.
|
|
8
|
+
// Vectors live in their own sidecar (embed-store.ts) so the BM25 floor never
|
|
9
|
+
// pays to read them.
|
|
10
|
+
// ============================================================================
|
|
11
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { appHomePath, persistenceEnabled } from '../brand.js';
|
|
15
|
+
import { emptyIndex, indexFromJSON, indexToJSON, } from './index-core.js';
|
|
16
|
+
export const SEARCH_DIR = appHomePath('search');
|
|
17
|
+
export const INDEX_PATH = join(SEARCH_DIR, 'index.json');
|
|
18
|
+
const FILE_VERSION = 1;
|
|
19
|
+
async function pathExists(p) {
|
|
20
|
+
try {
|
|
21
|
+
await stat(p);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** mtime of the on-disk index (ms) or 0 if absent — lets the engine cache-invalidate cheaply. */
|
|
29
|
+
export async function indexMtimeMs() {
|
|
30
|
+
try {
|
|
31
|
+
return (await stat(INDEX_PATH)).mtimeMs;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Load the persisted index + manifest. Pure read: a missing or malformed file
|
|
39
|
+
* degrades to an empty index rather than throwing, so a corrupt cache never
|
|
40
|
+
* bricks search — the next index() rebuilds it.
|
|
41
|
+
*/
|
|
42
|
+
export async function loadIndex() {
|
|
43
|
+
try {
|
|
44
|
+
const raw = JSON.parse(await readFile(INDEX_PATH, 'utf8'));
|
|
45
|
+
if (raw && raw.v === FILE_VERSION) {
|
|
46
|
+
return { index: indexFromJSON(raw.index), manifest: raw.manifest ?? {} };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* no file yet, or malformed → fall through to empty */
|
|
51
|
+
}
|
|
52
|
+
return { index: emptyIndex(), manifest: {} };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Persist index + manifest atomically (tmp+rename), 0o600. No-op when persistence
|
|
56
|
+
* is disabled (the in-memory index still works for the process, just uncached).
|
|
57
|
+
*/
|
|
58
|
+
export async function saveIndex(index, manifest) {
|
|
59
|
+
if (!persistenceEnabled())
|
|
60
|
+
return;
|
|
61
|
+
await mkdir(SEARCH_DIR, { recursive: true });
|
|
62
|
+
const payload = { v: FILE_VERSION, index: indexToJSON(index), manifest };
|
|
63
|
+
const tmp = join(SEARCH_DIR, `index.${randomUUID()}.tmp`);
|
|
64
|
+
try {
|
|
65
|
+
await writeFile(tmp, `${JSON.stringify(payload)}\n`, { mode: 0o600 });
|
|
66
|
+
await chmod(tmp, 0o600).catch(() => { });
|
|
67
|
+
await rename(tmp, INDEX_PATH);
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
await rm(tmp, { force: true }).catch(() => { });
|
|
71
|
+
throw e;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** true if a persisted index already exists on disk. */
|
|
75
|
+
export function hasIndex() {
|
|
76
|
+
return pathExists(INDEX_PATH);
|
|
77
|
+
}
|
package/dist/session.js
CHANGED
|
@@ -1,16 +1,45 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { chmod, readFile, writeFile, mkdir, readdir, realpath } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { appHomePath, persistenceEnabled } from './brand.js';
|
|
4
|
+
import { redactKey } from './providers/keys.js';
|
|
4
5
|
// session store — จำ conversation + ความคืบหน้า เพื่อ "ทำงานต่อได้" ไม่ลืมว่าทำถึงไหน
|
|
5
|
-
const SESSION_DIR =
|
|
6
|
+
const SESSION_DIR = appHomePath('sessions');
|
|
6
7
|
export function newSessionId() {
|
|
7
8
|
// CLI runtime — ใช้ Date/random ได้ (ไม่ใช่ workflow context)
|
|
8
9
|
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
9
10
|
return `${ts}-${Math.random().toString(36).slice(2, 8)}`;
|
|
10
11
|
}
|
|
12
|
+
function redactUnknown(value) {
|
|
13
|
+
if (typeof value === 'string')
|
|
14
|
+
return redactKey(value);
|
|
15
|
+
if (Array.isArray(value))
|
|
16
|
+
return value.map(redactUnknown);
|
|
17
|
+
if (value && typeof value === 'object') {
|
|
18
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactUnknown(v)]));
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
function sanitizeSession(s) {
|
|
23
|
+
return {
|
|
24
|
+
...s,
|
|
25
|
+
messages: redactUnknown(s.messages),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
async function canonicalPath(path) {
|
|
29
|
+
try {
|
|
30
|
+
return await realpath(path);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return resolve(path);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
11
36
|
export async function saveSession(s) {
|
|
37
|
+
if (!persistenceEnabled())
|
|
38
|
+
return;
|
|
12
39
|
await mkdir(SESSION_DIR, { recursive: true });
|
|
13
|
-
|
|
40
|
+
const path = join(SESSION_DIR, `${s.id}.json`);
|
|
41
|
+
await writeFile(path, `${JSON.stringify(sanitizeSession(s), null, 2)}\n`, { mode: 0o600 });
|
|
42
|
+
await chmod(path, 0o600).catch(() => { });
|
|
14
43
|
}
|
|
15
44
|
export async function loadSession(id) {
|
|
16
45
|
try {
|
|
@@ -20,13 +49,18 @@ export async function loadSession(id) {
|
|
|
20
49
|
return null;
|
|
21
50
|
}
|
|
22
51
|
}
|
|
23
|
-
/** session ล่าสุด (สำหรับ --continue) */
|
|
24
|
-
export async function latestSession() {
|
|
52
|
+
/** session ล่าสุด (สำหรับ --continue). ค่า default จำกัดเฉพาะ cwd ปัจจุบัน กัน context ข้าม project */
|
|
53
|
+
export async function latestSession(cwd = process.cwd()) {
|
|
25
54
|
try {
|
|
26
55
|
const ids = (await readdir(SESSION_DIR)).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5));
|
|
27
56
|
if (!ids.length)
|
|
28
57
|
return null;
|
|
29
|
-
|
|
58
|
+
let sessions = (await Promise.all(ids.map(loadSession))).filter((s) => s !== null);
|
|
59
|
+
if (cwd) {
|
|
60
|
+
const current = await canonicalPath(cwd);
|
|
61
|
+
const pairs = await Promise.all(sessions.map(async (s) => ({ session: s, cwd: await canonicalPath(s.cwd) })));
|
|
62
|
+
sessions = pairs.filter((p) => p.cwd === current).map((p) => p.session);
|
|
63
|
+
}
|
|
30
64
|
sessions.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
31
65
|
return sessions[0] ?? null;
|
|
32
66
|
}
|
package/dist/skill-install.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir, readdir, rm, stat, lstat, copyFile } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join, basename, resolve, sep, dirname } from 'node:path';
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
@@ -7,8 +7,9 @@ import { randomUUID } from 'node:crypto';
|
|
|
7
7
|
import { lookup } from 'node:dns/promises';
|
|
8
8
|
import { isIP } from 'node:net';
|
|
9
9
|
import { parseFrontmatter, isValidSkillName } from './skills.js';
|
|
10
|
+
import { appHomePath, BRAND } from './brand.js';
|
|
10
11
|
const execFileAsync = promisify(execFile);
|
|
11
|
-
const USER_SKILLS =
|
|
12
|
+
const USER_SKILLS = appHomePath('skills');
|
|
12
13
|
const MAX_FILES = 300;
|
|
13
14
|
const MAX_BYTES = 20 * 1024 * 1024; // 20MB ต่อ skill
|
|
14
15
|
const MAX_MD = 2 * 1024 * 1024; // 2MB ต่อ SKILL.md จาก URL
|
|
@@ -75,6 +76,7 @@ async function installFromContent(content, fallbackName) {
|
|
|
75
76
|
if (!isValidSkillName(name))
|
|
76
77
|
throw new Error(`ชื่อ skill ไม่ถูกต้อง: "${name}"`);
|
|
77
78
|
const dest = join(USER_SKILLS, name);
|
|
79
|
+
await rm(dest, { recursive: true, force: true });
|
|
78
80
|
await mkdir(dest, { recursive: true });
|
|
79
81
|
await writeFile(join(dest, 'SKILL.md'), content);
|
|
80
82
|
return { name, path: dest };
|
|
@@ -109,7 +111,7 @@ async function installFromLocal(path, onLog) {
|
|
|
109
111
|
}
|
|
110
112
|
/** clone GitHub repo (shallow) → ติดตั้ง skill — subPath ต้องอยู่ใต้ clone dir (กัน traversal escape) */
|
|
111
113
|
async function installFromGitHub(repoUrl, subPath, onLog) {
|
|
112
|
-
const tmp = join(tmpdir(),
|
|
114
|
+
const tmp = join(tmpdir(), `${BRAND.skillTempPrefix}${randomUUID().slice(0, 8)}`);
|
|
113
115
|
try {
|
|
114
116
|
onLog?.(`clone ${repoUrl} …`);
|
|
115
117
|
// execFile (no shell) + '--' กัน url ขึ้นต้น '-' ถูกตีเป็น git option + timeout
|
|
@@ -138,14 +140,12 @@ async function fetchSkillMd(url) {
|
|
|
138
140
|
if (u.protocol !== 'https:')
|
|
139
141
|
throw new Error('รองรับเฉพาะ https สำหรับ URL ของ SKILL.md');
|
|
140
142
|
// resolve hostname → block private/loopback IP (กัน SSRF ยิง internal/cloud-metadata)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
ip = res?.address ?? '';
|
|
145
|
-
}
|
|
146
|
-
if (!ip || PRIVATE_IP.test(ip))
|
|
143
|
+
const host = u.hostname.replace(/^\[|\]$/g, '');
|
|
144
|
+
const ips = isIP(host) ? [host] : (await lookup(host, { all: true }).catch(() => [])).map((r) => r.address);
|
|
145
|
+
if (!ips.length || ips.some((ip) => PRIVATE_IP.test(ip))) {
|
|
147
146
|
throw new Error(`URL ชี้ไป internal/private address — ปฏิเสธ (${u.hostname})`);
|
|
148
|
-
|
|
147
|
+
}
|
|
148
|
+
const r = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(30_000) });
|
|
149
149
|
if (!r.ok)
|
|
150
150
|
throw new Error(`fetch ไม่สำเร็จ: HTTP ${r.status}`);
|
|
151
151
|
if (Number(r.headers.get('content-length') ?? 0) > MAX_MD)
|
package/dist/skills.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
2
|
import { join, dirname } from 'node:path';
|
|
4
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { appHomePath } from './brand.js';
|
|
5
|
+
import { projectConfigPathIfTrusted } from './trust.js';
|
|
5
6
|
// skills = วิธีทำงานเฉพาะทาง/runbook ที่โหลด on-demand (progressive disclosure)
|
|
6
7
|
// agent เห็นแค่ name+description ใน system prompt → โหลดเต็มด้วย `skill` tool เมื่อ task ตรง
|
|
7
8
|
// self-improvement: agent สร้าง skill เองด้วย `create_skill` เมื่อเจอ procedure ที่ reuse ได้
|
|
8
9
|
// 3 ชั้น: bundled (ship กับ CLI) → global (~/.sanook) → project (.sanook) — ชั้นหลัง override ชื่อซ้ำ
|
|
9
10
|
const BUNDLED_SKILLS = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
|
|
10
|
-
const GLOBAL_SKILLS =
|
|
11
|
-
const projectSkills = () => join(process.cwd(), '.sanook', 'skills');
|
|
11
|
+
const GLOBAL_SKILLS = appHomePath('skills');
|
|
12
12
|
/** minimal frontmatter parser (key: value ใน --- block) — ไม่พึ่ง YAML dep */
|
|
13
13
|
export function parseFrontmatter(content) {
|
|
14
14
|
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
@@ -34,10 +34,11 @@ export function isValidSkillName(name) {
|
|
|
34
34
|
return /^[a-z0-9][a-z0-9-]{0,63}$/.test(name);
|
|
35
35
|
}
|
|
36
36
|
/** scan project + global skills → list (name+description เท่านั้น สำหรับ inject). project ทับ global ชื่อซ้ำ */
|
|
37
|
-
export async function loadSkills() {
|
|
37
|
+
export async function loadSkills(cwd = process.cwd()) {
|
|
38
38
|
const out = new Map();
|
|
39
|
+
const projectSkills = await projectConfigPathIfTrusted('skills', cwd);
|
|
39
40
|
// bundled ก่อน → global → project ทับ (specific กว่าอยู่ท้าย)
|
|
40
|
-
for (const dir of [BUNDLED_SKILLS, GLOBAL_SKILLS, projectSkills()
|
|
41
|
+
for (const dir of [BUNDLED_SKILLS, GLOBAL_SKILLS, projectSkills].filter((d) => Boolean(d))) {
|
|
41
42
|
let entries;
|
|
42
43
|
try {
|
|
43
44
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -51,8 +52,9 @@ export async function loadSkills() {
|
|
|
51
52
|
const p = join(dir, e.name, 'SKILL.md');
|
|
52
53
|
try {
|
|
53
54
|
const { meta } = parseFrontmatter(await readFile(p, 'utf8'));
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
const name = meta.name && isValidSkillName(meta.name) ? meta.name : e.name;
|
|
56
|
+
out.set(name, {
|
|
57
|
+
name,
|
|
56
58
|
description: meta.description ?? '',
|
|
57
59
|
whenToUse: meta.when_to_use,
|
|
58
60
|
path: p,
|
|
@@ -66,10 +68,11 @@ export async function loadSkills() {
|
|
|
66
68
|
return [...out.values()];
|
|
67
69
|
}
|
|
68
70
|
/** อ่านเนื้อหา SKILL.md เต็ม (skill tool เรียกเมื่อ agent ตัดสินใจใช้) */
|
|
69
|
-
export async function getSkillBody(name) {
|
|
71
|
+
export async function getSkillBody(name, cwd = process.cwd()) {
|
|
70
72
|
if (!isValidSkillName(name))
|
|
71
73
|
return null;
|
|
72
|
-
|
|
74
|
+
const projectSkills = await projectConfigPathIfTrusted('skills', cwd);
|
|
75
|
+
for (const dir of [projectSkills, GLOBAL_SKILLS, BUNDLED_SKILLS].filter((d) => Boolean(d))) {
|
|
73
76
|
try {
|
|
74
77
|
return await readFile(join(dir, name, 'SKILL.md'), 'utf8');
|
|
75
78
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// src/summarize.ts — cheap-model transcript summarizer for compaction.
|
|
3
|
+
//
|
|
4
|
+
// compaction='summarize' replaces the dropped middle of a long conversation with
|
|
5
|
+
// a condensed brief instead of truncating it — better recall at the same token
|
|
6
|
+
// budget. The summary runs on a CHEAP model (the fast sibling of the main model,
|
|
7
|
+
// same provider/key) so the saving isn't eaten by the summarization call itself.
|
|
8
|
+
// resolveModel() is called lazily inside the returned fn, so a missing key throws
|
|
9
|
+
// at summarize-time and summarizeCompact() catches it → falls back to truncation.
|
|
10
|
+
// ============================================================================
|
|
11
|
+
import { generateText } from 'ai';
|
|
12
|
+
import { resolveModel, fastSibling } from './providers/registry.js';
|
|
13
|
+
const SUMMARY_PROMPT = 'You are compacting a coding-session transcript so the agent can CONTINUE the work with less context. ' +
|
|
14
|
+
'Write a terse factual brief (bullet points, no preamble) that preserves: the task/intent, decisions made, ' +
|
|
15
|
+
'files created or changed, key findings, and unfinished TODOs. Drop chit-chat and verbose tool output.\n\nTRANSCRIPT:\n';
|
|
16
|
+
/**
|
|
17
|
+
* Build a summarizer using a cheap model — `summaryModel` if set, else the fast
|
|
18
|
+
* sibling of `mainModel` (same provider, cheaper tier). Returns a fn ready for
|
|
19
|
+
* compaction.summarizeCompact().
|
|
20
|
+
*/
|
|
21
|
+
export function makeSummarizer(mainModel, summaryModel) {
|
|
22
|
+
const spec = summaryModel ?? fastSibling(mainModel);
|
|
23
|
+
return async (transcript) => {
|
|
24
|
+
const { text } = await generateText({
|
|
25
|
+
model: resolveModel(spec), // lazy: throws here if no key → caller falls back to truncation
|
|
26
|
+
prompt: SUMMARY_PROMPT + transcript,
|
|
27
|
+
maxOutputTokens: 1024,
|
|
28
|
+
});
|
|
29
|
+
return text;
|
|
30
|
+
};
|
|
31
|
+
}
|
package/dist/tools/bash.js
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import { tool } from 'ai';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { exec } from 'node:child_process';
|
|
3
|
+
import { exec, execFile } from 'node:child_process';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { clamp } from './util.js';
|
|
6
6
|
import { checkBash } from './permission.js';
|
|
7
|
+
import { maybeSandbox } from './sandbox.js';
|
|
8
|
+
import { agentCwd } from '../agentContext.js';
|
|
7
9
|
const execAsync = promisify(exec);
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const SAFE_ENV_KEYS = ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'LANG', 'LC_ALL', 'USER', 'SHELL', 'TERM', 'NODE_PATH', 'NVM_DIR', 'APPDATA'];
|
|
12
|
+
function safeEnv() {
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const k of SAFE_ENV_KEYS) {
|
|
15
|
+
const v = process.env[k];
|
|
16
|
+
if (v != null)
|
|
17
|
+
out[k] = v;
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
8
21
|
export const bashTool = tool({
|
|
9
22
|
description: 'รันคำสั่ง shell (ls/grep/find/cat/test/npm ฯลฯ) เพื่อค้นหา ตรวจสอบ หรือรัน build/test',
|
|
10
23
|
inputSchema: z.object({
|
|
@@ -14,8 +27,14 @@ export const bashTool = tool({
|
|
|
14
27
|
const guard = checkBash(cmd);
|
|
15
28
|
if (!guard.ok)
|
|
16
29
|
return `BLOCKED: ${guard.reason}`;
|
|
30
|
+
const cwd = agentCwd(); // worktree ของ sub-agent ถ้ามี (sandbox confine write ตาม cwd นี้)
|
|
31
|
+
const opts = { cwd, env: safeEnv(), timeout: 120_000, maxBuffer: 10 * 1024 * 1024 };
|
|
17
32
|
try {
|
|
18
|
-
|
|
33
|
+
// OS sandbox (Seatbelt/bubblewrap) confine write ให้อยู่ใน workspace ถ้ามี — ไม่งั้นรันตรงตามเดิม
|
|
34
|
+
const sb = await maybeSandbox(cmd, cwd);
|
|
35
|
+
const { stdout, stderr } = sb
|
|
36
|
+
? await execFileAsync(sb.file, sb.args, opts)
|
|
37
|
+
: await execAsync(cmd, opts);
|
|
19
38
|
const out = (stdout + (stderr ? `\n[stderr]\n${stderr}` : '')).trim();
|
|
20
39
|
return clamp(out) || '(no output)';
|
|
21
40
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { tool } from 'ai';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { diagnose } from '../lsp/index.js';
|
|
4
|
+
import { resolveAgentPath } from './util.js';
|
|
5
|
+
import { agentCwd } from '../agentContext.js';
|
|
6
|
+
import { checkReadPath } from './permission.js';
|
|
7
|
+
import { clamp } from './util.js';
|
|
8
|
+
const SYM = { error: '✗', warning: '⚠', info: 'ℹ', hint: '·' };
|
|
9
|
+
const MAX_SHOWN = 100;
|
|
10
|
+
/**
|
|
11
|
+
* diagnostics tool — type errors/warnings จาก language server (LSP) ของไฟล์เดียว
|
|
12
|
+
* โดยไม่ต้อง build ทั้งโปรเจค. ปิด verify-loop: agent แก้ไฟล์ → ตรวจ → แก้ error ต่อทันที.
|
|
13
|
+
* graceful: ไม่มี server ติดตั้ง → บอกวิธีติดตั้ง (ไม่ crash). respect worktree (agentCwd).
|
|
14
|
+
*/
|
|
15
|
+
export const diagnosticsTool = tool({
|
|
16
|
+
description: 'ตรวจ type error / warning ของไฟล์ด้วย language server (LSP) — เรียก "หลังแก้ไฟล์" เพื่อจับ error ทันทีโดยไม่ต้อง build/test ทั้งโปรเจค. ' +
|
|
17
|
+
'รองรับ TS/JS · Python · Go · Rust · JSON ฯลฯ (ถ้าติดตั้ง LSP server ไว้; ไม่มี = บอกวิธีติดตั้ง). ' +
|
|
18
|
+
'ใส่ content เพื่อตรวจฉบับที่ยังไม่ save ได้',
|
|
19
|
+
inputSchema: z.object({
|
|
20
|
+
path: z.string().describe('path ไฟล์ที่จะตรวจ'),
|
|
21
|
+
content: z.string().optional().describe('เนื้อหาที่จะตรวจ (ฉบับยังไม่ save) — ไม่ใส่ = อ่านจากดิสก์'),
|
|
22
|
+
}),
|
|
23
|
+
execute: async ({ path, content }) => {
|
|
24
|
+
const full = resolveAgentPath(path);
|
|
25
|
+
const guard = await checkReadPath(full);
|
|
26
|
+
if (!guard.ok)
|
|
27
|
+
return `BLOCKED: ${guard.reason}`;
|
|
28
|
+
const r = await diagnose(full, { cwd: agentCwd(), content });
|
|
29
|
+
if (!r.ok)
|
|
30
|
+
return `LSP: ${r.reason}`;
|
|
31
|
+
if (!r.diagnostics.length)
|
|
32
|
+
return `✓ ไม่มี diagnostics (${r.serverId}) — ${path}`;
|
|
33
|
+
const errs = r.diagnostics.filter((d) => d.severity === 'error').length;
|
|
34
|
+
const warns = r.diagnostics.filter((d) => d.severity === 'warning').length;
|
|
35
|
+
const lines = r.diagnostics
|
|
36
|
+
.slice(0, MAX_SHOWN)
|
|
37
|
+
.map((d) => `${SYM[d.severity]} ${path}:${d.line}:${d.character} ${d.message}${d.code != null ? ` [${d.code}]` : ''}`);
|
|
38
|
+
const more = r.diagnostics.length > MAX_SHOWN ? `\n… +${r.diagnostics.length - MAX_SHOWN} เพิ่มเติม` : '';
|
|
39
|
+
return clamp(`${errs} error · ${warns} warning (${r.serverId}):\n${lines.join('\n')}${more}`);
|
|
40
|
+
},
|
|
41
|
+
});
|
package/dist/tools/edit.js
CHANGED
|
@@ -2,6 +2,7 @@ import { tool } from 'ai';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { checkWritePath } from './permission.js';
|
|
5
|
+
import { resolveAgentPath } from './util.js';
|
|
5
6
|
import { renderEditDiff } from '../diff.js';
|
|
6
7
|
/** tier 1: exact substring match + นับจำนวนครั้ง */
|
|
7
8
|
export function exactMatch(content, needle) {
|
|
@@ -58,14 +59,18 @@ export function findMatch(content, needle) {
|
|
|
58
59
|
return exactMatch(content, needle) ?? whitespaceFlexMatch(content, needle);
|
|
59
60
|
}
|
|
60
61
|
export const editFileTool = tool({
|
|
61
|
-
description: '
|
|
62
|
+
description: 'แก้ไฟล์แบบ search/replace — แทนที่เฉพาะ "ช่วงที่ส่งมา" ไม่ใช่ทั้งไฟล์/ทั้งบรรทัด. ' +
|
|
63
|
+
'ให้ old_string สั้นที่สุดเท่าที่ยัง unique (ประหยัด token — ไม่ต้องลอกทั้งบรรทัด/ทั้ง block ถ้าไม่จำเป็น). ' +
|
|
64
|
+
'จะแก้ token เดิมหลายที่ (rename) → ตั้ง replace_all:true แล้วใส่ old_string สั้นๆ ได้เลย ไม่ต้องทำให้ unique. อ่านไฟล์ด้วย read_file ก่อนเสมอ',
|
|
62
65
|
inputSchema: z.object({
|
|
63
66
|
path: z.string().describe('path ของไฟล์ที่จะแก้'),
|
|
64
|
-
old_string: z.string().describe('ข้อความเดิมที่จะถูกแทนที่ (
|
|
67
|
+
old_string: z.string().describe('ข้อความเดิมที่จะถูกแทนที่ — สั้นที่สุดที่ยัง unique (replace_all:true ไม่ต้อง unique)'),
|
|
65
68
|
new_string: z.string().describe('ข้อความใหม่'),
|
|
69
|
+
replace_all: z.boolean().optional().describe('true = แทนที่ทุกที่ที่ตรง old_string เป๊ะ (เหมาะกับ rename) — old_string ไม่ต้อง unique'),
|
|
66
70
|
}),
|
|
67
|
-
execute: async ({ path, old_string, new_string }) => {
|
|
68
|
-
const
|
|
71
|
+
execute: async ({ path, old_string, new_string, replace_all = false }) => {
|
|
72
|
+
const full = resolveAgentPath(path); // relative ผูกกับ agentCwd (worktree ของ sub-agent ถ้ามี)
|
|
73
|
+
const guard = await checkWritePath(full);
|
|
69
74
|
if (!guard.ok)
|
|
70
75
|
return `BLOCKED: ${guard.reason}`;
|
|
71
76
|
if (old_string === '')
|
|
@@ -75,7 +80,7 @@ export const editFileTool = tool({
|
|
|
75
80
|
}
|
|
76
81
|
let raw;
|
|
77
82
|
try {
|
|
78
|
-
raw = await readFile(
|
|
83
|
+
raw = await readFile(full, 'utf8');
|
|
79
84
|
}
|
|
80
85
|
catch (err) {
|
|
81
86
|
return `ERROR: อ่านไฟล์ "${path}" ไม่ได้ — ${err.message}`;
|
|
@@ -86,18 +91,35 @@ export const editFileTool = tool({
|
|
|
86
91
|
const content = usesCRLF ? raw.replace(/\r\n/g, '\n') : raw;
|
|
87
92
|
const oldNorm = old_string.replace(/\r\n/g, '\n');
|
|
88
93
|
const newNorm = new_string.replace(/\r\n/g, '\n');
|
|
94
|
+
// replace_all: แทนที่ทุกที่ที่ตรง "เป๊ะ" (exact เท่านั้น — flex หลายช่วงกำกวม) → old_string สั้นได้ ไม่ต้อง unique
|
|
95
|
+
if (replace_all) {
|
|
96
|
+
const exact = exactMatch(content, oldNorm);
|
|
97
|
+
if (!exact) {
|
|
98
|
+
return `ERROR: ไม่พบ old_string ในไฟล์ "${path}" — replace_all ใช้ match แบบตรงเป๊ะเท่านั้น (อ่านไฟล์ใหม่แล้วคัดข้อความที่ตรง)`;
|
|
99
|
+
}
|
|
100
|
+
let updated = content.split(oldNorm).join(newNorm); // split/join = แทนที่ทุกที่ (string literal, ไม่ใช่ regex)
|
|
101
|
+
if (usesCRLF)
|
|
102
|
+
updated = updated.replace(/\n/g, '\r\n');
|
|
103
|
+
try {
|
|
104
|
+
await writeFile(full, updated, 'utf8');
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
return `ERROR: เขียนไฟล์ "${path}" ไม่ได้ — ${err.message}`;
|
|
108
|
+
}
|
|
109
|
+
return `OK: แก้ "${path}" (${exact.count} ที่)\n${renderEditDiff(oldNorm, newNorm)}`;
|
|
110
|
+
}
|
|
89
111
|
const m = findMatch(content, oldNorm);
|
|
90
112
|
if (!m) {
|
|
91
113
|
return `ERROR: ไม่พบ old_string ในไฟล์ "${path}" — อ่านไฟล์ใหม่ด้วย read_file แล้วคัดข้อความที่ตรงเป๊ะมาใช้`;
|
|
92
114
|
}
|
|
93
115
|
if (m.count > 1) {
|
|
94
|
-
return `ERROR: old_string พบ ${m.count} ที่ในไฟล์ "${path}"
|
|
116
|
+
return `ERROR: old_string พบ ${m.count} ที่ในไฟล์ "${path}" — ตั้ง replace_all:true เพื่อแก้ทุกที่ หรือใส่ context รอบๆ ให้พอ unique (ใช้เท่าที่จำเป็น ประหยัด token)`;
|
|
95
117
|
}
|
|
96
118
|
let updated = content.slice(0, m.start) + newNorm + content.slice(m.end);
|
|
97
119
|
if (usesCRLF)
|
|
98
120
|
updated = updated.replace(/\n/g, '\r\n');
|
|
99
121
|
try {
|
|
100
|
-
await writeFile(
|
|
122
|
+
await writeFile(full, updated, 'utf8');
|
|
101
123
|
}
|
|
102
124
|
catch (err) {
|
|
103
125
|
return `ERROR: เขียนไฟล์ "${path}" ไม่ได้ — ${err.message}`;
|
package/dist/tools/index.js
CHANGED
|
@@ -8,7 +8,8 @@ import { rememberTool } from './remember.js';
|
|
|
8
8
|
import { skillTool, createSkillTool, findSkillsTool } from './skill.js';
|
|
9
9
|
import { recallTool } from './recall.js';
|
|
10
10
|
import { scheduleTaskTool, listScheduledTool, cancelScheduledTool } from './schedule.js';
|
|
11
|
-
import { taskTool } from './task.js';
|
|
11
|
+
import { taskTool, taskParallelTool, taskSpawnTool, taskCollectTool, taskCancelTool, taskStatusTool } from './task.js';
|
|
12
|
+
import { diagnosticsTool } from './diagnostics.js';
|
|
12
13
|
import { gitStatusTool, gitDiffTool, gitLogTool, gitCommitTool } from './git.js';
|
|
13
14
|
/** tool registry ที่ส่งให้ agent loop */
|
|
14
15
|
export const tools = {
|
|
@@ -28,6 +29,12 @@ export const tools = {
|
|
|
28
29
|
list_scheduled: listScheduledTool,
|
|
29
30
|
cancel_scheduled: cancelScheduledTool,
|
|
30
31
|
task: taskTool,
|
|
32
|
+
task_parallel: taskParallelTool,
|
|
33
|
+
task_spawn: taskSpawnTool,
|
|
34
|
+
task_collect: taskCollectTool,
|
|
35
|
+
task_cancel: taskCancelTool,
|
|
36
|
+
task_status: taskStatusTool,
|
|
37
|
+
diagnostics: diagnosticsTool,
|
|
31
38
|
git_status: gitStatusTool,
|
|
32
39
|
git_diff: gitDiffTool,
|
|
33
40
|
git_log: gitLogTool,
|
package/dist/tools/list.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { tool } from 'ai';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { readdir } from 'node:fs/promises';
|
|
4
|
-
import { clamp } from './util.js';
|
|
4
|
+
import { clamp, resolveAgentPath } from './util.js';
|
|
5
|
+
import { checkReadPath } from './permission.js';
|
|
5
6
|
export const listDirTool = tool({
|
|
6
7
|
description: 'list ไฟล์และโฟลเดอร์ใน directory (โฟลเดอร์ลงท้ายด้วย /)',
|
|
7
8
|
inputSchema: z.object({
|
|
8
9
|
path: z.string().default('.').describe('directory ที่จะ list (default: current dir)'),
|
|
9
10
|
}),
|
|
10
11
|
execute: async ({ path }) => {
|
|
12
|
+
const full = resolveAgentPath(path); // relative ผูกกับ agentCwd (worktree ของ sub-agent ถ้ามี)
|
|
13
|
+
const guard = await checkReadPath(full);
|
|
14
|
+
if (!guard.ok)
|
|
15
|
+
return `BLOCKED: ${guard.reason}`;
|
|
11
16
|
try {
|
|
12
|
-
const entries = await readdir(
|
|
17
|
+
const entries = await readdir(full, { withFileTypes: true });
|
|
13
18
|
const out = entries
|
|
14
19
|
.filter((e) => !e.name.startsWith('.') || e.name === '.env.example' || e.name === '.gitignore')
|
|
15
20
|
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
|
package/dist/tools/permission.js
CHANGED
|
@@ -1,30 +1,111 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
|
-
import {
|
|
2
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, resolve, join, sep } from 'node:path';
|
|
4
|
+
import { getBrainPath } from '../memory.js';
|
|
5
|
+
import { BRAND_ENV, envFlag } from '../brand.js';
|
|
6
|
+
import { agentCwd } from '../agentContext.js';
|
|
3
7
|
// Permission gate (M1): ก่อนมี interactive ask (M4) — hard-deny อันตราย, allow ที่เหลือ
|
|
4
8
|
// คำสั่ง shell ที่ทำลายล้าง irreversible
|
|
5
|
-
const DESTRUCTIVE_CMD = /(\
|
|
9
|
+
const DESTRUCTIVE_CMD = /(\bgit\s+reset\s+--hard\b|\bgit\s+push\b.*--force|\bmkfs\b|\bdd\s+if=|:\(\)\s*\{|\bchmod\s+-R\s+777\b|>\s*\/dev\/sd|\bsudo\b|\bcrontab\b)/i;
|
|
10
|
+
const PROTECTED_CMD_PATH = /(\$HOME|~)?\/?(\.ssh|\.aws|\.gnupg|\.sanook)(\/|\b)|(^|\s)(cat|less|more|sed|awk|tail|head)\s+[^|;&]*\.env(\.|\b)/i;
|
|
6
11
|
const HOME = homedir();
|
|
7
12
|
// ไฟล์ที่ห้ามเขียน (persistence backdoor): shell rc, git/npm config, ~/.sanook (token/mcp/hooks)
|
|
8
13
|
const PROTECTED_EXACT = new Set(['.gitconfig', '.zshrc', '.bashrc', '.bash_profile', '.profile', '.zprofile', '.npmrc'].map((f) => join(HOME, f)));
|
|
9
14
|
// โฟลเดอร์ที่ห้ามเขียนเข้าไป (credentials + sanook internal)
|
|
10
15
|
const PROTECTED_DIRS = ['.ssh', '.aws', '.gnupg', '.sanook'].map((d) => join(HOME, d));
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
const PROTECTED_SEGMENTS = new Set(['.git', 'node_modules', '.ssh', '.aws', '.gnupg', '.sanook']);
|
|
17
|
+
function hasRmRecursiveForce(cmd) {
|
|
18
|
+
for (const match of cmd.matchAll(/\brm\b([^;&|]*)/gi)) {
|
|
19
|
+
const parts = match[1].split(/\s+/).filter(Boolean);
|
|
20
|
+
const shortFlags = parts.filter((part) => /^-[^-]/.test(part)).join('');
|
|
21
|
+
const recursive = /r/i.test(shortFlags) || parts.includes('--recursive') || parts.includes('--dir');
|
|
22
|
+
const force = /f/i.test(shortFlags) || parts.includes('--force');
|
|
23
|
+
if (recursive && force)
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
13
28
|
export function checkBash(cmd) {
|
|
14
|
-
if (DESTRUCTIVE_CMD.test(cmd)) {
|
|
29
|
+
if (hasRmRecursiveForce(cmd) || DESTRUCTIVE_CMD.test(cmd)) {
|
|
15
30
|
return { ok: false, reason: `คำสั่งทำลายล้าง/irreversible ถูกปฏิเสธ: "${cmd}"` };
|
|
16
31
|
}
|
|
32
|
+
if (PROTECTED_CMD_PATH.test(cmd)) {
|
|
33
|
+
return { ok: false, reason: `คำสั่งที่อ่าน/แตะ path ลับถูกปฏิเสธ: "${cmd}"` };
|
|
34
|
+
}
|
|
35
|
+
return { ok: true };
|
|
36
|
+
}
|
|
37
|
+
async function canonicalExisting(path) {
|
|
38
|
+
try {
|
|
39
|
+
return await realpath(path);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return resolve(path);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function existingAncestor(path) {
|
|
46
|
+
let dir = resolve(path);
|
|
47
|
+
for (;;) {
|
|
48
|
+
try {
|
|
49
|
+
await stat(dir);
|
|
50
|
+
return canonicalExisting(dir);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
const parent = dirname(dir);
|
|
54
|
+
if (parent === dir)
|
|
55
|
+
return dir;
|
|
56
|
+
dir = parent;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function allowedRoots() {
|
|
61
|
+
if (envFlag(BRAND_ENV.allowOutsideWorkspace))
|
|
62
|
+
return ['/'];
|
|
63
|
+
// agentCwd() = worktree ของ sub-agent ที่ถูก isolate (ถ้ามี) ไม่งั้น = process.cwd().
|
|
64
|
+
// ผล: sub-agent ใน worktree เขียนได้เฉพาะใน worktree ตัวเอง (isolation) ส่วน main agent เขียนใน workspace ปกติ
|
|
65
|
+
const roots = [await canonicalExisting(agentCwd())];
|
|
66
|
+
const brain = await getBrainPath();
|
|
67
|
+
if (brain)
|
|
68
|
+
roots.push(await canonicalExisting(brain));
|
|
69
|
+
return roots;
|
|
70
|
+
}
|
|
71
|
+
function inside(abs, root) {
|
|
72
|
+
return abs === root || abs.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
73
|
+
}
|
|
74
|
+
function protectedSegment(abs) {
|
|
75
|
+
const parts = abs.split(/[\\/]+/);
|
|
76
|
+
if (parts.some((p) => PROTECTED_SEGMENTS.has(p)))
|
|
77
|
+
return true;
|
|
78
|
+
const base = basename(abs);
|
|
79
|
+
return base.startsWith('.env') && base !== '.env.example';
|
|
80
|
+
}
|
|
81
|
+
async function checkPathScope(path, intent) {
|
|
82
|
+
const abs = intent === 'write' ? await existingAncestor(path) : await canonicalExisting(path);
|
|
83
|
+
const roots = await allowedRoots();
|
|
84
|
+
if (!roots.some((root) => inside(abs, root))) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
reason: `path อยู่นอก workspace/brain ที่อนุญาต: "${path}" (ตั้ง ${BRAND_ENV.allowOutsideWorkspace}=1 เพื่อ opt-in)`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
17
90
|
return { ok: true };
|
|
18
91
|
}
|
|
19
|
-
/**
|
|
20
|
-
export function
|
|
92
|
+
/** กันอ่าน secrets/.git/node_modules และกันอ่านนอก workspace/brain */
|
|
93
|
+
export async function checkReadPath(path) {
|
|
94
|
+
const abs = await canonicalExisting(path);
|
|
95
|
+
if (protectedSegment(abs)) {
|
|
96
|
+
return { ok: false, reason: `path ที่ป้องกันถูกปฏิเสธ: "${path}" (secrets / .git / .env / node_modules)` };
|
|
97
|
+
}
|
|
98
|
+
return checkPathScope(path, 'read');
|
|
99
|
+
}
|
|
100
|
+
/** กันเขียนทับ secrets/shell-rc/.sanook + กันเขียนนอก workspace/brain */
|
|
101
|
+
export async function checkWritePath(path) {
|
|
21
102
|
const abs = resolve(path);
|
|
22
103
|
const inProtectedDir = PROTECTED_DIRS.some((d) => abs === d || abs.startsWith(d + sep));
|
|
23
|
-
if (PROTECTED_EXACT.has(abs) || inProtectedDir ||
|
|
104
|
+
if (PROTECTED_EXACT.has(abs) || inProtectedDir || protectedSegment(abs)) {
|
|
24
105
|
return {
|
|
25
106
|
ok: false,
|
|
26
107
|
reason: `path ที่ป้องกันถูกปฏิเสธ: "${path}" (secrets / shell-rc / .sanook / .git / .env / node_modules)`,
|
|
27
108
|
};
|
|
28
109
|
}
|
|
29
|
-
return
|
|
110
|
+
return checkPathScope(path, 'write');
|
|
30
111
|
}
|