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.
Files changed (238) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +173 -0
  3. package/README.md +153 -20
  4. package/README.th.md +136 -0
  5. package/dist/agentContext.js +4 -0
  6. package/dist/approval.js +6 -0
  7. package/dist/bin.js +405 -57
  8. package/dist/brain.js +92 -59
  9. package/dist/brand.js +47 -0
  10. package/dist/checkpoint.js +37 -0
  11. package/dist/commands.js +86 -6
  12. package/dist/compaction.js +76 -5
  13. package/dist/config.js +100 -12
  14. package/dist/cost.js +60 -3
  15. package/dist/doctor.js +92 -0
  16. package/dist/gateway/auth.js +2 -2
  17. package/dist/gateway/ledger.js +2 -2
  18. package/dist/gateway/scheduler.js +1 -0
  19. package/dist/gateway/serve.js +6 -4
  20. package/dist/gateway/server.js +10 -2
  21. package/dist/git.js +11 -2
  22. package/dist/hooks.js +43 -17
  23. package/dist/knowledge.js +48 -49
  24. package/dist/loop.js +182 -66
  25. package/dist/lsp/client.js +173 -0
  26. package/dist/lsp/framing.js +56 -0
  27. package/dist/lsp/index.js +138 -0
  28. package/dist/lsp/servers.js +82 -0
  29. package/dist/mcp-server.js +244 -0
  30. package/dist/mcp.js +184 -29
  31. package/dist/memory-store.js +559 -0
  32. package/dist/memory.js +143 -29
  33. package/dist/orchestrate.js +150 -0
  34. package/dist/providers/codex.js +21 -7
  35. package/dist/providers/keys.js +3 -2
  36. package/dist/providers/models.js +22 -6
  37. package/dist/providers/registry.js +155 -1
  38. package/dist/repomap.js +93 -0
  39. package/dist/search/chunk.js +158 -0
  40. package/dist/search/embed-store.js +187 -0
  41. package/dist/search/engine.js +203 -0
  42. package/dist/search/fuse.js +35 -0
  43. package/dist/search/index-core.js +187 -0
  44. package/dist/search/indexer.js +241 -0
  45. package/dist/search/store.js +77 -0
  46. package/dist/session.js +42 -8
  47. package/dist/skill-install.js +10 -10
  48. package/dist/skills.js +12 -9
  49. package/dist/summarize.js +31 -0
  50. package/dist/tools/bash.js +21 -2
  51. package/dist/tools/diagnostics.js +41 -0
  52. package/dist/tools/edit.js +29 -7
  53. package/dist/tools/index.js +8 -1
  54. package/dist/tools/list.js +7 -2
  55. package/dist/tools/permission.js +90 -9
  56. package/dist/tools/read.js +23 -4
  57. package/dist/tools/remember.js +1 -1
  58. package/dist/tools/sandbox.js +61 -0
  59. package/dist/tools/search.js +105 -4
  60. package/dist/tools/task.js +195 -29
  61. package/dist/tools/timeout.js +35 -0
  62. package/dist/tools/util.js +10 -0
  63. package/dist/tools/write.js +6 -4
  64. package/dist/trust.js +89 -0
  65. package/dist/ui/app.js +228 -31
  66. package/dist/ui/banner.js +4 -9
  67. package/dist/ui/brain-wizard.js +2 -2
  68. package/dist/ui/history.js +30 -0
  69. package/dist/ui/mentions.js +44 -0
  70. package/dist/ui/render.js +55 -15
  71. package/dist/ui/setup.js +97 -12
  72. package/dist/ui/useEditor.js +83 -0
  73. package/dist/update.js +114 -0
  74. package/dist/worktree.js +173 -0
  75. package/package.json +11 -5
  76. package/scripts/postinstall.mjs +33 -0
  77. package/second-brain/.agents/_Index.md +30 -0
  78. package/second-brain/.agents/skills/_Index.md +30 -0
  79. package/second-brain/.agents/workflows/_Index.md +30 -0
  80. package/second-brain/AGENTS.md +4 -4
  81. package/second-brain/Acceptance/_Index.md +30 -0
  82. package/second-brain/Acceptance/golden-case-template.md +39 -0
  83. package/second-brain/Areas/_Index.md +30 -0
  84. package/second-brain/Bugs/System-OS/_Index.md +30 -0
  85. package/second-brain/Bugs/_Index.md +30 -0
  86. package/second-brain/CLAUDE.md +4 -1
  87. package/second-brain/Checklists/_Index.md +30 -0
  88. package/second-brain/Checklists/preflight-postflight-template.md +29 -0
  89. package/second-brain/Distillations/_Index.md +30 -0
  90. package/second-brain/Entities/_Index.md +30 -0
  91. package/second-brain/Entities/entity-template.md +33 -0
  92. package/second-brain/Evals/_Index.md +30 -0
  93. package/second-brain/Evals/correction-pairs.md +24 -0
  94. package/second-brain/Evals/failure-taxonomy.md +24 -0
  95. package/second-brain/Evals/golden-set.md +25 -0
  96. package/second-brain/Evals/quality-ledger.md +23 -0
  97. package/second-brain/Evals/self-eval-rubric.md +23 -0
  98. package/second-brain/GEMINI.md +4 -4
  99. package/second-brain/Goals/_Index.md +30 -0
  100. package/second-brain/Handoffs/_Index.md +30 -0
  101. package/second-brain/Home.md +7 -0
  102. package/second-brain/Intake/Raw Sources/_Index.md +30 -0
  103. package/second-brain/Intake/_Index.md +30 -0
  104. package/second-brain/Intake/_Quarantine/_Index.md +30 -0
  105. package/second-brain/Learning/_Index.md +30 -0
  106. package/second-brain/Playbooks/_Index.md +30 -0
  107. package/second-brain/Playbooks/playbook-template.md +23 -0
  108. package/second-brain/Projects/_Index.md +30 -0
  109. package/second-brain/Prompts/_Index.md +30 -0
  110. package/second-brain/README.md +2 -1
  111. package/second-brain/Research/_Index.md +30 -0
  112. package/second-brain/Retrospectives/_Index.md +30 -0
  113. package/second-brain/Reviews/_Index.md +30 -0
  114. package/second-brain/Runbooks/_Index.md +30 -0
  115. package/second-brain/Runbooks/eval-loop.md +24 -0
  116. package/second-brain/Sessions/_Index.md +30 -0
  117. package/second-brain/Shared/AI-Context-Index.md +20 -0
  118. package/second-brain/Shared/AI-Threads/_Index.md +30 -0
  119. package/second-brain/Shared/Archive/_Index.md +30 -0
  120. package/second-brain/Shared/Assets/_Index.md +30 -0
  121. package/second-brain/Shared/Context-Packs/_Index.md +30 -0
  122. package/second-brain/Shared/Context7-Docs/_Index.md +30 -0
  123. package/second-brain/Shared/Coordination/NOW.md +28 -0
  124. package/second-brain/Shared/Coordination/_Index.md +30 -0
  125. package/second-brain/Shared/Coordination/agent-registry.md +24 -0
  126. package/second-brain/Shared/Coordination/task-board/_Index.md +30 -0
  127. package/second-brain/Shared/Coordination/task-board/task-template.md +43 -0
  128. package/second-brain/Shared/Coordination/task-board.md +32 -0
  129. package/second-brain/Shared/Core-Facts/_Index.md +30 -0
  130. package/second-brain/Shared/Decision-Memory/_Index.md +30 -0
  131. package/second-brain/Shared/Glossary/_Index.md +30 -0
  132. package/second-brain/Shared/Memory-Inbox/_Index.md +30 -0
  133. package/second-brain/Shared/Operating-State/_Index.md +30 -0
  134. package/second-brain/Shared/Prompting/_Index.md +30 -0
  135. package/second-brain/Shared/Provenance/_Index.md +30 -0
  136. package/second-brain/Shared/Rules/_Index.md +30 -0
  137. package/second-brain/Shared/Rules/contextual-note-rule.md +30 -0
  138. package/second-brain/Shared/Rules/frontmatter-standard.md +10 -0
  139. package/second-brain/Shared/Rules/memory-write-protocol.md +28 -0
  140. package/second-brain/Shared/Rules/procedural-runbook-header.md +40 -0
  141. package/second-brain/Shared/Rules/review-and-staleness-policy.md +22 -0
  142. package/second-brain/Shared/Rules/rules-formatting.md +34 -0
  143. package/second-brain/Shared/Scripts/_Index.md +30 -0
  144. package/second-brain/Shared/Scripts-Archive/_Index.md +30 -0
  145. package/second-brain/Shared/Tech-Standards/_Index.md +30 -0
  146. package/second-brain/Shared/Tech-Standards/verification-standard.md +40 -0
  147. package/second-brain/Shared/User-Memory/_Index.md +30 -0
  148. package/second-brain/Shared/User-Persona/_Index.md +30 -0
  149. package/second-brain/Shared/User-Persona/owner-profile.md +25 -0
  150. package/second-brain/Shared/Working-Memory/_Index.md +30 -0
  151. package/second-brain/Shared/_Index.md +30 -0
  152. package/second-brain/Shared/mcp-servers/_Index.md +30 -0
  153. package/second-brain/Skills/_Index.md +30 -0
  154. package/second-brain/Templates/_Index.md +30 -0
  155. package/second-brain/Templates/bug.md +2 -0
  156. package/second-brain/Templates/handoff.md +2 -0
  157. package/second-brain/Templates/session.md +2 -0
  158. package/second-brain/Tools/_Index.md +30 -0
  159. package/second-brain/Traces/_Index.md +30 -0
  160. package/second-brain/Vault Structure Map.md +33 -1
  161. package/second-brain/copilot/_Index.md +30 -0
  162. package/skills/audit-license-compliance/SKILL.md +117 -0
  163. package/skills/author-codemod/SKILL.md +110 -0
  164. package/skills/build-audit-logging/SKILL.md +112 -0
  165. package/skills/build-cdc-streaming-pipeline/SKILL.md +123 -0
  166. package/skills/build-cli-tool/SKILL.md +108 -0
  167. package/skills/build-data-table/SKILL.md +141 -0
  168. package/skills/build-native-mobile-ui/SKILL.md +154 -0
  169. package/skills/build-offline-first-sync/SKILL.md +118 -0
  170. package/skills/build-realtime-channel/SKILL.md +122 -0
  171. package/skills/build-vector-search/SKILL.md +131 -0
  172. package/skills/compose-local-dev-stack/SKILL.md +149 -0
  173. package/skills/configure-bundler-build/SKILL.md +166 -0
  174. package/skills/configure-dns-tls/SKILL.md +142 -0
  175. package/skills/configure-reverse-proxy-lb/SKILL.md +129 -0
  176. package/skills/configure-security-headers-csp/SKILL.md +122 -0
  177. package/skills/contract-testing/SKILL.md +140 -0
  178. package/skills/datetime-timezone-correctness/SKILL.md +125 -0
  179. package/skills/debug-ci-pipeline-failure/SKILL.md +134 -0
  180. package/skills/debug-flaky-tests/SKILL.md +128 -0
  181. package/skills/defend-llm-prompt-injection/SKILL.md +110 -0
  182. package/skills/deliver-webhooks/SKILL.md +116 -0
  183. package/skills/design-api-pagination/SKILL.md +144 -0
  184. package/skills/design-authorization-model/SKILL.md +119 -0
  185. package/skills/design-backup-dr-recovery/SKILL.md +113 -0
  186. package/skills/design-event-sourcing-cqrs/SKILL.md +143 -0
  187. package/skills/design-multi-tenancy/SKILL.md +100 -0
  188. package/skills/design-protobuf-grpc-service/SKILL.md +146 -0
  189. package/skills/design-relational-schema/SKILL.md +129 -0
  190. package/skills/design-search-index-infra/SKILL.md +151 -0
  191. package/skills/design-state-machine/SKILL.md +108 -0
  192. package/skills/design-token-system/SKILL.md +109 -0
  193. package/skills/distributed-locks-leases/SKILL.md +120 -0
  194. package/skills/encrypt-sensitive-data/SKILL.md +148 -0
  195. package/skills/feature-flags-rollout/SKILL.md +130 -0
  196. package/skills/file-upload-object-storage/SKILL.md +107 -0
  197. package/skills/fuzz-dynamic-security-test/SKILL.md +111 -0
  198. package/skills/harden-llm-app-reliability/SKILL.md +126 -0
  199. package/skills/i18n-localization-setup/SKILL.md +113 -0
  200. package/skills/idempotency-keys/SKILL.md +107 -0
  201. package/skills/implement-push-notifications/SKILL.md +142 -0
  202. package/skills/ingest-webhook-secure/SKILL.md +120 -0
  203. package/skills/integrate-oauth-oidc/SKILL.md +126 -0
  204. package/skills/load-stress-test/SKILL.md +129 -0
  205. package/skills/map-privacy-data-gdpr/SKILL.md +146 -0
  206. package/skills/model-nosql-data/SKILL.md +118 -0
  207. package/skills/money-decimal-arithmetic/SKILL.md +123 -0
  208. package/skills/monitor-ml-drift/SKILL.md +109 -0
  209. package/skills/numeric-precision-units/SKILL.md +144 -0
  210. package/skills/optimize-llm-cost-latency/SKILL.md +103 -0
  211. package/skills/optimize-react-rerenders/SKILL.md +124 -0
  212. package/skills/orchestrate-agent-workflow/SKILL.md +100 -0
  213. package/skills/payments-billing-integration/SKILL.md +114 -0
  214. package/skills/pin-toolchain-versions/SKILL.md +116 -0
  215. package/skills/plan-strangler-migration/SKILL.md +95 -0
  216. package/skills/property-based-testing/SKILL.md +108 -0
  217. package/skills/publish-package-registry/SKILL.md +130 -0
  218. package/skills/recover-git-state/SKILL.md +119 -0
  219. package/skills/remediate-web-vulnerabilities/SKILL.md +125 -0
  220. package/skills/resilience-timeouts-retries/SKILL.md +104 -0
  221. package/skills/resolve-merge-rebase-conflict/SKILL.md +97 -0
  222. package/skills/rewrite-git-history/SKILL.md +109 -0
  223. package/skills/scaffold-cross-platform-app/SKILL.md +137 -0
  224. package/skills/schema-evolution-compatibility/SKILL.md +121 -0
  225. package/skills/send-transactional-email/SKILL.md +126 -0
  226. package/skills/serve-deploy-ml-model/SKILL.md +107 -0
  227. package/skills/setup-cdn-edge-waf/SKILL.md +107 -0
  228. package/skills/setup-devcontainer-env/SKILL.md +131 -0
  229. package/skills/setup-lint-format-precommit/SKILL.md +140 -0
  230. package/skills/setup-monorepo-tooling/SKILL.md +125 -0
  231. package/skills/ship-mobile-app-store-release/SKILL.md +137 -0
  232. package/skills/structured-output-llm/SKILL.md +86 -0
  233. package/skills/supply-chain-sbom-provenance/SKILL.md +120 -0
  234. package/skills/test-data-factories/SKILL.md +158 -0
  235. package/skills/threat-model-stride/SKILL.md +123 -0
  236. package/skills/train-evaluate-ml-model/SKILL.md +109 -0
  237. package/skills/unicode-text-correctness/SKILL.md +109 -0
  238. package/skills/visual-regression-testing/SKILL.md +120 -0
package/dist/memory.js CHANGED
@@ -1,10 +1,11 @@
1
- import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
1
+ import { readFile, writeFile, stat } from 'node:fs/promises';
3
2
  import { join, dirname, resolve } from 'node:path';
4
- const MEMORY_FILE = 'SANOOK.md';
5
- // auto-memory: สิ่งที่ agent จำเองข้าม session (เลียน MEMORY.md ของ Claude Code)
6
- const AUTO_MEMORY_DIR = join(homedir(), '.sanook', 'memory');
7
- const AUTO_MEMORY_FILE = join(AUTO_MEMORY_DIR, 'MEMORY.md');
3
+ import { appHomePath, BRAND, persistenceEnabled, worklogEnabled } from './brand.js';
4
+ import { redactKey } from './providers/keys.js';
5
+ import { loadStore, saveStore, mergeFact, maybeConsolidate, consolidate, renderPromptBlock } from './memory-store.js';
6
+ const MEMORY_FILE = BRAND.memoryFileName;
7
+ // auto-memory (สิ่งที่ agent จำเองข้าม session) ย้ายไปอยู่ใน ./memory-store.ts —
8
+ // memory.json เป็น source of truth, MEMORY.md เป็น view ที่ render จากมัน
8
9
  // เดินขึ้นหยุดที่ project root — ไม่เลยขึ้นไปถึง filesystem root
9
10
  // (กัน prompt-injection จาก SANOOK.md ที่ใครก็วางใน parent dir ที่ share กันได้)
10
11
  const BOUNDARY_MARKERS = ['.git', 'package.json'];
@@ -40,7 +41,7 @@ export async function loadMemory(cwd = process.cwd()) {
40
41
  dir = parent;
41
42
  }
42
43
  chain.reverse(); // project root ก่อน → cwd ท้าย (local override general)
43
- const paths = [join(homedir(), '.sanook', MEMORY_FILE), ...chain.map((d) => join(d, MEMORY_FILE))];
44
+ const paths = [appHomePath(MEMORY_FILE), ...chain.map((d) => join(d, MEMORY_FILE))];
44
45
  const blocks = [];
45
46
  const seen = new Set();
46
47
  for (const p of paths) {
@@ -58,11 +59,14 @@ export async function loadMemory(cwd = process.cwd()) {
58
59
  }
59
60
  return blocks.join('\n\n');
60
61
  }
61
- /** โหลด auto-memory (สิ่งที่ agent จำเองข้าม session) จาก ~/.sanook/memory/MEMORY.md */
62
+ /**
63
+ * โหลด auto-memory เข้า system prompt — render จาก structured store (./memory-store.ts)
64
+ * เป็น block ที่ rank + cap แล้ว (top facts ตาม importance·recency, ≤ ~2k token กัน context-rot)
65
+ * contract เดิม: '' ถ้าว่าง, ไม่งั้นคืน <auto_memory> block เดียวที่ self-contained
66
+ */
62
67
  export async function loadAutoMemory() {
63
68
  try {
64
- const content = (await readFile(AUTO_MEMORY_FILE, 'utf8')).trim();
65
- return content ? `<auto_memory note="สิ่งที่จำไว้จาก session ก่อน">\n${content}\n</auto_memory>` : '';
69
+ return renderPromptBlock(await loadStore());
66
70
  }
67
71
  catch {
68
72
  return '';
@@ -74,35 +78,145 @@ export async function loadAutoMemory() {
74
78
  * brainPath มาจาก ~/.sanook/config.json · ไม่มี/ไฟล์หาย → คืน '' (เงียบ)
75
79
  */
76
80
  export async function loadBrainContext() {
81
+ const brainPath = await getBrainPath();
82
+ return brainPath ? buildBrainContext(brainPath) : '';
83
+ }
84
+ /** ประกอบ brain context จาก vault path (pure → testable) — entry + current-state + remembered facts */
85
+ export async function buildBrainContext(brainPath) {
86
+ const parts = [];
87
+ // 1. entry/pointers (Vault Structure Map ฯลฯ)
88
+ const idx = await readTrimmed(join(brainPath, 'Shared', 'AI-Context-Index.md'), 3000);
89
+ if (idx)
90
+ parts.push(idx);
91
+ // 2. current-state — เนื้อจริง (live focus) ไม่ใช่แค่ pointer
92
+ const cs = await readTrimmed(join(brainPath, 'Shared', 'Operating-State', 'current-state.md'), 1500);
93
+ if (cs)
94
+ parts.push(`## current-state\n${cs}`);
95
+ // 3. ปิด loop: fact ที่ remember ไว้ (Memory-Inbox) กลับเข้า context — ไม่งั้น vault = write-only
96
+ const inbox = await inboxCandidates(join(brainPath, 'Shared', 'Memory-Inbox', 'memory-inbox.md'), 1200);
97
+ if (inbox)
98
+ parts.push(`## remembered (Memory-Inbox)\n${inbox}`);
99
+ if (!parts.length)
100
+ return '';
101
+ return `<brain_vault path="${brainPath}" note="second-brain ของ user — สิ่งที่จำไว้/state ปัจจุบันอยู่ใน block นี้; route โน้ตตาม Vault Structure Map; อ่าน/เขียนไฟล์ใน vault ด้วย absolute path ได้">\n${parts.join('\n\n')}\n</brain_vault>`;
102
+ }
103
+ /** อ่านไฟล์ + trim หัว N ตัว ('' ถ้าไม่มี/ว่าง) */
104
+ async function readTrimmed(p, max) {
77
105
  try {
78
- const cfg = JSON.parse(await readFile(join(homedir(), '.sanook', 'config.json'), 'utf8'));
79
- if (!cfg.brainPath)
106
+ const c = (await readFile(p, 'utf8')).trim();
107
+ if (!c)
80
108
  return '';
81
- const idxPath = join(cfg.brainPath, 'Shared', 'AI-Context-Index.md');
82
- const content = (await readFile(idxPath, 'utf8')).trim();
83
- if (!content)
109
+ return c.length > max ? `${c.slice(0, max)}\n…` : c;
110
+ }
111
+ catch {
112
+ return '';
113
+ }
114
+ }
115
+ /** ดึงรายการ "- ..." ใต้ "## New Candidates" จาก memory-inbox (fact ที่ remember ไว้) */
116
+ async function inboxCandidates(p, max) {
117
+ try {
118
+ const after = (await readFile(p, 'utf8')).split('## New Candidates')[1];
119
+ if (!after)
84
120
  return '';
85
- // budget (context-assembly): signal สูงอยู่หัวไฟล์ → เอาหัว ~4000 ตัว, ที่เหลอ agent อ่านเองได้
86
- const body = content.length > 4000 ? `${content.slice(0, 4000)}\n…(ตัด — อ่านเต็มที่ ${idxPath})` : content;
87
- return `<brain_vault path="${cfg.brainPath}" note="second-brain ของ user — อ่าน context นี้ก่อน; route/เก็บโน้ตตาม Vault Structure Map; อ่านไฟล์อื่นใน vault ด้วย absolute path ได้">\n${body}\n</brain_vault>`;
121
+ const lines = after
122
+ .split('\n')
123
+ .filter((l) => l.trim().startsWith('- ') && !l.includes('_('))
124
+ .map((l) => l.trim());
125
+ const text = lines.join('\n').trim();
126
+ return text.length > max ? `${text.slice(0, max)}\n…` : text;
88
127
  }
89
128
  catch {
90
129
  return '';
91
130
  }
92
131
  }
93
- /** บันทึก fact ลง auto-memory (remember tool เรียก) dedup บรรทัดซ้ำ */
94
- export async function appendMemory(fact) {
95
- const line = `- ${fact.trim().replace(/\s+/g, ' ')}`;
96
- await mkdir(AUTO_MEMORY_DIR, { recursive: true });
97
- let existing = '';
132
+ /** path ของ second-brain vault จาก config (undefined = ไม่ได้ตั้ง) */
133
+ export async function getBrainPath() {
98
134
  try {
99
- existing = await readFile(AUTO_MEMORY_FILE, 'utf8');
135
+ const cfg = JSON.parse(await readFile(appHomePath('config.json'), 'utf8'));
136
+ return cfg.brainPath;
100
137
  }
101
138
  catch {
102
- /* ยังไม่มีไฟล์ */
139
+ return undefined;
103
140
  }
104
- if (existing.includes(line))
105
- return; // จำแล้ว ไม่ซ้ำ
106
- const header = existing.trim() ? existing.trimEnd() : '# Sanook Auto-Memory';
107
- await writeFile(AUTO_MEMORY_FILE, `${header}\n${line}\n`);
141
+ }
142
+ /**
143
+ * route fact เข้า vault Memory-Inbox (candidate buffer ตาม §4) "AI เขียนลง second brain ของคุณ"
144
+ * เขียนเฉพาะถ้า memory-inbox.md มีจริง (กันสร้างไฟล์ใน path ที่ไม่ใช่ vault) · คืน true ถ้าเขียน
145
+ */
146
+ export async function appendToVaultInbox(brainPath, fact) {
147
+ const p = join(brainPath, 'Shared', 'Memory-Inbox', 'memory-inbox.md');
148
+ let content;
149
+ try {
150
+ content = await readFile(p, 'utf8');
151
+ }
152
+ catch {
153
+ return false; // ไม่ใช่ vault ที่มีไฟล์นี้ → ไม่ route
154
+ }
155
+ const safeFact = redactKey(fact);
156
+ const line = `- ${safeFact.trim().replace(/\s+/g, ' ')}`;
157
+ if (content.includes(line))
158
+ return false; // dedup
159
+ const marker = '## New Candidates';
160
+ const next = content.includes(marker)
161
+ ? content.replace(marker, `${marker}\n${line}`)
162
+ : `${content.trimEnd()}\n${line}\n`;
163
+ await writeFile(p, next);
164
+ return true;
165
+ }
166
+ /** บันทึก worklog ย่อเข้า vault Sessions/ (รายวัน) — "second brain จำว่าวันนี้ทำอะไร" */
167
+ export async function appendBrainWorklog(brainPath, entry) {
168
+ if (!worklogEnabled())
169
+ return false;
170
+ const dir = join(brainPath, 'Sessions');
171
+ if (!(await exists(dir)))
172
+ return false; // ไม่ใช่ vault → ข้าม
173
+ const topic = entry.prompt.trim().split(/\s+/).slice(0, 6).join(' ').slice(0, 50) || 'work';
174
+ const file = join(dir, `${entry.today}-worklog.md`);
175
+ let content;
176
+ try {
177
+ content = await readFile(file, 'utf8');
178
+ }
179
+ catch {
180
+ content = `---\ntags: [session, session-log, worklog]\nnote_type: session-log\ncreated: ${entry.today}\nupdated: ${entry.today}\nparent: "[[Sessions/_Index]]"\nai_surface: history\n---\n\n# ${entry.today} — Worklog (auto by ${BRAND.cliName})\n\nup:: [[Sessions/_Index]]\n`;
181
+ }
182
+ const block = `\n## ${topic}\n- prompt: ${redactKey(entry.prompt).trim().slice(0, 200)}\n- model: ${entry.model}\n- ${redactKey(entry.summary).trim().slice(0, 300)}\n`;
183
+ // แทรกก่อน up:: ท้ายไฟล์ (กัน up:: หลุดไปกลาง)
184
+ const out = content.includes('\nup:: ')
185
+ ? content.replace(/\nup:: .*$/s, `\n${block}\nup:: [[Sessions/_Index]]\n`)
186
+ : `${content.trimEnd()}\n${block}`;
187
+ await writeFile(file, out);
188
+ return true;
189
+ }
190
+ // in-process write serializer: the AI SDK runs tool calls from one model step concurrently, so two
191
+ // `remember` calls in a turn would otherwise load → mergeFact → save on the SAME baseline and the
192
+ // last save would clobber the first (lost update). Chaining the read-modify-write makes them sequential.
193
+ let memWriteChain = Promise.resolve();
194
+ function withMemLock(fn) {
195
+ const run = memWriteChain.then(fn, fn); // run regardless of the prior task's outcome
196
+ memWriteChain = run.catch(() => { }); // a rejection must not break the chain for the next writer
197
+ return run;
198
+ }
199
+ /**
200
+ * บันทึก fact ลง auto-memory (remember tool เรียก) — "Merge, Don't Append":
201
+ * โหลด store → mergeFact (ADD/UPDATE/NOOP/SUPERSEDE) → save (ถ้าไม่ใช่ no-write op)
202
+ * → consolidate เป็นระยะ → route เข้า vault inbox (best-effort) เหมือนเดิม
203
+ * read-modify-write ของ store และ vault inbox ถูก serialize ด้วย withMemLock กัน lost-update ตอน parallel remember
204
+ */
205
+ export async function appendMemory(fact, noteType) {
206
+ if (!persistenceEnabled())
207
+ return;
208
+ const safeFact = redactKey(fact);
209
+ await withMemLock(async () => {
210
+ const store = await loadStore();
211
+ const { store: next, op } = mergeFact(store, { text: safeFact, trust: 'agent', noteType });
212
+ // PROTECTED_HALT = ไม่เขียน (ขัดกับ protected fact); op อื่นเขียนหมด (NOOP ก็เขียนเพราะ touch accessCount)
213
+ if (op !== 'PROTECTED_HALT') {
214
+ const toSave = maybeConsolidate(next) ? consolidate(next).store : next;
215
+ await saveStore(toSave);
216
+ }
217
+ // route เข้า vault second-brain ด้วย (best-effort) — ส่ง plain redacted string เหมือนเดิม
218
+ const brain = await getBrainPath();
219
+ if (brain)
220
+ await appendToVaultInbox(brain, safeFact).catch(() => false);
221
+ });
108
222
  }
@@ -0,0 +1,150 @@
1
+ // ============================================================================
2
+ // src/orchestrate.ts — subagent ORCHESTRATION (parallel fan-out + background).
3
+ //
4
+ // The single Task subagent (src/tools/task.ts) is one-shot and synchronous. This
5
+ // module adds the two missing orchestration primitives a frontier harness needs:
6
+ // 1. runParallel() — fan a list of subagents out concurrently with a real
7
+ // concurrency cap and PER-ITEM error isolation (one failure never sinks the
8
+ // batch), results returned in input order.
9
+ // 2. TaskRegistry — fire-and-forget BACKGROUND subagents: spawn() returns an id
10
+ // immediately, the work runs detached, and collect()/list()/cancel() let the
11
+ // main agent keep working and gather results later in the same session.
12
+ //
13
+ // Everything is PURE w.r.t. the actual agent: the subagent runner is INJECTED
14
+ // (SubagentRunner), and the clock + id generator are injectable, so the whole
15
+ // orchestration layer unit-tests with a fake runner — zero model calls, zero
16
+ // network — exactly like the search subsystem injects its fs.
17
+ // ============================================================================
18
+ const DEFAULT_CONCURRENCY = 5;
19
+ /**
20
+ * Run thunks concurrently, capped at `concurrency`, results in input order.
21
+ * The generic concurrency primitive both runParallel and worktree isolation use.
22
+ */
23
+ export async function runThunks(thunks, concurrency = DEFAULT_CONCURRENCY) {
24
+ const cap = Math.max(1, Math.floor(concurrency));
25
+ const results = new Array(thunks.length);
26
+ let next = 0;
27
+ const worker = async () => {
28
+ for (;;) {
29
+ const i = next++;
30
+ if (i >= thunks.length)
31
+ return;
32
+ results[i] = await thunks[i]();
33
+ }
34
+ };
35
+ await Promise.all(Array.from({ length: Math.min(cap, thunks.length) }, worker));
36
+ return results;
37
+ }
38
+ async function runOne(spec, runner, signal) {
39
+ try {
40
+ const text = await runner(spec, signal);
41
+ return { ok: true, description: spec.description, text };
42
+ }
43
+ catch (e) {
44
+ return { ok: false, description: spec.description, text: '', error: e.message };
45
+ }
46
+ }
47
+ /**
48
+ * Run subagents concurrently, capped at `concurrency`, results in input order.
49
+ * Never rejects: a thrown subagent becomes an {ok:false,error} outcome so the
50
+ * caller always gets one outcome per spec.
51
+ */
52
+ export async function runParallel(specs, runner, opts = {}) {
53
+ return runThunks(specs.map((s) => () => runOne(s, runner, opts.signal)), opts.concurrency ?? DEFAULT_CONCURRENCY);
54
+ }
55
+ /**
56
+ * In-process registry of BACKGROUND subagents. spawn() launches detached work and
57
+ * returns an id; collect() awaits (optionally with a timeout so the agent can poll
58
+ * instead of block); cancel() aborts via the runner's AbortSignal. Lives for the
59
+ * process — background work dies when the CLI exits, so it is for within-session
60
+ * fan-out ("kick off research, keep coding, gather it later"), not durable jobs
61
+ * (use `schedule_task` / the gateway for those).
62
+ */
63
+ export class TaskRegistry {
64
+ tasks = new Map();
65
+ settles = new Map();
66
+ controllers = new Map();
67
+ counter = 0;
68
+ now;
69
+ idGen;
70
+ constructor(opts = {}) {
71
+ this.now = opts.now ?? Date.now;
72
+ this.idGen = opts.idGen ?? (() => `t${++this.counter}`);
73
+ }
74
+ /** launch a detached subagent; returns its id immediately. */
75
+ spawn(spec, runner) {
76
+ const id = this.idGen();
77
+ const ac = new AbortController();
78
+ this.controllers.set(id, ac);
79
+ const rec = { id, description: spec.description, state: 'running', startedMs: this.now() };
80
+ this.tasks.set(id, rec);
81
+ const settle = (async () => {
82
+ try {
83
+ const text = await runner(spec, ac.signal);
84
+ const cur = this.tasks.get(id);
85
+ if (cur.state !== 'canceled')
86
+ Object.assign(cur, { state: 'done', text, endedMs: this.now() });
87
+ return cur;
88
+ }
89
+ catch (e) {
90
+ const cur = this.tasks.get(id);
91
+ if (cur.state !== 'canceled')
92
+ Object.assign(cur, { state: 'error', error: e.message, endedMs: this.now() });
93
+ return cur;
94
+ }
95
+ finally {
96
+ this.controllers.delete(id);
97
+ }
98
+ })();
99
+ // swallow at the source — collect() is the consumer; an uncollected reject must not crash the process
100
+ settle.catch(() => { });
101
+ this.settles.set(id, settle);
102
+ return id;
103
+ }
104
+ get(id) {
105
+ return this.tasks.get(id);
106
+ }
107
+ list() {
108
+ return [...this.tasks.values()];
109
+ }
110
+ /**
111
+ * Await a background task. With timeoutMs, resolves to the current (possibly
112
+ * still-running) record if it hasn't settled in time, so the caller can poll.
113
+ * Returns undefined for an unknown id.
114
+ */
115
+ async collect(id, timeoutMs) {
116
+ const settle = this.settles.get(id);
117
+ if (!settle)
118
+ return undefined;
119
+ if (timeoutMs == null)
120
+ return settle;
121
+ let timer;
122
+ const timeout = new Promise((resolve) => {
123
+ timer = setTimeout(() => resolve(this.tasks.get(id)), Math.max(0, timeoutMs));
124
+ });
125
+ try {
126
+ return await Promise.race([settle, timeout]);
127
+ }
128
+ finally {
129
+ if (timer)
130
+ clearTimeout(timer);
131
+ }
132
+ }
133
+ /** abort a running task (best-effort via its AbortSignal). Returns false if not running. */
134
+ cancel(id) {
135
+ const rec = this.tasks.get(id);
136
+ if (!rec || rec.state !== 'running')
137
+ return false;
138
+ this.controllers.get(id)?.abort();
139
+ Object.assign(rec, { state: 'canceled', endedMs: this.now() });
140
+ return true;
141
+ }
142
+ /** number of tasks still running — used to gate runaway fan-out. */
143
+ runningCount() {
144
+ let n = 0;
145
+ for (const r of this.tasks.values())
146
+ if (r.state === 'running')
147
+ n++;
148
+ return n;
149
+ }
150
+ }
@@ -5,9 +5,20 @@ import { join } from 'node:path';
5
5
  /** เช็กว่า codex CLI ติดตั้ง + login ChatGPT แล้ว */
6
6
  export async function detectCodex() {
7
7
  const hasBinary = await new Promise((resolve) => {
8
- const p = spawn('codex', ['--version']);
9
- p.on('error', () => resolve(false));
10
- p.on('close', (code) => resolve(code === 0));
8
+ const p = spawn('codex', ['--version'], { shell: process.platform === 'win32' });
9
+ // timeout: binary ค้าง (shim รอ stdin / Gatekeeper stall ตอนรันครั้งแรกบน macOS) ไม่ให้ wizard ตัน
10
+ const timer = setTimeout(() => {
11
+ p.kill();
12
+ resolve(false);
13
+ }, 5000);
14
+ p.on('error', () => {
15
+ clearTimeout(timer);
16
+ resolve(false);
17
+ });
18
+ p.on('close', (code) => {
19
+ clearTimeout(timer);
20
+ resolve(code === 0);
21
+ });
11
22
  });
12
23
  if (!hasBinary) {
13
24
  return { installed: false, loggedIn: false, reason: 'ไม่พบ codex CLI — ติดตั้ง: npm i -g @openai/codex' };
@@ -26,16 +37,19 @@ export async function detectCodex() {
26
37
  * tolerant ต่อ malformed JSONL (codex bug #15451: --json ถูก ignore เมื่อมี tools active)
27
38
  */
28
39
  export async function runCodex(opts) {
29
- const args = ['exec', '--skip-git-repo-check', '--sandbox', opts.sandbox ?? 'read-only', '--json'];
40
+ // --ask-for-approval never: รัน non-interactive ไม่ค้างรอ approval (ปลอดภัยเพราะ default sandbox = read-only)
41
+ const args = ['exec', '--skip-git-repo-check', '--sandbox', opts.sandbox ?? 'read-only', '--ask-for-approval', 'never', '--json'];
30
42
  if (opts.model)
31
43
  args.push('-m', opts.model);
32
44
  if (opts.resumeThreadId)
33
45
  args.push('resume', opts.resumeThreadId);
34
46
  args.push('-'); // prompt via stdin
35
47
  return new Promise((resolve, reject) => {
36
- // OPENAI_API_KEY='' กัน BYOK key ของ Sanook ไป override ChatGPT login ของ codex
37
- const env = { ...process.env, OPENAI_API_KEY: '' };
38
- const p = spawn('codex', args, { env });
48
+ // ลบ OPENAI_API_KEY ออกจาก env ของ child — กัน BYOK key ของ Sanook ไป override/ชนกับ ChatGPT login
49
+ // (codex bug #2733/#3286: ตั้ง OPENAI_API_KEY ค้าง env ทำให้ ChatGPT-plan auth วน loop sign-in)
50
+ const env = { ...process.env };
51
+ delete env.OPENAI_API_KEY;
52
+ const p = spawn('codex', args, { env, shell: process.platform === 'win32' }); // Windows: codex = JS shim ผ่าน .cmd → ต้อง shell
39
53
  let finalText = '';
40
54
  let threadId;
41
55
  let buf = '';
@@ -28,10 +28,11 @@ export function assertDirectApiKey(policy, key) {
28
28
  }
29
29
  }
30
30
  if (policy.keyFormat && !policy.keyFormat.test(k)) {
31
- throw new Error(`${policy.label}: format ของ API key ไม่ถูกต้อง — เช็ก/วางใหม่ (คาดว่าขึ้นต้นตาม ${policy.keyFormat.source})`);
31
+ const hint = policy.keyExample ? `ขึ้นต้นแบบ ${policy.keyExample}` : `ขึ้นต้นตาม ${policy.keyFormat.source}`;
32
+ throw new Error(`${policy.label}: format ของ API key ไม่ถูกต้อง — เช็ก/วางใหม่ (${hint})`);
32
33
  }
33
34
  }
34
35
  /** ปิดบัง API key ในข้อความ log/error — เก็บแค่หัว 4 + ท้าย 2 ตัว */
35
36
  export function redactKey(s) {
36
- return s.replace(/\b(sk-[A-Za-z0-9_-]{6,}|AIza[A-Za-z0-9_-]{10,}|xai-[A-Za-z0-9]{10,}|gsk_[A-Za-z0-9]{10,}|[A-Za-z0-9_-]{24,})\b/g, (m) => (m.length > 8 ? `${m.slice(0, 4)}…${m.slice(-2)}` : '…'));
37
+ return s.replace(/\b(AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9_-]{6,}|AIza[A-Za-z0-9_-]{10,}|xai-[A-Za-z0-9]{10,}|gsk_[A-Za-z0-9]{10,}|[A-Za-z0-9_-]{24,})\b/g, (m) => (m.length > 8 ? `${m.slice(0, 4)}…${m.slice(-2)}` : '…'));
37
38
  }
@@ -43,13 +43,29 @@ export async function listRemoteModels(cfg, key, timeoutMs = 6000) {
43
43
  }
44
44
  /**
45
45
  * merge: curated alias (registry — มี label สื่อความหมาย) นำหน้า + remote id ที่เหลือต่อท้าย
46
- * dedup ด้วย model id (ไม่โชว์ id ซ้ำสองครั้ง). ใช้ทั้ง setup wizard และ /model picker
46
+ * dedup ด้วย model id alias หลายตัวที่ชี้ id เดียวกัน (เช่น haiku/fast → claude-haiku-4-5,
47
+ * smart/gpt → gpt-5.5) ต้องรวมเป็น "haiku / fast — id" บรรทัดเดียว ไม่งั้น value ซ้ำ → React key ชน
48
+ * → ตัวเลือกโผล่ซ้ำ/หาย (bug "มีตัวเลือกสองตัวเลือกเป็น model เดียวกัน"). ใช้ทั้ง setup wizard และ /model picker
47
49
  */
48
50
  export function mergeModelOptions(cfg, remote = []) {
49
- const curated = Object.entries(cfg.models)
50
- .filter(([alias]) => alias !== 'default')
51
- .map(([alias, id]) => ({ id, label: `${alias} — ${id}` }));
52
- const seen = new Set(curated.map((c) => c.id));
53
- const extra = remote.filter((id) => !seen.has(id)).map((id) => ({ id, label: id }));
51
+ // group alias ทั้งหมดตาม id (รวม 'default' ด้วย — กัน id ที่มีแต่ alias 'default' เช่น lmstudio:local-model,
52
+ // ollama:qwen3 หายไปจนเลือกไม่ได้/Select ว่าง). ตอนทำ label ค่อยซ่อนคำ "default" ถ้ามีชื่ออื่นอยู่แล้ว
53
+ const aliasesById = new Map();
54
+ const order = []; // คง first-seen order ของ id
55
+ for (const [alias, id] of Object.entries(cfg.models)) {
56
+ if (!aliasesById.has(id)) {
57
+ aliasesById.set(id, []);
58
+ order.push(id);
59
+ }
60
+ aliasesById.get(id)?.push(alias);
61
+ }
62
+ const curated = order.map((id) => {
63
+ const aliases = aliasesById.get(id) ?? [];
64
+ const named = aliases.filter((a) => a !== 'default');
65
+ const shown = named.length ? named : aliases; // มีแต่ 'default' → โชว์ 'default' (ดีกว่าซ่อน id หายไป)
66
+ return { id, label: `${shown.join(' / ')} — ${id}` };
67
+ });
68
+ const seen = new Set(order);
69
+ const extra = [...new Set(remote)].filter((id) => id && !seen.has(id)).map((id) => ({ id, label: id }));
54
70
  return [...curated, ...extra].map((o) => ({ label: o.label, value: o.id }));
55
71
  }