sanook-cli 0.5.5 → 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,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
+ }
@@ -0,0 +1,112 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { BRAND, persistenceEnabled } from './brand.js';
3
+ import { createBrainNote } from './brain-new.js';
4
+ import { getBrainPath } from './memory.js';
5
+ import { PROVIDERS, parseSpec } from './providers/registry.js';
6
+ import { makeSummarizer } from './summarize.js';
7
+ import { distilledFactsFromMessages } from './session-distill.js';
8
+ import { autoMaintainEnabled } from './auto-maintain.js';
9
+ import { saveSession } from './session.js';
10
+ function transcriptFromTurns(turns) {
11
+ return turns
12
+ .filter((t) => t.role === 'user' || t.role === 'assistant')
13
+ .map((t) => `${t.role === 'user' ? 'User' : 'Assistant'}: ${t.text.trim()}`)
14
+ .filter((line) => line.length > 8)
15
+ .join('\n\n');
16
+ }
17
+ function sessionTitleFromHistory(history) {
18
+ const firstUser = history.find((t) => t.role === 'user')?.text.trim();
19
+ if (!firstUser)
20
+ return 'repl session';
21
+ const cleaned = firstUser.replace(/^\/\w+\s*/, '').trim();
22
+ return cleaned.split(/\s+/).slice(0, 8).join(' ').slice(0, 72) || 'repl session';
23
+ }
24
+ function injectSessionSummary(template, summary, facts) {
25
+ const summaryBlock = [summary.trim(), facts.length ? `\n### Key facts\n${facts.map((f) => `- ${f}`).join('\n')}` : '']
26
+ .filter(Boolean)
27
+ .join('\n');
28
+ if (/^## Summary\s*$/m.test(template)) {
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}`);
31
+ }
32
+ return `${template.trimEnd()}\n\n## Summary\n\n${summaryBlock}\n`;
33
+ }
34
+ async function summarizeSession(model, transcript, messages) {
35
+ const provider = parseSpec(model).provider;
36
+ if (PROVIDERS[provider]?.kind !== 'delegate' && transcript.trim().length > 40) {
37
+ try {
38
+ const text = await makeSummarizer(model)(transcript);
39
+ if (text.trim())
40
+ return text.trim();
41
+ }
42
+ catch {
43
+ // fall through to heuristic distill
44
+ }
45
+ }
46
+ const facts = distilledFactsFromMessages(messages);
47
+ if (facts.length)
48
+ return facts.map((f) => `- ${f}`).join('\n');
49
+ const lines = transcript.split('\n\n').slice(-6);
50
+ return lines.length ? lines.join('\n\n') : 'Session ended with no durable transcript.';
51
+ }
52
+ /** Persist REPL session + write a Sessions/ note in the configured second-brain vault. */
53
+ export async function finalizeReplSession(options) {
54
+ const hasConversation = options.messages.length > 0 || options.history.some((t) => t.role === 'user' || t.role === 'assistant');
55
+ if (!hasConversation || !persistenceEnabled()) {
56
+ return { sessionSaved: false };
57
+ }
58
+ const now = new Date().toISOString();
59
+ const session = {
60
+ id: options.sessionId,
61
+ title: sessionTitleFromHistory(options.history),
62
+ created: options.sessionCreated,
63
+ updated: now,
64
+ model: options.model,
65
+ cwd: options.cwd,
66
+ messages: options.messages,
67
+ };
68
+ await saveSession(session);
69
+ const brainPath = await getBrainPath();
70
+ if (!brainPath)
71
+ return { sessionSaved: true };
72
+ const transcript = transcriptFromTurns(options.history);
73
+ const summary = await summarizeSession(options.model, transcript, options.messages);
74
+ const title = sessionTitleFromHistory(options.history);
75
+ const slugSuffix = options.sessionId.slice(-6);
76
+ const today = now.slice(0, 10);
77
+ const output = `Sessions/${today}-${slugSuffix}-session.md`;
78
+ const report = await createBrainNote({
79
+ brainPath,
80
+ type: 'session',
81
+ title,
82
+ output,
83
+ force: true,
84
+ today,
85
+ });
86
+ if (!report.ok || !report.path)
87
+ return { sessionSaved: true };
88
+ const facts = distilledFactsFromMessages(options.messages);
89
+ const raw = await readFile(report.path, 'utf8');
90
+ const next = injectSessionSummary(raw, summary, facts.slice(0, 8));
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
+ }
99
+ return {
100
+ sessionSaved: true,
101
+ brainNoteRel: report.relPath,
102
+ brainNotePath: report.path,
103
+ };
104
+ }
105
+ export function formatFinalizeMessage(result) {
106
+ if (!result.sessionSaved)
107
+ return undefined;
108
+ if (result.brainNoteRel) {
109
+ return `${BRAND.cliName}: session saved · second-brain → [[${result.brainNoteRel.replace(/\.md$/i, '')}]]`;
110
+ }
111
+ return `${BRAND.cliName}: session saved`;
112
+ }
@@ -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' },