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.
package/dist/ui/app.js CHANGED
@@ -9,7 +9,7 @@ import { runAgent } from '../loop.js';
9
9
  import { finalizeReplSession, formatFinalizeMessage } from '../session-brain.js';
10
10
  import { saveSession, newSessionId, listSessions, removeSession, renameSession } from '../session.js';
11
11
  import { TOOL_CATALOG } from '../tool-catalog.js';
12
- import { getBrainPath, appendBrainWorklog } from '../memory.js';
12
+ import { getBrainPath, appendBrainWorklog, appendBrainTranscript } from '../memory.js';
13
13
  import { autoCompact, estimateTokens, summarizeCompact } from '../compaction.js';
14
14
  import { makeSummarizer } from '../summarize.js';
15
15
  import { agentTuning, patchGlobalConfig } from '../config.js';
@@ -19,7 +19,9 @@ import { loadMcpHubEntries } from '../mcp-hub.js';
19
19
  import { probeMcpServer } from '../mcp.js';
20
20
  import { filterModelPickerOptions, initialModelPickerIndex, modelPickerOptions, modelProviderEntries, } from '../model-picker.js';
21
21
  import { clampCompletionIndex, completionForInput, completionReplaceValue } from '../slash-completion.js';
22
- import { loadSkills } from '../skills.js';
22
+ import { loadSkills, saveSkill } from '../skills.js';
23
+ import { maybeAutoSkill } from '../self-improve.js';
24
+ import { defaultSkillSynthesizer } from '../self-improve-synth.js';
23
25
  import { copyTextToClipboard } from '../clipboard.js';
24
26
  import { useEditor } from './useEditor.js';
25
27
  import { useBusyElapsedSeconds } from './useBusyElapsed.js';
@@ -35,8 +37,10 @@ import { MarkdownText, StreamingMarkdownText } from './markdown.js';
35
37
  import { SessionPanel } from './session-panel.js';
36
38
  import { getTranscriptWindow, transcriptScrollStep, transcriptWindowSize } from './transcript.js';
37
39
  import { footerStatus } from './status.js';
40
+ import { inputViewport, graphemesOf, cursorGraphemeIndex, SCROLL_LEAD, SCROLL_TAIL } from './input-view.js';
41
+ import { PersonaOverlay } from './persona-wizard.js';
38
42
  import { thinkingPanelLines, snapshotThinking } from './thinking-panel.js';
39
- import { toolTrailLines, updateToolTrailOnEvent } from './tool-trail.js';
43
+ import { toolTrailLines, toolTrailHeader, toolTrailWidth, updateToolTrailOnEvent } from './tool-trail.js';
40
44
  const execFileP = promisify(execFile);
41
45
  const PRE_TURN_COMPACT_TOKENS = 100_000; // session ยาวมากเท่านั้นถึง summarize ก่อน turn (mode summarize)
42
46
  const startupCount = (value) => value === 'checking' ? 'checking' : value.count ? `${value.count}` : 'none';
@@ -67,6 +71,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
67
71
  const [thinkingMode, setThinkingMode] = useState('collapsed');
68
72
  const [contextCompression, setContextCompression] = useState();
69
73
  const [transcriptScroll, setTranscriptScroll] = useState(0);
74
+ const [personaOpen, setPersonaOpen] = useState(false);
70
75
  const idRef = useRef(0);
71
76
  const lastCost = useRef('');
72
77
  const nextToolTrailId = useRef(0);
@@ -961,6 +966,10 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
961
966
  .catch((e) => addTurn('system', `personality: ${e.message}`));
962
967
  return;
963
968
  }
969
+ if (cmd.action === 'personaSetup') {
970
+ setPersonaOpen(true);
971
+ return;
972
+ }
964
973
  if (cmd.action === 'insights') {
965
974
  void renderInsights({ days: cmd.insightsDays, cwd: cmd.insightsAll ? null : undefined })
966
975
  .then((msg) => addTurn('system', msg))
@@ -1052,6 +1061,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1052
1061
  let reasoningBuf = '';
1053
1062
  let lastFlush = 0;
1054
1063
  let lastThinkingFlush = 0;
1064
+ const rememberedFacts = []; // 🧠 ที่ user สั่งจำใน turn นี้ → โชว์ indicator ใน terminal
1055
1065
  try {
1056
1066
  const { cost, messages, text } = await runAgent({
1057
1067
  model,
@@ -1078,6 +1088,11 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1078
1088
  }
1079
1089
  }
1080
1090
  else if (e.type === 'tool-call') {
1091
+ if (e.tool === 'remember') {
1092
+ const fact = e.detail?.fact;
1093
+ if (typeof fact === 'string' && fact.trim())
1094
+ rememberedFacts.push(fact.trim());
1095
+ }
1081
1096
  recordToolTrailEvent(e);
1082
1097
  }
1083
1098
  else if (e.type === 'tool-result' || e.type === 'error') {
@@ -1096,7 +1111,11 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1096
1111
  });
1097
1112
  msgsRef.current = messages;
1098
1113
  lastCost.current = cost.summary();
1099
- addTurn('assistant', buf.trim() || text.trim(), { thinking: snapshotThinking(reasoningBuf), toolTrail: snapshotToolTrail() });
1114
+ const answerText = buf.trim() || text.trim();
1115
+ addTurn('assistant', answerText, { thinking: snapshotThinking(reasoningBuf), toolTrail: snapshotToolTrail() });
1116
+ // 🧠 indicator: ผู้ใช้สั่งให้จำ → บันทึกถาวรแล้ว (memory + second-brain ถ้าตั้งไว้)
1117
+ for (const fact of rememberedFacts)
1118
+ addTurn('system', `🧠 จำไว้แล้ว: ${fact}`);
1100
1119
  // เซฟ session ทุกรอบ → resume ได้ด้วย sanook -c
1101
1120
  void saveSession({
1102
1121
  id: sessionId.current,
@@ -1106,7 +1125,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1106
1125
  cwd: process.cwd(),
1107
1126
  messages,
1108
1127
  });
1109
- // worklog เข้า second-brain vault จำว่าทำอะไรใน session นี้
1128
+ // worklog (ย่อ) + บทสนทนาเต็ม (ถ้าเปิด brainTranscript) เข้า second-brain
1110
1129
  void (async () => {
1111
1130
  const brain = await getBrainPath();
1112
1131
  if (brain) {
@@ -1116,6 +1135,31 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1116
1135
  model,
1117
1136
  today: new Date().toISOString().slice(0, 10),
1118
1137
  }).catch(() => { });
1138
+ await appendBrainTranscript(brain, {
1139
+ sessionId: sessionId.current,
1140
+ prompt: promptText,
1141
+ answer: answerText,
1142
+ model,
1143
+ // per-turn timestamp → correct HH:MM heading + correct day file (matches the worklog
1144
+ // above); sessionCreated is frozen at session start so it stamped every turn the same.
1145
+ createdIso: new Date().toISOString(),
1146
+ }).catch(() => { });
1147
+ }
1148
+ })();
1149
+ // self-improvement: งานเดิมที่สั่งซ้ำถึง threshold → สร้าง skill อัตโนมัติ + แจ้งใน terminal
1150
+ void (async () => {
1151
+ try {
1152
+ const existing = new Set((await loadSkills()).map((s) => s.name));
1153
+ const result = await maybeAutoSkill(promptText, {
1154
+ synthesize: defaultSkillSynthesizer(model),
1155
+ saveSkill,
1156
+ existingSkillNames: existing,
1157
+ });
1158
+ if (result.created && result.announcement)
1159
+ addTurn('system', result.announcement);
1160
+ }
1161
+ catch {
1162
+ /* self-improvement เป็น best-effort — ไม่ให้ล้ม turn */
1119
1163
  }
1120
1164
  })();
1121
1165
  }
@@ -1147,12 +1191,24 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1147
1191
  const contextTokens = estimateTokens(msgsRef.current);
1148
1192
  const activeQueueIndex = clampQueueActiveIndex(queueActiveIndex, queued.length);
1149
1193
  const queueWindow = getQueueWindow(queued.length, activeQueueIndex);
1150
- const toolTrailView = toolTrailLines(toolTrail, columns, toolTrailMode);
1194
+ // gate only the expanded view renders via ToolTrailView/ActivityRow and the compact view
1195
+ // recomputes its own strings; equivalent to toolTrailLines(...).length without building the array each render.
1196
+ const showToolTrail = toolTrailMode !== 'hidden' && toolTrail.length > 0;
1197
+ // id of the most recent turn that has a tool trail — only it keeps full expanded diffs in
1198
+ // scrollback (older tool turns downgrade to compact). Tracks the trail, not just the last turn,
1199
+ // so trailing text/command turns don't strip detail off the latest tool work.
1200
+ let latestTrailTurnId;
1201
+ for (let i = history.length - 1; i >= 0; i -= 1) {
1202
+ if (history[i].toolTrail) {
1203
+ latestTrailTurnId = history[i].id;
1204
+ break;
1205
+ }
1206
+ }
1151
1207
  const thinkingView = thinkingPanelLines(thinking, columns, thinkingMode);
1152
1208
  const transcriptLimit = transcriptWindowSize(stdout?.rows);
1153
1209
  const transcriptView = getTranscriptWindow(history.length, transcriptLimit, transcriptScroll);
1154
1210
  const visibleHistory = history.slice(transcriptView.start, transcriptView.end);
1155
- return (_jsxs(Box, { flexDirection: "column", children: [history.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Banner, { columns: columns, model: model, mode: permissionMode === 'ask' ? 'ask' : 'auto', signals: bannerSignals }), _jsx(SessionPanel, { columns: columns, cwd: cwd, mcp: startupReadiness.mcp, model: model, mode: permissionMode === 'ask' ? 'ask' : 'auto', skills: startupReadiness.skills })] })) : null, transcriptView.showOlder ? (_jsxs(Text, { dimColor: true, children: ["\u2026 ", transcriptView.start, " older turns \u00B7 PgUp/Ctrl+U scroll \u00B7 PgDn/Ctrl+D newer"] })) : null, visibleHistory.map((turn) => (_jsx(TurnView, { columns: columns, thinkingMode: thinkingMode, toolTrailMode: toolTrailMode, turn: turn }, `${historyResetKey}-${turn.id}`))), transcriptView.showNewer ? (_jsxs(Text, { dimColor: true, children: ["\u2026 ", transcriptView.scrollFromBottom, " newer turns hidden \u00B7 PgDn/Ctrl+D to catch up"] })) : null, thinkingView.length ? _jsx(ThinkingView, { columns: columns, mode: thinkingMode, text: thinking }) : null, streaming ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(StreamingMarkdownText, { columns: columns, text: streaming }) })) : null, toolTrailView.length ? (_jsx(ToolTrailView, { columns: columns, items: toolTrail, mode: toolTrailMode })) : null, _jsx(FloatingOverlay, { columns: columns, overlay: overlay, pageSize: pagerPageSize }), _jsx(CompletionOverlay, { columns: columns, items: completions, selected: selectedCompletion }), queued.length ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["queued (", queued.length, ") \u00B7 \u2191\u2193 select \u00B7 Ctrl+X delete \u00B7 Esc clears"] }), queueWindow.showLead ? _jsx(Text, { dimColor: true, children: " \u2026" }) : null, queued.slice(queueWindow.start, queueWindow.end).map((q, i) => (_jsxs(Text, { color: queueWindow.start + i === activeQueueIndex ? 'yellow' : undefined, dimColor: queueWindow.start + i !== activeQueueIndex, children: [queueWindow.start + i === activeQueueIndex ? '›' : ' ', " ", queueWindow.start + i + 1, ".", ' ', compactPreview(q, Math.max(16, columns - 10))] }, `${queueWindow.start + i}-${q.slice(0, 16)}`))), queueWindow.showTail ? _jsxs(Text, { dimColor: true, children: [" \u2026and ", queued.length - queueWindow.end, " more"] }) : null] })) : null, approvalReq ? (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34\u0E23\u0E31\u0E19 ", approvalReq.tool, "?"] }), _jsx(Text, { dimColor: true, children: approvalReq.summary }), _jsx(Text, { dimColor: true, children: "y = \u0E23\u0E31\u0E19 \u00B7 n = \u0E1B\u0E0F\u0E34\u0E40\u0E2A\u0E18" })] })) : (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: busy ? 'gray' : 'blue', paddingX: 1, children: [_jsx(Text, { color: busy ? 'gray' : 'cyan', children: busy ? '· ' : '› ' }), _jsx(InputView, { value: editor.value, cursor: editor.cursor, busy: busy, agentStatus: agentStatus, toolTrail: toolTrail })] })), _jsx(Text, { dimColor: true, children: footerStatus({
1211
+ return (_jsxs(Box, { flexDirection: "column", children: [history.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Banner, { columns: columns, model: model, mode: permissionMode === 'ask' ? 'ask' : 'auto', signals: bannerSignals }), _jsx(SessionPanel, { columns: columns, cwd: cwd, mcp: startupReadiness.mcp, model: model, mode: permissionMode === 'ask' ? 'ask' : 'auto', skills: startupReadiness.skills })] })) : null, transcriptView.showOlder ? (_jsxs(Text, { dimColor: true, children: ["\u2026 ", transcriptView.start, " older turns \u00B7 PgUp/Ctrl+U scroll \u00B7 PgDn/Ctrl+D newer"] })) : null, visibleHistory.map((turn) => (_jsx(TurnView, { columns: columns, isLatest: turn.id === latestTrailTurnId, thinkingMode: thinkingMode, toolTrailMode: toolTrailMode, turn: turn }, `${historyResetKey}-${turn.id}`))), transcriptView.showNewer ? (_jsxs(Text, { dimColor: true, children: ["\u2026 ", transcriptView.scrollFromBottom, " newer turns hidden \u00B7 PgDn/Ctrl+D to catch up"] })) : null, thinkingView.length ? _jsx(ThinkingView, { columns: columns, mode: thinkingMode, text: thinking }) : null, streaming ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(StreamingMarkdownText, { columns: columns, text: streaming }) })) : null, showToolTrail ? (_jsx(ToolTrailView, { columns: columns, items: toolTrail, mode: toolTrailMode })) : null, _jsx(FloatingOverlay, { columns: columns, overlay: overlay, pageSize: pagerPageSize }), _jsx(CompletionOverlay, { columns: columns, items: completions, selected: selectedCompletion }), queued.length ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["queued (", queued.length, ") \u00B7 \u2191\u2193 select \u00B7 Ctrl+X delete \u00B7 Esc clears"] }), queueWindow.showLead ? _jsx(Text, { dimColor: true, children: " \u2026" }) : null, queued.slice(queueWindow.start, queueWindow.end).map((q, i) => (_jsxs(Text, { color: queueWindow.start + i === activeQueueIndex ? 'yellow' : undefined, dimColor: queueWindow.start + i !== activeQueueIndex, children: [queueWindow.start + i === activeQueueIndex ? '›' : ' ', " ", queueWindow.start + i + 1, ".", ' ', compactPreview(q, Math.max(16, columns - 10))] }, `${queueWindow.start + i}-${q.slice(0, 16)}`))), queueWindow.showTail ? _jsxs(Text, { dimColor: true, children: [" \u2026and ", queued.length - queueWindow.end, " more"] }) : null] })) : null, approvalReq ? (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34\u0E23\u0E31\u0E19 ", approvalReq.tool, "?"] }), _jsx(Text, { dimColor: true, children: approvalReq.summary }), _jsx(Text, { dimColor: true, children: "y = \u0E23\u0E31\u0E19 \u00B7 n = \u0E1B\u0E0F\u0E34\u0E40\u0E2A\u0E18" })] })) : personaOpen ? (_jsx(PersonaOverlay, { onDone: (msg) => { setPersonaOpen(false); addTurn('system', msg); } })) : (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: busy ? 'gray' : 'blue', paddingX: 1, children: [_jsx(Text, { color: busy ? 'gray' : 'cyan', children: busy ? '· ' : '› ' }), _jsx(InputView, { value: editor.value, cursor: editor.cursor, busy: busy, agentStatus: agentStatus, toolTrail: toolTrail, columns: columns })] })), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: footerStatus({
1156
1212
  branch: gitBranch,
1157
1213
  backgroundTaskCount: bgTaskCount,
1158
1214
  busy,
@@ -1168,30 +1224,65 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1168
1224
  }) })] }));
1169
1225
  }
1170
1226
  /** input ที่มี cursor (inverse) + placeholder — minimal; รับ input ได้แม้ busy (ต่อคิว) */
1171
- function InputView({ value, cursor, busy, agentStatus, toolTrail, }) {
1227
+ function InputView({ value, cursor, busy, agentStatus, toolTrail, columns = 80, }) {
1172
1228
  if (busy && !value) {
1173
1229
  const runningTool = toolTrail?.find((item) => item.status === 'running');
1174
- const detail = agentStatus || (runningTool ? `Tool · ${runningTool.name}` : 'Working…');
1175
- return (_jsxs(Text, { dimColor: true, children: [detail, " \u00B7 Esc/Ctrl+C \u0E2B\u0E22\u0E38\u0E14 \u00B7 \u0E1E\u0E34\u0E21\u0E1E\u0E4C\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E15\u0E48\u0E2D\u0E04\u0E34\u0E27 (\u23CE)"] }));
1230
+ // prefer the running tool's friendly activity title (e.g. "📖 อ่านไฟล์ src/x.ts", "$ npm test") over
1231
+ // the raw "Tool · read_file" status — same detail the tool trail shows, so the one-line status during a
1232
+ // turn says specifically what's happening; falls back to agentStatus (Thinking…/Writing…/Agent · model).
1233
+ const detail = runningTool?.activity?.title || agentStatus || (runningTool ? `Tool · ${runningTool.name}` : 'Working…');
1234
+ // wrap="truncate-end": status ต้องอยู่ 1 บรรทัดเสมอ — กัน timer/elapsed ทำบรรทัดเด้งหลังส่ง prompt
1235
+ return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [detail, " \u00B7 Esc/Ctrl+C \u0E2B\u0E22\u0E38\u0E14 \u00B7 \u0E1E\u0E34\u0E21\u0E1E\u0E4C\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E15\u0E48\u0E2D\u0E04\u0E34\u0E27 (\u23CE)"] }));
1236
+ }
1237
+ if (!busy && !value) {
1238
+ return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "\u0E16\u0E32\u0E21\u0E2D\u0E30\u0E44\u0E23\u0E01\u0E47\u0E44\u0E14\u0E49 \u2014 /help \u0E14\u0E39\u0E04\u0E33\u0E2A\u0E31\u0E48\u0E07 \u00B7 /tools \u0E14\u0E39 tools \u00B7 @\u0E44\u0E1F\u0E25\u0E4C \u0E41\u0E19\u0E1A context/\u0E23\u0E39\u0E1B" }));
1176
1239
  }
1177
- if (!busy && !value)
1178
- return _jsx(Text, { dimColor: true, children: "\u0E16\u0E32\u0E21\u0E2D\u0E30\u0E44\u0E23\u0E01\u0E47\u0E44\u0E14\u0E49 \u2014 /help \u0E14\u0E39\u0E04\u0E33\u0E2A\u0E31\u0E48\u0E07 \u00B7 /tools \u0E14\u0E39 tools \u00B7 @\u0E44\u0E1F\u0E25\u0E4C \u0E41\u0E19\u0E1A context/\u0E23\u0E39\u0E1B" });
1179
- const before = value.slice(0, cursor);
1180
- const at = value.slice(cursor, cursor + 1) || ' ';
1181
- const after = value.slice(cursor + 1);
1182
- return (_jsxs(Text, { children: [before, _jsx(Text, { inverse: true, children: at }), after, busy ? _jsxs(Text, { dimColor: true, children: [' ', "(\u23CE \u0E15\u0E48\u0E2D\u0E04\u0E34\u0E27)"] }) : null] }));
1240
+ // multiline (กด Alt+Enter / ลงท้าย \) — สูงหลายบรรทัดตั้งใจอยู่แล้ว: render grapheme-cursor แบบ wrap ปกติ
1241
+ if (value.includes('\n')) {
1242
+ const ci = cursorGraphemeIndex(value, cursor);
1243
+ const graphemes = graphemesOf(value);
1244
+ const before = graphemes.slice(0, ci).join('');
1245
+ const at = ci < graphemes.length ? graphemes[ci] : ' ';
1246
+ const after = ci < graphemes.length ? graphemes.slice(ci + 1).join('') : '';
1247
+ return (_jsxs(Text, { children: [before, _jsx(Text, { inverse: true, children: at }), after, busy ? _jsxs(Text, { dimColor: true, children: [' ', "(\u23CE \u0E15\u0E48\u0E2D\u0E04\u0E34\u0E27)"] }) : null] }));
1248
+ }
1249
+ // บรรทัดเดียว: viewport กว้างคงที่ (เลื่อนแนวนอนแทน wrap) → กล่อง input สูง 1 บรรทัดเสมอ ไม่เด้งตอนพิมพ์ไทย
1250
+ // เผื่อ overhead: border(2) + paddingX(2) + prefix "› "(2) + ช่อง cursor/suffix ~2
1251
+ const queueHint = busy ? ' (⏎ ต่อคิว)' : '';
1252
+ const reserved = 8 + queueHint.length;
1253
+ const vp = inputViewport(value, cursor, Math.max(8, columns - reserved));
1254
+ return (_jsxs(Text, { wrap: "truncate-end", children: [vp.lead ? _jsx(Text, { dimColor: true, children: SCROLL_LEAD }) : null, vp.before, _jsx(Text, { inverse: true, children: vp.at }), vp.after, vp.tail ? _jsx(Text, { dimColor: true, children: SCROLL_TAIL }) : null, queueHint ? _jsx(Text, { dimColor: true, children: queueHint }) : null] }));
1255
+ }
1256
+ function statusColor(status) {
1257
+ return status === 'error' ? 'red' : status === 'running' ? 'yellow' : 'green';
1258
+ }
1259
+ function statusMarker(status) {
1260
+ return status === 'error' ? '✗' : status === 'running' ? '›' : '✓';
1261
+ }
1262
+ // total diff rows rendered per tool row — a hard per-item height bound. diffLines caps each SIDE at
1263
+ // MAX_DIFF_LINES, so a two-sided edit can exceed this; we cap the combined rows here and show a plain
1264
+ // "…" (no count — the inner per-side "…(+N)" sentinels already carry the numbers, so we don't double-count).
1265
+ const MAX_ROW_DIFF_LINES = 14;
1266
+ /** one tool's activity: a friendly title line + colored diff (green +, red -), height-bounded. */
1267
+ function ActivityRow({ item, width }) {
1268
+ const title = item.activity?.title ?? item.name;
1269
+ const fullDiff = item.activity?.diff;
1270
+ const diff = fullDiff?.slice(0, MAX_ROW_DIFF_LINES);
1271
+ const diffClipped = (fullDiff?.length ?? 0) > MAX_ROW_DIFF_LINES;
1272
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: statusColor(item.status), wrap: "truncate-end", children: [statusMarker(item.status), " ", title] }), diff?.map((line, idx) => (_jsxs(Text, { color: line.sign === '+' ? 'green' : line.sign === '-' ? 'red' : undefined, dimColor: line.sign === ' ', wrap: "truncate-end", children: [' ', line.sign === ' ' ? ' ' : line.sign, " ", line.text.slice(0, Math.max(0, width - 4))] }, `d-${item.id}-${idx}`))), diffClipped ? _jsx(Text, { dimColor: true, children: ' …' }) : null, item.status !== 'running' && item.detail ? (_jsxs(Text, { color: item.status === 'error' ? 'red' : undefined, dimColor: true, wrap: "truncate-end", children: [' ↳ ', item.detail.slice(0, Math.max(0, width - 6))] })) : null] }));
1183
1273
  }
1184
1274
  function ToolTrailView({ columns, items, mode }) {
1185
- const lines = toolTrailLines(items, columns, mode);
1186
- if (!lines.length)
1275
+ if (mode === 'hidden' || !items.length)
1187
1276
  return null;
1188
- return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: lines.map((line, index) => {
1189
- const isRunning = line.startsWith('>');
1190
- const isError = line.startsWith('!');
1191
- const isDone = line.startsWith('+');
1192
- const isMeta = line.startsWith('view:') || line.startsWith('tools:');
1193
- return (_jsx(Text, { color: index === 0 ? 'cyan' : isError ? 'red' : isRunning ? 'yellow' : undefined, dimColor: isDone || isMeta, wrap: "truncate-end", children: line }, `${index}-${line}`));
1194
- }) }));
1277
+ const header = toolTrailHeader(items, mode);
1278
+ // compact mode keeps the terse one-line string rendering
1279
+ if (mode === 'compact') {
1280
+ const lines = toolTrailLines(items, columns, mode);
1281
+ return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: lines.map((line, index) => (_jsx(Text, { color: index === 0 ? 'cyan' : undefined, dimColor: index > 0, wrap: "truncate-end", children: line }, `${index}-${line}`))) }));
1282
+ }
1283
+ // expanded: rich, colored per-tool activity with diffs
1284
+ const width = toolTrailWidth(columns);
1285
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "cyan", wrap: "truncate-end", children: header[0] }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: header[1] }), items.map((item) => (_jsx(ActivityRow, { item: item, width: width }, item.id)))] }));
1195
1286
  }
1196
1287
  function ThinkingView({ columns, mode, text }) {
1197
1288
  const lines = thinkingPanelLines(text, columns, mode);
@@ -1199,10 +1290,10 @@ function ThinkingView({ columns, mode, text }) {
1199
1290
  return null;
1200
1291
  return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: lines.map((line, index) => (_jsx(Text, { color: index === 0 ? 'cyan' : undefined, dimColor: index > 0, wrap: "truncate-end", children: line }, `${index}-${line}`))) }));
1201
1292
  }
1202
- function TurnView({ columns, thinkingMode, toolTrailMode, turn, }) {
1293
+ function TurnView({ columns, isLatest, thinkingMode, toolTrailMode, turn, }) {
1203
1294
  if (turn.role === 'system')
1204
1295
  return _jsx(Text, { dimColor: true, children: turn.text });
1205
1296
  if (turn.role === 'user')
1206
1297
  return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "\u203A " }), _jsx(Text, { color: "cyan", children: turn.text })] }));
1207
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [turn.thinking ? _jsx(ThinkingView, { columns: columns, mode: thinkingMode, text: turn.thinking }) : null, _jsx(MarkdownText, { columns: columns, text: turn.text }), turn.toolTrail ? _jsx(ToolTrailView, { columns: columns, items: turn.toolTrail, mode: toolTrailMode }) : null] }));
1298
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [turn.thinking ? _jsx(ThinkingView, { columns: columns, mode: thinkingMode, text: turn.thinking }) : null, _jsx(MarkdownText, { columns: columns, text: turn.text }), turn.toolTrail ? (_jsx(ToolTrailView, { columns: columns, items: turn.toolTrail, mode: isLatest || toolTrailMode !== 'expanded' ? toolTrailMode : 'compact' })) : null] }));
1208
1299
  }
@@ -0,0 +1,104 @@
1
+ import stringWidth from 'string-width';
2
+ import { graphemeBoundaries } from './useEditor.js';
3
+ // ────────────────────────────────────────────────────────────────────────────
4
+ // Stable, Thai-safe rendering of the REPL input line.
5
+ //
6
+ // Two bugs this fixes (เทียบกับ CLI เจ้าอื่นที่ "นิ่ง"):
7
+ // 1) cursor split a grapheme cluster — the old code did value.slice(cursor, cursor+1),
8
+ // which on Thai cuts a base char away from its combining vowel/tone mark (สระ/วรรณยุกต์
9
+ // เป็น zero-width). The orphaned mark then renders on its own cell → "อักษรห่างเกินไป".
10
+ // Fix: the cursor highlights a WHOLE grapheme cluster (base + all its marks).
11
+ // 2) the line bounced between 1 and 2 rows while typing — a wrapping <Text> grows the box
12
+ // vertically the moment content crosses the right edge, shoving the footer down on every
13
+ // keystroke. Fix: a fixed-width horizontal viewport (readline-style) so the input box is
14
+ // always exactly one row; long lines scroll left with ‹ / › markers instead of wrapping.
15
+ //
16
+ // Display width is measured with string-width (the same lib Ink wraps with), so Thai combining
17
+ // marks count as 0 and the window math matches what the terminal actually paints.
18
+ // ────────────────────────────────────────────────────────────────────────────
19
+ export const SCROLL_LEAD = '‹';
20
+ export const SCROLL_TAIL = '›';
21
+ /** split a string into grapheme clusters (base char + its combining marks stay together) */
22
+ export function graphemesOf(value) {
23
+ const bounds = graphemeBoundaries(value);
24
+ const out = [];
25
+ for (let i = 0; i < bounds.length - 1; i += 1)
26
+ out.push(value.slice(bounds[i], bounds[i + 1]));
27
+ return out;
28
+ }
29
+ /** grapheme-cluster index that a code-unit cursor sits at (0..graphemeCount) */
30
+ export function cursorGraphemeIndex(value, cursor) {
31
+ const bounds = graphemeBoundaries(value);
32
+ let index = 0;
33
+ for (let i = 0; i < bounds.length; i += 1) {
34
+ if (bounds[i] <= cursor)
35
+ index = i;
36
+ else
37
+ break;
38
+ }
39
+ return index;
40
+ }
41
+ /** display width of one grapheme, never less than 1 cell (so the cursor always has a cell) */
42
+ function cellWidth(grapheme) {
43
+ return Math.max(1, stringWidth(grapheme));
44
+ }
45
+ /**
46
+ * Compute the visible window for a single physical line.
47
+ * `width` = number of terminal columns available to the value (caller subtracts prefix/border/padding).
48
+ * Returns the slice around the cursor that fits, plus whether truncation markers are needed.
49
+ */
50
+ export function inputViewport(value, cursor, width) {
51
+ const w = Math.max(4, Math.floor(width));
52
+ const graphemes = graphemesOf(value);
53
+ const ci = cursorGraphemeIndex(value, cursor);
54
+ // a trailing sentinel cell so a cursor parked at end-of-line still has somewhere to sit
55
+ const units = [...graphemes.map((g) => ({ text: g, width: cellWidth(g) })), { text: ' ', width: 1 }];
56
+ const cursorUnit = Math.min(ci, units.length - 1);
57
+ const totalWidth = units.reduce((sum, u) => sum + u.width, 0);
58
+ if (totalWidth <= w) {
59
+ return {
60
+ lead: false,
61
+ before: graphemes.slice(0, cursorUnit).join(''),
62
+ at: cursorUnit < graphemes.length ? graphemes[cursorUnit] : ' ',
63
+ after: cursorUnit < graphemes.length ? graphemes.slice(cursorUnit + 1).join('') : '',
64
+ tail: false,
65
+ };
66
+ }
67
+ // overflow → slide a window that always contains the cursor unit; reserve 1 cell for each
68
+ // truncation marker that will actually be shown.
69
+ let start = cursorUnit;
70
+ let end = cursorUnit + 1;
71
+ let used = units[cursorUnit].width;
72
+ // extend right first (so typing at end keeps the tail in view), then backfill left context.
73
+ // the marker reservations (start>0 ⇒ ‹, end<len ⇒ ›) are folded into each fit check.
74
+ while (end < units.length) {
75
+ const next = units[end].width;
76
+ if (used + next + (start > 0 ? 1 : 0) + (end + 1 < units.length ? 1 : 0) <= w) {
77
+ used += next;
78
+ end += 1;
79
+ }
80
+ else
81
+ break;
82
+ }
83
+ while (start > 0) {
84
+ const prev = units[start - 1].width;
85
+ if (used + prev + (start - 1 > 0 ? 1 : 0) + (end < units.length ? 1 : 0) <= w) {
86
+ used += prev;
87
+ start -= 1;
88
+ }
89
+ else
90
+ break;
91
+ }
92
+ const slice = (from, to) => units
93
+ .slice(from, to)
94
+ .map((u) => u.text)
95
+ .join('');
96
+ const atUnit = units[cursorUnit];
97
+ return {
98
+ lead: start > 0,
99
+ before: slice(start, cursorUnit),
100
+ at: cursorUnit === units.length - 1 ? ' ' : atUnit.text,
101
+ after: slice(cursorUnit + 1, end),
102
+ tail: end < units.length,
103
+ };
104
+ }
@@ -0,0 +1,89 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { TextInput, Select } from '@inkjs/ui';
5
+ import { PERSONA_QUESTIONS, PERSONA_OTHER } from '../persona.js';
6
+ /**
7
+ * Interactive persona questionnaire (`sanook persona`). Walks PERSONA_QUESTIONS one at a
8
+ * time — A/B/C/D Selects and free-text inputs. A select "อื่นๆ (พิมพ์เอง)" drops into a
9
+ * free-text follow-up for that same question. Esc goes back one step. Calls onComplete with
10
+ * the full answers map when finished.
11
+ */
12
+ export function PersonaWizard({ onComplete, initialAnswers = {}, }) {
13
+ const total = PERSONA_QUESTIONS.length;
14
+ const [index, setIndex] = useState(0);
15
+ const [answers, setAnswers] = useState(initialAnswers);
16
+ const [otherMode, setOtherMode] = useState(false);
17
+ const prefilled = Object.keys(initialAnswers).filter((k) => initialAnswers[k]?.trim()).length;
18
+ const q = PERSONA_QUESTIONS[index];
19
+ const goBack = () => {
20
+ if (otherMode) {
21
+ setOtherMode(false);
22
+ return;
23
+ }
24
+ if (index > 0)
25
+ setIndex(index - 1);
26
+ };
27
+ useInput((_input, key) => {
28
+ if (key.escape)
29
+ goBack();
30
+ });
31
+ const advance = (value) => {
32
+ const next = { ...answers, [q.id]: value };
33
+ setAnswers(next);
34
+ setOtherMode(false);
35
+ if (index + 1 >= total) {
36
+ onComplete(next);
37
+ }
38
+ else {
39
+ setIndex(index + 1);
40
+ }
41
+ };
42
+ const showTextInput = q.type === 'text' || otherMode;
43
+ // On Esc-back, surface the previously-chosen option FIRST so the highlight lands on it. @inkjs/ui
44
+ // Select always focuses the first option on (re)mount and ignores defaultValue for the highlight, so
45
+ // with no defaultValue pressing Enter selects the focused (first) option — making that the prior
46
+ // answer keeps "Esc-back then Enter to re-confirm" from silently overwriting it with the first option.
47
+ // If the prior answer was a custom "อื่นๆ" value (not a literal option), highlight the PERSONA_OTHER
48
+ // option so Enter re-enters other-mode (where the TextInput restores the typed text via defaultValue).
49
+ const selectOptions = (() => {
50
+ const opts = (q.options ?? []).map((o) => ({ label: o.label, value: o.value }));
51
+ const prior = answers[q.id];
52
+ if (!prior)
53
+ return opts;
54
+ let i = opts.findIndex((o) => o.value === prior);
55
+ if (i < 0)
56
+ i = opts.findIndex((o) => o.value === PERSONA_OTHER); // custom typed value → highlight "อื่นๆ"
57
+ return i > 0 ? [opts[i], ...opts.slice(0, i), ...opts.slice(i + 1)] : opts;
58
+ })();
59
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, marginY: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["\uD83E\uDEAA \u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 Persona ", _jsx(Text, { color: "gray", children: "\u2014 \u0E1A\u0E2D\u0E01 AI \u0E27\u0E48\u0E32\u0E04\u0E38\u0E13\u0E40\u0E1B\u0E47\u0E19\u0E43\u0E04\u0E23 + \u0E2D\u0E22\u0E32\u0E01\u0E43\u0E2B\u0E49\u0E17\u0E33\u0E07\u0E32\u0E19\u0E22\u0E31\u0E07\u0E44\u0E07" })] }), _jsxs(Text, { color: "gray", children: ["\u0E02\u0E49\u0E2D ", index + 1, "/", total, " \u00B7 Esc = \u0E22\u0E49\u0E2D\u0E19\u0E01\u0E25\u0E31\u0E1A \u00B7 Ctrl+C = \u0E2D\u0E2D\u0E01", prefilled ? ` · โหลดค่าเดิม ${prefilled} ข้อ` : ''] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: q.prompt }), showTextInput ? (_jsx(TextInput, { defaultValue: answers[q.id] ?? '', placeholder: otherMode ? 'พิมพ์คำตอบของคุณ…' : (q.placeholder ?? ''), onSubmit: (v) => advance(v.trim()) }, `text-${index}-${otherMode ? 'other' : 'main'}`)) : (_jsx(Select, { options: selectOptions, onChange: (v) => {
60
+ if (v === PERSONA_OTHER)
61
+ setOtherMode(true);
62
+ else
63
+ advance(v);
64
+ } }, `select-${index}`))] })] }));
65
+ }
66
+ /** REPL overlay — `/persona` loads existing answers then runs the same wizard inline */
67
+ export function PersonaOverlay({ onDone }) {
68
+ const [initialAnswers, setInitialAnswers] = useState(null);
69
+ useEffect(() => {
70
+ void import('../memory.js')
71
+ .then((m) => m.loadPersonaAnswers())
72
+ .then(setInitialAnswers)
73
+ .catch(() => setInitialAnswers({}));
74
+ }, []);
75
+ if (!initialAnswers) {
76
+ return (_jsx(Box, { marginY: 1, children: _jsx(Text, { color: "gray", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14 persona\u2026" }) }));
77
+ }
78
+ return (_jsx(PersonaWizard, { initialAnswers: initialAnswers, onComplete: (answers) => {
79
+ void (async () => {
80
+ const { persistPersonaAnswers } = await import('../memory.js');
81
+ const { memoryWritten, vaultWritten, brainPath } = await persistPersonaAnswers(answers);
82
+ const memLine = memoryWritten > 0
83
+ ? `จำเข้า memory ${memoryWritten} ข้อ`
84
+ : 'ไม่มีข้อมูลใหม่ (ตรงกับของเดิม)';
85
+ const vaultLine = vaultWritten ? ` · vault: ${brainPath}/Shared/User-Persona/persona.md` : '';
86
+ onDone(`✅ บันทึก Persona — ${memLine}${vaultLine}`);
87
+ })();
88
+ } }));
89
+ }
package/dist/ui/render.js CHANGED
@@ -4,6 +4,7 @@ import { render } from 'ink';
4
4
  import { App } from './app.js';
5
5
  import { SetupWizard } from './setup.js';
6
6
  import { BrainWizard } from './brain-wizard.js';
7
+ import { PersonaWizard } from './persona-wizard.js';
7
8
  import { saveKey, saveGlobalConfig, saveBrainPath } from '../config.js';
8
9
  import { BRAND } from '../brand.js';
9
10
  // Ink needs raw mode; mounting on a non-TTY stdin (piped/redirected/cron/CI) throws
@@ -16,6 +17,10 @@ function requireInteractiveTTY() {
16
17
  process.exit(1);
17
18
  }
18
19
  }
20
+ /** locale → ค่า language ที่เก็บลง persona ของ second brain (ขั้นที่ 9) */
21
+ function languageForLocale(locale) {
22
+ return locale === 'en' ? 'English + tech-en' : 'ไทย + tech-en';
23
+ }
19
24
  /**
20
25
  * Root — โฮสต์ setup wizard → brain wizard → REPL ใน **Ink render เดียว**
21
26
  *
@@ -23,10 +28,16 @@ function requireInteractiveTTY() {
23
28
  * พอ instance แรก unmount, stdin raw-mode/keypress listener ไม่ reattach กับ instance ที่ 2
24
29
  * → พิมพ์ในช่องแชทไม่ได้. รวมเป็น tree เดียว (React สลับ component ภายใน) stdin ต่อเนื่องไม่หลุด.
25
30
  */
26
- export function Root({ needsSetup, appProps }) {
31
+ export function Root({ needsSetup, appProps, clearScreen }) {
27
32
  const [phase, setPhase] = useState(needsSetup ? 'setup' : 'app');
28
33
  const [model, setModel] = useState(appProps.initialModel);
29
34
  const [brainNote, setBrainNote] = useState(undefined);
35
+ const [locale, setLocale] = useState('th');
36
+ // เข้า REPL: เคลียร์จอที่เต็มไปด้วย wizard ก่อน → banner "Sanook AI" เด้งบนจอว่าง
37
+ const enterApp = () => {
38
+ clearScreen?.();
39
+ setPhase('app');
40
+ };
30
41
  if (phase === 'setup') {
31
42
  const onComplete = (r) => {
32
43
  void (async () => {
@@ -39,7 +50,11 @@ export function Root({ needsSetup, appProps }) {
39
50
  permissionMode: r.permissionMode,
40
51
  });
41
52
  setModel(r.model);
42
- setPhase(r.createBrain ? 'brain' : 'app');
53
+ setLocale(r.locale);
54
+ if (r.createBrain)
55
+ setPhase('brain');
56
+ else
57
+ enterApp();
43
58
  })();
44
59
  };
45
60
  return _jsx(SetupWizard, { onComplete: onComplete });
@@ -49,29 +64,41 @@ export function Root({ needsSetup, appProps }) {
49
64
  void (async () => {
50
65
  const { scaffoldBrain, BRAIN_DEFAULTS, expandHome, wireBrainMcp } = await import('../brain.js');
51
66
  const { linkBrainToProject } = await import('../brain-link.js');
67
+ const { seedPersonaMemory } = await import('../memory.js');
52
68
  const today = new Date().toISOString().slice(0, 10);
53
69
  const target = expandHome(a.path);
70
+ const language = languageForLocale(locale);
54
71
  try {
55
72
  const res = await scaffoldBrain(target, {
56
73
  ...BRAIN_DEFAULTS,
57
74
  ownerName: a.ownerName,
58
75
  aiName: a.aiName,
59
76
  autonomy: a.autonomy,
77
+ language,
60
78
  today,
61
79
  });
62
80
  await saveBrainPath(target);
63
81
  const wired = await wireBrainMcp(target).catch(() => 'skip');
64
82
  const linked = await linkBrainToProject({ brainPath: target, cwd: process.cwd(), today }).catch(() => null);
83
+ // เซฟ persona/identity ที่เก็บใน wizard ลง durable memory (owner ground-truth) → agent จำได้ทันที
84
+ const seeded = await seedPersonaMemory({
85
+ ownerName: a.ownerName,
86
+ aiName: a.aiName,
87
+ language,
88
+ autonomy: a.autonomy,
89
+ defaults: { ownerName: BRAIN_DEFAULTS.ownerName, aiName: BRAIN_DEFAULTS.aiName },
90
+ }).catch(() => 0);
65
91
  const linkNote = linked?.projectRelDir
66
92
  ? ` · project ${linked.projectRelDir} · ${linked.memoryCreated ? 'created' : 'linked'} ${BRAND.memoryFileName}`
67
93
  : '';
94
+ const memNote = seeded ? ` · จำ persona ${seeded} ข้อ` : '';
68
95
  setBrainNote(`✅ second-brain — ${target} · สร้าง ${res.created.length} ไฟล์ · ` +
69
- `${wired === 'added' ? 'wire filesystem MCP เข้า vault แล้ว' : 'MCP เดิมอยู่แล้ว (ไม่ทับ)'}${linkNote} · เปิดใน Obsidian: Open folder as vault`);
96
+ `${wired === 'added' ? 'wire filesystem MCP เข้า vault แล้ว' : 'MCP เดิมอยู่แล้ว (ไม่ทับ)'}${linkNote}${memNote} · เปิดใน Obsidian: Open folder as vault`);
70
97
  }
71
98
  catch (e) {
72
99
  setBrainNote(`⚠ สร้าง second-brain ไม่สำเร็จ: ${e.message} — ลองใหม่ด้วย ${'`'}sanook brain init${'`'}`);
73
100
  }
74
- setPhase('app');
101
+ enterApp();
75
102
  })();
76
103
  };
77
104
  return _jsx(BrainWizard, { onComplete: onComplete });
@@ -82,7 +109,17 @@ export function Root({ needsSetup, appProps }) {
82
109
  /** เปิดแอป: wizard (ถ้า first-run) → REPL — Ink render ครั้งเดียว (fix: พิมพ์ในช่องแชทไม่ได้) */
83
110
  export function startApp(props) {
84
111
  requireInteractiveTTY();
85
- render(_jsx(Root, { ...props }));
112
+ // background, best-effort: weekly memory + vault consolidation (auto-maintain). Non-blocking so the
113
+ // REPL opens instantly; the consolidated store is ready for the next turn. Runs only when due + enabled.
114
+ void import('../auto-maintain.js').then((m) => m.maybeStartupMaintain()).catch(() => { });
115
+ let instance;
116
+ // \x1b[2J เคลียร์จอ · \x1b[3J เคลียร์ scrollback · \x1b[H cursor กลับมุมซ้ายบน
117
+ // instance.clear() ลบ frame ล่าสุดที่ Ink จำไว้ → App วาดใหม่จากบนสุดไม่เหลือเศษ wizard
118
+ const clearScreen = () => {
119
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
120
+ instance?.clear();
121
+ };
122
+ instance = render(_jsx(Root, { ...props, clearScreen: clearScreen }));
86
123
  }
87
124
  /** เปิด REPL ตรงๆ (ไม่ผ่าน wizard) — เก็บไว้เผื่อ caller อื่น */
88
125
  export function startRepl(appProps) {
@@ -98,6 +135,7 @@ export function startBrainSetup() {
98
135
  void (async () => {
99
136
  const { scaffoldBrain, BRAIN_DEFAULTS, expandHome, wireBrainMcp } = await import('../brain.js');
100
137
  const { linkBrainToProject } = await import('../brain-link.js');
138
+ const { seedPersonaMemory } = await import('../memory.js');
101
139
  const today = new Date().toISOString().slice(0, 10);
102
140
  const target = expandHome(a.path);
103
141
  const res = await scaffoldBrain(target, {
@@ -110,6 +148,12 @@ export function startBrainSetup() {
110
148
  await saveBrainPath(target);
111
149
  const wired = await wireBrainMcp(target).catch(() => 'skip');
112
150
  const linked = await linkBrainToProject({ brainPath: target, cwd: process.cwd(), today }).catch(() => null);
151
+ await seedPersonaMemory({
152
+ ownerName: a.ownerName,
153
+ aiName: a.aiName,
154
+ autonomy: a.autonomy,
155
+ defaults: { ownerName: BRAIN_DEFAULTS.ownerName, aiName: BRAIN_DEFAULTS.aiName },
156
+ }).catch(() => 0);
113
157
  unmount();
114
158
  const linkLine = linked?.projectRelDir ? `\n linked repo → ${linked.projectRelDir} · ${BRAND.memoryFileName} in cwd` : '';
115
159
  process.stdout.write(`\n✅ second-brain — ${target}\n สร้าง ${res.created.length} · ข้าม ${res.skipped.length} (มีอยู่แล้ว ไม่ทับ)` +
@@ -123,3 +167,32 @@ export function startBrainSetup() {
123
167
  unmount = instance.unmount;
124
168
  });
125
169
  }
170
+ /** standalone `sanook persona` (interactive): ถามชุดคำถาม persona → seed auto-memory + เขียนโปรไฟล์ลง vault */
171
+ export function startPersonaSetup() {
172
+ requireInteractiveTTY();
173
+ return new Promise((resolve) => {
174
+ let unmount = () => { };
175
+ void (async () => {
176
+ const { loadPersonaAnswers, persistPersonaAnswers } = await import('../memory.js');
177
+ const initialAnswers = await loadPersonaAnswers().catch(() => ({}));
178
+ const onComplete = (answers) => {
179
+ void (async () => {
180
+ const { memoryWritten, vaultWritten, brainPath } = await persistPersonaAnswers(answers);
181
+ unmount();
182
+ const memLine = memoryWritten > 0
183
+ ? ` จำเข้า memory แล้ว ${memoryWritten} ข้อ (protected — agent อ่านทุก session)`
184
+ : ` ไม่มีข้อมูลใหม่ที่ต้องจำ (ข้ามหมด/ตรงกับของเดิม)`;
185
+ const vaultLine = vaultWritten
186
+ ? `\n เขียนโปรไฟล์ลง vault → ${brainPath}/Shared/User-Persona/persona.md`
187
+ : brainPath
188
+ ? `\n ⚠ ข้ามการเขียนโปรไฟล์ลง vault (ไม่พบโฟลเดอร์ Shared/User-Persona — รัน \`${BRAND.cliName} brain init\` เพื่อ scaffold ใหม่)`
189
+ : `\n (ยังไม่มี second-brain — รัน \`${BRAND.cliName} brain init\` เพื่อเก็บโปรไฟล์ลง vault ด้วย)`;
190
+ process.stdout.write(`\n✅ บันทึก Persona เรียบร้อย\n${memLine}${vaultLine}\n`);
191
+ resolve();
192
+ })();
193
+ };
194
+ const instance = render(_jsx(PersonaWizard, { onComplete: onComplete, initialAnswers: initialAnswers }));
195
+ unmount = instance.unmount;
196
+ })();
197
+ });
198
+ }