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.
@@ -47,6 +47,69 @@ export async function detectCodex() {
47
47
  }
48
48
  return { installed: true, loggedIn: false, reason: 'ยังไม่ได้ login — รัน: codex login' };
49
49
  }
50
+ /** Models rejected by official `codex exec` when auth is ChatGPT plan (not OpenAI API key). */
51
+ export const CODEX_CHATGPT_UNSUPPORTED_MODELS = new Set([
52
+ 'gpt-5-codex',
53
+ 'gpt-5.2-codex',
54
+ 'gpt-5.3-codex',
55
+ 'gpt-5.3-codex-spark',
56
+ ]);
57
+ /** Models verified to work with ChatGPT-plan auth via `codex exec` (Jun 2026). */
58
+ export const CODEX_CHATGPT_SUPPORTED_MODELS = ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini'];
59
+ /** Curated aliases for setup + /model picker — only ChatGPT-plan-safe ids. */
60
+ export const CODEX_CHATGPT_MODEL_ALIASES = {
61
+ default: 'gpt-5.5',
62
+ codex: 'gpt-5.5',
63
+ smart: 'gpt-5.5',
64
+ '5.5': 'gpt-5.5',
65
+ '5.4': 'gpt-5.4',
66
+ '5.4-mini': 'gpt-5.4-mini',
67
+ fast: 'gpt-5.4-mini',
68
+ };
69
+ export function isCodexChatGptSupportedModel(model) {
70
+ return CODEX_CHATGPT_SUPPORTED_MODELS.includes(model.trim());
71
+ }
72
+ /** Migrate saved config specs that still point at deprecated Codex CLI model ids. */
73
+ export function migrateDeprecatedCodexModel(spec) {
74
+ const idx = spec.indexOf(':');
75
+ if (idx === -1)
76
+ return spec;
77
+ const provider = spec.slice(0, idx);
78
+ if (provider !== 'codex')
79
+ return spec;
80
+ const { model, migratedFrom } = normalizeCodexChatGptModel(spec.slice(idx + 1));
81
+ return migratedFrom ? `${provider}:${model}` : spec;
82
+ }
83
+ /** Map legacy/unsupported Codex CLI model ids to a ChatGPT-plan-safe default. */
84
+ export function normalizeCodexChatGptModel(model) {
85
+ const trimmed = model.trim();
86
+ if (!trimmed || !CODEX_CHATGPT_UNSUPPORTED_MODELS.has(trimmed)) {
87
+ return { model: trimmed };
88
+ }
89
+ return { model: 'gpt-5.5', migratedFrom: trimmed };
90
+ }
91
+ function extractCodexErrorMessage(raw) {
92
+ const t = raw.trim();
93
+ if (!t)
94
+ return '';
95
+ try {
96
+ const outer = JSON.parse(t);
97
+ const nested = typeof outer.message === 'string' ? outer.message : '';
98
+ if (nested.startsWith('{')) {
99
+ try {
100
+ const inner = JSON.parse(nested);
101
+ return inner.error?.message ?? inner.message ?? nested;
102
+ }
103
+ catch {
104
+ return nested;
105
+ }
106
+ }
107
+ return outer.error?.message ?? outer.message ?? t;
108
+ }
109
+ catch {
110
+ return t;
111
+ }
112
+ }
50
113
  /**
51
114
  * รัน `codex exec` แบบ non-interactive — ส่ง prompt ทาง stdin, parse JSONL events
52
115
  * tolerant ต่อ malformed JSONL (codex bug #15451: --json ถูก ignore เมื่อมี tools active)
@@ -69,6 +132,7 @@ export async function runCodex(opts) {
69
132
  let threadId;
70
133
  let buf = '';
71
134
  let stderr = '';
135
+ let jsonlError = '';
72
136
  let aborted = false;
73
137
  const handleStdoutLine = (line) => {
74
138
  const t = line.trim();
@@ -93,6 +157,13 @@ export async function runCodex(opts) {
93
157
  else if (ev.type === 'turn.completed') {
94
158
  opts.onEvent?.({ type: 'usage', usage: ev.usage });
95
159
  }
160
+ else if (ev.type === 'error' || ev.type === 'turn.failed') {
161
+ const msg = extractCodexErrorMessage(ev.message ?? ev.error?.message ?? t);
162
+ if (msg) {
163
+ jsonlError = msg;
164
+ opts.onEvent?.({ type: 'error', message: msg });
165
+ }
166
+ }
96
167
  }
97
168
  catch {
98
169
  // malformed JSON line — ข้าม
@@ -139,8 +210,10 @@ export async function runCodex(opts) {
139
210
  reject(new Error(`codex exec ถูกยกเลิก${stderr.trim() ? `: ${stderr.trim()}` : ''}`));
140
211
  else if (code === 0)
141
212
  resolve({ text: finalText.trim(), threadId });
142
- else
143
- reject(new Error(`codex exec จบด้วย exit code ${code}${stderr.trim() ? `: ${stderr.trim()}` : ''}`));
213
+ else {
214
+ const detail = jsonlError.trim() || stderr.trim();
215
+ reject(new Error(`codex exec จบด้วย exit code ${code}${detail ? `: ${detail}` : ''}`));
216
+ }
144
217
  });
145
218
  });
146
219
  }
@@ -1,3 +1,15 @@
1
+ import { isCodexChatGptSupportedModel, normalizeCodexChatGptModel } from './codex.js';
2
+ function curatedModels(cfg) {
3
+ if (cfg.id !== 'codex')
4
+ return cfg.models;
5
+ const out = {};
6
+ for (const [alias, id] of Object.entries(cfg.models)) {
7
+ const model = normalizeCodexChatGptModel(id).model;
8
+ if (isCodexChatGptSupportedModel(model))
9
+ out[alias] = model;
10
+ }
11
+ return out;
12
+ }
1
13
  /**
2
14
  * ดึงรายชื่อ model จริงจาก provider (GET /models) — "เลือกโมเดลที่เจ้าของมี" แบบ Hermes
3
15
  * - provider เป็นคน authoritative เรื่อง id (เราไม่ต้อง hardcode/เดา id ที่อาจ stale)
@@ -48,11 +60,12 @@ export async function listRemoteModels(cfg, key, timeoutMs = 6000) {
48
60
  * → ตัวเลือกโผล่ซ้ำ/หาย (bug "มีตัวเลือกสองตัวเลือกเป็น model เดียวกัน"). ใช้ทั้ง setup wizard และ /model picker
49
61
  */
50
62
  export function mergeModelOptions(cfg, remote = []) {
63
+ const models = curatedModels(cfg);
51
64
  // group alias ทั้งหมดตาม id (รวม 'default' ด้วย — กัน id ที่มีแต่ alias 'default' เช่น lmstudio:local-model,
52
65
  // ollama:llama3.3 หายไปจนเลือกไม่ได้/Select ว่าง). ตอนทำ label ค่อยซ่อนคำ "default" ถ้ามีชื่ออื่นอยู่แล้ว
53
66
  const aliasesById = new Map();
54
67
  const order = []; // คง first-seen order ของ id
55
- for (const [alias, id] of Object.entries(cfg.models)) {
68
+ for (const [alias, id] of Object.entries(models)) {
56
69
  if (!aliasesById.has(id)) {
57
70
  aliasesById.set(id, []);
58
71
  order.push(id);
@@ -66,6 +79,8 @@ export function mergeModelOptions(cfg, remote = []) {
66
79
  return { id, label: `${shown.join(' / ')} — ${id}` };
67
80
  });
68
81
  const seen = new Set(order);
69
- const extra = [...new Set(remote)].filter((id) => id && !seen.has(id)).map((id) => ({ id, label: id }));
82
+ const extra = cfg.id === 'codex'
83
+ ? []
84
+ : [...new Set(remote)].filter((id) => id && !seen.has(id)).map((id) => ({ id, label: id }));
70
85
  return [...curated, ...extra].map((o) => ({ label: o.label, value: o.id }));
71
86
  }
@@ -6,6 +6,7 @@ import { createMistral } from '@ai-sdk/mistral';
6
6
  import { createGroq } from '@ai-sdk/groq';
7
7
  import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
8
8
  import { resolveKeyFromEnv, assertDirectApiKey } from './keys.js';
9
+ import { CODEX_CHATGPT_MODEL_ALIASES, normalizeCodexChatGptModel } from './codex.js';
9
10
  import { BRAND } from '../brand.js';
10
11
  // ────────────────────────────────────────────────────────────────────────────
11
12
  // PROVIDER TABLE — เพิ่มค่าย = เพิ่ม 1 entry (loop/cost/keys ไม่ต้องแตะ)
@@ -134,21 +135,11 @@ export const PROVIDERS = {
134
135
  requiresKey: false,
135
136
  localPlaceholderKey: 'codex',
136
137
  keyFormat: null,
137
- models: {
138
- default: 'gpt-5.5',
139
- codex: 'gpt-5.5',
140
- '5.5': 'gpt-5.5',
141
- '5.4': 'gpt-5.4',
142
- '5.4-mini': 'gpt-5.4-mini',
143
- '5.3-codex': 'gpt-5.3-codex',
144
- '5.2-codex': 'gpt-5.2-codex',
145
- '5-codex': 'gpt-5-codex',
146
- spark: 'gpt-5.3-codex-spark',
147
- },
138
+ models: { ...CODEX_CHATGPT_MODEL_ALIASES },
148
139
  create: () => {
149
140
  throw new Error('codex เป็น delegate provider — ใช้ผ่าน codex subprocess ไม่ใช่ Vercel AI SDK');
150
141
  },
151
- note: 'ใช้ ChatGPT plan quota ผ่าน official codex CLI (ToS-safe, ไม่เก็บ credential). ต้อง codex login ก่อน',
142
+ note: 'ChatGPT plan ผ่าน codex CLI — รองรับ gpt-5.5 / gpt-5.4 / gpt-5.4-mini เท่านั้น (ต้อง codex login)',
152
143
  },
153
144
  };
154
145
  export const SUPPORTED_PROVIDERS = Object.keys(PROVIDERS);
@@ -177,7 +168,9 @@ export function parseSpec(spec) {
177
168
  const rest = spec.slice(idx + 1);
178
169
  const cfg = PROVIDERS[provider];
179
170
  // ถ้าเป็น alias ของ provider นั้น → map เป็น model id จริง, ไม่งั้นใช้ rest เป็น raw model id
180
- const model = cfg?.models[rest] ?? rest;
171
+ let model = cfg?.models[rest] ?? rest;
172
+ if (provider === 'codex')
173
+ model = normalizeCodexChatGptModel(model).model;
181
174
  return { provider, model };
182
175
  }
183
176
  const g = GLOBAL_ALIAS[spec];
@@ -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' },