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,300 @@
1
+ // Persona questionnaire — questions, durable-fact mapping, and the vault profile note.
2
+ // Pure / no-UI so it stays unit-testable; the Ink wizard (src/ui/persona-wizard.tsx)
3
+ // renders PERSONA_QUESTIONS and the persist layer (src/memory.ts) uses personaFacts /
4
+ // renderPersonaProfile to write to auto-memory + the second-brain vault.
5
+ /** sentinel value: a select option that drops into a free-text follow-up */
6
+ export const PERSONA_OTHER = '__other__';
7
+ const otherOption = { label: 'อื่นๆ (พิมพ์เอง)', value: PERSONA_OTHER };
8
+ /**
9
+ * The questionnaire. Mix of A/B/C/D selects and free-text inputs so the agent learns
10
+ * who the owner is + how they want to be worked with. Text answers may be left blank
11
+ * (Enter to skip) — blanks are not written as facts.
12
+ */
13
+ export const PERSONA_QUESTIONS = [
14
+ {
15
+ id: 'ownerName',
16
+ prompt: 'เรียกคุณว่าอะไรดี? (ชื่อ / ชื่อเล่น)',
17
+ type: 'text',
18
+ label: 'ชื่อ / เรียกว่า',
19
+ placeholder: 'เช่น ชวกร, พี่หนึ่ง',
20
+ fact: (v) => (v ? `เจ้าของชื่อ ${v} — เรียกเจ้าของด้วยชื่อนี้` : null),
21
+ },
22
+ {
23
+ id: 'aiName',
24
+ prompt: 'อยากให้ AI เรียกตัวเองว่าอะไร?',
25
+ type: 'text',
26
+ label: 'AI เรียกตัวเองว่า',
27
+ placeholder: 'เช่น สนุก, ผู้ช่วย',
28
+ fact: (v) => (v ? `AI เรียกตัวเองว่า "${v}" เมื่อคุยกับเจ้าของ` : null),
29
+ },
30
+ {
31
+ id: 'role',
32
+ prompt: 'อาชีพ / บทบาทหลักของคุณคืออะไร?',
33
+ type: 'select',
34
+ label: 'บทบาท / อาชีพ',
35
+ options: [
36
+ { label: 'นักพัฒนา / โปรแกรมเมอร์', value: 'นักพัฒนา/โปรแกรมเมอร์' },
37
+ { label: 'นักเรียน / นักศึกษา', value: 'นักเรียน/นักศึกษา' },
38
+ { label: 'ครู / อาจารย์', value: 'ครู/อาจารย์' },
39
+ { label: 'เจ้าของธุรกิจ / ฟรีแลนซ์', value: 'เจ้าของธุรกิจ/ฟรีแลนซ์' },
40
+ otherOption,
41
+ ],
42
+ fact: (v) => (v ? `บทบาท/อาชีพของเจ้าของ: ${v}` : null),
43
+ },
44
+ {
45
+ id: 'experience',
46
+ prompt: 'ระดับประสบการณ์การเขียนโปรแกรมของคุณ?',
47
+ type: 'select',
48
+ label: 'ประสบการณ์',
49
+ options: [
50
+ { label: 'เริ่มต้น (beginner)', value: 'beginner' },
51
+ { label: 'ระดับกลาง (intermediate)', value: 'intermediate' },
52
+ { label: 'ระดับสูง (advanced)', value: 'advanced' },
53
+ { label: 'เชี่ยวชาญ (expert)', value: 'expert' },
54
+ { label: 'ไม่ใช่สายโค้ด', value: 'ไม่ใช่สายโค้ด' },
55
+ ],
56
+ fact: (v) => (v ? `ระดับประสบการณ์เขียนโปรแกรมของเจ้าของ: ${v}` : null),
57
+ },
58
+ {
59
+ id: 'language',
60
+ prompt: 'อยากให้ AI ตอบเป็นภาษาอะไร?',
61
+ type: 'select',
62
+ label: 'ภาษา',
63
+ options: [
64
+ { label: 'ไทยล้วน', value: 'ไทย' },
65
+ { label: 'ไทย + ศัพท์เทคนิคอังกฤษ', value: 'ไทย + tech-en' },
66
+ { label: 'อังกฤษล้วน', value: 'English' },
67
+ ],
68
+ fact: (v) => (v ? `ภาษาที่เจ้าของต้องการให้ตอบ: ${v}` : null),
69
+ },
70
+ {
71
+ id: 'tone',
72
+ prompt: 'อยากให้โทนการสื่อสารเป็นแบบไหน?',
73
+ type: 'select',
74
+ label: 'โทน',
75
+ options: [
76
+ { label: 'กระชับ ตรงประเด็น', value: 'กระชับ ตรงประเด็น' },
77
+ { label: 'ละเอียด อธิบายเยอะ', value: 'ละเอียด อธิบายเยอะ' },
78
+ { label: 'เป็นกันเอง สนุก', value: 'เป็นกันเอง สนุก' },
79
+ { label: 'ทางการ สุภาพ', value: 'ทางการ สุภาพ' },
80
+ ],
81
+ fact: (v) => (v ? `โทนการสื่อสารที่เจ้าของชอบ: ${v}` : null),
82
+ },
83
+ {
84
+ id: 'depth',
85
+ prompt: 'เวลาอธิบายโค้ด/คำตอบ อยากได้ละเอียดแค่ไหน?',
86
+ type: 'select',
87
+ label: 'ความละเอียดของคำอธิบาย',
88
+ options: [
89
+ { label: 'เอาแค่คำตอบ / โค้ด', value: 'เอาแค่คำตอบ' },
90
+ { label: 'คำตอบ + เหตุผลสั้นๆ', value: 'คำตอบ + เหตุผลสั้นๆ' },
91
+ { label: 'อธิบายละเอียดทีละขั้น', value: 'อธิบายละเอียดทีละขั้น' },
92
+ ],
93
+ fact: (v) => (v ? `ระดับความละเอียดที่เจ้าของอยากได้เวลาอธิบาย: ${v}` : null),
94
+ },
95
+ {
96
+ id: 'autonomy',
97
+ prompt: 'อยากให้ AI ทำงานแบบไหน?',
98
+ type: 'select',
99
+ label: 'Autonomy',
100
+ options: [
101
+ { label: 'ask-on-risk — ทำเลย ถามเฉพาะตอนเสี่ยง', value: 'ask-on-risk' },
102
+ { label: 'act-first — ลงมือก่อน รายงานทีหลัง', value: 'act-first' },
103
+ { label: 'ask-first — ถามก่อนทุกครั้ง', value: 'ask-first' },
104
+ ],
105
+ fact: (v) => (v ? `ระดับ autonomy ที่เจ้าของเลือก: ${v}` : null),
106
+ },
107
+ {
108
+ id: 'stack',
109
+ prompt: 'ภาษา / เทคโนโลยีที่ใช้บ่อย? (Enter เพื่อข้าม)',
110
+ type: 'text',
111
+ label: 'เทคโนโลยีที่ใช้บ่อย',
112
+ placeholder: 'เช่น TypeScript, React, Python, PostgreSQL',
113
+ fact: (v) => (v ? `เทคโนโลยีที่เจ้าของใช้บ่อย: ${v}` : null),
114
+ },
115
+ {
116
+ id: 'domains',
117
+ prompt: 'สนใจ / ทำงานด้านไหนเป็นหลัก? (Enter เพื่อข้าม)',
118
+ type: 'text',
119
+ label: 'ด้านที่สนใจ',
120
+ placeholder: 'เช่น web, AI/ML, mobile, การศึกษา',
121
+ fact: (v) => (v ? `ด้านที่เจ้าของทำงาน/สนใจ: ${v}` : null),
122
+ },
123
+ {
124
+ id: 'goals',
125
+ prompt: 'ตอนนี้กำลังโฟกัสทำอะไร / เป้าหมายหลัก? (Enter เพื่อข้าม)',
126
+ type: 'text',
127
+ label: 'เป้าหมาย / โฟกัส',
128
+ placeholder: 'เช่น สร้าง CLI ของตัวเอง, เรียน Rust',
129
+ fact: (v) => (v ? `เป้าหมาย/สิ่งที่เจ้าของกำลังโฟกัส: ${v}` : null),
130
+ },
131
+ {
132
+ id: 'preferences',
133
+ prompt: 'มีอะไรที่ชอบ / ไม่ชอบให้ AI ทำไหม? (Enter เพื่อข้าม)',
134
+ type: 'text',
135
+ label: 'สิ่งที่ชอบ/ไม่ชอบ',
136
+ placeholder: 'เช่น อย่าใส่ emoji, ใส่คอมเมนต์ภาษาไทย',
137
+ fact: (v) => (v ? `สิ่งที่เจ้าของชอบ/ไม่ชอบให้ AI ทำ: ${v}` : null),
138
+ },
139
+ {
140
+ id: 'emoji',
141
+ prompt: 'ใช้ emoji ในคำตอบไหม?',
142
+ type: 'select',
143
+ label: 'การใช้ emoji',
144
+ options: [
145
+ { label: 'ใช้ได้', value: 'ใช้ได้' },
146
+ { label: 'ใช้น้อยๆ', value: 'ใช้น้อยๆ' },
147
+ { label: 'ไม่ใช้เลย', value: 'ไม่ใช้เลย' },
148
+ ],
149
+ fact: (v) => (v ? `การใช้ emoji ที่เจ้าของต้องการ: ${v}` : null),
150
+ },
151
+ {
152
+ id: 'timezone',
153
+ prompt: 'Timezone / เวลาทำงานปกติ? (Enter เพื่อข้าม)',
154
+ type: 'text',
155
+ label: 'Timezone / เวลาทำงาน',
156
+ placeholder: 'เช่น Asia/Bangkok, ชอบทำงานกลางคืน',
157
+ fact: (v) => (v ? `Timezone/เวลาทำงานของเจ้าของ: ${v}` : null),
158
+ },
159
+ ];
160
+ function clean(value) {
161
+ return (value ?? '').trim();
162
+ }
163
+ /** Build durable owner facts (protected tier) from the answers, skipping blanks/sentinels. */
164
+ export function personaFacts(answers) {
165
+ const out = [];
166
+ for (const q of PERSONA_QUESTIONS) {
167
+ const v = clean(answers[q.id]);
168
+ if (!v || v === PERSONA_OTHER)
169
+ continue;
170
+ const fact = q.fact(v);
171
+ if (fact)
172
+ out.push(fact);
173
+ }
174
+ return out;
175
+ }
176
+ /** human-friendly label for a stored select value (falls back to the raw value / free text). */
177
+ function answerLabel(q, value) {
178
+ if (q.type === 'select' && q.options) {
179
+ const hit = q.options.find((o) => o.value === value);
180
+ if (hit && hit.value !== PERSONA_OTHER)
181
+ return hit.label;
182
+ }
183
+ return value;
184
+ }
185
+ /** Render the second-brain persona profile note (markdown) from the answers. */
186
+ export function renderPersonaProfile(answers, today) {
187
+ const rows = PERSONA_QUESTIONS.map((q) => {
188
+ const v = clean(answers[q.id]);
189
+ const shown = v && v !== PERSONA_OTHER ? answerLabel(q, v) : '—';
190
+ return `| ${q.label} | ${shown.replace(/\|/g, '\\|')} |`;
191
+ }).join('\n');
192
+ return `---
193
+ tags: [persona, identity, user-owned]
194
+ note_type: persona
195
+ created: ${today}
196
+ updated: ${today}
197
+ source: "sanook persona"
198
+ parent: "[[Shared/User-Persona/_Index]]"
199
+ ---
200
+
201
+ # Persona — โปรไฟล์เจ้าของ
202
+
203
+ > สร้างจากคำสั่ง \`sanook persona\` — AI อ่านบริบทนี้เพื่อเข้าใจเจ้าของและปรับสไตล์การทำงาน.
204
+ > แก้ไขได้โดยตรง หรือรัน \`sanook persona\` ใหม่เพื่ออัปเดต (เขียนทับไฟล์นี้).
205
+
206
+ ## โปรไฟล์
207
+
208
+ | หัวข้อ | ค่า |
209
+ |---|---|
210
+ ${rows}
211
+
212
+ up:: [[Shared/User-Persona/_Index]]
213
+ `;
214
+ }
215
+ /** map display label (stored in persona.md table) back to select value */
216
+ export function valueFromDisplayLabel(q, shown) {
217
+ const s = shown.trim();
218
+ if (!s || s === '—')
219
+ return '';
220
+ if (q.type === 'select' && q.options) {
221
+ const byLabel = q.options.find((o) => o.label === s);
222
+ if (byLabel)
223
+ return byLabel.value;
224
+ const byValue = q.options.find((o) => o.value === s);
225
+ if (byValue)
226
+ return byValue.value;
227
+ }
228
+ return s;
229
+ }
230
+ /** Parse persona.md table rows back into answers (label → value). */
231
+ export function parsePersonaProfileMarkdown(md) {
232
+ const out = {};
233
+ const labelToId = new Map(PERSONA_QUESTIONS.map((q) => [q.label, q.id]));
234
+ for (const line of md.split('\n')) {
235
+ const m = line.match(/^\|\s*(.+?)\s*\|\s*(.+?)\s*\|$/);
236
+ if (!m)
237
+ continue;
238
+ const label = m[1].trim();
239
+ const shown = m[2].trim().replace(/\\\|/g, '|');
240
+ if (label === 'หัวข้อ' || label === '---')
241
+ continue;
242
+ const id = labelToId.get(label);
243
+ if (!id)
244
+ continue;
245
+ const q = PERSONA_QUESTIONS.find((x) => x.id === id);
246
+ const v = valueFromDisplayLabel(q, shown);
247
+ if (v)
248
+ out[id] = v;
249
+ }
250
+ return out;
251
+ }
252
+ /** Extract persona fields from protected owner facts in auto-memory. */
253
+ export function personaAnswersFromFacts(factTexts) {
254
+ const out = {};
255
+ for (const raw of factTexts) {
256
+ const text = raw.trim();
257
+ let m = text.match(/^เจ้าของชื่อ (.+?) — เรียกเจ้าของด้วยชื่อนี้$/);
258
+ if (m) {
259
+ out.ownerName = m[1];
260
+ continue;
261
+ }
262
+ m = text.match(/^AI เรียกตัวเองว่า "(.+?)" เมื่อคุยกับเจ้าของ$/);
263
+ if (m) {
264
+ out.aiName = m[1];
265
+ continue;
266
+ }
267
+ const prefixes = [
268
+ ['บทบาท/อาชีพของเจ้าของ: ', 'role'],
269
+ ['ระดับประสบการณ์เขียนโปรแกรมของเจ้าของ: ', 'experience'],
270
+ ['ภาษาที่เจ้าของต้องการให้ตอบ: ', 'language'],
271
+ ['โทนการสื่อสารที่เจ้าของชอบ: ', 'tone'],
272
+ ['ระดับความละเอียดที่เจ้าของอยากได้เวลาอธิบาย: ', 'depth'],
273
+ ['ระดับ autonomy ที่เจ้าของเลือก: ', 'autonomy'],
274
+ ['เทคโนโลยีที่เจ้าของใช้บ่อย: ', 'stack'],
275
+ ['ด้านที่เจ้าของทำงาน/สนใจ: ', 'domains'],
276
+ ['เป้าหมาย/สิ่งที่เจ้าของกำลังโฟกัส: ', 'goals'],
277
+ ['สิ่งที่เจ้าของชอบ/ไม่ชอบให้ AI ทำ: ', 'preferences'],
278
+ ['การใช้ emoji ที่เจ้าของต้องการ: ', 'emoji'],
279
+ ['Timezone/เวลาทำงานของเจ้าของ: ', 'timezone'],
280
+ ];
281
+ for (const [prefix, id] of prefixes) {
282
+ if (text.startsWith(prefix)) {
283
+ out[id] = text.slice(prefix.length);
284
+ break;
285
+ }
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+ /** Merge persona answers — later sources override earlier for each field. */
291
+ export function mergePersonaAnswers(...sources) {
292
+ const out = {};
293
+ for (const src of sources) {
294
+ for (const [k, v] of Object.entries(src)) {
295
+ if (v?.trim())
296
+ out[k] = v.trim();
297
+ }
298
+ }
299
+ return out;
300
+ }
@@ -16,7 +16,8 @@ export function slugifyProject(value) {
16
16
  function renderTemplate(raw, vars) {
17
17
  let out = raw;
18
18
  for (const [key, value] of Object.entries(vars)) {
19
- out = out.replaceAll(`{{${key}}}`, value);
19
+ // replacer function so `$`-sequences in a var value aren't interpreted as String.replace patterns
20
+ out = out.replaceAll(`{{${key}}}`, () => value);
20
21
  }
21
22
  return out;
22
23
  }
@@ -46,7 +47,8 @@ async function maybeAppendProjectsIndex(brainPath, slug, title) {
46
47
  return false;
47
48
  const line = `- ${link} — ${title}`;
48
49
  const marker = 'up:: [[Home]]';
49
- const next = content.includes(marker) ? content.replace(marker, `${line}\n\n${marker}`) : `${content.trimEnd()}\n${line}\n`;
50
+ // replacer function so `$`-sequences in the project title aren't interpreted as replace patterns
51
+ const next = content.includes(marker) ? content.replace(marker, () => `${line}\n\n${marker}`) : `${content.trimEnd()}\n${line}\n`;
50
52
  await writeFile(indexPath, next, 'utf8');
51
53
  return true;
52
54
  }
@@ -0,0 +1,86 @@
1
+ // Default skill synthesizer for self-improvement. Tries the model (cheap sibling) to write a
2
+ // proper runbook; if no key resolves or the model output isn't usable, falls back to a deterministic
3
+ // template built from the observed prompts — so a skill is ALWAYS created on the Nth repeat without
4
+ // depending on fragile LLM JSON. Kept out of self-improve.ts so the detection core stays offline.
5
+ import { generateText } from 'ai';
6
+ import { resolveModel, fastSibling, PROVIDERS, parseSpec } from './providers/registry.js';
7
+ import { redactKey } from './providers/keys.js';
8
+ import { slugifySkillName } from './self-improve.js';
9
+ const SYNTH_PROMPT = 'คุณกำลังสร้าง "skill" (runbook ทำซ้ำได้) จากคำสั่งที่ผู้ใช้สั่งซ้ำหลายครั้ง. ' +
10
+ 'ตอบเป็น JSON อย่างเดียว (ไม่มีข้อความอื่น) รูปแบบ: ' +
11
+ '{"name":"slug-a-z0-9-","description":"1 บรรทัดบอกว่า skill ทำอะไร","when_to_use":"เมื่อไรควรหยิบมาใช้","steps":["ขั้นตอน 1","ขั้นตอน 2"]}. ' +
12
+ 'name ต้องเป็น slug a-z 0-9 - สั้นๆ. steps เป็นขั้นตอนที่ทำให้ครั้งหน้าทำงานนี้ได้เร็วและไม่พลาด.\n\nคำสั่งที่สั่งซ้ำ:\n';
13
+ function templateDraft(family) {
14
+ const top = family.terms.slice(0, 4).join('-');
15
+ const name = slugifySkillName(top || family.samples[0] || 'recurring-task');
16
+ const requests = family.samples.map((s) => `- ${s}`).join('\n');
17
+ const body = [
18
+ '## When to Use',
19
+ `งานเกี่ยวกับ: ${family.terms.slice(0, 8).join(', ')}`,
20
+ '',
21
+ '## Observed requests',
22
+ requests,
23
+ '',
24
+ '## Steps',
25
+ '_(สร้างอัตโนมัติจากงานที่ทำซ้ำ — เพิ่มขั้นตอน/คำสั่งที่ใช้จริงได้)_',
26
+ '',
27
+ '## Common Errors / Gotchas',
28
+ '_(เติมข้อควรระวังที่เจอระหว่างทำงานนี้)_',
29
+ ].join('\n');
30
+ return {
31
+ name,
32
+ description: `งานที่ทำซ้ำ: ${family.samples[0]?.slice(0, 80) ?? family.terms.slice(0, 5).join(' ')}`,
33
+ whenToUse: `เมื่อเจองานเกี่ยวกับ ${family.terms.slice(0, 5).join(', ')}`,
34
+ body,
35
+ };
36
+ }
37
+ function draftFromModelText(text, family) {
38
+ const match = text.match(/\{[\s\S]*\}/);
39
+ if (!match)
40
+ return null;
41
+ let parsed;
42
+ try {
43
+ parsed = JSON.parse(match[0]);
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ const name = typeof parsed.name === 'string' ? parsed.name : '';
49
+ const description = typeof parsed.description === 'string' ? parsed.description : '';
50
+ const whenToUse = typeof parsed.when_to_use === 'string' ? parsed.when_to_use : undefined;
51
+ const steps = Array.isArray(parsed.steps) ? parsed.steps.filter((s) => typeof s === 'string') : [];
52
+ if (!name.trim() || !steps.length)
53
+ return null;
54
+ const fallback = templateDraft(family);
55
+ const body = [
56
+ '## When to Use',
57
+ whenToUse || fallback.whenToUse || '',
58
+ '',
59
+ '## Steps',
60
+ steps.map((s, i) => `${i + 1}. ${s}`).join('\n'),
61
+ '',
62
+ '## Observed requests',
63
+ family.samples.map((s) => `- ${s}`).join('\n'),
64
+ ].join('\n');
65
+ return { name, description: description || fallback.description, whenToUse, body };
66
+ }
67
+ /** สร้าง synthesizer: ลอง model ก่อน (ค่าย sibling ถูก) → ถ้าไม่ได้ key/parse พัง ใช้ template */
68
+ export function defaultSkillSynthesizer(mainModel) {
69
+ return async (family) => {
70
+ // delegate provider (codex) ไม่มี generateText ตรง → ใช้ template ทันที
71
+ if (PROVIDERS[parseSpec(mainModel).provider]?.kind === 'delegate')
72
+ return templateDraft(family);
73
+ const transcript = family.samples.map((s, i) => `${i + 1}. ${s}`).join('\n');
74
+ try {
75
+ const { text } = await generateText({
76
+ model: resolveModel(fastSibling(mainModel)),
77
+ prompt: SYNTH_PROMPT + redactKey(transcript),
78
+ maxOutputTokens: 700,
79
+ });
80
+ return draftFromModelText(text, family) ?? templateDraft(family);
81
+ }
82
+ catch {
83
+ return templateDraft(family); // ไม่มี key / network ล้ม → ยังสร้าง skill ได้
84
+ }
85
+ };
86
+ }
@@ -0,0 +1,203 @@
1
+ // ============================================================================
2
+ // src/self-improve.ts — Hermes-style "Self-improvement": when the user keeps asking
3
+ // for the SAME kind of task, Sanook notices, writes a reusable skill automatically,
4
+ // and announces it in the terminal ("✨ Self-improvement: created skill …").
5
+ //
6
+ // How it works (cheap, deterministic detection · LLM only fires on the Nth repeat):
7
+ // 1) every completed turn, the prompt is reduced to a TERM SIGNATURE and matched
8
+ // (token-Jaccard) against a small persistent ledger at ~/.sanook/self-improve/ledger.json.
9
+ // 2) when a task family reaches the threshold (default 3) and no skill exists for it yet,
10
+ // we synthesize a SKILL.md (model if a key resolves, else a deterministic template) and
11
+ // save it via skills.saveSkill — then mark the family so we never duplicate it.
12
+ //
13
+ // Pure functions (signature, match, ledger transforms) are unit-tested with an injected
14
+ // clock and no FS/LLM. The orchestrator takes an injected `synthesize` so tests stay offline.
15
+ // ============================================================================
16
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
17
+ import { dirname } from 'node:path';
18
+ import { z } from 'zod';
19
+ import { appHomePath, persistenceEnabled, selfImproveEnabled, selfImproveThreshold } from './brand.js';
20
+ import { termList } from './search/index-core.js';
21
+ export const LEDGER_PATH = appHomePath('self-improve', 'ledger.json');
22
+ const MAX_SAMPLES = 6; // prompts kept per task family (for synthesis context)
23
+ const MAX_FAMILIES = 200; // ledger cap (drop oldest)
24
+ const MATCH_THRESHOLD = 0.5; // token-Jaccard ≥ this ⇒ "same kind of task"
25
+ const MAX_SIG_TERMS = 12;
26
+ export const TaskFamilySchema = z.object({
27
+ sig: z.string(),
28
+ terms: z.array(z.string()),
29
+ samples: z.array(z.string()),
30
+ count: z.number().int().nonnegative(),
31
+ skillCreated: z.boolean().default(false),
32
+ skillName: z.string().nullable().default(null),
33
+ firstSeen: z.number(),
34
+ lastSeen: z.number(),
35
+ });
36
+ export const LedgerSchema = z.object({
37
+ version: z.literal(1).default(1),
38
+ families: z.array(TaskFamilySchema).default([]),
39
+ });
40
+ export function emptyLedger() {
41
+ return { version: 1, families: [] };
42
+ }
43
+ /** prompt → ชุด term สำคัญ (ตัด slash-command / @mention / ของสั้น) — เป็น signature ของงาน */
44
+ export function signatureTerms(prompt) {
45
+ const cleaned = prompt
46
+ .replace(/^\s*\/\w+\s*/, '') // ตัด /command นำหน้า
47
+ .replace(/@[^\s]+/g, ' ') // ตัด @file mention
48
+ .replace(/\[\[\s*paste[^\]]*\]\]/gi, ' '); // ตัด paste token
49
+ const seen = new Set();
50
+ const out = [];
51
+ for (const t of termList(cleaned)) {
52
+ if (seen.has(t))
53
+ continue;
54
+ seen.add(t);
55
+ out.push(t);
56
+ if (out.length >= MAX_SIG_TERMS)
57
+ break;
58
+ }
59
+ return out;
60
+ }
61
+ export function signatureKey(terms) {
62
+ return [...terms].sort().join(' ');
63
+ }
64
+ /** token-Jaccard ระหว่าง 2 ชุด term (0..1) */
65
+ export function jaccard(a, b) {
66
+ if (!a.length || !b.length)
67
+ return 0;
68
+ const setA = new Set(a);
69
+ const setB = new Set(b);
70
+ let inter = 0;
71
+ for (const t of setA)
72
+ if (setB.has(t))
73
+ inter += 1;
74
+ return inter / (setA.size + setB.size - inter);
75
+ }
76
+ /**
77
+ * บันทึก task 1 ครั้งเข้า ledger — match กับ family เดิมด้วย Jaccard, ไม่งั้นเปิด family ใหม่.
78
+ * คืน shouldCreateSkill=true เมื่อ count ถึง threshold ครั้งแรก (ยังไม่เคยสร้าง skill).
79
+ */
80
+ export function recordTask(ledger, prompt, now, createThresholdOverride) {
81
+ const createThreshold = createThresholdOverride ?? selfImproveThreshold();
82
+ const terms = signatureTerms(prompt);
83
+ const sample = prompt.trim().replace(/\s+/g, ' ').slice(0, 240);
84
+ // งานสั้น/generic เกินไป (term น้อย) → ไม่ติดตาม (กัน false positive เช่น "ok", "ลองอีกที")
85
+ if (terms.length < 3) {
86
+ return { ledger, family: phantomFamily(terms, sample, now), shouldCreateSkill: false };
87
+ }
88
+ const families = ledger.families.slice();
89
+ let bestIdx = -1;
90
+ let bestSim = 0;
91
+ for (let i = 0; i < families.length; i += 1) {
92
+ const s = jaccard(terms, families[i].terms);
93
+ if (s > bestSim) {
94
+ bestSim = s;
95
+ bestIdx = i;
96
+ }
97
+ }
98
+ if (bestIdx >= 0 && bestSim >= MATCH_THRESHOLD) {
99
+ const prev = families[bestIdx];
100
+ const samples = prev.samples.includes(sample) ? prev.samples : [...prev.samples, sample].slice(-MAX_SAMPLES);
101
+ // keep family.terms = the first-seen signature (stable) so Jaccard doesn't drift/shrink as the family grows
102
+ const next = { ...prev, samples, count: prev.count + 1, lastSeen: now };
103
+ families[bestIdx] = next;
104
+ const shouldCreateSkill = !next.skillCreated && next.count >= createThreshold;
105
+ return { ledger: { ...ledger, families: capFamilies(families) }, family: next, shouldCreateSkill };
106
+ }
107
+ const fresh = {
108
+ sig: signatureKey(terms),
109
+ terms,
110
+ samples: [sample],
111
+ count: 1,
112
+ skillCreated: false,
113
+ skillName: null,
114
+ firstSeen: now,
115
+ lastSeen: now,
116
+ };
117
+ families.push(fresh);
118
+ return { ledger: { ...ledger, families: capFamilies(families) }, family: fresh, shouldCreateSkill: false };
119
+ }
120
+ /** mark ว่า family นี้สร้าง skill แล้ว (กันสร้างซ้ำ) */
121
+ export function markSkillCreated(ledger, sig, skillName) {
122
+ return {
123
+ ...ledger,
124
+ families: ledger.families.map((f) => (f.sig === sig ? { ...f, skillCreated: true, skillName } : f)),
125
+ };
126
+ }
127
+ function capFamilies(families) {
128
+ if (families.length <= MAX_FAMILIES)
129
+ return families;
130
+ return [...families].sort((x, y) => y.lastSeen - x.lastSeen).slice(0, MAX_FAMILIES);
131
+ }
132
+ function phantomFamily(terms, sample, now) {
133
+ return { sig: signatureKey(terms), terms, samples: [sample], count: 0, skillCreated: false, skillName: null, firstSeen: now, lastSeen: now };
134
+ }
135
+ // ---- FS (gated by persistence) ---------------------------------------------
136
+ export async function loadLedger() {
137
+ try {
138
+ const parsed = LedgerSchema.safeParse(JSON.parse(await readFile(LEDGER_PATH, 'utf8')));
139
+ return parsed.success ? parsed.data : emptyLedger();
140
+ }
141
+ catch {
142
+ return emptyLedger();
143
+ }
144
+ }
145
+ export async function saveLedger(ledger) {
146
+ if (!persistenceEnabled())
147
+ return;
148
+ await mkdir(dirname(LEDGER_PATH), { recursive: true });
149
+ await writeFile(LEDGER_PATH, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 });
150
+ }
151
+ /**
152
+ * เรียกหลังจบ turn (best-effort, fire-and-forget). บันทึก task เข้า ledger; ถ้าถึง threshold
153
+ * และยังไม่เคยมี skill → synthesize + save + mark + คืน announcement สำหรับโชว์ใน terminal.
154
+ */
155
+ export async function maybeAutoSkill(prompt, deps) {
156
+ if (!selfImproveEnabled())
157
+ return { created: false };
158
+ const now = deps.now ?? Date.now();
159
+ const ledger = await loadLedger();
160
+ const rec = recordTask(ledger, prompt, now);
161
+ await saveLedger(rec.ledger);
162
+ if (!rec.shouldCreateSkill)
163
+ return { created: false };
164
+ const draft = await deps.synthesize(rec.family).catch(() => null);
165
+ if (!draft || !draft.name.trim())
166
+ return { created: false };
167
+ const name = uniqueSkillName(draft.name, deps.existingSkillNames);
168
+ let path;
169
+ try {
170
+ path = await deps.saveSkill(name, draft.description, draft.body, draft.whenToUse);
171
+ }
172
+ catch {
173
+ return { created: false };
174
+ }
175
+ await saveLedger(markSkillCreated(rec.ledger, rec.family.sig, name)).catch(() => { });
176
+ return {
177
+ created: true,
178
+ skillName: name,
179
+ count: rec.family.count,
180
+ path,
181
+ announcement: `✨ Self-improvement: สร้าง skill \`${name}\` อัตโนมัติ จากงานที่ทำซ้ำ ${rec.family.count} ครั้ง — ครั้งหน้าหยิบใช้ได้เลย (\`sanook skill list\`)`,
182
+ };
183
+ }
184
+ /** slug ชื่อ skill ให้ไม่ชนของเดิม (เติม -2, -3, …) */
185
+ export function uniqueSkillName(raw, existing) {
186
+ const base = slugifySkillName(raw);
187
+ if (!existing || !existing.has(base))
188
+ return base;
189
+ for (let i = 2; i < 100; i += 1) {
190
+ const candidate = `${base}-${i}`;
191
+ if (!existing.has(candidate))
192
+ return candidate;
193
+ }
194
+ return `${base}-${Date.now().toString(36).slice(-4)}`;
195
+ }
196
+ export function slugifySkillName(raw) {
197
+ const slug = raw
198
+ .toLowerCase()
199
+ .replace(/[^a-z0-9]+/g, '-')
200
+ .replace(/^-+|-+$/g, '')
201
+ .slice(0, 48);
202
+ return slug || `auto-skill-${Date.now().toString(36).slice(-4)}`;
203
+ }
@@ -5,6 +5,7 @@ import { getBrainPath } from './memory.js';
5
5
  import { PROVIDERS, parseSpec } from './providers/registry.js';
6
6
  import { makeSummarizer } from './summarize.js';
7
7
  import { distilledFactsFromMessages } from './session-distill.js';
8
+ import { autoMaintainEnabled } from './auto-maintain.js';
8
9
  import { saveSession } from './session.js';
9
10
  function transcriptFromTurns(turns) {
10
11
  return turns
@@ -25,7 +26,8 @@ function injectSessionSummary(template, summary, facts) {
25
26
  .filter(Boolean)
26
27
  .join('\n');
27
28
  if (/^## Summary\s*$/m.test(template)) {
28
- return template.replace(/^## Summary\s*$/m, `## Summary\n\n${summaryBlock}`);
29
+ // replacer function so `$`-sequences in the AI summary/facts aren't interpreted as replace patterns
30
+ return template.replace(/^## Summary\s*$/m, () => `## Summary\n\n${summaryBlock}`);
29
31
  }
30
32
  return `${template.trimEnd()}\n\n## Summary\n\n${summaryBlock}\n`;
31
33
  }
@@ -87,6 +89,13 @@ export async function finalizeReplSession(options) {
87
89
  const raw = await readFile(report.path, 'utf8');
88
90
  const next = injectSessionSummary(raw, summary, facts.slice(0, 8));
89
91
  await writeFile(report.path, next, 'utf8');
92
+ // also compound the distilled facts into durable auto-memory (not just the session note) so the
93
+ // self-retrieving brain surfaces them next session. Gated by autoMaintain (default on). Best-effort.
94
+ if (await autoMaintainEnabled()) {
95
+ const { appendMemory } = await import('./memory.js');
96
+ for (const fact of facts.slice(0, 8))
97
+ await appendMemory(fact).catch(() => { });
98
+ }
90
99
  return {
91
100
  sessionSaved: true,
92
101
  brainNoteRel: report.relPath,
@@ -41,6 +41,7 @@ const BUILTIN_SLASH_COMPLETIONS = [
41
41
  { text: '/cost', display: '/cost', meta: 'last usage/cost' },
42
42
  { text: '/usage', display: '/usage', meta: 'last usage/cost' },
43
43
  { text: '/insights', display: '/insights', meta: 'local usage insights' },
44
+ { text: '/persona', display: '/persona', meta: 'set owner persona' },
44
45
  { text: '/personality', display: '/personality', meta: 'set response style' },
45
46
  { text: '/compact', display: '/compact', meta: 'compress context' },
46
47
  { text: '/compress', display: '/compress', meta: 'compress context' },