sanook-cli 0.5.7 → 0.5.8

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.
@@ -0,0 +1,214 @@
1
+ import { runAgent } from '../loop.js';
2
+ import { loadConfig } from '../config.js';
3
+ import { redactKey } from '../providers/keys.js';
4
+ import { describeToolCall } from '../ui/tool-activity.js';
5
+ const HISTORY = new Map(); // sessionId → conversation (localhost single-user)
6
+ const MAX_HISTORY_SESSIONS = 20;
7
+ function sseSend(res, event) {
8
+ if (res.destroyed || res.writableEnded)
9
+ return; // client gone — don't write to a dead socket
10
+ try {
11
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
12
+ }
13
+ catch {
14
+ /* socket closed mid-write */
15
+ }
16
+ }
17
+ async function readJsonBody(req) {
18
+ const chunks = [];
19
+ for await (const chunk of req)
20
+ chunks.push(Buffer.from(chunk));
21
+ const raw = Buffer.concat(chunks).toString('utf8');
22
+ try {
23
+ const parsed = JSON.parse(raw || '{}');
24
+ return parsed && typeof parsed === 'object' ? parsed : {};
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
30
+ /** POST /api/terminal/run — body {prompt, sessionId, autoApprove?} → SSE stream of agent events */
31
+ export async function handleTerminalRun(req, res) {
32
+ const body = await readJsonBody(req);
33
+ const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
34
+ const sessionId = typeof body.sessionId === 'string' && body.sessionId ? body.sessionId : 'web';
35
+ const autoApprove = body.autoApprove !== false; // default true (localhost = same trust as running CLI)
36
+ res.writeHead(200, {
37
+ 'content-type': 'text/event-stream; charset=utf-8',
38
+ 'cache-control': 'no-cache, no-transform',
39
+ connection: 'keep-alive',
40
+ });
41
+ if (!prompt) {
42
+ sseSend(res, { type: 'error', message: 'empty prompt' });
43
+ sseSend(res, { type: 'done' });
44
+ res.end();
45
+ return;
46
+ }
47
+ const rememberedFacts = [];
48
+ // abort the agent run if the browser disconnects mid-stream — otherwise it keeps executing
49
+ // (and metering cost) and writing to a closed socket. RunAgent honors opts.signal.
50
+ const ac = new AbortController();
51
+ const onClose = () => ac.abort();
52
+ res.on('close', onClose);
53
+ // headers are already sent above, so everything that can throw stays inside this try and emits an
54
+ // SSE error here — otherwise the throw propagates to the server's catch, which calls writeHead(500)
55
+ // on an already-headed response (ERR_HTTP_HEADERS_SENT).
56
+ try {
57
+ const config = await loadConfig({});
58
+ const model = config.model;
59
+ const history = HISTORY.get(sessionId) ?? [];
60
+ sseSend(res, { type: 'status', detail: `Agent · ${model}` });
61
+ const { messages } = await runAgent({
62
+ model,
63
+ prompt,
64
+ history,
65
+ maxSteps: 20,
66
+ permissionMode: autoApprove ? 'auto' : 'ask',
67
+ signal: ac.signal,
68
+ usageMeta: { sessionId: `web:${sessionId}`, source: 'repl' },
69
+ onEvent: (e) => {
70
+ switch (e.type) {
71
+ case 'text':
72
+ if (e.text)
73
+ sseSend(res, { type: 'text', text: e.text });
74
+ break;
75
+ case 'reasoning':
76
+ if (e.text)
77
+ sseSend(res, { type: 'reasoning', text: e.text });
78
+ break;
79
+ case 'tool-call': {
80
+ if (e.tool === 'remember') {
81
+ const fact = e.detail?.fact;
82
+ if (typeof fact === 'string' && fact.trim())
83
+ rememberedFacts.push(fact.trim());
84
+ }
85
+ const activity = describeToolCall(e.tool ?? 'tool', e.detail);
86
+ sseSend(res, { type: 'tool-call', tool: e.tool, title: activity.title, diff: activity.diff ?? null });
87
+ break;
88
+ }
89
+ case 'tool-result':
90
+ sseSend(res, { type: 'tool-result', tool: e.tool });
91
+ break;
92
+ case 'status':
93
+ if (typeof e.detail === 'string')
94
+ sseSend(res, { type: 'status', detail: e.detail });
95
+ break;
96
+ case 'error':
97
+ sseSend(res, { type: 'error', message: redactKey(String(e.detail ?? e.text ?? 'error')) });
98
+ break;
99
+ }
100
+ },
101
+ });
102
+ HISTORY.set(sessionId, messages);
103
+ if (HISTORY.size > MAX_HISTORY_SESSIONS)
104
+ HISTORY.delete([...HISTORY.keys()][0]);
105
+ for (const fact of rememberedFacts)
106
+ sseSend(res, { type: 'memory', fact });
107
+ // ✨ self-improvement (same path as REPL/headless)
108
+ try {
109
+ const { maybeAutoSkill } = await import('../self-improve.js');
110
+ const { defaultSkillSynthesizer } = await import('../self-improve-synth.js');
111
+ const { loadSkills, saveSkill } = await import('../skills.js');
112
+ const existing = new Set((await loadSkills()).map((s) => s.name));
113
+ const auto = await maybeAutoSkill(prompt, { synthesize: defaultSkillSynthesizer(model), saveSkill, existingSkillNames: existing });
114
+ if (auto.created && auto.skillName)
115
+ sseSend(res, { type: 'skill', name: auto.skillName, count: auto.count });
116
+ }
117
+ catch {
118
+ /* best-effort */
119
+ }
120
+ }
121
+ catch (err) {
122
+ sseSend(res, { type: 'error', message: redactKey(err.message) });
123
+ }
124
+ finally {
125
+ res.off('close', onClose);
126
+ }
127
+ sseSend(res, { type: 'done' });
128
+ if (!res.writableEnded)
129
+ res.end();
130
+ }
131
+ export function resetTerminalSession(sessionId) {
132
+ HISTORY.delete(sessionId);
133
+ }
134
+ // ---- Raw shell (optional node-pty + ws) ------------------------------------
135
+ let shellAvailability = null;
136
+ // computed specifiers so TS/bundler don't statically resolve these optional, possibly-absent deps
137
+ const PTY_MODULE = 'node-pty';
138
+ const WS_MODULE = 'ws';
139
+ export async function shellStatus() {
140
+ if (shellAvailability)
141
+ return shellAvailability;
142
+ const missing = [];
143
+ try {
144
+ await import(PTY_MODULE);
145
+ }
146
+ catch {
147
+ missing.push('node-pty');
148
+ }
149
+ try {
150
+ await import(WS_MODULE);
151
+ }
152
+ catch {
153
+ missing.push('ws');
154
+ }
155
+ shellAvailability = missing.length
156
+ ? { available: false, reason: `ติดตั้ง dependency เสริมก่อน: npm i ${missing.join(' ')}` }
157
+ : { available: true, reason: 'ready' };
158
+ return shellAvailability;
159
+ }
160
+ /** Attach a ws upgrade handler for the raw shell at /api/terminal/shell. No-op if deps missing. */
161
+ export async function attachShell(server) {
162
+ const status = await shellStatus();
163
+ if (!status.available)
164
+ return;
165
+ // dynamic import keeps node-pty/ws optional at build/runtime
166
+ const pty = (await import(PTY_MODULE));
167
+ const wsmod = (await import(WS_MODULE));
168
+ const wss = new wsmod.WebSocketServer({ noServer: true });
169
+ server.on('upgrade', (req, socket, head) => {
170
+ const url = new URL(req.url ?? '/', 'http://local');
171
+ if (url.pathname !== '/api/terminal/shell') {
172
+ // not ours — destroy the half-open upgraded socket so it doesn't leak (this is the only
173
+ // 'upgrade' handler on the dashboard server; with noServer ws, Node won't auto-close it).
174
+ socket.destroy();
175
+ return;
176
+ }
177
+ wss.handleUpgrade(req, socket, head, (ws) => {
178
+ const shell = process.platform === 'win32' ? 'powershell.exe' : process.env.SHELL || 'bash';
179
+ const term = pty.spawn(shell, [], {
180
+ name: 'xterm-color',
181
+ cols: 80,
182
+ rows: 24,
183
+ cwd: process.env.HOME || process.cwd(),
184
+ env: process.env,
185
+ });
186
+ const safeKill = () => {
187
+ try {
188
+ term.kill();
189
+ }
190
+ catch {
191
+ /* pty already exited — kill() can throw for a reaped pid on some platforms */
192
+ }
193
+ };
194
+ term.onData((data) => ws.send(JSON.stringify({ type: 'data', data })));
195
+ term.onExit(() => ws.close());
196
+ ws.on('message', (raw) => {
197
+ try {
198
+ const msg = JSON.parse(String(raw));
199
+ if (msg.type === 'data' && typeof msg.data === 'string')
200
+ term.write(msg.data);
201
+ else if (msg.type === 'resize' && msg.cols && msg.rows)
202
+ term.resize(msg.cols, msg.rows);
203
+ }
204
+ catch {
205
+ /* ignore malformed frame */
206
+ }
207
+ });
208
+ ws.on('close', safeKill);
209
+ // without an 'error' listener, a ws error (abrupt TCP reset / protocol violation) is rethrown
210
+ // by Node's EventEmitter as an uncaught exception and crashes the whole dashboard server.
211
+ ws.on('error', safeKill);
212
+ });
213
+ });
214
+ }
package/dist/diff.js CHANGED
@@ -1,7 +1,11 @@
1
1
  // minimal unified-ish diff (zero dep) — โชว์ให้เห็นว่าแก้อะไรก่อน/หลัง โปร่งใส
2
2
  const MAX_LINES = 14;
3
- /** diff ของ edit (old block → new block) — render เป็น -old / +new */
4
- export function renderEditDiff(oldStr, newStr) {
3
+ /**
4
+ * core prefix/suffix-trim diff — shared by renderEditDiff (string) and the REPL's colored
5
+ * diffLines (src/ui/tool-activity.ts) so the algorithm lives in one place. Callers pick their
6
+ * own line cap; only the formatting differs.
7
+ */
8
+ export function editDiffSegments(oldStr, newStr, max = MAX_LINES) {
5
9
  const oldL = oldStr.split('\n');
6
10
  const newL = newStr.split('\n');
7
11
  // ตัด common prefix/suffix lines ที่เหมือนกัน เพื่อโชว์เฉพาะส่วนที่เปลี่ยน
@@ -15,15 +19,25 @@ export function renderEditDiff(oldStr, newStr) {
15
19
  suf++;
16
20
  const oldMid = oldL.slice(pre, oldL.length - suf);
17
21
  const newMid = newL.slice(pre, newL.length - suf);
22
+ return {
23
+ removed: oldMid.slice(0, max),
24
+ added: newMid.slice(0, max),
25
+ moreRemoved: Math.max(0, oldMid.length - max),
26
+ moreAdded: Math.max(0, newMid.length - max),
27
+ };
28
+ }
29
+ /** diff ของ edit (old block → new block) — render เป็น -old / +new */
30
+ export function renderEditDiff(oldStr, newStr) {
31
+ const seg = editDiffSegments(oldStr, newStr, MAX_LINES);
18
32
  const lines = [];
19
- for (const l of oldMid.slice(0, MAX_LINES))
33
+ for (const l of seg.removed)
20
34
  lines.push(`- ${l}`);
21
- if (oldMid.length > MAX_LINES)
22
- lines.push(` …(-${oldMid.length - MAX_LINES} บรรทัด)`);
23
- for (const l of newMid.slice(0, MAX_LINES))
35
+ if (seg.moreRemoved)
36
+ lines.push(` …(-${seg.moreRemoved} บรรทัด)`);
37
+ for (const l of seg.added)
24
38
  lines.push(`+ ${l}`);
25
- if (newMid.length > MAX_LINES)
26
- lines.push(` …(+${newMid.length - MAX_LINES} บรรทัด)`);
39
+ if (seg.moreAdded)
40
+ lines.push(` …(+${seg.moreAdded} บรรทัด)`);
27
41
  return lines.join('\n');
28
42
  }
29
43
  /** สรุปการ write — จำนวนบรรทัด/ตัวอักษร + ถ้าเขียนทับ บอก before→after */
@@ -0,0 +1,91 @@
1
+ // Multi-platform install metadata — single source for Dashboard, README sync, and docs.
2
+ import { readFileSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ export const INSTALL_PKG = 'sanook-cli';
6
+ export const INSTALL_REPO = 'Sir-chawakorn/sanook-cli';
7
+ export const INSTALL_REPO_URL = `https://github.com/${INSTALL_REPO}`;
8
+ export const INSTALL_BRANCH = 'main';
9
+ /** Custom domain when DNS + Pages are configured */
10
+ export const INSTALL_DOMAIN = 'sanook.ai';
11
+ /** GitHub Pages project URL (works when gh-pages branch is deployed) */
12
+ export const INSTALL_PAGES_URL = `https://${INSTALL_REPO.split('/')[0]}.github.io/${INSTALL_REPO.split('/')[1]}`;
13
+ /** jsDelivr CDN — stable short URL without custom domain */
14
+ export const INSTALL_CDN_URL = `https://cdn.jsdelivr.net/gh/${INSTALL_REPO}@${INSTALL_BRANCH}/scripts`;
15
+ const __dir = dirname(fileURLToPath(import.meta.url));
16
+ /** package.json version at build time (repo root when running from src/) */
17
+ export function installPkgVersion() {
18
+ try {
19
+ const pkg = JSON.parse(readFileSync(join(__dir, '..', 'package.json'), 'utf8'));
20
+ return pkg.version ?? 'latest';
21
+ }
22
+ catch {
23
+ return 'latest';
24
+ }
25
+ }
26
+ export function installScriptUrl(name, preferDomain = false) {
27
+ if (preferDomain)
28
+ return `https://${INSTALL_DOMAIN}/${name}`;
29
+ return `https://raw.githubusercontent.com/${INSTALL_REPO}/${INSTALL_BRANCH}/scripts/${name}`;
30
+ }
31
+ export function installScriptPagesUrl(name) {
32
+ return `${INSTALL_PAGES_URL}/${name}`;
33
+ }
34
+ export function installScriptCdnUrl(name) {
35
+ return `${INSTALL_CDN_URL}/${name}`;
36
+ }
37
+ /** Install channels shown on Dashboard + docs. `ready` = users can run today without extra infra. */
38
+ export function installMethods() {
39
+ const sh = installScriptUrl('install.sh');
40
+ const ps1 = installScriptUrl('install.ps1');
41
+ const shPages = installScriptPagesUrl('install.sh');
42
+ const shDomain = installScriptUrl('install.sh', true);
43
+ const shCdn = installScriptCdnUrl('install.sh');
44
+ return [
45
+ {
46
+ id: 'npm',
47
+ label: 'npm / npx',
48
+ recommended: true,
49
+ ready: true,
50
+ commands: [
51
+ { os: 'macOS / Linux / Windows', cmd: `npm install -g ${INSTALL_PKG}` },
52
+ { os: 'Run without installing', cmd: `npx ${INSTALL_PKG}` },
53
+ ],
54
+ note: 'ต้องมี Node.js ≥ 22',
55
+ },
56
+ {
57
+ id: 'curl',
58
+ label: 'Install script',
59
+ ready: true,
60
+ commands: [
61
+ { os: 'macOS / Linux / WSL (GitHub raw)', cmd: `curl -fsSL ${sh} | bash` },
62
+ { os: 'Windows PowerShell (GitHub raw)', cmd: `irm ${ps1} | iex` },
63
+ { os: 'CDN (jsDelivr)', cmd: `curl -fsSL ${shCdn} | bash` },
64
+ { os: 'GitHub Pages', cmd: `curl -fsSL ${shPages} | bash` },
65
+ { os: `Short URL (${INSTALL_DOMAIN})`, cmd: `curl -fsSL ${shDomain} | bash` },
66
+ ],
67
+ note: `${INSTALL_DOMAIN} ต้องตั้ง DNS ที่ GoDaddy ก่อน — ดู scripts/configure-sanook-ai-dns.sh`,
68
+ },
69
+ {
70
+ id: 'homebrew',
71
+ label: 'Homebrew',
72
+ ready: true,
73
+ commands: [
74
+ { os: 'macOS / Linux (trust tap ครั้งแรก)', cmd: `brew trust ${INSTALL_REPO.split('/')[0]}/tap` },
75
+ { os: 'macOS / Linux', cmd: `brew tap ${INSTALL_REPO.split('/')[0]}/tap` },
76
+ { os: 'macOS / Linux', cmd: `brew install ${INSTALL_PKG}` },
77
+ ],
78
+ note: `Live ที่ homebrew-tap — brew tap ${INSTALL_REPO.split('/')[0]}/tap && brew install ${INSTALL_PKG}`,
79
+ },
80
+ {
81
+ id: 'winget',
82
+ label: 'WinGet',
83
+ ready: false,
84
+ commands: [{ os: 'Windows', cmd: 'winget install Sanook.SanookCLI' }],
85
+ note: 'CLA ลงนามแล้ว — PR #391114 รอ validation + merge (release zip v0.5.7 พร้อม)',
86
+ },
87
+ ];
88
+ }
89
+ export function dashboardInstallPayload() {
90
+ return { pkg: INSTALL_PKG, version: installPkgVersion(), methods: installMethods() };
91
+ }
package/dist/memory.js CHANGED
@@ -2,9 +2,10 @@ import { readFile, writeFile, stat } from 'node:fs/promises';
2
2
  import { join, dirname, resolve } from 'node:path';
3
3
  import { buildContextPackBlock, listContextPacks, readContextPackExcerpt, selectContextPack } from './context-pack.js';
4
4
  import { buildProjectContextBlock, resolveVaultProject } from './project-registry.js';
5
- import { appHomePath, BRAND, persistenceEnabled, worklogEnabled } from './brand.js';
5
+ import { appHomePath, BRAND, brainTranscriptEnvForced, persistenceEnabled, worklogEnabled } from './brand.js';
6
6
  import { redactKey } from './providers/keys.js';
7
- import { loadStore, saveStore, mergeFact, maybeConsolidate, consolidate, renderPromptBlock } from './memory-store.js';
7
+ import { loadStore, saveStore, mergeFact, maybeConsolidate, consolidate, renderPromptBlock, activeFacts } from './memory-store.js';
8
+ import { renderPersonaProfile, personaFacts, mergePersonaAnswers, parsePersonaProfileMarkdown, personaAnswersFromFacts } from './persona.js';
8
9
  const MEMORY_FILE = BRAND.memoryFileName;
9
10
  // auto-memory (สิ่งที่ agent จำเองข้าม session) ย้ายไปอยู่ใน ./memory-store.ts —
10
11
  // memory.json เป็น source of truth, MEMORY.md เป็น view ที่ render จากมัน
@@ -250,6 +251,23 @@ export async function getBrainPath() {
250
251
  return undefined;
251
252
  }
252
253
  }
254
+ /**
255
+ * เปิด/ปิดการเก็บ "บทสนทนาเต็ม" (prompt + คำตอบ AI ทุก turn) ลง vault หรือไม่ —
256
+ * config.brainTranscript = true (persistent) หรือ env SANOOK_BRAIN_TRANSCRIPT (force ชั่วคราว)
257
+ */
258
+ export async function brainTranscriptEnabled() {
259
+ if (!persistenceEnabled())
260
+ return false;
261
+ if (brainTranscriptEnvForced())
262
+ return true;
263
+ try {
264
+ const cfg = JSON.parse(await readFile(appHomePath('config.json'), 'utf8'));
265
+ return cfg.brainTranscript === true;
266
+ }
267
+ catch {
268
+ return false;
269
+ }
270
+ }
253
271
  /**
254
272
  * route fact เข้า vault Memory-Inbox (candidate buffer ตาม §4) — "AI เขียนลง second brain ของคุณ"
255
273
  * เขียนเฉพาะถ้า memory-inbox.md มีจริง (กันสร้างไฟล์ใน path ที่ไม่ใช่ vault) · คืน true ถ้าเขียน
@@ -268,13 +286,50 @@ export async function appendToVaultInbox(brainPath, fact) {
268
286
  if (content.includes(line))
269
287
  return false; // dedup
270
288
  const marker = '## New Candidates';
289
+ // replacer function so `$`-sequences in the fact aren't interpreted as String.replace patterns
271
290
  const next = content.includes(marker)
272
- ? content.replace(marker, `${marker}\n${line}`)
291
+ ? content.replace(marker, () => `${marker}\n${line}`)
273
292
  : `${content.trimEnd()}\n${line}\n`;
274
293
  await writeFile(p, next);
275
294
  return true;
276
295
  }
277
296
  /** บันทึก worklog ย่อเข้า vault Sessions/ (รายวัน) — "second brain จำว่าวันนี้ทำอะไร" */
297
+ function worklogHeader(today) {
298
+ return `---\ntags: [session, session-log, worklog]\nnote_type: session-log\ncreated: ${today}\nupdated: ${today}\nparent: "[[Sessions/_Index]]"\nai_surface: history\n---\n\n# ${today} — Worklog (auto by ${BRAND.cliName})\n`;
299
+ }
300
+ function chatTranscriptHeader(today) {
301
+ return `---\ntags: [session, transcript, chat]\nnote_type: session-log\ncreated: ${today}\nupdated: ${today}\nparent: "[[Sessions/_Index]]"\nai_surface: history\n---\n\n# ${today} — Chat transcript (auto by ${BRAND.cliName})\n\n> บทสนทนาเต็มของ session นี้ — เก็บอัตโนมัติทุก turn\n`;
302
+ }
303
+ // Matches ONLY the trailing `up:: ...` footer (anchored to EOF), never a body line that merely
304
+ // starts with `up:: ` (e.g. pasted vault content). `[^\n]*` can't cross newlines and `\s*$` forces
305
+ // the match to reach end-of-file, so a mid-file `up:: ` occurrence can't be mistaken for the footer.
306
+ const UP_FOOTER_RE = /\nup:: [^\n]*\s*$/;
307
+ function ensureSessionNoteScaffold(content, header) {
308
+ const clean = content.trimEnd();
309
+ let next = clean;
310
+ if (!next)
311
+ next = header.trimEnd();
312
+ else if (!next.includes('note_type: session-log'))
313
+ next = `${header.trimEnd()}\n\n${next}`;
314
+ // Append a trailing footer only if the file doesn't already end with one. We deliberately do NOT strip
315
+ // any mid-file `up:: ` line — it may be real user content (e.g. pasted vault text). In the rare
316
+ // manual-edit case where a canonical footer sits mid-body, this yields a harmless duplicate footer line
317
+ // rather than risk deleting vault content.
318
+ if (!UP_FOOTER_RE.test(`${next}\n`))
319
+ next = `${next.trimEnd()}\n\nup:: [[Sessions/_Index]]`;
320
+ return `${next.trimEnd()}\n`;
321
+ }
322
+ /**
323
+ * Insert `block` just before the note's trailing `up:: ...` footer, preserving everything above it.
324
+ * Uses a REPLACER FUNCTION (so `$`-sequences in user prompt/answer text are written literally, not
325
+ * interpreted as String.replace patterns) and matches only the final footer (UP_FOOTER_RE), so a
326
+ * body `up:: ` line can never trigger a greedy truncation-to-EOF. Falls back to appending if no footer.
327
+ */
328
+ function insertBeforeUpFooter(content, block) {
329
+ return UP_FOOTER_RE.test(content)
330
+ ? content.replace(UP_FOOTER_RE, () => `\n${block}\nup:: [[Sessions/_Index]]\n`)
331
+ : `${content.trimEnd()}\n${block}`;
332
+ }
278
333
  export async function appendBrainWorklog(brainPath, entry) {
279
334
  if (!persistenceEnabled() || !worklogEnabled())
280
335
  return false;
@@ -283,20 +338,60 @@ export async function appendBrainWorklog(brainPath, entry) {
283
338
  return false; // ไม่ใช่ vault → ข้าม
284
339
  const topic = entry.prompt.trim().split(/\s+/).slice(0, 6).join(' ').slice(0, 50) || 'work';
285
340
  const file = join(dir, `${entry.today}-worklog.md`);
286
- let content;
287
- try {
288
- content = await readFile(file, 'utf8');
289
- }
290
- catch {
291
- 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`;
292
- }
293
341
  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`;
294
- // แทรกก่อน up:: ท้ายไฟล์ (กัน up:: หลุดไปกลาง)
295
- const out = content.includes('\nup:: ')
296
- ? content.replace(/\nup:: .*$/s, `\n${block}\nup:: [[Sessions/_Index]]\n`)
297
- : `${content.trimEnd()}\n${block}`;
298
- await writeFile(file, out);
299
- return true;
342
+ // serialize the read-modify-write with withMemLock (same as appendBrainTranscript) — turns run
343
+ // fire-and-forget in the REPL, so two concurrent worklog appends to the same daily file would
344
+ // otherwise read the same baseline and the later writeFile would clobber the earlier turn's block.
345
+ return withMemLock(async () => {
346
+ let content;
347
+ try {
348
+ content = await readFile(file, 'utf8');
349
+ }
350
+ catch {
351
+ content = '';
352
+ }
353
+ content = ensureSessionNoteScaffold(content, worklogHeader(entry.today));
354
+ // แทรกก่อน up:: footer ท้ายไฟล์ (กัน up:: หลุดไปกลาง + กัน $ ใน prompt ทำ replace เพี้ยน)
355
+ const out = insertBeforeUpFooter(content, block);
356
+ await writeFile(file, out);
357
+ return true;
358
+ });
359
+ }
360
+ /**
361
+ * เก็บ "บทสนทนาเต็ม" ลง vault Sessions/<date>-<sid>-chat.md — ต่อ 1 turn = 1 block (prompt + คำตอบ AI)
362
+ * ต่างจาก worklog (ย่อ prompt + cost): อันนี้เก็บข้อความจริงที่คุยกัน เพื่อ "ทุกอย่างที่คุยไปอยู่ใน second brain"
363
+ * - gate: brainTranscriptEnabled() (config.brainTranscript / env) + ต้องมีโฟลเดอร์ Sessions/ (เป็น vault จริง)
364
+ * - redact API key ทั้ง 2 ฝั่ง · serialize การเขียนไฟล์เดียวกันด้วย withMemLock กัน turn ชนกัน
365
+ */
366
+ export async function appendBrainTranscript(brainPath, entry) {
367
+ if (!(await brainTranscriptEnabled()))
368
+ return false;
369
+ const dir = join(brainPath, 'Sessions');
370
+ if (!(await exists(dir)))
371
+ return false; // ไม่ใช่ vault → ข้าม
372
+ const created = entry.createdIso ?? new Date().toISOString();
373
+ const today = created.slice(0, 10);
374
+ const sid = entry.sessionId.slice(-6) || 'session';
375
+ const file = join(dir, `${today}-${sid}-chat.md`);
376
+ const time = created.slice(11, 16); // HH:MM (UTC)
377
+ const prompt = redactKey(entry.prompt).trim();
378
+ const answer = redactKey(entry.answer).trim();
379
+ if (!prompt && !answer)
380
+ return false;
381
+ return withMemLock(async () => {
382
+ let content;
383
+ try {
384
+ content = await readFile(file, 'utf8');
385
+ }
386
+ catch {
387
+ content = '';
388
+ }
389
+ content = ensureSessionNoteScaffold(content, chatTranscriptHeader(today));
390
+ const block = [`\n## ${time} · ${entry.model}`, '', '**You:**', '', prompt || '_(empty)_', '', `**${BRAND.agentName}:**`, '', answer || '_(no text output)_', ''].join('\n');
391
+ const out = insertBeforeUpFooter(content, block);
392
+ await writeFile(file, out);
393
+ return true;
394
+ });
300
395
  }
301
396
  // in-process write serializer: the AI SDK runs tool calls from one model step concurrently, so two
302
397
  // `remember` calls in a turn would otherwise load → mergeFact → save on the SAME baseline and the
@@ -331,3 +426,128 @@ export async function appendMemory(fact, noteType) {
331
426
  await appendToVaultInbox(brain, safeFact).catch(() => false);
332
427
  });
333
428
  }
429
+ /**
430
+ * เขียน persona/identity ที่เก็บตอน setup (ขั้นที่ 9) ลง durable auto-memory เป็น owner ground-truth
431
+ * (tier protected, trust owner) → หลัง setup เสร็จ agent "จำ" ว่าเจ้าของชื่ออะไร / เรียก AI ว่าอะไร /
432
+ * ภาษา + autonomy ทันที โดยไม่ต้องรอ remember. ใช้คู่กับ scaffoldBrain ที่ substitute ลงไฟล์ vault อยู่แล้ว.
433
+ * idempotent: เขียนซ้ำ = NOOP (mergeFact). ข้ามค่า default/ว่าง เพื่อไม่ปน noise.
434
+ */
435
+ export async function seedPersonaMemory(input) {
436
+ if (!persistenceEnabled())
437
+ return 0;
438
+ const facts = [];
439
+ const owner = input.ownerName?.trim();
440
+ const ai = input.aiName?.trim();
441
+ const lang = input.language?.trim();
442
+ const autonomy = input.autonomy?.trim();
443
+ if (owner && owner !== input.defaults?.ownerName)
444
+ facts.push({ text: `เจ้าของชื่อ ${owner} — เรียกเจ้าของด้วยชื่อนี้`, noteType: 'entity', trust: 'owner', tier: 'protected' });
445
+ if (ai && ai !== input.defaults?.aiName)
446
+ facts.push({ text: `AI เรียกตัวเองว่า "${ai}" เมื่อคุยกับเจ้าของ`, noteType: 'preference', trust: 'owner', tier: 'protected' });
447
+ if (lang)
448
+ facts.push({ text: `ภาษาที่เจ้าของต้องการให้ตอบ: ${lang}`, noteType: 'preference', trust: 'owner', tier: 'protected' });
449
+ if (autonomy)
450
+ facts.push({ text: `ระดับ autonomy ที่เจ้าของเลือก: ${autonomy}`, noteType: 'preference', trust: 'owner', tier: 'protected' });
451
+ if (!facts.length)
452
+ return 0;
453
+ let written = 0;
454
+ await withMemLock(async () => {
455
+ let store = await loadStore();
456
+ for (const inc of facts) {
457
+ const { store: next, op } = mergeFact(store, { ...inc, text: redactKey(inc.text) });
458
+ // persist any store mutation (NOOP bumps accessCount, QUARANTINE holds the fact aside),
459
+ // but only count facts actually remembered as new/changed — so a re-run with identical
460
+ // answers reports "nothing new" instead of falsely claiming N facts were saved.
461
+ if (op !== 'PROTECTED_HALT') {
462
+ store = next;
463
+ if (op === 'ADD' || op === 'UPDATE' || op === 'SUPERSEDE')
464
+ written += 1;
465
+ }
466
+ }
467
+ await saveStore(store);
468
+ });
469
+ return written;
470
+ }
471
+ /**
472
+ * เขียน persona facts จาก `sanook persona` ลง durable auto-memory เป็น owner ground-truth
473
+ * (tier protected, trust owner) → agent "จำ" persona ทันทีทุก session โดยไม่ต้องรอ remember.
474
+ * idempotent: เขียนซ้ำ = merge/NOOP. รับ plain-string facts (สร้างจาก personaFacts()).
475
+ */
476
+ export async function seedPersonaFacts(facts) {
477
+ if (!persistenceEnabled())
478
+ return 0;
479
+ const list = facts.map((f) => f.trim()).filter(Boolean);
480
+ if (!list.length)
481
+ return 0;
482
+ let written = 0;
483
+ await withMemLock(async () => {
484
+ let store = await loadStore();
485
+ for (const text of list) {
486
+ const { store: next, op } = mergeFact(store, {
487
+ text: redactKey(text),
488
+ noteType: 'preference',
489
+ trust: 'owner',
490
+ tier: 'protected',
491
+ });
492
+ // persist any store mutation (NOOP bumps accessCount, QUARANTINE holds the fact aside),
493
+ // but only count facts actually remembered as new/changed — so a re-run with identical
494
+ // answers reports "nothing new" instead of falsely claiming N facts were saved.
495
+ if (op !== 'PROTECTED_HALT') {
496
+ store = next;
497
+ if (op === 'ADD' || op === 'UPDATE' || op === 'SUPERSEDE')
498
+ written += 1;
499
+ }
500
+ }
501
+ await saveStore(store);
502
+ });
503
+ return written;
504
+ }
505
+ /**
506
+ * เขียนโปรไฟล์ persona ลง second-brain vault ที่ Shared/User-Persona/persona.md
507
+ * (เขียนทับเสมอ — ไฟล์นี้เป็น canonical output ของ `sanook persona`). คืน false ถ้าไม่ใช่ vault จริง.
508
+ */
509
+ export async function writePersonaProfile(brainPath, answers) {
510
+ const personaDir = join(brainPath, 'Shared', 'User-Persona');
511
+ if (!(await exists(personaDir)))
512
+ return false; // ไม่ใช่ vault ที่ scaffold แล้ว → ข้าม
513
+ const today = new Date().toISOString().slice(0, 10);
514
+ const md = redactKey(renderPersonaProfile(answers, today));
515
+ await writeFile(join(personaDir, 'persona.md'), md);
516
+ return true;
517
+ }
518
+ /** โหลดคำตอบ persona ที่มีอยู่ — vault persona.md ชนะ memory facts (สำหรับ pre-fill wizard) */
519
+ export async function loadPersonaAnswers() {
520
+ const brain = await getBrainPath().catch(() => undefined);
521
+ let fromVault = {};
522
+ if (brain) {
523
+ const p = join(brain, 'Shared', 'User-Persona', 'persona.md');
524
+ try {
525
+ fromVault = parsePersonaProfileMarkdown(await readFile(p, 'utf8'));
526
+ }
527
+ catch {
528
+ /* no profile yet */
529
+ }
530
+ }
531
+ let fromMemory = {};
532
+ if (persistenceEnabled()) {
533
+ try {
534
+ const store = await loadStore();
535
+ const texts = activeFacts(store)
536
+ .filter((f) => f.trust === 'owner' || f.tier === 'protected')
537
+ .map((f) => f.text);
538
+ fromMemory = personaAnswersFromFacts(texts);
539
+ }
540
+ catch {
541
+ /* best-effort */
542
+ }
543
+ }
544
+ return mergePersonaAnswers(fromMemory, fromVault);
545
+ }
546
+ /** บันทึก persona ครบชุด — auto-memory + vault profile (ใช้ทั้ง CLI และ REPL /persona) */
547
+ export async function persistPersonaAnswers(answers) {
548
+ const facts = personaFacts(answers);
549
+ const memoryWritten = await seedPersonaFacts(facts).catch(() => 0);
550
+ const brain = await getBrainPath().catch(() => undefined);
551
+ const vaultWritten = brain ? await writePersonaProfile(brain, answers).catch(() => false) : false;
552
+ return { memoryWritten, vaultWritten, brainPath: brain };
553
+ }