sanook-cli 0.5.7 → 0.5.9
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/CHANGELOG.md +42 -0
- package/README.md +392 -42
- package/README.th.md +15 -8
- package/dist/auto-maintain.js +113 -0
- package/dist/bin.js +63 -15
- package/dist/brain-final.js +8 -4
- package/dist/brain-new.js +9 -5
- package/dist/brain-repair.js +7 -4
- package/dist/brand.js +17 -0
- package/dist/commands.js +17 -6
- package/dist/config.js +11 -0
- package/dist/dashboard/api-helpers.js +112 -3
- package/dist/dashboard/server.js +85 -1
- package/dist/dashboard/static/app.js +381 -0
- package/dist/dashboard/static/styles.css +36 -0
- package/dist/dashboard/terminal.js +214 -0
- package/dist/diff.js +22 -8
- package/dist/i18n/en.js +1 -0
- package/dist/i18n/th.js +1 -0
- package/dist/install-info.js +91 -0
- package/dist/loop.js +10 -1
- package/dist/memory.js +236 -16
- package/dist/model-picker.js +4 -1
- package/dist/persona.js +300 -0
- package/dist/project-scaffold.js +4 -2
- package/dist/providers/codex.js +75 -2
- package/dist/providers/models.js +17 -2
- package/dist/providers/registry.js +6 -13
- package/dist/self-improve-synth.js +86 -0
- package/dist/self-improve.js +203 -0
- package/dist/session-brain.js +10 -1
- package/dist/slash-completion.js +1 -0
- package/dist/ui/app.js +118 -27
- package/dist/ui/input-view.js +104 -0
- package/dist/ui/persona-wizard.js +89 -0
- package/dist/ui/render.js +78 -5
- package/dist/ui/setup.js +3 -4
- package/dist/ui/tool-activity.js +118 -0
- package/dist/ui/tool-trail.js +15 -3
- package/package.json +9 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1175
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
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
|
-
|
|
1186
|
-
if (!lines.length)
|
|
1275
|
+
if (mode === 'hidden' || !items.length)
|
|
1187
1276
|
return null;
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|