sanook-cli 0.5.1 → 0.5.2

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 (144) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +57 -8
  3. package/README.md +240 -23
  4. package/README.th.md +87 -6
  5. package/dist/approval.js +6 -0
  6. package/dist/bin.js +3026 -196
  7. package/dist/brain-context.js +223 -0
  8. package/dist/brain-doctor.js +318 -0
  9. package/dist/brain-eval.js +186 -0
  10. package/dist/brain-final.js +371 -0
  11. package/dist/brain-review.js +382 -0
  12. package/dist/brain.js +12 -1
  13. package/dist/brand.js +1 -1
  14. package/dist/cli-args.js +152 -0
  15. package/dist/cli-option-values.js +16 -0
  16. package/dist/commands.js +172 -13
  17. package/dist/compaction.js +96 -11
  18. package/dist/config.js +118 -28
  19. package/dist/context-compression.js +191 -0
  20. package/dist/cost.js +49 -15
  21. package/dist/first-run.js +21 -0
  22. package/dist/gateway/auth.js +37 -8
  23. package/dist/gateway/bluebubbles.js +205 -0
  24. package/dist/gateway/config.js +929 -0
  25. package/dist/gateway/deliver.js +357 -0
  26. package/dist/gateway/discord.js +124 -0
  27. package/dist/gateway/email.js +472 -0
  28. package/dist/gateway/googlechat.js +207 -0
  29. package/dist/gateway/homeassistant.js +256 -0
  30. package/dist/gateway/ledger.js +18 -0
  31. package/dist/gateway/line.js +171 -0
  32. package/dist/gateway/lock.js +3 -1
  33. package/dist/gateway/matrix.js +366 -0
  34. package/dist/gateway/mattermost.js +322 -0
  35. package/dist/gateway/ntfy.js +218 -0
  36. package/dist/gateway/schedule.js +31 -4
  37. package/dist/gateway/serve.js +267 -7
  38. package/dist/gateway/server.js +253 -19
  39. package/dist/gateway/service.js +224 -0
  40. package/dist/gateway/session.js +343 -0
  41. package/dist/gateway/signal.js +351 -0
  42. package/dist/gateway/slack.js +124 -0
  43. package/dist/gateway/sms.js +169 -0
  44. package/dist/gateway/targets.js +576 -0
  45. package/dist/gateway/teams.js +106 -0
  46. package/dist/gateway/telegram.js +38 -15
  47. package/dist/gateway/webhooks.js +220 -0
  48. package/dist/gateway/whatsapp.js +230 -0
  49. package/dist/hooks.js +13 -2
  50. package/dist/insights-args.js +35 -0
  51. package/dist/insights.js +86 -0
  52. package/dist/loop.js +123 -24
  53. package/dist/lsp/index.js +23 -5
  54. package/dist/mcp-registry.js +350 -0
  55. package/dist/mcp-server.js +1 -1
  56. package/dist/mcp.js +44 -6
  57. package/dist/memory.js +100 -33
  58. package/dist/orchestrate.js +49 -19
  59. package/dist/personality.js +58 -0
  60. package/dist/providers/codex.js +70 -36
  61. package/dist/providers/keys.js +1 -1
  62. package/dist/providers/models.js +1 -1
  63. package/dist/providers/registry.js +14 -47
  64. package/dist/search/chunk.js +7 -8
  65. package/dist/search/cli.js +75 -0
  66. package/dist/search/embed-store.js +3 -0
  67. package/dist/search/indexer.js +44 -1
  68. package/dist/search/store.js +23 -1
  69. package/dist/session.js +93 -7
  70. package/dist/skill-install.js +29 -12
  71. package/dist/support-dump.js +175 -0
  72. package/dist/tools/edit.js +45 -15
  73. package/dist/tools/git.js +10 -5
  74. package/dist/tools/homeassistant.js +106 -0
  75. package/dist/tools/index.js +5 -0
  76. package/dist/tools/list.js +19 -6
  77. package/dist/tools/permission.js +923 -9
  78. package/dist/tools/read.js +16 -4
  79. package/dist/tools/schedule.js +19 -3
  80. package/dist/tools/search.js +217 -13
  81. package/dist/tools/task.js +18 -7
  82. package/dist/tools/timeout.js +21 -3
  83. package/dist/trust.js +11 -1
  84. package/dist/ui/app.js +48 -8
  85. package/dist/ui/history.js +37 -5
  86. package/dist/ui/mentions.js +3 -2
  87. package/dist/ui/setup.js +17 -4
  88. package/dist/update.js +24 -11
  89. package/dist/worktree.js +175 -4
  90. package/package.json +4 -4
  91. package/second-brain/AGENTS.md +6 -4
  92. package/second-brain/CLAUDE.md +7 -1
  93. package/second-brain/Evals/_Index.md +10 -2
  94. package/second-brain/Evals/quality-ledger.md +9 -1
  95. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  96. package/second-brain/GEMINI.md +5 -4
  97. package/second-brain/Home.md +1 -1
  98. package/second-brain/Projects/_Index.md +3 -1
  99. package/second-brain/Projects/sanook-cli/_Index.md +26 -0
  100. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +156 -0
  101. package/second-brain/README.md +1 -1
  102. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  103. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  104. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  105. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  106. package/second-brain/Research/_Index.md +6 -1
  107. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  108. package/second-brain/Reviews/_Index.md +1 -1
  109. package/second-brain/Runbooks/_Index.md +6 -1
  110. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  111. package/second-brain/SANOOK.md +45 -0
  112. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  113. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  114. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  115. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  116. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  117. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  118. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  119. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  120. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  121. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  122. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  123. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  124. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  125. package/second-brain/Sessions/_Index.md +15 -1
  126. package/second-brain/Shared/AI-Context-Index.md +22 -0
  127. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  128. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  129. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  130. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  131. package/second-brain/Shared/Operating-State/current-state.md +22 -3
  132. package/second-brain/Shared/Scripts/_Index.md +3 -1
  133. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  134. package/second-brain/Shared/Tech-Standards/_Index.md +4 -1
  135. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  136. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  137. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  138. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  139. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  140. package/second-brain/Templates/_Index.md +9 -0
  141. package/second-brain/Templates/final-lite.md +111 -0
  142. package/second-brain/Templates/final.md +231 -0
  143. package/second-brain/Vault Structure Map.md +2 -1
  144. package/skills/structured-output-llm/SKILL.md +1 -1
@@ -3,6 +3,17 @@ import { z } from 'zod';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { clamp, resolveAgentPath } from './util.js';
5
5
  import { checkReadPath } from './permission.js';
6
+ function splitReadableLines(content) {
7
+ if (!content)
8
+ return [];
9
+ const lines = content.split(/\r\n|\n|\r/);
10
+ if (/(\r\n|\n|\r)$/.test(content))
11
+ lines.pop();
12
+ return lines;
13
+ }
14
+ function normalizeReadableLineEndings(content) {
15
+ return content.replace(/\r\n|\r/g, '\n');
16
+ }
6
17
  export const readFileTool = tool({
7
18
  description: 'อ่านไฟล์ใน workspace (UTF-8). อ่านก่อนแก้ไฟล์เสมอ. ' +
8
19
  'ไฟล์ใหญ่หรือต้องการแค่บางส่วน → ใส่ offset/limit อ่านเฉพาะช่วงบรรทัด (ประหยัด token มาก — คู่กับ grep ที่ให้เลขบรรทัด)',
@@ -20,12 +31,13 @@ export const readFileTool = tool({
20
31
  const content = await readFile(full, 'utf8');
21
32
  // ไม่ระบุช่วง → คืนทั้งไฟล์ (clamp) เหมือนเดิม
22
33
  if (offset == null && limit == null)
23
- return clamp(content);
34
+ return clamp(normalizeReadableLineEndings(content));
24
35
  // ระบุช่วง → อ่านเฉพาะบรรทัด start..end (ส่งเฉพาะที่ต้องการเข้า context, ประหยัด token)
25
- const lines = content.split('\n');
26
- const start = Math.max(0, (offset ?? 1) - 1);
36
+ const lines = splitReadableLines(content);
37
+ const requestedOffset = offset ?? 1;
38
+ const start = Math.max(0, requestedOffset - 1);
27
39
  if (start >= lines.length)
28
- return `(ไฟล์มี ${lines.length} บรรทัด — offset ${offset} เกินช่วง)`;
40
+ return `(ไฟล์มี ${lines.length} บรรทัด — offset ${requestedOffset} เกินช่วง)`;
29
41
  const end = limit == null ? lines.length : Math.min(lines.length, start + limit);
30
42
  const slice = lines.slice(start, end).join('\n');
31
43
  return clamp(`[บรรทัด ${start + 1}-${end} จาก ${lines.length}]\n${slice}`);
@@ -2,6 +2,7 @@ import { tool } from 'ai';
2
2
  import { z } from 'zod';
3
3
  import { parseSchedule } from '../gateway/schedule.js';
4
4
  import { enqueueTask, listTasks, removeTask } from '../gateway/ledger.js';
5
+ import { formatTarget, parseSendTarget } from '../gateway/targets.js';
5
6
  /** ตั้งงานตามเวลา — agent เรียกเองเมื่อ user พูดเรื่องเวลา/รอบ ("ทุกๆ X โมง/นาที") */
6
7
  export const scheduleTaskTool = tool({
7
8
  description: 'ตั้งงานให้ทำตามเวลา/เป็นรอบ — เรียกเมื่อ user ขอให้ทำอะไร "ทุกๆ X" หรือ "ตอน X โมง" หรือเวลาในอนาคต. ' +
@@ -13,21 +14,36 @@ export const scheduleTaskTool = tool({
13
14
  when: z.string().describe('เวลา: every 30m / 09:00 / ISO / "ทุก 2 ชั่วโมง"'),
14
15
  task: z.string().describe('สิ่งที่จะให้ทำตอนถึงเวลา — เขียนเป็น prompt เต็มในตัวเอง (รันเป็น fresh agent ไม่มี context นี้)'),
15
16
  model: z.string().optional().describe('model spec (ไม่ใส่ = default ของ gateway)'),
17
+ deliver: z
18
+ .string()
19
+ .optional()
20
+ .describe('ปลายทางส่งผลลัพธ์ เช่น telegram, telegram:123, discord:channel, slack:C01, mattermost:channel, homeassistant:notification_id, email:owner@example.com, line:U123, sms:+15551234567, ntfy:topic, signal:+15551234567, whatsapp:15551234567, matrix:!room:server, googlechat:spaces/AAA, bluebubbles:user@example.com, teams'),
16
21
  }),
17
- execute: async ({ when, task, model }) => {
22
+ execute: async ({ when, task, model, deliver }) => {
18
23
  const sched = parseSchedule(when, Date.now());
19
24
  if (!sched) {
20
25
  return `ตั้งเวลาไม่ได้: "${when}" ไม่ใช่รูปแบบที่รองรับ — ลอง "every 30m", "09:00", ISO, หรือ "ทุก 2 ชั่วโมง"`;
21
26
  }
27
+ let normalizedDeliver;
28
+ if (deliver?.trim()) {
29
+ try {
30
+ normalizedDeliver = formatTarget(parseSendTarget(deliver));
31
+ }
32
+ catch (e) {
33
+ return `ตั้งปลายทางส่งผลลัพธ์ไม่ได้: ${e.message}`;
34
+ }
35
+ }
22
36
  const t = await enqueueTask({
23
37
  kind: sched.recurring ? 'cron' : 'once',
24
38
  spec: task,
25
39
  schedule: sched.recurring ? sched.normalized : undefined,
26
40
  model,
41
+ deliver: normalizedDeliver,
27
42
  runAt: sched.runAt,
28
43
  });
29
44
  const at = new Date(t.runAt).toLocaleString();
30
- return (`ตั้งงาน ${t.id} แล้ว — รัน ${at}${sched.recurring ? ` แล้วทุก ${sched.normalized}` : ' (ครั้งเดียว)'}. ` +
45
+ return (`ตั้งงาน ${t.id} แล้ว — รัน ${at}${sched.recurring ? ` แล้วทุก ${sched.normalized}` : ' (ครั้งเดียว)'}` +
46
+ `${normalizedDeliver ? ` และส่งผลลัพธ์ไป ${normalizedDeliver}` : ''}. ` +
31
47
  `งานจะทำงานเมื่อ gateway เปิดอยู่ (sanook serve)`);
32
48
  },
33
49
  });
@@ -44,7 +60,7 @@ export const listScheduledTool = tool({
44
60
  if (!tasks.length)
45
61
  return filter ? `ไม่มีงานสถานะ ${filter}` : 'ยังไม่มีงานที่ตั้งเวลาไว้';
46
62
  return tasks
47
- .map((t) => `${t.id} [${t.status}] ${t.schedule ?? 'once'} → ${t.spec.slice(0, 60)} (next ${new Date(t.runAt).toLocaleString()})`)
63
+ .map((t) => `${t.id} [${t.status}] ${t.schedule ?? 'once'}${t.deliver ? ` to:${t.deliver}` : ''} → ${t.spec.slice(0, 60)} (next ${new Date(t.runAt).toLocaleString()})`)
48
64
  .join('\n');
49
65
  },
50
66
  });
@@ -11,17 +11,194 @@ import { agentCwd } from '../agentContext.js';
11
11
  const FALLBACK_IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.cache', '.turbo', '.vercel', 'vendor']);
12
12
  const FALLBACK_MAX_FILE = 2 * 1024 * 1024; // ข้ามไฟล์ใหญ่ (กันช้า/binary)
13
13
  const PER_FILE_CAP = 50; // เหมือน rg --max-count 50
14
+ function otherAsciiCase(ch) {
15
+ const code = ch.charCodeAt(0);
16
+ if (code >= 65 && code <= 90)
17
+ return ch.toLowerCase();
18
+ if (code >= 97 && code <= 122)
19
+ return ch.toUpperCase();
20
+ return undefined;
21
+ }
22
+ function isAsciiLower(ch) {
23
+ const code = ch.charCodeAt(0);
24
+ return code >= 97 && code <= 122;
25
+ }
26
+ function isAsciiUpper(ch) {
27
+ const code = ch.charCodeAt(0);
28
+ return code >= 65 && code <= 90;
29
+ }
30
+ function findCharClassEnd(source, start) {
31
+ let escaping = false;
32
+ const literalRightBracket = source[start] === '^' ? start + 1 : start;
33
+ for (let i = start; i < source.length; i += 1) {
34
+ const ch = source[i];
35
+ if (escaping) {
36
+ escaping = false;
37
+ continue;
38
+ }
39
+ if (ch === '\\') {
40
+ escaping = true;
41
+ continue;
42
+ }
43
+ if (ch === ']' && i !== literalRightBracket)
44
+ return i;
45
+ }
46
+ return -1;
47
+ }
48
+ function findScopedGroupEnd(source, start) {
49
+ let depth = 1;
50
+ let escaping = false;
51
+ for (let i = start; i < source.length; i += 1) {
52
+ const ch = source[i];
53
+ if (escaping) {
54
+ escaping = false;
55
+ continue;
56
+ }
57
+ if (ch === '\\') {
58
+ escaping = true;
59
+ continue;
60
+ }
61
+ if (ch === '[') {
62
+ const end = findCharClassEnd(source, i + 1);
63
+ if (end < 0)
64
+ return -1;
65
+ i = end;
66
+ continue;
67
+ }
68
+ if (ch === '(')
69
+ depth += 1;
70
+ if (ch === ')') {
71
+ depth -= 1;
72
+ if (depth === 0)
73
+ return i;
74
+ }
75
+ }
76
+ return -1;
77
+ }
78
+ function foldAsciiRegexCharClass(source) {
79
+ let out = '';
80
+ const literalRightBracket = source[0] === '^' ? 1 : 0;
81
+ for (let i = 0; i < source.length; i += 1) {
82
+ const ch = source[i];
83
+ if (ch === ']' && i === literalRightBracket) {
84
+ out += '\\]';
85
+ continue;
86
+ }
87
+ if (ch === '\\' && i + 1 < source.length) {
88
+ out += `${ch}${source[i + 1]}`;
89
+ i += 1;
90
+ continue;
91
+ }
92
+ if (i + 2 < source.length &&
93
+ source[i + 1] === '-' &&
94
+ source[i + 2] !== ']' &&
95
+ ((isAsciiLower(ch) && isAsciiLower(source[i + 2])) || (isAsciiUpper(ch) && isAsciiUpper(source[i + 2]))) &&
96
+ ch.charCodeAt(0) <= source[i + 2].charCodeAt(0)) {
97
+ out += `${ch}-${source[i + 2]}${otherAsciiCase(ch)}-${otherAsciiCase(source[i + 2])}`;
98
+ i += 2;
99
+ continue;
100
+ }
101
+ const other = i === 0 && ch === '^' ? undefined : otherAsciiCase(ch);
102
+ out += other ? `${ch}${other}` : ch;
103
+ }
104
+ return out;
105
+ }
106
+ function foldAsciiRegexLetters(source) {
107
+ let out = '';
108
+ let escaping = false;
109
+ for (let i = 0; i < source.length; i += 1) {
110
+ const ch = source[i];
111
+ if (escaping) {
112
+ out += ch;
113
+ escaping = false;
114
+ continue;
115
+ }
116
+ if (ch === '\\') {
117
+ out += ch;
118
+ escaping = true;
119
+ continue;
120
+ }
121
+ if (ch === '[') {
122
+ const end = findCharClassEnd(source, i + 1);
123
+ if (end < 0) {
124
+ out += ch;
125
+ continue;
126
+ }
127
+ out += `[${foldAsciiRegexCharClass(source.slice(i + 1, end))}]`;
128
+ i = end;
129
+ continue;
130
+ }
131
+ const other = otherAsciiCase(ch);
132
+ out += other ? `[${ch}${other}]` : ch;
133
+ }
134
+ return out;
135
+ }
136
+ function expandScopedCaseInsensitiveGroups(pattern) {
137
+ let out = '';
138
+ let changed = false;
139
+ let escaping = false;
140
+ for (let i = 0; i < pattern.length; i += 1) {
141
+ const ch = pattern[i];
142
+ if (escaping) {
143
+ out += ch;
144
+ escaping = false;
145
+ continue;
146
+ }
147
+ if (ch === '\\') {
148
+ out += ch;
149
+ escaping = true;
150
+ continue;
151
+ }
152
+ if (ch === '[') {
153
+ const end = findCharClassEnd(pattern, i + 1);
154
+ if (end < 0)
155
+ return undefined;
156
+ out += pattern.slice(i, end + 1);
157
+ i = end;
158
+ continue;
159
+ }
160
+ if (!pattern.startsWith('(?i:', i)) {
161
+ out += ch;
162
+ continue;
163
+ }
164
+ const end = findScopedGroupEnd(pattern, i + 4);
165
+ if (end < 0)
166
+ return undefined;
167
+ out += `(?:${foldAsciiRegexLetters(pattern.slice(i + 4, end))})`;
168
+ i = end;
169
+ changed = true;
170
+ }
171
+ return changed ? out : undefined;
172
+ }
173
+ function compileFallbackRegex(pattern) {
174
+ const caseInsensitive = pattern.match(/^\(\?i\)([\s\S]*)$/);
175
+ if (caseInsensitive) {
176
+ const source = expandScopedCaseInsensitiveGroups(caseInsensitive[1]) ?? caseInsensitive[1];
177
+ return new RegExp(source, 'i');
178
+ }
179
+ const scopedCaseInsensitive = expandScopedCaseInsensitiveGroups(pattern);
180
+ if (scopedCaseInsensitive)
181
+ return new RegExp(scopedCaseInsensitive);
182
+ return new RegExp(pattern); // rg ใช้ Rust regex; JS regex ใกล้เคียงพอสำหรับ pattern ทั่วไป
183
+ }
14
184
  export async function jsGrep(pattern, base, target) {
15
185
  let re;
16
186
  try {
17
- re = new RegExp(pattern); // rg ใช้ Rust regex; JS regex ใกล้เคียงพอสำหรับ pattern ทั่วไป
187
+ re = compileFallbackRegex(pattern);
18
188
  }
19
189
  catch {
20
190
  return `ERROR: grep regex ไม่ถูกต้อง: "${pattern}"`;
21
191
  }
22
192
  const root = isAbsolute(target) ? target : join(base, target);
193
+ const rootGuard = await checkReadPath(root);
194
+ if (!rootGuard.ok)
195
+ return `BLOCKED: ${rootGuard.reason}`;
23
196
  const out = [];
197
+ let truncated = false;
24
198
  const scanFile = async (full) => {
199
+ const guard = await checkReadPath(full);
200
+ if (!guard.ok)
201
+ return;
25
202
  let s;
26
203
  try {
27
204
  s = await stat(full);
@@ -41,10 +218,14 @@ export async function jsGrep(pattern, base, target) {
41
218
  if (content.includes('\u0000'))
42
219
  return; // binary
43
220
  const rel = relative(base, full) || full;
44
- const lines = content.split(/\r?\n/);
221
+ const lines = content.split(/\r\n|\n|\r/);
45
222
  let perFile = 0;
46
- for (let i = 0; i < lines.length && out.length < MAX_RESULTS; i++) {
223
+ for (let i = 0; i < lines.length && !truncated; i++) {
47
224
  if (re.test(lines[i])) {
225
+ if (out.length >= MAX_RESULTS) {
226
+ truncated = true;
227
+ break;
228
+ }
48
229
  out.push(`${rel}:${i + 1}:${lines[i].slice(0, 300)}`);
49
230
  if (++perFile >= PER_FILE_CAP)
50
231
  break;
@@ -52,7 +233,7 @@ export async function jsGrep(pattern, base, target) {
52
233
  }
53
234
  };
54
235
  const walk = async (dir) => {
55
- if (out.length >= MAX_RESULTS)
236
+ if (truncated)
56
237
  return;
57
238
  let entries;
58
239
  try {
@@ -61,15 +242,19 @@ export async function jsGrep(pattern, base, target) {
61
242
  catch {
62
243
  return;
63
244
  }
64
- for (const e of entries) {
65
- if (out.length >= MAX_RESULTS)
245
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
246
+ if (truncated)
66
247
  return;
248
+ const full = join(dir, e.name);
249
+ const guard = await checkReadPath(full);
250
+ if (!guard.ok)
251
+ continue;
67
252
  if (e.isDirectory()) {
68
253
  if (!FALLBACK_IGNORE.has(e.name) && !e.name.startsWith('.'))
69
- await walk(join(dir, e.name));
254
+ await walk(full);
70
255
  }
71
256
  else if (e.isFile()) {
72
- await scanFile(join(dir, e.name));
257
+ await scanFile(full);
73
258
  }
74
259
  }
75
260
  };
@@ -86,10 +271,22 @@ export async function jsGrep(pattern, base, target) {
86
271
  await walk(root);
87
272
  if (!out.length)
88
273
  return '(no matches)';
274
+ if (truncated)
275
+ out.push(`... [>${MAX_RESULTS} matches, truncated]`);
89
276
  return `${clamp(out.join('\n'))}\n[JS fallback — ติดตั้ง ripgrep (rg) เพื่อความเร็ว + เคารพ .gitignore: brew/apt/choco/scoop install ripgrep]`;
90
277
  }
91
278
  const execFileAsync = promisify(execFile);
92
279
  const MAX_RESULTS = 200;
280
+ export function formatRipgrepOutput(stdout) {
281
+ const text = stdout.replace(/(?:\r\n|\n|\r)$/, '');
282
+ if (!text)
283
+ return '(no matches)';
284
+ const allLines = text.split(/\r\n|\n|\r/);
285
+ const lines = allLines.slice(0, MAX_RESULTS);
286
+ if (allLines.length > MAX_RESULTS)
287
+ lines.push(`... [>${MAX_RESULTS} matches, truncated]`);
288
+ return clamp(lines.join('\n')) || '(no matches)';
289
+ }
93
290
  function unsafeGlobPattern(pattern) {
94
291
  return isAbsolute(pattern) || pattern.split(/[\\/]+/).includes('..');
95
292
  }
@@ -109,14 +306,22 @@ export const globTool = tool({
109
306
  return `BLOCKED: ${guard.reason}`;
110
307
  try {
111
308
  const out = [];
309
+ let truncated = false;
112
310
  for await (const f of glob(pattern, { cwd: base })) {
113
- out.push(f);
311
+ const match = String(f);
312
+ const itemGuard = await checkReadPath(join(base, match));
313
+ if (!itemGuard.ok)
314
+ continue;
114
315
  if (out.length >= MAX_RESULTS) {
115
- out.push(`... [>${MAX_RESULTS} matches, truncated]`);
316
+ truncated = true;
116
317
  break;
117
318
  }
319
+ out.push(match);
118
320
  }
119
- return out.length ? out.sort().join('\n') : '(no matches)';
321
+ out.sort();
322
+ if (truncated)
323
+ out.push(`... [>${MAX_RESULTS} matches, truncated]`);
324
+ return out.length ? out.join('\n') : '(no matches)';
120
325
  }
121
326
  catch (err) {
122
327
  return `ERROR: glob "${pattern}" ล้มเหลว — ${err.message}`;
@@ -138,8 +343,7 @@ export const grepTool = tool({
138
343
  // execFile (args array, ไม่ผ่าน shell) → $(...)/backtick/$VAR ใน pattern/path เป็น inert
139
344
  // กัน command injection (JSON.stringify ไม่ใช่ shell quoting — เคยรั่ว); -e กัน pattern ขึ้นต้นด้วย -
140
345
  const { stdout } = await execFileAsync('rg', ['--line-number', '--no-heading', '--max-count', '50', '-e', pattern, '--', path], { cwd: base, maxBuffer: 10 * 1024 * 1024 });
141
- const lines = stdout.trim().split('\n').slice(0, MAX_RESULTS);
142
- return clamp(lines.join('\n')) || '(no matches)';
346
+ return formatRipgrepOutput(stdout);
143
347
  }
144
348
  catch (err) {
145
349
  // ripgrep exit code 1 = ไม่เจอ match (ไม่ใช่ error จริง)
@@ -2,7 +2,7 @@ import { tool } from 'ai';
2
2
  import { z } from 'zod';
3
3
  import { agentContext, agentCwd } from '../agentContext.js';
4
4
  import { approvalContext } from '../approval.js';
5
- import { runParallel, runThunks, TaskRegistry } from '../orchestrate.js';
5
+ import { runParallel, runThunks, TaskRegistry, formatSubagentError, withGlobalSubagentSlot, } from '../orchestrate.js';
6
6
  import { runInWorktrees, getRepoRoot } from '../worktree.js';
7
7
  // task = มอบงานย่อยให้ sub-agent ทำใน context แยก (เลียน Claude Code Task tool)
8
8
  // depth/model/budget thread ผ่าน AsyncLocalStorage (parallel-safe, ไม่ใช่ process.env)
@@ -22,7 +22,15 @@ const registry = new TaskRegistry();
22
22
  function parentCtx() {
23
23
  const ctx = agentContext.getStore();
24
24
  const appr = approvalContext.getStore();
25
- return { model: ctx?.model, budgetUsd: ctx?.budgetUsd, depth: ctx?.depth ?? 0, cwd: ctx?.cwd, mode: appr?.mode ?? 'ask', approve: appr?.approve };
25
+ return {
26
+ model: ctx?.model,
27
+ budgetUsd: ctx?.budgetUsd,
28
+ sharedBudget: ctx?.sharedBudget,
29
+ depth: ctx?.depth ?? 0,
30
+ cwd: ctx?.cwd,
31
+ mode: appr?.mode ?? 'ask',
32
+ approve: appr?.approve,
33
+ };
26
34
  }
27
35
  /**
28
36
  * real subagent runner — รัน runAgent ใน context แยก. ครอบด้วย agentContext.run() ให้
@@ -43,8 +51,8 @@ function makeRunner(parent) {
43
51
  const model = spec.model ?? process.env.SANOOK_SUBAGENT_MODEL ?? parent.model ?? 'sonnet';
44
52
  const depth = parent.depth + 1;
45
53
  const cwd = spec.cwd ?? parent.cwd; // worktree ของ subagent นี้ (ถ้า isolate) ไม่งั้น inherit
46
- const childStore = { model, budgetUsd: parent.budgetUsd, depth, cwd };
47
- const { text } = await agentContext.run(childStore, () => runAgent({
54
+ const childStore = { model, budgetUsd: parent.budgetUsd, sharedBudget: parent.sharedBudget, depth, cwd };
55
+ const { text } = await withGlobalSubagentSlot(() => agentContext.run(childStore, () => runAgent({
48
56
  model,
49
57
  budgetUsd: parent.budgetUsd, // cap เดียวกับ main (กัน subagent วิ่ง uncapped)
50
58
  subagentDepth: depth,
@@ -55,7 +63,7 @@ function makeRunner(parent) {
55
63
  maxSteps: SUB_MAX_STEPS,
56
64
  signal,
57
65
  tools: Object.fromEntries(picked),
58
- }));
66
+ })));
59
67
  return text || '(sub-agent ไม่มีผลลัพธ์)';
60
68
  };
61
69
  }
@@ -98,12 +106,15 @@ async function runIsolated(specs, parent, concurrency) {
98
106
  const root = await getRepoRoot(parent.cwd ?? agentCwd());
99
107
  if (!root)
100
108
  return 'isolate=worktree ต้องอยู่ใน git repo — ใช้ task_parallel แบบปกติแทน (ไม่มี worktree)';
101
- const runner = makeRunner(parent);
109
+ // isolate=true has one coarse approval at the task_parallel tool boundary. Inside
110
+ // the temporary worktree, subagents run auto so the REPL is not spammed with
111
+ // per-file approvals from hidden child turns; only the captured diff is merged.
112
+ const runner = makeRunner({ ...parent, mode: 'auto', approve: undefined });
102
113
  const runs = await runInWorktrees(specs, root,
103
114
  // งานต่อ subagent: รันใน worktree (cwd) ของมัน, readonly=false (isolate มีไว้ให้แก้ไฟล์)
104
115
  (spec, cwd) => runner({ ...spec, cwd, readonly: spec.readonly ?? false }, undefined)
105
116
  .then((text) => ({ ok: true, description: spec.description, text }))
106
- .catch((e) => ({ ok: false, description: spec.description, text: '', error: e.message })), (thunks) => runThunks(thunks, concurrency));
117
+ .catch((e) => ({ ok: false, description: spec.description, text: '', error: formatSubagentError(e) })), (thunks) => runThunks(thunks, concurrency));
107
118
  if (!runs)
108
119
  return 'สร้าง git worktree ไม่สำเร็จ (หรือไม่ใช่ git repo) — ยกเลิก isolate';
109
120
  const outcomes = runs.map((r) => r.result);
@@ -1,7 +1,25 @@
1
+ import { inspect } from 'node:util';
1
2
  // ครอบ tool ด้วย timeout — กัน read/grep/glob/edit บนไฟล์ใหญ่ค้าง แล้วแขวน loop ทั้ง session ไม่จบ
2
- // tool ที่จัดการ timeout เองอยู่แล้ว → ไม่ครอบ: run_bash (120s ในตัว), task (sub-agent อาจรันนาน)
3
- const SELF_TIMED = new Set(['run_bash', 'task']);
3
+ // tool ที่จัดการ timeout เองอยู่แล้ว → ไม่ครอบ: run_bash (120s ในตัว), sub-agent orchestration (อาจรัน/รอนานโดยตั้งใจ)
4
+ const SELF_TIMED = new Set(['run_bash', 'task', 'task_parallel', 'task_collect']);
4
5
  export const DEFAULT_TOOL_TIMEOUT = 120_000;
6
+ function formatToolError(e) {
7
+ if (e instanceof Error)
8
+ return e.message || e.name;
9
+ if (typeof e === 'string')
10
+ return e;
11
+ if (e == null)
12
+ return String(e);
13
+ try {
14
+ const json = JSON.stringify(e);
15
+ if (json)
16
+ return json;
17
+ }
18
+ catch {
19
+ return inspect(e, { breakLength: Infinity, depth: 2 });
20
+ }
21
+ return String(e);
22
+ }
5
23
  /** Promise.race tool execute กับ timer — timeout คืนเป็น ERROR string (tool ไม่ throw เข้า loop) */
6
24
  export function wrapToolsWithTimeout(tools, ms = DEFAULT_TOOL_TIMEOUT) {
7
25
  const out = {};
@@ -22,7 +40,7 @@ export function wrapToolsWithTimeout(tools, ms = DEFAULT_TOOL_TIMEOUT) {
22
40
  return await Promise.race([Promise.resolve(orig(input, opts)), timeout]);
23
41
  }
24
42
  catch (e) {
25
- return `ERROR: ${e.message}`;
43
+ return `ERROR: ${formatToolError(e)}`;
26
44
  }
27
45
  finally {
28
46
  if (timer)
package/dist/trust.js CHANGED
@@ -20,6 +20,9 @@ async function canonical(p) {
20
20
  return resolve(p);
21
21
  }
22
22
  }
23
+ function isUsableStoredRoot(root) {
24
+ return typeof root === 'string' && root.trim().length > 0 && !root.includes('\0');
25
+ }
23
26
  export async function projectRoot(cwd = process.cwd()) {
24
27
  let dir = resolve(cwd);
25
28
  for (;;) {
@@ -35,7 +38,14 @@ export async function projectRoot(cwd = process.cwd()) {
35
38
  async function readStore() {
36
39
  try {
37
40
  const parsed = JSON.parse(await readFile(TRUST_FILE, 'utf8'));
38
- return parsed && typeof parsed === 'object' ? parsed : {};
41
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
42
+ return {};
43
+ const roots = parsed.trustedProjectRoots;
44
+ if (roots === undefined)
45
+ return {};
46
+ return {
47
+ trustedProjectRoots: Array.isArray(roots) ? roots.filter(isUsableStoredRoot) : [],
48
+ };
39
49
  }
40
50
  catch {
41
51
  return {};
package/dist/ui/app.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useRef, useMemo } from 'react';
2
+ import { useState, useRef } from 'react';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import { Box, Text, Static, useApp, useInput } from 'ink';
@@ -9,8 +9,9 @@ import { saveSession, newSessionId } from '../session.js';
9
9
  import { getBrainPath, appendBrainWorklog } from '../memory.js';
10
10
  import { autoCompact, estimateTokens, summarizeCompact } from '../compaction.js';
11
11
  import { makeSummarizer } from '../summarize.js';
12
- import { agentTuning } from '../config.js';
12
+ import { agentTuning, patchGlobalConfig } from '../config.js';
13
13
  import { snapshotWorkTree, restoreWorkTree } from '../checkpoint.js';
14
+ import { renderInsights } from '../insights.js';
14
15
  import { useEditor } from './useEditor.js';
15
16
  import { loadHistory, appendHistory } from './history.js';
16
17
  import { expandMentions } from './mentions.js';
@@ -40,6 +41,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
40
41
  const approvalResolve = useRef(null);
41
42
  const replHistory = useRef(loadHistory()); // prompt เก่า (persist) สำหรับ ↑/↓
42
43
  const checkpoints = useRef([]);
44
+ const lastRun = useRef(null);
43
45
  const editor = useEditor(replHistory.current);
44
46
  // real-time steering: หยุด turn ที่กำลังรัน (abort) + คิวข้อความที่พิมพ์ระหว่าง busy
45
47
  const abortRef = useRef(null);
@@ -99,6 +101,13 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
99
101
  if (a === 'submit') {
100
102
  const v = editor.value.trim();
101
103
  editor.reset();
104
+ const slash = parseSlashInvocation(v);
105
+ if (slash?.name === 'stop') {
106
+ addTurn('user', v);
107
+ abortRef.current?.abort();
108
+ clearQueue();
109
+ return;
110
+ }
102
111
  if (v)
103
112
  enqueue(v);
104
113
  }
@@ -131,9 +140,26 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
131
140
  : ` · ไฟล์: ${r.reason}`;
132
141
  }
133
142
  msgsRef.current = msgsRef.current.slice(0, cp.msgLen);
143
+ lastRun.current = null;
134
144
  setHistory((h) => h.filter((t) => t.id < cp.turnId));
135
145
  addTurn('system', `↩ ย้อนกลับ 1 turn${note}`);
136
146
  }
147
+ async function retryLastTurn() {
148
+ const previous = lastRun.current;
149
+ if (!previous) {
150
+ addTurn('user', '/retry');
151
+ addTurn('system', 'ยังไม่มี turn ให้ retry');
152
+ return;
153
+ }
154
+ msgsRef.current = msgsRef.current.slice(0, previous.msgLen);
155
+ checkpoints.current = checkpoints.current.filter((cp) => cp.turnId < previous.turnId);
156
+ setHistory((h) => h.filter((t) => t.id < previous.turnId));
157
+ const mark = { turnId: idRef.current, msgLen: previous.msgLen };
158
+ const preview = previous.userText.length > 120 ? `${previous.userText.slice(0, 117)}...` : previous.userText;
159
+ addTurn('user', '/retry');
160
+ addTurn('system', `retry: ${preview}`);
161
+ await runAssistantTurn(previous.promptText, previous.images, mark, previous.userText);
162
+ }
137
163
  /** บีบ context: 'summarize' (ใช้ model ถูกย่อ) ถ้าตั้งไว้ ไม่งั้น 'truncate' (zero-LLM) */
138
164
  async function compactHistory(targetTokens, label) {
139
165
  const before = estimateTokens(msgsRef.current);
@@ -175,7 +201,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
175
201
  addTurn('system', `custom command /${slash.name} ว่าง`);
176
202
  return;
177
203
  }
178
- await runAssistantTurn(expanded, [], mark);
204
+ await runAssistantTurn(expanded, [], mark, text);
179
205
  return;
180
206
  }
181
207
  }
@@ -188,6 +214,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
188
214
  if (cmd.action === 'clear') {
189
215
  msgsRef.current = [];
190
216
  checkpoints.current = [];
217
+ lastRun.current = null;
191
218
  return setHistory([]);
192
219
  }
193
220
  if (cmd.action === 'compact') {
@@ -196,6 +223,20 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
196
223
  }
197
224
  if (cmd.action === 'diff')
198
225
  return void runGit(['diff', '--stat'], 'diff');
226
+ if (cmd.action === 'retry')
227
+ return void retryLastTurn();
228
+ if (cmd.action === 'personality') {
229
+ void patchGlobalConfig({ personality: cmd.personalityChange || undefined })
230
+ .then(() => addTurn('system', cmd.message ?? 'ตั้ง personality แล้ว'))
231
+ .catch((e) => addTurn('system', `personality: ${e.message}`));
232
+ return;
233
+ }
234
+ if (cmd.action === 'insights') {
235
+ void renderInsights({ days: cmd.insightsDays, cwd: cmd.insightsAll ? null : undefined })
236
+ .then((msg) => addTurn('system', msg))
237
+ .catch((e) => addTurn('system', `insights: ${e.message}`));
238
+ return;
239
+ }
199
240
  if (cmd.action === 'undo') {
200
241
  void runGit(['stash', 'push', '-u', '-m', BRAND.undoStashMessage], 'undo').then(() => addTurn('system', 'กู้คืน: git stash pop'));
201
242
  return;
@@ -212,9 +253,10 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
212
253
  const { text: expanded, images, errors } = await expandMentions(text);
213
254
  if (errors.length)
214
255
  addTurn('system', `@mention: ${errors.join(' · ')}`);
215
- await runAssistantTurn(expanded, images, mark);
256
+ await runAssistantTurn(expanded, images, mark, text);
216
257
  }
217
- async function runAssistantTurn(promptText, images, mark) {
258
+ async function runAssistantTurn(promptText, images, mark, userText = promptText) {
259
+ lastRun.current = { ...mark, userText, promptText, images };
218
260
  // proactive summarize-compaction สำหรับ session ยาวมาก (เฉพาะ mode summarize) — เริ่ม turn ให้ context lean
219
261
  // (mode truncate: ปล่อยให้ loop.ts ตัดต่อ-step เอา; ไม่บีบที่นี่ กัน latency)
220
262
  if (estimateTokens(msgsRef.current) > PRE_TURN_COMPACT_TOKENS) {
@@ -304,10 +346,8 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
304
346
  if (next)
305
347
  void submit(next);
306
348
  }
307
- // banner ผูกกับ live `model` (ไม่ใช่ initialModel) → /model เปลี่ยนแล้ว banner อัปเดตตาม ไม่ค้าง model เก่า
308
- const banner = useMemo(() => _jsx(Banner, { model: model }), [model]);
309
349
  const costHint = lastCost.current.includes('cost ') ? lastCost.current.split('cost ')[1] : '';
310
- return (_jsxs(Box, { flexDirection: "column", children: [banner, _jsx(Static, { items: history, children: (turn) => _jsx(TurnView, { turn: turn }, turn.id) }), streaming ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: streaming }) })) : null, queued.length ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: queued.map((q, i) => (_jsxs(Text, { dimColor: true, children: ["\u23F3 \u0E04\u0E34\u0E27 ", i + 1, ": ", q.length > 64 ? `${q.slice(0, 64)}…` : q] }, i))) })) : null, approvalReq ? (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34\u0E23\u0E31\u0E19 ", approvalReq.tool, "?"] }), _jsx(Text, { dimColor: true, children: approvalReq.summary }), _jsx(Text, { dimColor: true, children: "y = \u0E23\u0E31\u0E19 \u00B7 n = \u0E1B\u0E0F\u0E34\u0E40\u0E2A\u0E18" })] })) : (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: busy ? 'gray' : 'blue', paddingX: 1, children: [_jsx(Text, { color: busy ? 'gray' : 'cyan', children: busy ? '· ' : '› ' }), _jsx(InputView, { value: editor.value, cursor: editor.cursor, busy: busy })] })), _jsxs(Text, { dimColor: true, children: [' ', model, " \u00B7 ", permissionMode === 'ask' ? 'ask-mode' : 'auto', " \u00B7 /help \u00B7 @file \u00B7 \u2191 history", costHint ? ` · ${costHint}` : ''] })] }));
350
+ return (_jsxs(Box, { flexDirection: "column", children: [history.length === 0 ? _jsx(Banner, { model: model }) : null, _jsx(Static, { items: history, children: (turn) => _jsx(TurnView, { turn: turn }, turn.id) }), streaming ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: streaming }) })) : null, queued.length ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: queued.map((q, i) => (_jsxs(Text, { dimColor: true, children: ["\u23F3 \u0E04\u0E34\u0E27 ", i + 1, ": ", q.length > 64 ? `${q.slice(0, 64)}…` : q] }, i))) })) : null, approvalReq ? (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34\u0E23\u0E31\u0E19 ", approvalReq.tool, "?"] }), _jsx(Text, { dimColor: true, children: approvalReq.summary }), _jsx(Text, { dimColor: true, children: "y = \u0E23\u0E31\u0E19 \u00B7 n = \u0E1B\u0E0F\u0E34\u0E40\u0E2A\u0E18" })] })) : (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: busy ? 'gray' : 'blue', paddingX: 1, children: [_jsx(Text, { color: busy ? 'gray' : 'cyan', children: busy ? '· ' : '› ' }), _jsx(InputView, { value: editor.value, cursor: editor.cursor, busy: busy })] })), _jsxs(Text, { dimColor: true, children: [' ', model, " \u00B7 ", permissionMode === 'ask' ? 'ask-mode' : 'auto', " \u00B7 /help \u00B7 @file \u00B7 \u2191 history", costHint ? ` · ${costHint}` : ''] })] }));
311
351
  }
312
352
  /** input ที่มี cursor (inverse) + placeholder — minimal; รับ input ได้แม้ busy (ต่อคิว) */
313
353
  function InputView({ value, cursor, busy }) {