sanook-cli 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/.env.example +23 -0
  2. package/CHANGELOG.md +38 -0
  3. package/LICENSE +201 -0
  4. package/README.md +239 -0
  5. package/dist/agentContext.js +2 -0
  6. package/dist/approval.js +78 -0
  7. package/dist/bin.js +461 -0
  8. package/dist/brain.js +186 -0
  9. package/dist/commands.js +66 -0
  10. package/dist/compaction.js +85 -0
  11. package/dist/config.js +101 -0
  12. package/dist/cost.js +59 -0
  13. package/dist/diff.js +36 -0
  14. package/dist/gateway/auth.js +32 -0
  15. package/dist/gateway/ledger.js +94 -0
  16. package/dist/gateway/lock.js +114 -0
  17. package/dist/gateway/schedule.js +74 -0
  18. package/dist/gateway/scheduler.js +87 -0
  19. package/dist/gateway/serve.js +57 -0
  20. package/dist/gateway/server.js +94 -0
  21. package/dist/gateway/telegram.js +115 -0
  22. package/dist/git.js +55 -0
  23. package/dist/hooks.js +104 -0
  24. package/dist/knowledge.js +68 -0
  25. package/dist/loop.js +169 -0
  26. package/dist/mcp.js +191 -0
  27. package/dist/memory.js +108 -0
  28. package/dist/providers/codex.js +86 -0
  29. package/dist/providers/keys.js +37 -0
  30. package/dist/providers/models.js +55 -0
  31. package/dist/providers/registry.js +241 -0
  32. package/dist/session.js +36 -0
  33. package/dist/skill-install.js +190 -0
  34. package/dist/skills.js +111 -0
  35. package/dist/tools/bash.js +26 -0
  36. package/dist/tools/edit.js +107 -0
  37. package/dist/tools/git.js +68 -0
  38. package/dist/tools/index.js +36 -0
  39. package/dist/tools/list.js +24 -0
  40. package/dist/tools/permission.js +30 -0
  41. package/dist/tools/read.js +18 -0
  42. package/dist/tools/recall.js +12 -0
  43. package/dist/tools/remember.js +14 -0
  44. package/dist/tools/schedule.js +61 -0
  45. package/dist/tools/search.js +54 -0
  46. package/dist/tools/skill.js +65 -0
  47. package/dist/tools/task.js +46 -0
  48. package/dist/tools/util.js +5 -0
  49. package/dist/tools/write.js +27 -0
  50. package/dist/ui/app.js +132 -0
  51. package/dist/ui/banner.js +20 -0
  52. package/dist/ui/brain-wizard.js +29 -0
  53. package/dist/ui/render.js +57 -0
  54. package/dist/ui/setup.js +46 -0
  55. package/package.json +77 -0
  56. package/second-brain/AGENTS.md +18 -0
  57. package/second-brain/CLAUDE.md +96 -0
  58. package/second-brain/Evals/retrieval-eval.md +30 -0
  59. package/second-brain/GEMINI.md +15 -0
  60. package/second-brain/Home.md +33 -0
  61. package/second-brain/README.md +29 -0
  62. package/second-brain/Runbooks/ingest-quarantine.md +27 -0
  63. package/second-brain/Runbooks/sleep-time-consolidation.md +26 -0
  64. package/second-brain/Shared/AI-Context-Index.md +52 -0
  65. package/second-brain/Shared/Core-Facts/protected-facts.md +21 -0
  66. package/second-brain/Shared/Decision-Memory/decision-log.md +24 -0
  67. package/second-brain/Shared/Memory-Inbox/memory-inbox.md +23 -0
  68. package/second-brain/Shared/Operating-State/current-state.md +30 -0
  69. package/second-brain/Shared/Provenance/ingest-log.md +27 -0
  70. package/second-brain/Shared/Rules/context-assembly-policy.md +28 -0
  71. package/second-brain/Shared/Rules/frontmatter-standard.md +33 -0
  72. package/second-brain/Shared/Rules/skills-admission.md +30 -0
  73. package/second-brain/Shared/User-Memory/user-preferences.md +25 -0
  74. package/second-brain/Templates/bug.md +22 -0
  75. package/second-brain/Templates/handoff.md +21 -0
  76. package/second-brain/Templates/project.md +24 -0
  77. package/second-brain/Templates/session.md +26 -0
  78. package/second-brain/USER.md +36 -0
  79. package/second-brain/Vault Structure Map.md +106 -0
  80. package/skills/agent-tool-mcp-builder/SKILL.md +88 -0
  81. package/skills/api-design-review/SKILL.md +70 -0
  82. package/skills/async-concurrency-correctness/SKILL.md +93 -0
  83. package/skills/audit-accessibility-wcag/SKILL.md +59 -0
  84. package/skills/audit-technical-seo/SKILL.md +62 -0
  85. package/skills/auth-jwt-session/SKILL.md +88 -0
  86. package/skills/brainstorm-design/SKILL.md +73 -0
  87. package/skills/build-etl-pipeline/SKILL.md +58 -0
  88. package/skills/build-form-validation/SKILL.md +103 -0
  89. package/skills/build-office-docs/SKILL.md +80 -0
  90. package/skills/build-react-component/SKILL.md +116 -0
  91. package/skills/build-spreadsheet/SKILL.md +106 -0
  92. package/skills/caching-strategy/SKILL.md +75 -0
  93. package/skills/cicd-pipeline-author/SKILL.md +65 -0
  94. package/skills/cloud-cost-optimize/SKILL.md +91 -0
  95. package/skills/code-comments/SKILL.md +52 -0
  96. package/skills/code-review/SKILL.md +61 -0
  97. package/skills/db-migration-safety/SKILL.md +67 -0
  98. package/skills/debug-frontend-browser/SKILL.md +58 -0
  99. package/skills/debug-root-cause/SKILL.md +54 -0
  100. package/skills/dependency-upgrade/SKILL.md +56 -0
  101. package/skills/deploy-release/SKILL.md +64 -0
  102. package/skills/diff-table-parity/SKILL.md +58 -0
  103. package/skills/dockerfile-optimize/SKILL.md +82 -0
  104. package/skills/error-message/SKILL.md +58 -0
  105. package/skills/estimate-work/SKILL.md +54 -0
  106. package/skills/explore-codebase/SKILL.md +73 -0
  107. package/skills/git-commit-pr/SKILL.md +65 -0
  108. package/skills/gitops-deploy-workflow/SKILL.md +97 -0
  109. package/skills/implement-from-design/SKILL.md +69 -0
  110. package/skills/incident-response-sre/SKILL.md +78 -0
  111. package/skills/k8s-debug-workload/SKILL.md +135 -0
  112. package/skills/k8s-manifest-review/SKILL.md +86 -0
  113. package/skills/llm-eval-harness/SKILL.md +63 -0
  114. package/skills/manage-client-server-state/SKILL.md +94 -0
  115. package/skills/mermaid-diagram/SKILL.md +61 -0
  116. package/skills/message-queue-jobs/SKILL.md +139 -0
  117. package/skills/naming-helper/SKILL.md +57 -0
  118. package/skills/observability-instrument/SKILL.md +113 -0
  119. package/skills/optimize-core-web-vitals/SKILL.md +75 -0
  120. package/skills/optimize-sql-query/SKILL.md +67 -0
  121. package/skills/performance-profiling/SKILL.md +65 -0
  122. package/skills/process-pdf/SKILL.md +107 -0
  123. package/skills/profile-dataset/SKILL.md +97 -0
  124. package/skills/prompt-engineering/SKILL.md +70 -0
  125. package/skills/rag-pipeline/SKILL.md +53 -0
  126. package/skills/rate-limiting/SKILL.md +96 -0
  127. package/skills/refactor-cleanup/SKILL.md +54 -0
  128. package/skills/regex-build/SKILL.md +72 -0
  129. package/skills/release-notes/SKILL.md +79 -0
  130. package/skills/rest-graphql-contract/SKILL.md +71 -0
  131. package/skills/scrape-structured-web-data/SKILL.md +61 -0
  132. package/skills/secrets-management/SKILL.md +96 -0
  133. package/skills/security-review/SKILL.md +62 -0
  134. package/skills/shell-script-robust/SKILL.md +71 -0
  135. package/skills/style-responsive-tailwind/SKILL.md +70 -0
  136. package/skills/terraform-plan-review/SKILL.md +95 -0
  137. package/skills/type-safety-strict/SKILL.md +82 -0
  138. package/skills/validate-data-quality/SKILL.md +62 -0
  139. package/skills/wrangle-tabular-data/SKILL.md +75 -0
  140. package/skills/write-adr/SKILL.md +75 -0
  141. package/skills/write-analytical-sql/SKILL.md +71 -0
  142. package/skills/write-data-viz/SKILL.md +58 -0
  143. package/skills/write-docs/SKILL.md +54 -0
  144. package/skills/write-plan/SKILL.md +59 -0
  145. package/skills/write-playwright-e2e/SKILL.md +86 -0
  146. package/skills/write-prd/SKILL.md +65 -0
  147. package/skills/write-rfc/SKILL.md +75 -0
  148. package/skills/write-tests/SKILL.md +50 -0
@@ -0,0 +1,66 @@
1
+ import { PROVIDERS, parseSpec } from './providers/registry.js';
2
+ const HELP_TEXT = `คำสั่ง:
3
+ /help แสดงคำสั่งทั้งหมด
4
+ /model [spec] ดู/เปลี่ยน model (เช่น /model opus, /model openai:gpt-5)
5
+ /tools ดู tools ที่ agent ใช้ได้
6
+ /skills ดูจำนวน skills (จัดการ: sanook skill list)
7
+ /cost ดู token + cost รอบล่าสุด
8
+ /clear ล้าง conversation (เริ่มใหม่)
9
+ /compact บีบ context
10
+ /quit ออก`;
11
+ const TOOLS_LIST = [
12
+ 'read_file write_file edit_file list_dir glob grep run_bash',
13
+ 'git_status git_diff git_log git_commit',
14
+ 'remember recall · skill find_skills create_skill',
15
+ 'schedule_task list_scheduled cancel_scheduled · task',
16
+ ].join('\n ');
17
+ /** /model (ไม่มี arg) — โชว์ model ปัจจุบัน + ตัวเลือกของ provider นั้น (alias จาก registry) */
18
+ function modelMenu(current) {
19
+ const { provider } = parseSpec(current);
20
+ const cfg = PROVIDERS[provider];
21
+ const list = cfg
22
+ ? Object.entries(cfg.models)
23
+ .filter(([alias]) => alias !== 'default')
24
+ .map(([alias, id]) => ` ${provider}:${alias} → ${id}`)
25
+ .join('\n')
26
+ : '';
27
+ return [
28
+ `model ปัจจุบัน: ${current}`,
29
+ cfg ? `\nเลือกของ ${cfg.label}:\n${list}` : '',
30
+ `\nเปลี่ยน: /model <spec> (เช่น /model sonnet, /model openai:gpt-5.5)`,
31
+ `provider อื่น: ${Object.keys(PROVIDERS).join(' · ')}`,
32
+ ]
33
+ .filter(Boolean)
34
+ .join('\n');
35
+ }
36
+ /** parse input — ถ้าขึ้นต้น / = slash command, ไม่งั้น handled=false (ส่งเข้า agent) */
37
+ export function parseCommand(input, ctx) {
38
+ const trimmed = input.trim();
39
+ if (!trimmed.startsWith('/'))
40
+ return { handled: false };
41
+ const [cmd, ...args] = trimmed.slice(1).split(/\s+/);
42
+ switch (cmd) {
43
+ case 'help':
44
+ case '?':
45
+ return { handled: true, action: 'help', message: HELP_TEXT };
46
+ case 'clear':
47
+ return { handled: true, action: 'clear', message: 'ล้าง conversation แล้ว' };
48
+ case 'compact':
49
+ return { handled: true, action: 'compact', message: 'บีบ context แล้ว' };
50
+ case 'quit':
51
+ case 'exit':
52
+ return { handled: true, action: 'quit' };
53
+ case 'model':
54
+ if (!args[0])
55
+ return { handled: true, message: modelMenu(ctx.model) };
56
+ return { handled: true, modelChange: args[0], message: `เปลี่ยน model → ${args[0]}` };
57
+ case 'tools':
58
+ return { handled: true, message: `tools ที่ agent ใช้ได้ (+ MCP ที่ตั้งไว้):\n ${TOOLS_LIST}` };
59
+ case 'skills':
60
+ return { handled: true, message: 'skills โหลดจาก built-in + ~/.sanook/skills — จัดการด้วย "sanook skill list/add/remove"' };
61
+ case 'cost':
62
+ return { handled: true, message: ctx.costSummary ?? '(ยังไม่มี usage รอบนี้)' };
63
+ default:
64
+ return { handled: true, message: `ไม่รู้จักคำสั่ง /${cmd} — พิมพ์ /help` };
65
+ }
66
+ }
@@ -0,0 +1,85 @@
1
+ const TRUNC_HEAD = 400;
2
+ const TRUNC_TAIL = 600;
3
+ const CHARS_PER_TOKEN = 4; // ประมาณคร่าวๆ (จริง ~3.5-4 ต่อ token)
4
+ /** ตัดข้อความยาว เก็บหัว (intent) + ท้าย (error/result) */
5
+ export function truncateText(s) {
6
+ if (s.length <= TRUNC_HEAD + TRUNC_TAIL + 40)
7
+ return s;
8
+ return (s.slice(0, TRUNC_HEAD) +
9
+ `\n... [pruned ${s.length - TRUNC_HEAD - TRUNC_TAIL} chars] ...\n` +
10
+ s.slice(-TRUNC_TAIL));
11
+ }
12
+ /**
13
+ * prune tool-result ที่ยาวใน message เก่า (นอก tail) ให้สั้นลง
14
+ * — tool transcript มักเป็น ~90%+ ของ token ในงานยาว, clear ก่อนเป็นวิธีถูกสุด
15
+ * เก็บ message ท้าย keepTail ไว้เต็ม (ยัง relevant ต่อ step ถัดไป)
16
+ */
17
+ export function pruneToolResults(messages, keepTail = 4) {
18
+ const cut = Math.max(0, messages.length - keepTail);
19
+ return messages.map((m, i) => {
20
+ if (i >= cut)
21
+ return m;
22
+ if (m.role !== 'tool' || !Array.isArray(m.content))
23
+ return m;
24
+ return {
25
+ ...m,
26
+ content: m.content.map((part) => {
27
+ if (part.type === 'tool-result' &&
28
+ part.output?.type === 'text' &&
29
+ typeof part.output.value === 'string') {
30
+ return { ...part, output: { ...part.output, value: truncateText(part.output.value) } };
31
+ }
32
+ return part;
33
+ }),
34
+ };
35
+ });
36
+ }
37
+ /** ประมาณ token ของ conversation (chars/4) — ไม่เป๊ะแต่พอใช้ตัดสิน compact */
38
+ export function estimateTokens(messages) {
39
+ let chars = 0;
40
+ for (const m of messages) {
41
+ if (typeof m.content === 'string') {
42
+ chars += m.content.length;
43
+ }
44
+ else if (Array.isArray(m.content)) {
45
+ for (const part of m.content) {
46
+ if (typeof part.text === 'string')
47
+ chars += part.text.length;
48
+ else if (part.type === 'tool-result' &&
49
+ part.output?.type === 'text' &&
50
+ typeof part.output.value === 'string') {
51
+ chars += (part.output.value).length;
52
+ }
53
+ else {
54
+ chars += JSON.stringify(part).length;
55
+ }
56
+ }
57
+ }
58
+ }
59
+ return Math.ceil(chars / CHARS_PER_TOKEN);
60
+ }
61
+ /**
62
+ * auto-compact (zero LLM cost) — กัน context overflow ในงานยาว:
63
+ * 1) ถ้า token ≤ limit → คืนเดิม (no-op)
64
+ * 2) prune tool results เต็ม (transcript = token ส่วนใหญ่)
65
+ * 3) ยังเกิน → sliding window: เก็บ user แรก (intent) + N message ล่าสุด, ตัดกลาง + marker
66
+ */
67
+ export function autoCompact(messages, tokenLimit, keepRecent = 20) {
68
+ if (estimateTokens(messages) <= tokenLimit)
69
+ return messages;
70
+ const pruned = pruneToolResults(messages, 2);
71
+ if (estimateTokens(pruned) <= tokenLimit)
72
+ return pruned;
73
+ if (pruned.length <= keepRecent + 1)
74
+ return pruned;
75
+ const firstUser = pruned.find((m) => m.role === 'user');
76
+ let recent = pruned.slice(-keepRecent);
77
+ // ตัด tool message ที่ค้างหัว — tool-result ที่ tool-call ถูกตัดไปแล้ว = orphan → API reject
78
+ while (recent.length && recent[0].role === 'tool')
79
+ recent = recent.slice(1);
80
+ const marker = {
81
+ role: 'user',
82
+ content: '[บทสนทนาเก่าถูกตัดออกเพื่อประหยัด context — รายละเอียดดูได้จาก memory/session]',
83
+ };
84
+ return firstUser && !recent.includes(firstUser) ? [firstUser, marker, ...recent] : [marker, ...recent];
85
+ }
package/dist/config.js ADDED
@@ -0,0 +1,101 @@
1
+ import { z } from 'zod';
2
+ import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ export const CONFIG_DIR = join(homedir(), '.sanook');
6
+ const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
7
+ const AUTH_PATH = join(CONFIG_DIR, 'auth.json'); // API keys (chmod 0600)
8
+ export const ConfigSchema = z.object({
9
+ model: z.string().default('sonnet'),
10
+ budgetUsd: z.number().positive().optional(),
11
+ maxSteps: z.number().int().positive().default(20),
12
+ // auto = รัน tool เลย (act-first) · ask = ขออนุมัติก่อน write/bash/commit
13
+ permissionMode: z.enum(['auto', 'ask']).default('auto'),
14
+ // path ของ second-brain workspace ที่ scaffold ไว้ (sanook brain) — optional
15
+ brainPath: z.string().optional(),
16
+ });
17
+ async function readJson(path) {
18
+ try {
19
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
20
+ return parsed && typeof parsed === 'object' ? parsed : {};
21
+ }
22
+ catch {
23
+ return {}; // ไม่มีไฟล์ / parse ไม่ได้ = ใช้ default
24
+ }
25
+ }
26
+ /**
27
+ * โหลด config แบบ layered: global (~/.sanook) < project (.sanook) < CLI overrides
28
+ * merge raw ทุกชั้นก่อน แล้ว validate zod ทีเดียวที่ merged สุดท้าย
29
+ * (config flat — shallow merge พอ; strip undefined ใน overrides กัน override ทับ default)
30
+ */
31
+ export async function loadConfig(overrides = {}, cwd = process.cwd()) {
32
+ const global = await readJson(join(homedir(), '.sanook', 'config.json'));
33
+ const project = await readJson(join(cwd, '.sanook', 'config.json'));
34
+ const cleanOverrides = {};
35
+ for (const [k, v] of Object.entries(overrides)) {
36
+ if (v !== undefined)
37
+ cleanOverrides[k] = v;
38
+ }
39
+ const merged = { ...global, ...project, ...cleanOverrides };
40
+ return ConfigSchema.parse(merged);
41
+ }
42
+ /** ครั้งแรกที่รัน (ยังไม่มี global config) → ต้องทำ setup wizard */
43
+ export async function isFirstRun() {
44
+ try {
45
+ await readFile(CONFIG_PATH, 'utf8');
46
+ return false;
47
+ }
48
+ catch {
49
+ return true;
50
+ }
51
+ }
52
+ /** บันทึก global config (model/provider ที่เลือกตอน setup) */
53
+ export async function saveGlobalConfig(cfg) {
54
+ await mkdir(CONFIG_DIR, { recursive: true });
55
+ const existing = await readJson(CONFIG_PATH);
56
+ await writeFile(CONFIG_PATH, `${JSON.stringify({ ...existing, ...cfg }, null, 2)}\n`);
57
+ }
58
+ /** บันทึก path ของ second-brain workspace ลง global config (merge — ไม่ทับ field อื่น) */
59
+ export async function saveBrainPath(path) {
60
+ await mkdir(CONFIG_DIR, { recursive: true });
61
+ const existing = await readJson(CONFIG_PATH);
62
+ await writeFile(CONFIG_PATH, `${JSON.stringify({ ...existing, brainPath: path }, null, 2)}\n`);
63
+ }
64
+ /** อ่าน config.json ดิบ (ไม่ apply default/schema) — สำหรับ `sanook config` */
65
+ export async function readGlobalConfigRaw() {
66
+ return readJson(CONFIG_PATH);
67
+ }
68
+ /** merge patch ลง config.json (สำหรับ `sanook config set`) */
69
+ export async function patchGlobalConfig(patch) {
70
+ await mkdir(CONFIG_DIR, { recursive: true });
71
+ const existing = await readJson(CONFIG_PATH);
72
+ await writeFile(CONFIG_PATH, `${JSON.stringify({ ...existing, ...patch }, null, 2)}\n`);
73
+ }
74
+ /** บันทึก API key ลง ~/.sanook/auth.json (chmod 0600) + set env ทันทีสำหรับ session นี้ */
75
+ export async function saveKey(envVar, key) {
76
+ await mkdir(CONFIG_DIR, { recursive: true });
77
+ let auth = {};
78
+ try {
79
+ auth = JSON.parse(await readFile(AUTH_PATH, 'utf8'));
80
+ }
81
+ catch {
82
+ /* ยังไม่มีไฟล์ */
83
+ }
84
+ auth[envVar] = key;
85
+ await writeFile(AUTH_PATH, `${JSON.stringify(auth, null, 2)}\n`);
86
+ await chmod(AUTH_PATH, 0o600); // เจ้าของอ่าน/เขียนเท่านั้น
87
+ process.env[envVar] = key;
88
+ }
89
+ /** โหลด key จาก auth.json เข้า env ตอน boot (ไม่ override env ที่ตั้งไว้แล้ว) */
90
+ export async function loadKeysIntoEnv() {
91
+ try {
92
+ const auth = JSON.parse(await readFile(AUTH_PATH, 'utf8'));
93
+ for (const [k, v] of Object.entries(auth)) {
94
+ if (!process.env[k] && typeof v === 'string')
95
+ process.env[k] = v;
96
+ }
97
+ }
98
+ catch {
99
+ /* ไม่มี auth.json = ข้าม */
100
+ }
101
+ }
package/dist/cost.js ADDED
@@ -0,0 +1,59 @@
1
+ // key = specKey() = "anthropic:<curated model id>" — ต้องตรงกับ id ใน registry (มี test กัน drift)
2
+ export const PRICING = {
3
+ 'anthropic:claude-opus-4-8': { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
4
+ 'anthropic:claude-sonnet-4-6': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
5
+ 'anthropic:claude-haiku-4-5': { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 },
6
+ 'anthropic:claude-fable-5': { input: 10, output: 50, cacheWrite: 12.5, cacheRead: 1 },
7
+ };
8
+ export class CostMeter {
9
+ specKey;
10
+ budgetUsd;
11
+ inTok = 0;
12
+ outTok = 0;
13
+ cacheReadTok = 0;
14
+ cacheWriteTok = 0;
15
+ spent = 0;
16
+ constructor(specKey, budgetUsd) {
17
+ this.specKey = specKey;
18
+ this.budgetUsd = budgetUsd;
19
+ }
20
+ /**
21
+ * บวก usage ของ 1 step. cacheWriteTokens ดึงจาก providerMetadata แยก (default 0)
22
+ * AI SDK 6: usage.inputTokens = total → ต้องหัก cacheRead/cacheWrite ออกก่อนคิดราคา input
23
+ * ไม่งั้น double-count cacheRead (cache hit จะกลายเป็นแพงกว่า no-cache)
24
+ */
25
+ add(usage, cacheWriteTokens = 0) {
26
+ const totalInput = usage.inputTokens ?? 0;
27
+ const output = usage.outputTokens ?? 0;
28
+ const cacheRead = usage.cachedInputTokens ?? 0;
29
+ const noCacheInput = Math.max(0, totalInput - cacheRead - cacheWriteTokens);
30
+ this.inTok += noCacheInput;
31
+ this.outTok += output;
32
+ this.cacheReadTok += cacheRead;
33
+ this.cacheWriteTok += cacheWriteTokens;
34
+ const p = PRICING[this.specKey];
35
+ if (p) {
36
+ this.spent +=
37
+ (noCacheInput / 1e6) * p.input +
38
+ (output / 1e6) * p.output +
39
+ (cacheRead / 1e6) * p.cacheRead +
40
+ (cacheWriteTokens / 1e6) * p.cacheWrite;
41
+ }
42
+ }
43
+ get totalUsd() {
44
+ return this.spent;
45
+ }
46
+ get hasPricing() {
47
+ return this.specKey in PRICING;
48
+ }
49
+ /** true เมื่อใช้เกิน budget (เช็คก่อนยิง request ถัดไป) */
50
+ get overBudget() {
51
+ return this.budgetUsd != null && this.spent >= this.budgetUsd;
52
+ }
53
+ summary() {
54
+ const total = this.inTok + this.outTok + this.cacheReadTok + this.cacheWriteTok;
55
+ const cost = this.hasPricing ? `$${this.spent.toFixed(4)}` : '(ไม่มี pricing สำหรับ model นี้)';
56
+ const budget = this.budgetUsd != null ? ` / budget $${this.budgetUsd}` : '';
57
+ return `tokens: ${total} (in ${this.inTok} · out ${this.outTok} · cache-read ${this.cacheReadTok} · cache-write ${this.cacheWriteTok}) · cost ${cost}${budget}`;
58
+ }
59
+ }
package/dist/diff.js ADDED
@@ -0,0 +1,36 @@
1
+ // minimal unified-ish diff (zero dep) — โชว์ให้เห็นว่าแก้อะไรก่อน/หลัง โปร่งใส
2
+ const MAX_LINES = 14;
3
+ /** diff ของ edit (old block → new block) — render เป็น -old / +new */
4
+ export function renderEditDiff(oldStr, newStr) {
5
+ const oldL = oldStr.split('\n');
6
+ const newL = newStr.split('\n');
7
+ // ตัด common prefix/suffix lines ที่เหมือนกัน เพื่อโชว์เฉพาะส่วนที่เปลี่ยน
8
+ let pre = 0;
9
+ while (pre < oldL.length && pre < newL.length && oldL[pre] === newL[pre])
10
+ pre++;
11
+ let suf = 0;
12
+ while (suf < oldL.length - pre &&
13
+ suf < newL.length - pre &&
14
+ oldL[oldL.length - 1 - suf] === newL[newL.length - 1 - suf])
15
+ suf++;
16
+ const oldMid = oldL.slice(pre, oldL.length - suf);
17
+ const newMid = newL.slice(pre, newL.length - suf);
18
+ const lines = [];
19
+ for (const l of oldMid.slice(0, MAX_LINES))
20
+ lines.push(`- ${l}`);
21
+ if (oldMid.length > MAX_LINES)
22
+ lines.push(` …(-${oldMid.length - MAX_LINES} บรรทัด)`);
23
+ for (const l of newMid.slice(0, MAX_LINES))
24
+ lines.push(`+ ${l}`);
25
+ if (newMid.length > MAX_LINES)
26
+ lines.push(` …(+${newMid.length - MAX_LINES} บรรทัด)`);
27
+ return lines.join('\n');
28
+ }
29
+ /** สรุปการ write — จำนวนบรรทัด/ตัวอักษร + ถ้าเขียนทับ บอก before→after */
30
+ export function summarizeWrite(content, previous) {
31
+ const lines = content === '' ? 0 : content.split('\n').length;
32
+ if (previous === undefined)
33
+ return `เขียนใหม่ ${lines} บรรทัด (${content.length} ตัวอักษร)`;
34
+ const prevLines = previous === '' ? 0 : previous.split('\n').length;
35
+ return `เขียนทับ ${prevLines} → ${lines} บรรทัด (${content.length} ตัวอักษร)`;
36
+ }
@@ -0,0 +1,32 @@
1
+ import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
5
+ const GATEWAY_DIR = join(homedir(), '.sanook', 'gateway');
6
+ const TOKEN_FILE = join(GATEWAY_DIR, 'token');
7
+ /** โหลด bearer token ของ gateway; ไม่มี → สร้าง 256-bit ใหม่ เก็บ chmod 600 */
8
+ export async function loadOrCreateToken() {
9
+ try {
10
+ const t = (await readFile(TOKEN_FILE, 'utf8')).trim();
11
+ if (t)
12
+ return t;
13
+ }
14
+ catch {
15
+ /* ยังไม่มี → สร้างใหม่ */
16
+ }
17
+ const token = randomBytes(32).toString('hex');
18
+ await mkdir(GATEWAY_DIR, { recursive: true });
19
+ await writeFile(TOKEN_FILE, `${token}\n`, { mode: 0o600 });
20
+ await chmod(TOKEN_FILE, 0o600).catch(() => { });
21
+ return token;
22
+ }
23
+ /** constant-time compare กัน timing attack (length เทียบก่อนเพราะ timingSafeEqual ต้อง len เท่ากัน) */
24
+ export function tokenMatches(expected, provided) {
25
+ if (!provided)
26
+ return false;
27
+ const a = Buffer.from(expected);
28
+ const b = Buffer.from(provided);
29
+ if (a.length !== b.length)
30
+ return false;
31
+ return timingSafeEqual(a, b);
32
+ }
@@ -0,0 +1,94 @@
1
+ import { readFile, writeFile, rename, mkdir, chmod } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { withFileLock } from './lock.js';
6
+ // task-ledger = งานที่ gateway ต้องทำ (cron / message / one-shot) — Hermes "Kanban" / OpenClaw "Task Brain"
7
+ // เก็บเป็น JSON (zero native dep) แทน SQLite; ทุก mutation = locked read-modify-write (atomic ต่อ op)
8
+ // → กัน lost-write จากหลาย writer (server enqueue / scheduler update / cron CLI) ที่ยิงไฟล์เดียวกัน
9
+ const GATEWAY_DIR = join(homedir(), '.sanook', 'gateway');
10
+ const TASKS_FILE = join(GATEWAY_DIR, 'tasks.json');
11
+ const LOCK_FILE = join(GATEWAY_DIR, 'tasks.lock');
12
+ // ── low-level: read ตรงจากไฟล์ทุกครั้ง (ไม่ cache snapshot → ไม่มี stale-overwrite) ──
13
+ async function readTasks() {
14
+ try {
15
+ const parsed = JSON.parse(await readFile(TASKS_FILE, 'utf8'));
16
+ return Array.isArray(parsed) ? parsed : [];
17
+ }
18
+ catch {
19
+ return []; // ไม่มีไฟล์/พัง → empty (write แบบ atomic จึงไม่ทำลายของเดิม)
20
+ }
21
+ }
22
+ async function writeTasks(tasks) {
23
+ await mkdir(GATEWAY_DIR, { recursive: true });
24
+ const tmp = `${TASKS_FILE}.${randomUUID()}.tmp`;
25
+ await writeFile(tmp, `${JSON.stringify(tasks, null, 2)}\n`, { mode: 0o600 });
26
+ await rename(tmp, TASKS_FILE); // atomic — reader ไม่เห็นไฟล์ครึ่งๆ
27
+ await chmod(TASKS_FILE, 0o600).catch(() => { });
28
+ }
29
+ /** mutation ทุกตัววิ่งผ่านนี่: lock → re-read สด → แก้ → write (ไม่ trust snapshot เก่า) */
30
+ async function mutate(fn) {
31
+ await mkdir(GATEWAY_DIR, { recursive: true });
32
+ return withFileLock(LOCK_FILE, async () => {
33
+ const tasks = await readTasks();
34
+ const { tasks: next, result } = fn(tasks);
35
+ await writeTasks(next);
36
+ return result;
37
+ });
38
+ }
39
+ // ── reads (lock-free — atomic rename กัน torn read อยู่แล้ว) ──
40
+ export function listTasks() {
41
+ return readTasks();
42
+ }
43
+ export async function getTask(id) {
44
+ return (await readTasks()).find((t) => t.id === id);
45
+ }
46
+ export async function dueTasks(now = Date.now()) {
47
+ return (await readTasks()).filter((t) => t.status === 'queued' && t.runAt <= now);
48
+ }
49
+ // ── mutations (locked, atomic, re-read สด) ──
50
+ export async function enqueueTask(t) {
51
+ const task = { id: randomUUID().slice(0, 8), status: 'queued', createdAt: Date.now(), ...t };
52
+ await mutate((tasks) => {
53
+ tasks.push(task);
54
+ return { tasks, result: undefined };
55
+ });
56
+ return task;
57
+ }
58
+ export async function updateTask(id, patch) {
59
+ await mutate((tasks) => {
60
+ const t = tasks.find((x) => x.id === id);
61
+ if (t)
62
+ Object.assign(t, patch);
63
+ return { tasks, result: undefined };
64
+ });
65
+ }
66
+ export async function removeTask(id) {
67
+ return mutate((tasks) => {
68
+ const next = tasks.filter((t) => t.id !== id);
69
+ return { tasks: next, result: next.length !== tasks.length };
70
+ });
71
+ }
72
+ /** atomic claim: queued → running. false ถ้าโดน claim ไปแล้ว (กัน 2 writer รัน task เดียวกัน) */
73
+ export async function claimTask(id) {
74
+ return mutate((tasks) => {
75
+ const t = tasks.find((x) => x.id === id);
76
+ if (!t || t.status !== 'queued')
77
+ return { tasks, result: false };
78
+ t.status = 'running';
79
+ return { tasks, result: true };
80
+ });
81
+ }
82
+ /** recover task ที่ค้าง 'running' (จาก crash/shutdown กลางคัน) → 'queued'. เรียกตอน gateway start */
83
+ export async function recoverStaleRunning() {
84
+ return mutate((tasks) => {
85
+ let n = 0;
86
+ for (const t of tasks) {
87
+ if (t.status === 'running') {
88
+ t.status = 'queued';
89
+ n++;
90
+ }
91
+ }
92
+ return { tasks, result: n };
93
+ });
94
+ }
@@ -0,0 +1,114 @@
1
+ import { open, unlink, readFile, rename, stat } from 'node:fs/promises';
2
+ import { unlinkSync } from 'node:fs';
3
+ // advisory file lock ผ่าน O_EXCL lockfile — กัน lost-write จากหลาย writer (server/scheduler/CLI) ยิงไฟล์เดียว
4
+ // robustness (จาก adversarial re-review): TTL กัน pid-reuse deadlock · rename-evict ลด TOCTOU · fh-safe acquire
5
+ // หมายเหตุ: นี่คือ best-effort lock สำหรับ single-user local — ไม่ใช่ distributed lock; residual TOCTOU window
6
+ // แคบมาก (ต้องมี 2 mutator พร้อมกัน + holder ตายเป๊ะจังหวะ) ซึ่งแทบเป็นไปไม่ได้ใน workload นี้
7
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
8
+ const LOCK_TTL_MS = 5 * 60_000; // mutate สั้นระดับ ms — lock เก่ากว่านี้ = ค้างแน่ → ยึดได้ (กัน pid-reuse deadlock)
9
+ /** holder ตายไหม — เช็คจาก pid อย่างเดียว (ใช้กับ singleton ที่ถือยาว, ไม่มี TTL) */
10
+ async function holderDead(lockPath) {
11
+ try {
12
+ const pid = parseInt((await readFile(lockPath, 'utf8')).trim(), 10);
13
+ if (!Number.isInteger(pid) || pid <= 0)
14
+ return true; // pid พัง → ยึดได้
15
+ try {
16
+ process.kill(pid, 0); // เช็คว่ามี process นี้ (ไม่ส่ง signal จริง)
17
+ return false;
18
+ }
19
+ catch (e) {
20
+ return e.code === 'ESRCH'; // ไม่มี process → ตาย
21
+ }
22
+ }
23
+ catch {
24
+ return false; // อ่าน lock ไม่ได้ (อาจเพิ่งถูกย้าย) → ยังไม่ถือว่า stale
25
+ }
26
+ }
27
+ /** สำหรับ mutate lock (สั้น): pid ตาย OR lock เก่าเกิน TTL (กัน pid-reuse ทำ deadlock ถาวร) */
28
+ async function expiredOrDead(lockPath) {
29
+ try {
30
+ const st = await stat(lockPath);
31
+ if (Date.now() - st.mtimeMs > LOCK_TTL_MS)
32
+ return true; // อายุเกิน TTL → stale แน่
33
+ }
34
+ catch {
35
+ return false; // ไม่มีไฟล์แล้ว
36
+ }
37
+ return holderDead(lockPath);
38
+ }
39
+ /** ยึด stale lock แบบ atomic: rename ออกก่อน (winner เดียวที่ rename สำเร็จ) แล้วลบ tomb — ไม่ unlink path ตรงๆ */
40
+ async function evict(lockPath) {
41
+ const tomb = `${lockPath}.tomb.${process.pid}`;
42
+ try {
43
+ await rename(lockPath, tomb); // atomic — ถ้าคนอื่น evict ไปก่อน rename จะ ENOENT
44
+ await unlink(tomb).catch(() => { });
45
+ }
46
+ catch {
47
+ /* คนอื่นชิง evict ไปแล้ว — ไม่ทำอะไร */
48
+ }
49
+ }
50
+ /** สร้าง lockfile แบบ fh-safe (writeFile/close throw → close fd + ลบ lock ที่ตัวเองสร้าง ไม่ทิ้ง orphan) */
51
+ async function tryCreate(lockPath) {
52
+ let fh;
53
+ try {
54
+ fh = await open(lockPath, 'wx'); // O_EXCL — fail ถ้ามีอยู่
55
+ }
56
+ catch (e) {
57
+ if (e.code === 'EEXIST')
58
+ return false;
59
+ throw e;
60
+ }
61
+ try {
62
+ await fh.writeFile(String(process.pid));
63
+ }
64
+ catch (e) {
65
+ await fh.close().catch(() => { });
66
+ await unlink(lockPath).catch(() => { }); // ลบ orphan ที่ตัวเองสร้าง
67
+ throw e;
68
+ }
69
+ await fh.close().catch(() => { });
70
+ return true;
71
+ }
72
+ const backoff = (i) => Math.min(250, 10 * 2 ** Math.min(i, 5)) + Math.floor(Math.random() * 20);
73
+ /** ทำ fn ภายใต้ exclusive lock — serialize read-modify-write. capped-exponential backoff + jitter */
74
+ export async function withFileLock(lockPath, fn, retries = 300) {
75
+ for (let i = 0; i < retries; i++) {
76
+ if (await tryCreate(lockPath)) {
77
+ try {
78
+ return await fn();
79
+ }
80
+ finally {
81
+ await unlink(lockPath).catch(() => { });
82
+ }
83
+ }
84
+ if (await expiredOrDead(lockPath)) {
85
+ await evict(lockPath);
86
+ continue; // ลองยึดทันที
87
+ }
88
+ await sleep(backoff(i)); // มี writer อื่น alive ถืออยู่
89
+ }
90
+ throw new Error(`lock timeout: ${lockPath}`);
91
+ }
92
+ /**
93
+ * ยึด lock ระยะยาว (singleton เช่น gateway) — fail-safe: pid-reuse → ถือว่า busy (ปฏิเสธ start ดีกว่ารัน 2 ตัว)
94
+ * คืน release() แบบ sync (unlinkSync) เพื่อให้ปล่อย lock ทันก่อน process.exit, หรือ null ถ้ามี instance อื่น alive
95
+ */
96
+ export async function acquireSingleton(lockPath) {
97
+ for (;;) {
98
+ if (await tryCreate(lockPath)) {
99
+ return () => {
100
+ try {
101
+ unlinkSync(lockPath); // sync — เสร็จก่อน process.exit ตัด event loop
102
+ }
103
+ catch {
104
+ /* ลบไปแล้ว */
105
+ }
106
+ };
107
+ }
108
+ if (await holderDead(lockPath)) {
109
+ await evict(lockPath);
110
+ continue; // stale → ยึดต่อ
111
+ }
112
+ return null; // instance อื่น alive
113
+ }
114
+ }
@@ -0,0 +1,74 @@
1
+ // แปลง schedule string ของมนุษย์ → เวลา (epoch ms) + recurring
2
+ // support: interval ("every 30m" / "2h"), daily ("09:00"), ISO timestamp (one-shot), "now"
3
+ // pure — รับ now เป็น param (ไม่เรียก Date.now ใน body หลัก) เพื่อ test ได้
4
+ const UNIT_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
5
+ const pad = (n) => String(n).padStart(2, '0');
6
+ /** next occurrence ของ HH:MM (local time) หลัง now */
7
+ function nextDaily(minutesOfDay, now) {
8
+ const target = new Date(now);
9
+ target.setHours(Math.floor(minutesOfDay / 60), minutesOfDay % 60, 0, 0);
10
+ if (target.getTime() <= now)
11
+ target.setDate(target.getDate() + 1);
12
+ return target.getTime();
13
+ }
14
+ export function parseSchedule(input, now) {
15
+ const s = input.trim().toLowerCase();
16
+ if (!s)
17
+ return null;
18
+ if (s === 'now' || s === 'immediately') {
19
+ return { runAt: now, recurring: false, kind: 'once', normalized: 'now' };
20
+ }
21
+ // interval: "every 30m" | "30m" | "every 2 h" | "2hours"
22
+ const iv = s.match(/^(?:every\s+)?(\d+)\s*(s|m|h|d|sec|secs|min|mins|hour|hours|day|days)$/);
23
+ if (iv) {
24
+ const n = parseInt(iv[1], 10);
25
+ const unit = iv[2][0]; // s/m/h/d (ตัวแรกพอ)
26
+ const ms = n * (UNIT_MS[unit] ?? 0);
27
+ // กัน overflow → runAt เป็น Invalid Date ที่ due() ไม่มีวันยิง
28
+ if (!Number.isSafeInteger(ms) || ms <= 0 || !Number.isFinite(now + ms))
29
+ return null;
30
+ return { runAt: now + ms, recurring: true, kind: 'cron', normalized: `every ${n}${unit}` };
31
+ }
32
+ // daily time: "09:00" | "at 9:00" | "daily 09:30"
33
+ const dt = s.match(/^(?:at\s+|daily\s+(?:at\s+)?)?(\d{1,2}):(\d{2})$/);
34
+ if (dt) {
35
+ const hh = parseInt(dt[1], 10);
36
+ const mm = parseInt(dt[2], 10);
37
+ if (hh > 23 || mm > 59)
38
+ return null;
39
+ const mins = hh * 60 + mm;
40
+ return { runAt: nextDaily(mins, now), recurring: true, kind: 'cron', normalized: `${pad(hh)}:${pad(mm)}` };
41
+ }
42
+ // ── NL ภาษาไทย / aliases → map เป็น canonical แล้ว parse ซ้ำ ──
43
+ if (/^(ทุก\s*ๆ?\s*)?(ชั่วโมง|ชม\.?|hourly)$/.test(s))
44
+ return parseSchedule('every 1h', now);
45
+ if (/^(ทุก\s*ๆ?\s*)?(นาที|minutely)$/.test(s))
46
+ return parseSchedule('every 1m', now);
47
+ // "ทุก 30 นาที" / "ทุกๆ 2 ชั่วโมง" / "ทุก 1 ชม"
48
+ const thIv = s.match(/^ทุก\s*ๆ?\s*(\d+)\s*(นาที|ชม\.?|ชั่วโมง|วัน)$/);
49
+ if (thIv) {
50
+ const u = thIv[2];
51
+ const unit = u.startsWith('นาที') ? 'm' : u.startsWith('วัน') ? 'd' : 'h'; // ชม/ชั่วโมง → h
52
+ return parseSchedule(`every ${thIv[1]}${unit}`, now);
53
+ }
54
+ // "ทุกวัน 9:00" / "ทุกวัน 21.30"
55
+ const thDaily = s.match(/^ทุกวัน\s*(\d{1,2})[:.](\d{2})$/);
56
+ if (thDaily)
57
+ return parseSchedule(`${thDaily[1]}:${thDaily[2]}`, now);
58
+ // ISO timestamp (one-shot) — รับเฉพาะรูปแบบที่มี date จริง (กัน Date.parse รับ bare number/year-only กำกวม)
59
+ const raw = input.trim();
60
+ if (/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}|$)/.test(raw)) {
61
+ const t = Date.parse(raw);
62
+ if (!Number.isNaN(t)) {
63
+ if (t < now)
64
+ return null; // one-shot ในอดีต → ปฏิเสธ (ไม่ยิงย้อนหลังเงียบๆ)
65
+ return { runAt: t, recurring: false, kind: 'once', normalized: new Date(t).toISOString() };
66
+ }
67
+ }
68
+ return null; // parse ไม่ได้
69
+ }
70
+ /** เวลารอบถัดไปของ recurring task (re-parse normalized จากเวลาที่เพิ่งรันเสร็จ) */
71
+ export function nextRun(normalized, from) {
72
+ const p = parseSchedule(normalized, from);
73
+ return p?.recurring ? p.runAt : null;
74
+ }