sanook-cli 0.5.0 → 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 (146) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +83 -5
  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 +3045 -210
  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 +86 -38
  61. package/dist/providers/keys.js +1 -1
  62. package/dist/providers/models.js +22 -6
  63. package/dist/providers/registry.js +38 -49
  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 +57 -11
  85. package/dist/ui/brain-wizard.js +2 -2
  86. package/dist/ui/history.js +37 -5
  87. package/dist/ui/mentions.js +3 -2
  88. package/dist/ui/render.js +55 -15
  89. package/dist/ui/setup.js +107 -10
  90. package/dist/update.js +24 -11
  91. package/dist/worktree.js +175 -4
  92. package/package.json +4 -4
  93. package/second-brain/AGENTS.md +6 -4
  94. package/second-brain/CLAUDE.md +7 -1
  95. package/second-brain/Evals/_Index.md +10 -2
  96. package/second-brain/Evals/quality-ledger.md +9 -1
  97. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  98. package/second-brain/GEMINI.md +5 -4
  99. package/second-brain/Home.md +1 -1
  100. package/second-brain/Projects/_Index.md +3 -1
  101. package/second-brain/Projects/sanook-cli/_Index.md +26 -0
  102. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +156 -0
  103. package/second-brain/README.md +1 -1
  104. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  105. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  106. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  107. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  108. package/second-brain/Research/_Index.md +6 -1
  109. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  110. package/second-brain/Reviews/_Index.md +1 -1
  111. package/second-brain/Runbooks/_Index.md +6 -1
  112. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  113. package/second-brain/SANOOK.md +45 -0
  114. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  115. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  116. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  117. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  118. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  119. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  120. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  121. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  122. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  123. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  124. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  125. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  126. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  127. package/second-brain/Sessions/_Index.md +15 -1
  128. package/second-brain/Shared/AI-Context-Index.md +22 -0
  129. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  130. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  131. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  132. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  133. package/second-brain/Shared/Operating-State/current-state.md +22 -3
  134. package/second-brain/Shared/Scripts/_Index.md +3 -1
  135. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  136. package/second-brain/Shared/Tech-Standards/_Index.md +4 -1
  137. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  138. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  139. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  140. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  141. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  142. package/second-brain/Templates/_Index.md +9 -0
  143. package/second-brain/Templates/final-lite.md +111 -0
  144. package/second-brain/Templates/final.md +231 -0
  145. package/second-brain/Vault Structure Map.md +2 -1
  146. package/skills/structured-output-llm/SKILL.md +1 -1
@@ -25,6 +25,7 @@ import { activeFacts, effImportance, loadStore } from '../memory-store.js';
25
25
  import { chunkMarkdown } from './chunk.js';
26
26
  import { addDoc, removeDoc, removeSource } from './index-core.js';
27
27
  import { loadIndex, saveIndex } from './store.js';
28
+ import { buildVectorIndex, embedTexts, getEmbedder, invalidateVectors, saveVectors } from './embed-store.js';
28
29
  /** strip a .md path to a human title fallback when a chunk has no heading. */
29
30
  function fileTitle(rel) {
30
31
  return (rel.split('/').pop() ?? rel).replace(/\.md$/i, '');
@@ -130,6 +131,21 @@ export function foldSkills(index, skills) {
130
131
  }
131
132
  return skills.length;
132
133
  }
134
+ function docEmbeddingText(doc) {
135
+ return [doc.title?.trim(), doc.text.trim()].filter(Boolean).join('\n').slice(0, 4000);
136
+ }
137
+ export async function vectorizeIndex(index, tag, embed) {
138
+ const docs = [...index.docs.values()]
139
+ .filter((d) => d.text.trim())
140
+ .sort((a, b) => a.id.localeCompare(b.id));
141
+ if (!docs.length)
142
+ return buildVectorIndex(tag, []);
143
+ const vectors = await embed(docs.map(docEmbeddingText));
144
+ if (vectors.length !== docs.length) {
145
+ throw new Error(`embedding count mismatch: expected ${docs.length}, got ${vectors.length}`);
146
+ }
147
+ return buildVectorIndex(tag, docs.map((d, i) => ({ id: d.id, vec: vectors[i] })));
148
+ }
133
149
  // ---- real-filesystem wiring ------------------------------------------------
134
150
  const IGNORE_DIRS = new Set([
135
151
  'node_modules', 'dist', 'build', 'coverage', '.next', '.cache', '.git',
@@ -176,6 +192,15 @@ export function nodeVaultFS(root) {
176
192
  };
177
193
  }
178
194
  const SESSIONS_DIR = appHomePath('sessions');
195
+ async function configEmbeddingModel() {
196
+ try {
197
+ const cfg = JSON.parse(await readFile(appHomePath('config.json'), 'utf8'));
198
+ return cfg.embeddingModel;
199
+ }
200
+ catch {
201
+ return undefined;
202
+ }
203
+ }
179
204
  /** load first-user-message of the most recent sessions (bounded) for the session corpus. */
180
205
  export async function loadRecentSessions(limit = 60) {
181
206
  const out = [];
@@ -237,5 +262,23 @@ export async function reindex(now = Date.now()) {
237
262
  text: `${s.description} ${s.whenToUse ?? ''}`.trim(),
238
263
  })));
239
264
  await saveIndex(index, nextManifest);
240
- return { ...diff, memory, sessions, skills, vaultPath: brain ?? null };
265
+ let vectors = 0;
266
+ const embedder = getEmbedder(process.env.SANOOK_EMBEDDING_MODEL ?? (await configEmbeddingModel()));
267
+ if (!embedder) {
268
+ await invalidateVectors().catch(() => { });
269
+ }
270
+ else {
271
+ try {
272
+ const vi = await vectorizeIndex(index, embedder.tag, (texts) => embedTexts(embedder, texts));
273
+ await saveVectors(vi);
274
+ vectors = vi.ids.length;
275
+ }
276
+ catch {
277
+ // Semantic search is optional. A provider/network failure must never break
278
+ // the BM25 floor. Clear stale vectors so hybrid/semantic cannot rank the
279
+ // freshly-saved BM25 index with embeddings from an older corpus.
280
+ await invalidateVectors(embedder.tag).catch(() => { });
281
+ }
282
+ }
283
+ return { ...diff, memory, sessions, skills, vectors, vaultPath: brain ?? null };
241
284
  }
@@ -34,6 +34,28 @@ export async function indexMtimeMs() {
34
34
  return 0;
35
35
  }
36
36
  }
37
+ export function sanitizeManifest(raw) {
38
+ const out = Object.create(null);
39
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
40
+ return out;
41
+ for (const [path, entry] of Object.entries(raw)) {
42
+ if (!path || ['__proto__', 'prototype', 'constructor'].includes(path))
43
+ continue;
44
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
45
+ continue;
46
+ const r = entry;
47
+ const mtimeMs = Number(r.mtimeMs);
48
+ const size = Number(r.size);
49
+ if (!Number.isFinite(mtimeMs) || mtimeMs < 0 || !Number.isFinite(size) || size < 0)
50
+ continue;
51
+ if (typeof r.sha !== 'string' || !r.sha || r.sha.length > 256)
52
+ continue;
53
+ if (!Array.isArray(r.ids) || !r.ids.every((id) => typeof id === 'string' && id.length > 0 && id.length < 1024))
54
+ continue;
55
+ out[path] = { mtimeMs, size, sha: r.sha, ids: [...r.ids] };
56
+ }
57
+ return out;
58
+ }
37
59
  /**
38
60
  * Load the persisted index + manifest. Pure read: a missing or malformed file
39
61
  * degrades to an empty index rather than throwing, so a corrupt cache never
@@ -43,7 +65,7 @@ export async function loadIndex() {
43
65
  try {
44
66
  const raw = JSON.parse(await readFile(INDEX_PATH, 'utf8'));
45
67
  if (raw && raw.v === FILE_VERSION) {
46
- return { index: indexFromJSON(raw.index), manifest: raw.manifest ?? {} };
68
+ return { index: indexFromJSON(raw.index), manifest: sanitizeManifest(raw.manifest) };
47
69
  }
48
70
  }
49
71
  catch {
package/dist/session.js CHANGED
@@ -1,14 +1,50 @@
1
- import { chmod, readFile, writeFile, mkdir, readdir, realpath } from 'node:fs/promises';
1
+ import { chmod, readFile, writeFile, mkdir, readdir, realpath, rm } from 'node:fs/promises';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { appHomePath, persistenceEnabled } from './brand.js';
4
4
  import { redactKey } from './providers/keys.js';
5
5
  // session store — จำ conversation + ความคืบหน้า เพื่อ "ทำงานต่อได้" ไม่ลืมว่าทำถึงไหน
6
6
  const SESSION_DIR = appHomePath('sessions');
7
+ function isRecord(value) {
8
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
9
+ }
10
+ function isModelMessage(value) {
11
+ if (!isRecord(value))
12
+ return false;
13
+ if (value.role === 'system')
14
+ return typeof value.content === 'string';
15
+ if (value.role === 'tool')
16
+ return Array.isArray(value.content);
17
+ if (value.role === 'user' || value.role === 'assistant') {
18
+ return typeof value.content === 'string' || Array.isArray(value.content);
19
+ }
20
+ return false;
21
+ }
22
+ function isSession(value) {
23
+ if (!isRecord(value))
24
+ return false;
25
+ return (typeof value.id === 'string' &&
26
+ (value.title === undefined || typeof value.title === 'string') &&
27
+ typeof value.created === 'string' &&
28
+ typeof value.updated === 'string' &&
29
+ typeof value.model === 'string' &&
30
+ typeof value.cwd === 'string' &&
31
+ Array.isArray(value.messages) &&
32
+ value.messages.every(isModelMessage));
33
+ }
7
34
  export function newSessionId() {
8
35
  // CLI runtime — ใช้ Date/random ได้ (ไม่ใช่ workflow context)
9
36
  const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
10
37
  return `${ts}-${Math.random().toString(36).slice(2, 8)}`;
11
38
  }
39
+ export function sessionStorePath() {
40
+ return SESSION_DIR;
41
+ }
42
+ function sessionFilePath(id) {
43
+ if (!/^[A-Za-z0-9_.-]+$/.test(id) || id.includes('..')) {
44
+ throw new Error(`session id ไม่ถูกต้อง: ${id}`);
45
+ }
46
+ return join(SESSION_DIR, `${id}.json`);
47
+ }
12
48
  function redactUnknown(value) {
13
49
  if (typeof value === 'string')
14
50
  return redactKey(value);
@@ -22,9 +58,13 @@ function redactUnknown(value) {
22
58
  function sanitizeSession(s) {
23
59
  return {
24
60
  ...s,
61
+ title: typeof s.title === 'string' ? redactKey(s.title) : s.title,
25
62
  messages: redactUnknown(s.messages),
26
63
  };
27
64
  }
65
+ export function sanitizeSessionForExport(s) {
66
+ return sanitizeSession(s);
67
+ }
28
68
  async function canonicalPath(path) {
29
69
  try {
30
70
  return await realpath(path);
@@ -37,24 +77,25 @@ export async function saveSession(s) {
37
77
  if (!persistenceEnabled())
38
78
  return;
39
79
  await mkdir(SESSION_DIR, { recursive: true });
40
- const path = join(SESSION_DIR, `${s.id}.json`);
80
+ const path = sessionFilePath(s.id);
41
81
  await writeFile(path, `${JSON.stringify(sanitizeSession(s), null, 2)}\n`, { mode: 0o600 });
42
82
  await chmod(path, 0o600).catch(() => { });
43
83
  }
44
84
  export async function loadSession(id) {
45
85
  try {
46
- return JSON.parse(await readFile(join(SESSION_DIR, `${id}.json`), 'utf8'));
86
+ const parsed = JSON.parse(await readFile(sessionFilePath(id), 'utf8'));
87
+ return isSession(parsed) && parsed.id === id ? parsed : null;
47
88
  }
48
89
  catch {
49
90
  return null;
50
91
  }
51
92
  }
52
- /** session ล่าสุด (สำหรับ --continue). ค่า default จำกัดเฉพาะ cwd ปัจจุบัน กัน context ข้าม project */
53
- export async function latestSession(cwd = process.cwd()) {
93
+ export async function listSessions(options = {}) {
54
94
  try {
95
+ const cwd = options.cwd === undefined ? process.cwd() : options.cwd;
55
96
  const ids = (await readdir(SESSION_DIR)).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5));
56
97
  if (!ids.length)
57
- return null;
98
+ return [];
58
99
  let sessions = (await Promise.all(ids.map(loadSession))).filter((s) => s !== null);
59
100
  if (cwd) {
60
101
  const current = await canonicalPath(cwd);
@@ -62,9 +103,54 @@ export async function latestSession(cwd = process.cwd()) {
62
103
  sessions = pairs.filter((p) => p.cwd === current).map((p) => p.session);
63
104
  }
64
105
  sessions.sort((a, b) => b.updated.localeCompare(a.updated));
65
- return sessions[0] ?? null;
106
+ const limit = options.limit;
107
+ return typeof limit === 'number' && Number.isInteger(limit) && limit > 0 ? sessions.slice(0, limit) : sessions;
66
108
  }
67
109
  catch {
110
+ return [];
111
+ }
112
+ }
113
+ export async function removeSession(id) {
114
+ try {
115
+ await rm(sessionFilePath(id), { force: false });
116
+ return true;
117
+ }
118
+ catch (e) {
119
+ const code = e.code;
120
+ if (code === 'ENOENT')
121
+ return false;
122
+ throw e;
123
+ }
124
+ }
125
+ export async function renameSession(id, title) {
126
+ const session = await loadSession(id);
127
+ if (!session)
68
128
  return null;
129
+ const next = { ...session, title: title.trim(), updated: new Date().toISOString() };
130
+ await saveSession(next);
131
+ return next;
132
+ }
133
+ export async function pruneSessions(options = {}) {
134
+ const sessions = await listSessions({ cwd: options.cwd });
135
+ const removeIds = new Set();
136
+ if (Number.isInteger(options.keep) && options.keep >= 0) {
137
+ for (const s of sessions.slice(options.keep))
138
+ removeIds.add(s.id);
69
139
  }
140
+ if (options.before) {
141
+ const beforeMs = options.before.getTime();
142
+ for (const s of sessions) {
143
+ const updatedMs = Date.parse(s.updated);
144
+ if (Number.isFinite(updatedMs) && updatedMs < beforeMs)
145
+ removeIds.add(s.id);
146
+ }
147
+ }
148
+ const removed = sessions.filter((s) => removeIds.has(s.id));
149
+ for (const s of removed)
150
+ await removeSession(s.id);
151
+ return removed;
152
+ }
153
+ /** session ล่าสุด (สำหรับ --continue). ค่า default จำกัดเฉพาะ cwd ปัจจุบัน กัน context ข้าม project */
154
+ export async function latestSession(cwd = process.cwd()) {
155
+ return (await listSessions({ cwd, limit: 1 }))[0] ?? null;
70
156
  }
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile, mkdir, readdir, rm, stat, lstat, copyFile } from 'node:fs/promises';
1
+ import { readFile, writeFile, mkdir, readdir, rm, stat, lstat, copyFile, rename } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join, basename, resolve, sep, dirname } from 'node:path';
4
4
  import { execFile } from 'node:child_process';
@@ -13,6 +13,7 @@ const USER_SKILLS = appHomePath('skills');
13
13
  const MAX_FILES = 300;
14
14
  const MAX_BYTES = 20 * 1024 * 1024; // 20MB ต่อ skill
15
15
  const MAX_MD = 2 * 1024 * 1024; // 2MB ต่อ SKILL.md จาก URL
16
+ const SUPPORT_DIRS = new Set(['references', 'scripts', 'assets', 'templates']);
16
17
  const exists = async (p) => {
17
18
  try {
18
19
  await stat(p);
@@ -22,7 +23,7 @@ const exists = async (p) => {
22
23
  return false;
23
24
  }
24
25
  };
25
- async function copyTreeSafe(srcDir, destDir, budget, depth = 2) {
26
+ async function copyTreeSafe(srcDir, destDir, budget, depth = 2, atRoot = true) {
26
27
  if (depth < 0)
27
28
  return;
28
29
  let entries;
@@ -35,12 +36,14 @@ async function copyTreeSafe(srcDir, destDir, budget, depth = 2) {
35
36
  for (const e of entries) {
36
37
  if (e.name.startsWith('.'))
37
38
  continue; // skip .git/dotfiles
39
+ if (atRoot && e.name !== 'SKILL.md' && !SUPPORT_DIRS.has(e.name))
40
+ continue;
38
41
  const s = join(srcDir, e.name);
39
42
  const st = await lstat(s);
40
43
  if (st.isSymbolicLink())
41
44
  continue; // ห้าม copy symlink (กัน planted symlink หลุด ~/.sanook)
42
45
  if (st.isDirectory()) {
43
- await copyTreeSafe(s, join(destDir, e.name), budget, depth - 1);
46
+ await copyTreeSafe(s, join(destDir, e.name), budget, depth - 1, false);
44
47
  }
45
48
  else if (st.isFile()) {
46
49
  if (--budget.files < 0)
@@ -53,7 +56,22 @@ async function copyTreeSafe(srcDir, destDir, budget, depth = 2) {
53
56
  }
54
57
  }
55
58
  }
56
- /** copy skill dir (SKILL.md + references/scripts ที่เป็น regular file) → ~/.sanook/skills/<name> */
59
+ async function replaceSkillDir(name, populate) {
60
+ const dest = join(USER_SKILLS, name);
61
+ const tmp = join(USER_SKILLS, `.${name}.${randomUUID()}.tmp`);
62
+ await mkdir(USER_SKILLS, { recursive: true });
63
+ try {
64
+ await populate(tmp);
65
+ await rm(dest, { recursive: true, force: true });
66
+ await rename(tmp, dest);
67
+ return dest;
68
+ }
69
+ catch (e) {
70
+ await rm(tmp, { recursive: true, force: true }).catch(() => { });
71
+ throw e;
72
+ }
73
+ }
74
+ /** copy skill dir (SKILL.md + allowed support dirs as regular files) → ~/.sanook/skills/<name> */
57
75
  async function installFromDir(srcDir) {
58
76
  const md = join(srcDir, 'SKILL.md');
59
77
  const stMd = await lstat(md);
@@ -63,10 +81,7 @@ async function installFromDir(srcDir) {
63
81
  const name = meta.name || basename(srcDir);
64
82
  if (!isValidSkillName(name))
65
83
  throw new Error(`ชื่อ skill ไม่ถูกต้อง: "${name}"`);
66
- const dest = join(USER_SKILLS, name);
67
- await rm(dest, { recursive: true, force: true });
68
- await mkdir(dest, { recursive: true });
69
- await copyTreeSafe(srcDir, dest, { files: MAX_FILES, bytes: MAX_BYTES });
84
+ const dest = await replaceSkillDir(name, (tmp) => copyTreeSafe(srcDir, tmp, { files: MAX_FILES, bytes: MAX_BYTES }));
70
85
  return { name, path: dest };
71
86
  }
72
87
  /** เขียน SKILL.md เดียว (จาก URL) → ~/.sanook/skills/<name> */
@@ -75,10 +90,10 @@ async function installFromContent(content, fallbackName) {
75
90
  const name = meta.name || fallbackName;
76
91
  if (!isValidSkillName(name))
77
92
  throw new Error(`ชื่อ skill ไม่ถูกต้อง: "${name}"`);
78
- const dest = join(USER_SKILLS, name);
79
- await rm(dest, { recursive: true, force: true });
80
- await mkdir(dest, { recursive: true });
81
- await writeFile(join(dest, 'SKILL.md'), content);
93
+ const dest = await replaceSkillDir(name, async (tmp) => {
94
+ await mkdir(tmp, { recursive: true });
95
+ await writeFile(join(tmp, 'SKILL.md'), content);
96
+ });
82
97
  return { name, path: dest };
83
98
  }
84
99
  /** หา SKILL.md ใน dir (ตรงๆ, ทุก subdir, หรือ skills/ subdir) → ติดตั้งทั้งหมด */
@@ -95,6 +110,8 @@ async function installFromLocal(path, onLog) {
95
110
  continue;
96
111
  }
97
112
  for (const e of entries) {
113
+ if (e.name.startsWith('.'))
114
+ continue;
98
115
  if (e.isDirectory() && (await exists(join(root, e.name, 'SKILL.md')))) {
99
116
  try {
100
117
  out.push(await installFromDir(join(root, e.name)));
@@ -0,0 +1,175 @@
1
+ import { arch, platform, release } from 'node:os';
2
+ import { appHomePath, appProjectPath, BRAND } from './brand.js';
3
+ import { authConfigPath, loadConfig, readGlobalConfigRaw, readStoredAuthRaw } from './config.js';
4
+ import { loadMcpConfig } from './mcp.js';
5
+ import { parseSpec, PROVIDERS } from './providers/registry.js';
6
+ import { redactKey } from './providers/keys.js';
7
+ import { listSessions, sessionStorePath } from './session.js';
8
+ import { loadSkills } from './skills.js';
9
+ import { projectRoot, projectTrustStatus } from './trust.js';
10
+ function yesNo(value) {
11
+ return value ? 'yes' : 'no';
12
+ }
13
+ function valueOrUnset(value) {
14
+ if (value === undefined || value === null || value === '')
15
+ return '(not set)';
16
+ return String(value);
17
+ }
18
+ function keySource(envVar, fallbacks, env) {
19
+ for (const name of [envVar, ...fallbacks]) {
20
+ const key = env[name]?.trim();
21
+ if (key)
22
+ return { name, key };
23
+ }
24
+ return null;
25
+ }
26
+ function providerStatusLines(stored, env, showKeys) {
27
+ const lines = ['provider auth:'];
28
+ for (const [id, cfg] of Object.entries(PROVIDERS)) {
29
+ if (!cfg.requiresKey) {
30
+ lines.push(` ${id.padEnd(10)} ${cfg.label}: no API key required`);
31
+ continue;
32
+ }
33
+ const runtime = keySource(cfg.envVar, cfg.envFallbacks ?? [], env);
34
+ const saved = stored[cfg.envVar];
35
+ const state = runtime ? `ready via ${runtime.name}` : saved ? `stored in auth.json` : `missing ${cfg.envVar}`;
36
+ const key = runtime?.key ?? saved;
37
+ const keySuffix = showKeys && key ? ` (${runtime?.name ?? cfg.envVar}=${redactKey(key)})` : '';
38
+ lines.push(` ${id.padEnd(10)} ${cfg.label}: ${state}${keySuffix}`);
39
+ }
40
+ return lines;
41
+ }
42
+ function mcpEndpointLabel(url) {
43
+ try {
44
+ const parsed = new URL(url);
45
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
46
+ }
47
+ catch {
48
+ return '(http endpoint)';
49
+ }
50
+ }
51
+ export async function buildSupportDump(options = {}) {
52
+ const cwd = options.cwd ?? process.cwd();
53
+ const env = options.env ?? process.env;
54
+ const lines = [];
55
+ const mcpLogs = [];
56
+ const rawConfig = await readGlobalConfigRaw();
57
+ const storedAuth = await readStoredAuthRaw();
58
+ const loadedConfig = await loadConfig({}, cwd).catch((e) => e);
59
+ const parsed = loadedConfig instanceof Error ? null : parseSpec(loadedConfig.model);
60
+ const provider = parsed ? PROVIDERS[parsed.provider] : undefined;
61
+ const root = await projectRoot(cwd);
62
+ const trust = await projectTrustStatus(root);
63
+ const { readGatewayConfig, resolveBlueBubblesConfig, resolveDiscordConfig, resolveEmailConfig, resolveGoogleChatConfig, resolveHomeAssistantConfig, resolveLineConfig, resolveMattermostConfig, resolveMatrixConfig, resolveNtfyConfig, resolveSignalConfig, resolveSlackConfig, resolveSmsConfig, resolveTelegramConfig, resolveTeamsConfig, resolveWhatsAppConfig, resolveWebhookConfig, gatewayConfigPath, } = await import('./gateway/config.js');
64
+ const { gatewayServiceStatus } = await import('./gateway/service.js');
65
+ const { listConfiguredTargets } = await import('./gateway/targets.js');
66
+ const { redactSignalId } = await import('./gateway/signal.js');
67
+ const { redactWhatsAppId } = await import('./gateway/whatsapp.js');
68
+ const gatewayConfig = await readGatewayConfig();
69
+ const telegram = resolveTelegramConfig(gatewayConfig, env);
70
+ const discord = resolveDiscordConfig(gatewayConfig, env);
71
+ const slack = resolveSlackConfig(gatewayConfig, env);
72
+ const email = resolveEmailConfig(gatewayConfig, env);
73
+ const homeassistant = resolveHomeAssistantConfig(gatewayConfig, env);
74
+ const line = resolveLineConfig(gatewayConfig, env);
75
+ const mattermost = resolveMattermostConfig(gatewayConfig, env);
76
+ const sms = resolveSmsConfig(gatewayConfig, env);
77
+ const ntfy = resolveNtfyConfig(gatewayConfig, env);
78
+ const signal = resolveSignalConfig(gatewayConfig, env);
79
+ const whatsapp = resolveWhatsAppConfig(gatewayConfig, env);
80
+ const matrix = resolveMatrixConfig(gatewayConfig, env);
81
+ const googleChat = resolveGoogleChatConfig(gatewayConfig, env);
82
+ const bluebubbles = resolveBlueBubblesConfig(gatewayConfig, env);
83
+ const teams = resolveTeamsConfig(gatewayConfig, env);
84
+ const webhooks = resolveWebhookConfig(gatewayConfig, env);
85
+ const service = await gatewayServiceStatus();
86
+ const targets = listConfiguredTargets(gatewayConfig, env);
87
+ const mcp = await loadMcpConfig((m) => mcpLogs.push(m), cwd);
88
+ const skills = await loadSkills(cwd);
89
+ const currentSessions = await listSessions({ cwd });
90
+ const allSessions = await listSessions({ cwd: null });
91
+ const { tools } = await import('./tools/index.js');
92
+ lines.push(`${BRAND.productName} support dump`);
93
+ lines.push(`version: ${options.version ?? '(dev)'}`);
94
+ if (options.packageName)
95
+ lines.push(`package: ${options.packageName}`);
96
+ lines.push(`node: ${process.version}`);
97
+ lines.push(`platform: ${platform()} ${release()} ${arch()}`);
98
+ lines.push(`cwd: ${cwd}`);
99
+ lines.push(`project root: ${root}`);
100
+ lines.push(`project trust: ${trust.trusted ? 'trusted' : `untrusted (${trust.reason})`}`);
101
+ lines.push('');
102
+ lines.push('paths:');
103
+ lines.push(` config: ${appHomePath('config.json')}`);
104
+ lines.push(` auth: ${authConfigPath()}`);
105
+ lines.push(` gateway config: ${gatewayConfigPath()}`);
106
+ lines.push(` gateway service log: ${service.logPath}`);
107
+ lines.push(` sessions: ${sessionStorePath()}`);
108
+ lines.push(` mcp global: ${appHomePath('mcp.json')}`);
109
+ lines.push(` mcp project: ${appProjectPath(root, 'mcp.json')}`);
110
+ lines.push('');
111
+ lines.push('agent config:');
112
+ if (loadedConfig instanceof Error) {
113
+ lines.push(` load error: ${redactKey(loadedConfig.message)}`);
114
+ lines.push(` raw keys: ${Object.keys(rawConfig).sort().join(', ') || '(none)'}`);
115
+ }
116
+ else {
117
+ lines.push(` model: ${loadedConfig.model}`);
118
+ lines.push(` provider: ${provider?.label ?? parsed?.provider ?? '(unknown)'}`);
119
+ lines.push(` fallbackModel: ${valueOrUnset(loadedConfig.fallbackModel)}`);
120
+ lines.push(` permissionMode: ${loadedConfig.permissionMode}`);
121
+ lines.push(` maxSteps: ${loadedConfig.maxSteps}`);
122
+ lines.push(` budgetUsd: ${valueOrUnset(loadedConfig.budgetUsd)}`);
123
+ lines.push(` brainPath: ${valueOrUnset(loadedConfig.brainPath)}`);
124
+ lines.push(` cacheTtl: ${loadedConfig.cacheTtl}`);
125
+ lines.push(` compaction: ${loadedConfig.compaction}`);
126
+ lines.push(` thinking: ${valueOrUnset(loadedConfig.thinking)}`);
127
+ lines.push(` summaryModel: ${valueOrUnset(loadedConfig.summaryModel)}`);
128
+ lines.push(` embeddingModel: ${valueOrUnset(loadedConfig.embeddingModel)}`);
129
+ }
130
+ lines.push('');
131
+ lines.push(...providerStatusLines(storedAuth, env, options.showKeys === true));
132
+ lines.push('');
133
+ lines.push('gateway:');
134
+ lines.push(` service: ${service.running ? `running pid ${service.state?.pid}` : service.state ? `stopped last pid ${service.state.pid}` : 'not started'}`);
135
+ lines.push(` telegram: ${telegram.token ? `configured via ${telegram.source}` : 'not configured'}; enabled=${yesNo(telegram.enabled)}; allowed=${telegram.allowedChatIds.length}; write=${yesNo(telegram.allowWrite)}`);
136
+ lines.push(` discord: ${discord.token ? `configured via ${discord.source}` : 'not configured'}; enabled=${yesNo(discord.enabled)}; allowed=${discord.allowedChannelIds.length}; default=${valueOrUnset(discord.defaultChannelId)}; write=${yesNo(discord.allowWrite)}`);
137
+ lines.push(` slack: ${slack.botToken ? `configured via ${slack.source}` : 'not configured'}; enabled=${yesNo(slack.enabled)}; appToken=${yesNo(Boolean(slack.appToken))}; allowed=${slack.allowedChannelIds.length}; default=${valueOrUnset(slack.defaultChannelId)}; write=${yesNo(slack.allowWrite)}`);
138
+ lines.push(` mattermost: ${mattermost.serverUrl || mattermost.token ? `configured via ${mattermost.source}` : 'not configured'}; enabled=${yesNo(mattermost.enabled)}; url=${valueOrUnset(mattermost.serverUrl)}; token=${yesNo(Boolean(mattermost.token))}; allowedUsers=${mattermost.allowedUsers.length}; allowedChannels=${mattermost.allowedChannels.length}; home=${valueOrUnset(mattermost.homeChannel)}; requireMention=${yesNo(mattermost.requireMention)}; replyMode=${mattermost.replyMode}`);
139
+ lines.push(` homeassistant: ${homeassistant.token ? `configured via ${homeassistant.source}` : 'not configured'}; enabled=${yesNo(homeassistant.enabled)}; url=${valueOrUnset(homeassistant.url)}; token=${yesNo(Boolean(homeassistant.token))}; watchDomains=${homeassistant.watchDomains.length}; watchEntities=${homeassistant.watchEntities.length}; ignore=${homeassistant.ignoreEntities.length}; watchAll=${yesNo(homeassistant.watchAll)}; cooldown=${homeassistant.cooldownSeconds}s`);
140
+ lines.push(` email: ${email.address ? `configured via ${email.source}` : 'not configured'}; enabled=${yesNo(email.enabled)}; smtp=${valueOrUnset(email.smtpHost)}:${email.smtpPort}; imap=${valueOrUnset(email.imapHost)}:${email.imapPort}; allowed=${email.allowedUsers.length}; home=${valueOrUnset(email.homeAddress)}`);
141
+ lines.push(` line: ${line.channelAccessToken ? `configured via ${line.source}` : 'not configured'}; enabled=${yesNo(line.enabled)}; allowed=${line.allowedUsers.length + line.allowedGroups.length + line.allowedRooms.length}; home=${valueOrUnset(line.homeChannel)}; secret=${yesNo(Boolean(line.channelSecret))}`);
142
+ lines.push(` sms: ${sms.accountSid && sms.authToken && sms.phoneNumber ? `configured via ${sms.source}` : 'not configured'}; enabled=${yesNo(sms.enabled)}; allowed=${sms.allowedUsers.length}; home=${valueOrUnset(sms.homeChannel)}; webhook=${valueOrUnset(sms.webhookUrl)}; signature=${sms.insecureNoSignature ? 'disabled' : 'required'}`);
143
+ lines.push(` ntfy: ${ntfy.topic || ntfy.token ? `configured via ${ntfy.source}` : 'not configured'}; enabled=${yesNo(ntfy.enabled)}; server=${valueOrUnset(ntfy.serverUrl)}; topic=${valueOrUnset(ntfy.topic)}; publish=${valueOrUnset(ntfy.publishTopic)}; allowed=${ntfy.allowedUsers.length}; home=${valueOrUnset(ntfy.homeChannel)}; token=${yesNo(Boolean(ntfy.token))}; markdown=${yesNo(ntfy.markdown)}`);
144
+ lines.push(` signal: ${signal.account ? `configured via ${signal.source}` : 'not configured'}; enabled=${yesNo(signal.enabled)}; url=${valueOrUnset(signal.httpUrl)}; account=${redactSignalId(signal.account)}; allowed=${signal.allowedUsers.length}; groups=${signal.groupAllowedUsers.length}; home=${redactSignalId(signal.homeChannel)}; requireMention=${yesNo(signal.requireMention)}`);
145
+ lines.push(` whatsapp: ${whatsapp.phoneNumberId || whatsapp.accessToken ? `configured via ${whatsapp.source}` : 'not configured'}; enabled=${yesNo(whatsapp.enabled)}; phoneNumberId=${yesNo(Boolean(whatsapp.phoneNumberId))}; token=${yesNo(Boolean(whatsapp.accessToken))}; secret=${yesNo(Boolean(whatsapp.appSecret))}; verifyToken=${yesNo(Boolean(whatsapp.verifyToken))}; allowed=${whatsapp.allowedUsers.length}; home=${redactWhatsAppId(whatsapp.homeChannel)}; public=${valueOrUnset(whatsapp.publicUrl)}; api=${whatsapp.apiVersion}`);
146
+ lines.push(` matrix: ${matrix.homeserver || matrix.accessToken || matrix.userId ? `configured via ${matrix.source}` : 'not configured'}; enabled=${yesNo(matrix.enabled)}; homeserver=${valueOrUnset(matrix.homeserver)}; token=${yesNo(Boolean(matrix.accessToken))}; user=${valueOrUnset(matrix.userId)}; password=${yesNo(Boolean(matrix.password))}; allowedUsers=${matrix.allowedUsers.length}; allowedRooms=${matrix.allowedRooms.length}; home=${valueOrUnset(matrix.homeRoom)}; requireMention=${yesNo(matrix.requireMention)}; autoJoin=${yesNo(matrix.autoJoin)}`);
147
+ lines.push(` googlechat: ${googleChat.serviceAccountJson || googleChat.incomingWebhookUrl ? `configured via ${googleChat.source}` : 'not configured'}; enabled=${yesNo(googleChat.enabled)}; project=${valueOrUnset(googleChat.projectId)}; subscription=${yesNo(Boolean(googleChat.subscriptionName))}; serviceAccount=${yesNo(Boolean(googleChat.serviceAccountJson))}; webhook=${yesNo(Boolean(googleChat.incomingWebhookUrl))}; allowedUsers=${googleChat.allowedUsers.length}; allowedSpaces=${googleChat.allowedSpaces.length}; freeSpaces=${googleChat.freeResponseSpaces.length}; home=${valueOrUnset(googleChat.homeChannel)}; flow=${googleChat.maxMessages}/${googleChat.maxBytes}`);
148
+ lines.push(` bluebubbles: ${bluebubbles.serverUrl || bluebubbles.password ? `configured via ${bluebubbles.source}` : 'not configured'}; enabled=${yesNo(bluebubbles.enabled)}; server=${valueOrUnset(bluebubbles.serverUrl)}; password=${yesNo(Boolean(bluebubbles.password))}; webhook=${bluebubbles.webhookHost}:${bluebubbles.webhookPort}${bluebubbles.webhookPath}; allowed=${bluebubbles.allowedUsers.length}; home=${valueOrUnset(bluebubbles.homeChannel)}; requireMention=${yesNo(bluebubbles.requireMention)}`);
149
+ lines.push(` teams: ${teams.incomingWebhookUrl || teams.graphAccessToken || teams.clientId ? `configured via ${teams.source}` : 'not configured'}; enabled=${yesNo(teams.enabled)}; mode=${teams.deliveryMode}; webhook=${yesNo(Boolean(teams.incomingWebhookUrl))}; graphToken=${yesNo(Boolean(teams.graphAccessToken))}; chat=${valueOrUnset(teams.chatId)}; teamChannel=${teams.teamId && teams.channelId ? 'set' : '(not set)'}; home=${valueOrUnset(teams.homeChannel)}; botApp=${teams.clientId && teams.tenantId ? 'set' : '(not set)'}; allowed=${teams.allowedUsers.length}; port=${teams.port}`);
150
+ lines.push(` webhooks: ${webhooks.enabled ? `enabled via ${webhooks.source}` : 'not enabled'}; routes=${Object.keys(webhooks.routes).length}; secret=${yesNo(Boolean(webhooks.secret))}; public=${valueOrUnset(webhooks.publicUrl)}`);
151
+ lines.push(` send targets: ${targets.length}`);
152
+ lines.push('');
153
+ lines.push('mcp:');
154
+ const mcpEntries = Object.entries(mcp);
155
+ lines.push(` servers: ${mcpEntries.length}`);
156
+ for (const [name, cfg] of mcpEntries.slice(0, 20)) {
157
+ lines.push(` ${name}: ${cfg.url ? `http ${mcpEndpointLabel(cfg.url)}` : `stdio ${valueOrUnset(cfg.command)}`}`);
158
+ }
159
+ if (mcpEntries.length > 20)
160
+ lines.push(` ... ${mcpEntries.length - 20} more`);
161
+ for (const log of mcpLogs)
162
+ lines.push(` note: ${redactKey(log)}`);
163
+ lines.push('');
164
+ lines.push('inventory:');
165
+ lines.push(` built-in tools: ${Object.keys(tools).length}`);
166
+ lines.push(` skills: ${skills.length}`);
167
+ lines.push(` sessions current project: ${currentSessions.length}`);
168
+ lines.push(` sessions all projects: ${allSessions.length}`);
169
+ const latest = currentSessions[0] ?? allSessions[0];
170
+ if (latest)
171
+ lines.push(` latest session: ${latest.id} updated ${latest.updated}`);
172
+ lines.push('');
173
+ lines.push(options.showKeys ? 'secrets: redacted prefixes/suffixes shown; raw keys are never printed' : 'secrets: hidden; use --show-keys to show redacted key fingerprints');
174
+ return `${lines.join('\n')}\n`;
175
+ }
@@ -4,6 +4,19 @@ import { readFile, writeFile } from 'node:fs/promises';
4
4
  import { checkWritePath } from './permission.js';
5
5
  import { resolveAgentPath } from './util.js';
6
6
  import { renderEditDiff } from '../diff.js';
7
+ function detectLineEnding(content) {
8
+ if (content.includes('\r\n'))
9
+ return '\r\n';
10
+ if (content.includes('\r'))
11
+ return '\r';
12
+ return '\n';
13
+ }
14
+ function normalizeLineEndings(content) {
15
+ return content.replace(/\r\n|\r/g, '\n');
16
+ }
17
+ function restoreLineEndings(content, lineEnding) {
18
+ return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);
19
+ }
7
20
  /** tier 1: exact substring match + นับจำนวนครั้ง */
8
21
  export function exactMatch(content, needle) {
9
22
  if (needle.length === 0)
@@ -25,6 +38,8 @@ export function exactMatch(content, needle) {
25
38
  * คืน offset ของบล็อกที่ match ในไฟล์จริง (รวม indentation เดิม)
26
39
  */
27
40
  export function whitespaceFlexMatch(content, needle) {
41
+ if (needle.length === 0)
42
+ return null;
28
43
  const needleLines = needle.split('\n').map((l) => l.trim());
29
44
  const contentLines = content.split('\n');
30
45
  // offset อักขระของจุดเริ่มแต่ละบรรทัด
@@ -85,39 +100,54 @@ export const editFileTool = tool({
85
100
  catch (err) {
86
101
  return `ERROR: อ่านไฟล์ "${path}" ไม่ได้ — ${err.message}`;
87
102
  }
88
- // normalize CRLF→LF เพื่อให้ match/offset consistent แล้ว restore EOL เดิมตอนเขียน
89
- // (กัน flex match กิน \r แล้วทำ line ending พังบนไฟล์ Windows)
90
- const usesCRLF = raw.includes('\r\n');
91
- const content = usesCRLF ? raw.replace(/\r\n/g, '\n') : raw;
92
- const oldNorm = old_string.replace(/\r\n/g, '\n');
93
- const newNorm = new_string.replace(/\r\n/g, '\n');
103
+ // normalize CRLF/CR→LF เพื่อให้ match/offset consistent แล้ว restore EOL เดิมตอนเขียน
104
+ // (กัน flex match กิน \r แล้วทำ line ending พังบนไฟล์ Windows/legacy Mac)
105
+ const lineEnding = detectLineEnding(raw);
106
+ const content = normalizeLineEndings(raw);
107
+ const oldNorm = normalizeLineEndings(old_string);
108
+ const newNorm = normalizeLineEndings(new_string);
94
109
  // replace_all: แทนที่ทุกที่ที่ตรง "เป๊ะ" (exact เท่านั้น — flex หลายช่วงกำกวม) → old_string สั้นได้ ไม่ต้อง unique
95
110
  if (replace_all) {
96
111
  const exact = exactMatch(content, oldNorm);
97
112
  if (!exact) {
98
113
  return `ERROR: ไม่พบ old_string ในไฟล์ "${path}" — replace_all ใช้ match แบบตรงเป๊ะเท่านั้น (อ่านไฟล์ใหม่แล้วคัดข้อความที่ตรง)`;
99
114
  }
100
- let updated = content.split(oldNorm).join(newNorm); // split/join = แทนที่ทุกที่ (string literal, ไม่ใช่ regex)
101
- if (usesCRLF)
102
- updated = updated.replace(/\n/g, '\r\n');
115
+ const parts = content.split(oldNorm); // split/join = แทนที่ทุกที่ (string literal, ไม่ใช่ regex)
116
+ const updated = restoreLineEndings(parts.join(newNorm), lineEnding);
103
117
  try {
104
118
  await writeFile(full, updated, 'utf8');
105
119
  }
106
120
  catch (err) {
107
121
  return `ERROR: เขียนไฟล์ "${path}" ไม่ได้ — ${err.message}`;
108
122
  }
109
- return `OK: แก้ "${path}" (${exact.count} ที่)\n${renderEditDiff(oldNorm, newNorm)}`;
123
+ // นับจาก split (non-overlapping จริง) ไม่ใช่ exact.count ที่นับ overlapping → เลขตรงกับที่แทนจริง
124
+ return `OK: แก้ "${path}" (${parts.length - 1} ที่)\n${renderEditDiff(oldNorm, newNorm)}`;
110
125
  }
111
- const m = findMatch(content, oldNorm);
126
+ const exact = exactMatch(content, oldNorm);
127
+ const m = exact ?? whitespaceFlexMatch(content, oldNorm);
128
+ const isFlex = !exact && !!m; // match มาจาก tier whitespace-flex (old_string indentation ไม่ตรงไฟล์)
112
129
  if (!m) {
113
130
  return `ERROR: ไม่พบ old_string ในไฟล์ "${path}" — อ่านไฟล์ใหม่ด้วย read_file แล้วคัดข้อความที่ตรงเป๊ะมาใช้`;
114
131
  }
115
132
  if (m.count > 1) {
116
- return `ERROR: old_string พบ ${m.count} ที่ในไฟล์ "${path}" — ตั้ง replace_all:true เพื่อแก้ทุกที่ หรือใส่ context รอบๆ ให้พอ unique (ใช้เท่าที่จำเป็น ประหยัด token)`;
133
+ // flex tier: replace_all ใช้ไม่ได้ (exact-only) แนะให้ใส่ context อย่างเดียว กัน dead-end loop
134
+ return isFlex
135
+ ? `ERROR: old_string ตรง ${m.count} ที่ในไฟล์ "${path}" (แบบ flex) — ใส่ context รอบๆ ให้ unique แล้วลองใหม่`
136
+ : `ERROR: old_string พบ ${m.count} ที่ในไฟล์ "${path}" — ตั้ง replace_all:true เพื่อแก้ทุกที่ หรือใส่ context รอบๆ ให้พอ unique (ใช้เท่าที่จำเป็น ประหยัด token)`;
137
+ }
138
+ // flex match กิน indentation เดิมของไฟล์ (เทียบแบบ trim) — ต้อง re-apply indent ให้ replacement
139
+ // ไม่งั้น code โดน de-indent (พัง Python/YAML + เยื้องเพี้ยนทุกภาษา) แบบเงียบๆ
140
+ let replacement = newNorm;
141
+ if (isFlex) {
142
+ const baseIndent = content.slice(m.start).match(/^[ \t]*/)?.[0] ?? '';
143
+ if (baseIndent) {
144
+ const newLines = newNorm.split('\n');
145
+ const nonBlank = newLines.filter((l) => l.trim() !== '');
146
+ const commonNew = nonBlank.length ? Math.min(...nonBlank.map((l) => (l.match(/^[ \t]*/)?.[0].length ?? 0))) : 0;
147
+ replacement = newLines.map((l) => (l.trim() === '' ? l : baseIndent + l.slice(commonNew))).join('\n');
148
+ }
117
149
  }
118
- let updated = content.slice(0, m.start) + newNorm + content.slice(m.end);
119
- if (usesCRLF)
120
- updated = updated.replace(/\n/g, '\r\n');
150
+ const updated = restoreLineEndings(content.slice(0, m.start) + replacement + content.slice(m.end), lineEnding);
121
151
  try {
122
152
  await writeFile(full, updated, 'utf8');
123
153
  }