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/memory.js
CHANGED
|
@@ -2,9 +2,10 @@ import { readFile, writeFile, stat } from 'node:fs/promises';
|
|
|
2
2
|
import { join, dirname, resolve } from 'node:path';
|
|
3
3
|
import { buildContextPackBlock, listContextPacks, readContextPackExcerpt, selectContextPack } from './context-pack.js';
|
|
4
4
|
import { buildProjectContextBlock, resolveVaultProject } from './project-registry.js';
|
|
5
|
-
import { appHomePath, BRAND, persistenceEnabled, worklogEnabled } from './brand.js';
|
|
5
|
+
import { appHomePath, BRAND, brainTranscriptEnvForced, persistenceEnabled, worklogEnabled } from './brand.js';
|
|
6
6
|
import { redactKey } from './providers/keys.js';
|
|
7
|
-
import { loadStore, saveStore, mergeFact, maybeConsolidate, consolidate, renderPromptBlock } from './memory-store.js';
|
|
7
|
+
import { loadStore, saveStore, mergeFact, maybeConsolidate, consolidate, renderPromptBlock, activeFacts } from './memory-store.js';
|
|
8
|
+
import { renderPersonaProfile, personaFacts, mergePersonaAnswers, parsePersonaProfileMarkdown, personaAnswersFromFacts } from './persona.js';
|
|
8
9
|
const MEMORY_FILE = BRAND.memoryFileName;
|
|
9
10
|
// auto-memory (สิ่งที่ agent จำเองข้าม session) ย้ายไปอยู่ใน ./memory-store.ts —
|
|
10
11
|
// memory.json เป็น source of truth, MEMORY.md เป็น view ที่ render จากมัน
|
|
@@ -250,6 +251,23 @@ export async function getBrainPath() {
|
|
|
250
251
|
return undefined;
|
|
251
252
|
}
|
|
252
253
|
}
|
|
254
|
+
/**
|
|
255
|
+
* เปิด/ปิดการเก็บ "บทสนทนาเต็ม" (prompt + คำตอบ AI ทุก turn) ลง vault หรือไม่ —
|
|
256
|
+
* config.brainTranscript = true (persistent) หรือ env SANOOK_BRAIN_TRANSCRIPT (force ชั่วคราว)
|
|
257
|
+
*/
|
|
258
|
+
export async function brainTranscriptEnabled() {
|
|
259
|
+
if (!persistenceEnabled())
|
|
260
|
+
return false;
|
|
261
|
+
if (brainTranscriptEnvForced())
|
|
262
|
+
return true;
|
|
263
|
+
try {
|
|
264
|
+
const cfg = JSON.parse(await readFile(appHomePath('config.json'), 'utf8'));
|
|
265
|
+
return cfg.brainTranscript === true;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
253
271
|
/**
|
|
254
272
|
* route fact เข้า vault Memory-Inbox (candidate buffer ตาม §4) — "AI เขียนลง second brain ของคุณ"
|
|
255
273
|
* เขียนเฉพาะถ้า memory-inbox.md มีจริง (กันสร้างไฟล์ใน path ที่ไม่ใช่ vault) · คืน true ถ้าเขียน
|
|
@@ -268,13 +286,50 @@ export async function appendToVaultInbox(brainPath, fact) {
|
|
|
268
286
|
if (content.includes(line))
|
|
269
287
|
return false; // dedup
|
|
270
288
|
const marker = '## New Candidates';
|
|
289
|
+
// replacer function so `$`-sequences in the fact aren't interpreted as String.replace patterns
|
|
271
290
|
const next = content.includes(marker)
|
|
272
|
-
? content.replace(marker, `${marker}\n${line}`)
|
|
291
|
+
? content.replace(marker, () => `${marker}\n${line}`)
|
|
273
292
|
: `${content.trimEnd()}\n${line}\n`;
|
|
274
293
|
await writeFile(p, next);
|
|
275
294
|
return true;
|
|
276
295
|
}
|
|
277
296
|
/** บันทึก worklog ย่อเข้า vault Sessions/ (รายวัน) — "second brain จำว่าวันนี้ทำอะไร" */
|
|
297
|
+
function worklogHeader(today) {
|
|
298
|
+
return `---\ntags: [session, session-log, worklog]\nnote_type: session-log\ncreated: ${today}\nupdated: ${today}\nparent: "[[Sessions/_Index]]"\nai_surface: history\n---\n\n# ${today} — Worklog (auto by ${BRAND.cliName})\n`;
|
|
299
|
+
}
|
|
300
|
+
function chatTranscriptHeader(today) {
|
|
301
|
+
return `---\ntags: [session, transcript, chat]\nnote_type: session-log\ncreated: ${today}\nupdated: ${today}\nparent: "[[Sessions/_Index]]"\nai_surface: history\n---\n\n# ${today} — Chat transcript (auto by ${BRAND.cliName})\n\n> บทสนทนาเต็มของ session นี้ — เก็บอัตโนมัติทุก turn\n`;
|
|
302
|
+
}
|
|
303
|
+
// Matches ONLY the trailing `up:: ...` footer (anchored to EOF), never a body line that merely
|
|
304
|
+
// starts with `up:: ` (e.g. pasted vault content). `[^\n]*` can't cross newlines and `\s*$` forces
|
|
305
|
+
// the match to reach end-of-file, so a mid-file `up:: ` occurrence can't be mistaken for the footer.
|
|
306
|
+
const UP_FOOTER_RE = /\nup:: [^\n]*\s*$/;
|
|
307
|
+
function ensureSessionNoteScaffold(content, header) {
|
|
308
|
+
const clean = content.trimEnd();
|
|
309
|
+
let next = clean;
|
|
310
|
+
if (!next)
|
|
311
|
+
next = header.trimEnd();
|
|
312
|
+
else if (!next.includes('note_type: session-log'))
|
|
313
|
+
next = `${header.trimEnd()}\n\n${next}`;
|
|
314
|
+
// Append a trailing footer only if the file doesn't already end with one. We deliberately do NOT strip
|
|
315
|
+
// any mid-file `up:: ` line — it may be real user content (e.g. pasted vault text). In the rare
|
|
316
|
+
// manual-edit case where a canonical footer sits mid-body, this yields a harmless duplicate footer line
|
|
317
|
+
// rather than risk deleting vault content.
|
|
318
|
+
if (!UP_FOOTER_RE.test(`${next}\n`))
|
|
319
|
+
next = `${next.trimEnd()}\n\nup:: [[Sessions/_Index]]`;
|
|
320
|
+
return `${next.trimEnd()}\n`;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Insert `block` just before the note's trailing `up:: ...` footer, preserving everything above it.
|
|
324
|
+
* Uses a REPLACER FUNCTION (so `$`-sequences in user prompt/answer text are written literally, not
|
|
325
|
+
* interpreted as String.replace patterns) and matches only the final footer (UP_FOOTER_RE), so a
|
|
326
|
+
* body `up:: ` line can never trigger a greedy truncation-to-EOF. Falls back to appending if no footer.
|
|
327
|
+
*/
|
|
328
|
+
function insertBeforeUpFooter(content, block) {
|
|
329
|
+
return UP_FOOTER_RE.test(content)
|
|
330
|
+
? content.replace(UP_FOOTER_RE, () => `\n${block}\nup:: [[Sessions/_Index]]\n`)
|
|
331
|
+
: `${content.trimEnd()}\n${block}`;
|
|
332
|
+
}
|
|
278
333
|
export async function appendBrainWorklog(brainPath, entry) {
|
|
279
334
|
if (!persistenceEnabled() || !worklogEnabled())
|
|
280
335
|
return false;
|
|
@@ -283,20 +338,60 @@ export async function appendBrainWorklog(brainPath, entry) {
|
|
|
283
338
|
return false; // ไม่ใช่ vault → ข้าม
|
|
284
339
|
const topic = entry.prompt.trim().split(/\s+/).slice(0, 6).join(' ').slice(0, 50) || 'work';
|
|
285
340
|
const file = join(dir, `${entry.today}-worklog.md`);
|
|
286
|
-
let content;
|
|
287
|
-
try {
|
|
288
|
-
content = await readFile(file, 'utf8');
|
|
289
|
-
}
|
|
290
|
-
catch {
|
|
291
|
-
content = `---\ntags: [session, session-log, worklog]\nnote_type: session-log\ncreated: ${entry.today}\nupdated: ${entry.today}\nparent: "[[Sessions/_Index]]"\nai_surface: history\n---\n\n# ${entry.today} — Worklog (auto by ${BRAND.cliName})\n\nup:: [[Sessions/_Index]]\n`;
|
|
292
|
-
}
|
|
293
341
|
const block = `\n## ${topic}\n- prompt: ${redactKey(entry.prompt).trim().slice(0, 200)}\n- model: ${entry.model}\n- ${redactKey(entry.summary).trim().slice(0, 300)}\n`;
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
342
|
+
// serialize the read-modify-write with withMemLock (same as appendBrainTranscript) — turns run
|
|
343
|
+
// fire-and-forget in the REPL, so two concurrent worklog appends to the same daily file would
|
|
344
|
+
// otherwise read the same baseline and the later writeFile would clobber the earlier turn's block.
|
|
345
|
+
return withMemLock(async () => {
|
|
346
|
+
let content;
|
|
347
|
+
try {
|
|
348
|
+
content = await readFile(file, 'utf8');
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
content = '';
|
|
352
|
+
}
|
|
353
|
+
content = ensureSessionNoteScaffold(content, worklogHeader(entry.today));
|
|
354
|
+
// แทรกก่อน up:: footer ท้ายไฟล์ (กัน up:: หลุดไปกลาง + กัน $ ใน prompt ทำ replace เพี้ยน)
|
|
355
|
+
const out = insertBeforeUpFooter(content, block);
|
|
356
|
+
await writeFile(file, out);
|
|
357
|
+
return true;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* เก็บ "บทสนทนาเต็ม" ลง vault Sessions/<date>-<sid>-chat.md — ต่อ 1 turn = 1 block (prompt + คำตอบ AI)
|
|
362
|
+
* ต่างจาก worklog (ย่อ prompt + cost): อันนี้เก็บข้อความจริงที่คุยกัน เพื่อ "ทุกอย่างที่คุยไปอยู่ใน second brain"
|
|
363
|
+
* - gate: brainTranscriptEnabled() (config.brainTranscript / env) + ต้องมีโฟลเดอร์ Sessions/ (เป็น vault จริง)
|
|
364
|
+
* - redact API key ทั้ง 2 ฝั่ง · serialize การเขียนไฟล์เดียวกันด้วย withMemLock กัน turn ชนกัน
|
|
365
|
+
*/
|
|
366
|
+
export async function appendBrainTranscript(brainPath, entry) {
|
|
367
|
+
if (!(await brainTranscriptEnabled()))
|
|
368
|
+
return false;
|
|
369
|
+
const dir = join(brainPath, 'Sessions');
|
|
370
|
+
if (!(await exists(dir)))
|
|
371
|
+
return false; // ไม่ใช่ vault → ข้าม
|
|
372
|
+
const created = entry.createdIso ?? new Date().toISOString();
|
|
373
|
+
const today = created.slice(0, 10);
|
|
374
|
+
const sid = entry.sessionId.slice(-6) || 'session';
|
|
375
|
+
const file = join(dir, `${today}-${sid}-chat.md`);
|
|
376
|
+
const time = created.slice(11, 16); // HH:MM (UTC)
|
|
377
|
+
const prompt = redactKey(entry.prompt).trim();
|
|
378
|
+
const answer = redactKey(entry.answer).trim();
|
|
379
|
+
if (!prompt && !answer)
|
|
380
|
+
return false;
|
|
381
|
+
return withMemLock(async () => {
|
|
382
|
+
let content;
|
|
383
|
+
try {
|
|
384
|
+
content = await readFile(file, 'utf8');
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
content = '';
|
|
388
|
+
}
|
|
389
|
+
content = ensureSessionNoteScaffold(content, chatTranscriptHeader(today));
|
|
390
|
+
const block = [`\n## ${time} · ${entry.model}`, '', '**You:**', '', prompt || '_(empty)_', '', `**${BRAND.agentName}:**`, '', answer || '_(no text output)_', ''].join('\n');
|
|
391
|
+
const out = insertBeforeUpFooter(content, block);
|
|
392
|
+
await writeFile(file, out);
|
|
393
|
+
return true;
|
|
394
|
+
});
|
|
300
395
|
}
|
|
301
396
|
// in-process write serializer: the AI SDK runs tool calls from one model step concurrently, so two
|
|
302
397
|
// `remember` calls in a turn would otherwise load → mergeFact → save on the SAME baseline and the
|
|
@@ -331,3 +426,128 @@ export async function appendMemory(fact, noteType) {
|
|
|
331
426
|
await appendToVaultInbox(brain, safeFact).catch(() => false);
|
|
332
427
|
});
|
|
333
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* เขียน persona/identity ที่เก็บตอน setup (ขั้นที่ 9) ลง durable auto-memory เป็น owner ground-truth
|
|
431
|
+
* (tier protected, trust owner) → หลัง setup เสร็จ agent "จำ" ว่าเจ้าของชื่ออะไร / เรียก AI ว่าอะไร /
|
|
432
|
+
* ภาษา + autonomy ทันที โดยไม่ต้องรอ remember. ใช้คู่กับ scaffoldBrain ที่ substitute ลงไฟล์ vault อยู่แล้ว.
|
|
433
|
+
* idempotent: เขียนซ้ำ = NOOP (mergeFact). ข้ามค่า default/ว่าง เพื่อไม่ปน noise.
|
|
434
|
+
*/
|
|
435
|
+
export async function seedPersonaMemory(input) {
|
|
436
|
+
if (!persistenceEnabled())
|
|
437
|
+
return 0;
|
|
438
|
+
const facts = [];
|
|
439
|
+
const owner = input.ownerName?.trim();
|
|
440
|
+
const ai = input.aiName?.trim();
|
|
441
|
+
const lang = input.language?.trim();
|
|
442
|
+
const autonomy = input.autonomy?.trim();
|
|
443
|
+
if (owner && owner !== input.defaults?.ownerName)
|
|
444
|
+
facts.push({ text: `เจ้าของชื่อ ${owner} — เรียกเจ้าของด้วยชื่อนี้`, noteType: 'entity', trust: 'owner', tier: 'protected' });
|
|
445
|
+
if (ai && ai !== input.defaults?.aiName)
|
|
446
|
+
facts.push({ text: `AI เรียกตัวเองว่า "${ai}" เมื่อคุยกับเจ้าของ`, noteType: 'preference', trust: 'owner', tier: 'protected' });
|
|
447
|
+
if (lang)
|
|
448
|
+
facts.push({ text: `ภาษาที่เจ้าของต้องการให้ตอบ: ${lang}`, noteType: 'preference', trust: 'owner', tier: 'protected' });
|
|
449
|
+
if (autonomy)
|
|
450
|
+
facts.push({ text: `ระดับ autonomy ที่เจ้าของเลือก: ${autonomy}`, noteType: 'preference', trust: 'owner', tier: 'protected' });
|
|
451
|
+
if (!facts.length)
|
|
452
|
+
return 0;
|
|
453
|
+
let written = 0;
|
|
454
|
+
await withMemLock(async () => {
|
|
455
|
+
let store = await loadStore();
|
|
456
|
+
for (const inc of facts) {
|
|
457
|
+
const { store: next, op } = mergeFact(store, { ...inc, text: redactKey(inc.text) });
|
|
458
|
+
// persist any store mutation (NOOP bumps accessCount, QUARANTINE holds the fact aside),
|
|
459
|
+
// but only count facts actually remembered as new/changed — so a re-run with identical
|
|
460
|
+
// answers reports "nothing new" instead of falsely claiming N facts were saved.
|
|
461
|
+
if (op !== 'PROTECTED_HALT') {
|
|
462
|
+
store = next;
|
|
463
|
+
if (op === 'ADD' || op === 'UPDATE' || op === 'SUPERSEDE')
|
|
464
|
+
written += 1;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
await saveStore(store);
|
|
468
|
+
});
|
|
469
|
+
return written;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* เขียน persona facts จาก `sanook persona` ลง durable auto-memory เป็น owner ground-truth
|
|
473
|
+
* (tier protected, trust owner) → agent "จำ" persona ทันทีทุก session โดยไม่ต้องรอ remember.
|
|
474
|
+
* idempotent: เขียนซ้ำ = merge/NOOP. รับ plain-string facts (สร้างจาก personaFacts()).
|
|
475
|
+
*/
|
|
476
|
+
export async function seedPersonaFacts(facts) {
|
|
477
|
+
if (!persistenceEnabled())
|
|
478
|
+
return 0;
|
|
479
|
+
const list = facts.map((f) => f.trim()).filter(Boolean);
|
|
480
|
+
if (!list.length)
|
|
481
|
+
return 0;
|
|
482
|
+
let written = 0;
|
|
483
|
+
await withMemLock(async () => {
|
|
484
|
+
let store = await loadStore();
|
|
485
|
+
for (const text of list) {
|
|
486
|
+
const { store: next, op } = mergeFact(store, {
|
|
487
|
+
text: redactKey(text),
|
|
488
|
+
noteType: 'preference',
|
|
489
|
+
trust: 'owner',
|
|
490
|
+
tier: 'protected',
|
|
491
|
+
});
|
|
492
|
+
// persist any store mutation (NOOP bumps accessCount, QUARANTINE holds the fact aside),
|
|
493
|
+
// but only count facts actually remembered as new/changed — so a re-run with identical
|
|
494
|
+
// answers reports "nothing new" instead of falsely claiming N facts were saved.
|
|
495
|
+
if (op !== 'PROTECTED_HALT') {
|
|
496
|
+
store = next;
|
|
497
|
+
if (op === 'ADD' || op === 'UPDATE' || op === 'SUPERSEDE')
|
|
498
|
+
written += 1;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
await saveStore(store);
|
|
502
|
+
});
|
|
503
|
+
return written;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* เขียนโปรไฟล์ persona ลง second-brain vault ที่ Shared/User-Persona/persona.md
|
|
507
|
+
* (เขียนทับเสมอ — ไฟล์นี้เป็น canonical output ของ `sanook persona`). คืน false ถ้าไม่ใช่ vault จริง.
|
|
508
|
+
*/
|
|
509
|
+
export async function writePersonaProfile(brainPath, answers) {
|
|
510
|
+
const personaDir = join(brainPath, 'Shared', 'User-Persona');
|
|
511
|
+
if (!(await exists(personaDir)))
|
|
512
|
+
return false; // ไม่ใช่ vault ที่ scaffold แล้ว → ข้าม
|
|
513
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
514
|
+
const md = redactKey(renderPersonaProfile(answers, today));
|
|
515
|
+
await writeFile(join(personaDir, 'persona.md'), md);
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
/** โหลดคำตอบ persona ที่มีอยู่ — vault persona.md ชนะ memory facts (สำหรับ pre-fill wizard) */
|
|
519
|
+
export async function loadPersonaAnswers() {
|
|
520
|
+
const brain = await getBrainPath().catch(() => undefined);
|
|
521
|
+
let fromVault = {};
|
|
522
|
+
if (brain) {
|
|
523
|
+
const p = join(brain, 'Shared', 'User-Persona', 'persona.md');
|
|
524
|
+
try {
|
|
525
|
+
fromVault = parsePersonaProfileMarkdown(await readFile(p, 'utf8'));
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
/* no profile yet */
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
let fromMemory = {};
|
|
532
|
+
if (persistenceEnabled()) {
|
|
533
|
+
try {
|
|
534
|
+
const store = await loadStore();
|
|
535
|
+
const texts = activeFacts(store)
|
|
536
|
+
.filter((f) => f.trust === 'owner' || f.tier === 'protected')
|
|
537
|
+
.map((f) => f.text);
|
|
538
|
+
fromMemory = personaAnswersFromFacts(texts);
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
/* best-effort */
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return mergePersonaAnswers(fromMemory, fromVault);
|
|
545
|
+
}
|
|
546
|
+
/** บันทึก persona ครบชุด — auto-memory + vault profile (ใช้ทั้ง CLI และ REPL /persona) */
|
|
547
|
+
export async function persistPersonaAnswers(answers) {
|
|
548
|
+
const facts = personaFacts(answers);
|
|
549
|
+
const memoryWritten = await seedPersonaFacts(facts).catch(() => 0);
|
|
550
|
+
const brain = await getBrainPath().catch(() => undefined);
|
|
551
|
+
const vaultWritten = brain ? await writePersonaProfile(brain, answers).catch(() => false) : false;
|
|
552
|
+
return { memoryWritten, vaultWritten, brainPath: brain };
|
|
553
|
+
}
|
package/dist/model-picker.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { canonicalSpec, hasUsableEnvKey, PROVIDERS, parseSpec } from './providers/registry.js';
|
|
2
|
+
import { isCodexChatGptSupportedModel } from './providers/codex.js';
|
|
2
3
|
function statusFor(provider) {
|
|
3
4
|
const cfg = PROVIDERS[provider];
|
|
4
5
|
if (cfg.kind === 'delegate')
|
|
@@ -22,7 +23,9 @@ export function modelPickerOptions(current) {
|
|
|
22
23
|
grouped.set(model, aliases);
|
|
23
24
|
}
|
|
24
25
|
const status = statusFor(provider);
|
|
25
|
-
return [...grouped.entries()]
|
|
26
|
+
return [...grouped.entries()]
|
|
27
|
+
.filter(([model]) => provider !== 'codex' || isCodexChatGptSupportedModel(model))
|
|
28
|
+
.map(([model, aliases]) => {
|
|
26
29
|
const nonDefaultAliases = aliases.filter((alias) => alias !== 'default');
|
|
27
30
|
const displayAliases = nonDefaultAliases.length ? nonDefaultAliases.join('/') : 'default';
|
|
28
31
|
const spec = `${provider}:${model}`;
|
package/dist/persona.js
ADDED
|
@@ -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
|
+
}
|
package/dist/project-scaffold.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|