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/providers/codex.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/providers/models.js
CHANGED
|
@@ -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(
|
|
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 =
|
|
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: '
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/session-brain.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/slash-completion.js
CHANGED
|
@@ -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' },
|