sanook-cli 0.5.2 → 0.5.7

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.
Files changed (127) hide show
  1. package/CHANGELOG.md +112 -2
  2. package/README.md +15 -3
  3. package/README.th.md +8 -1
  4. package/dist/approval.js +7 -0
  5. package/dist/bin.js +637 -56
  6. package/dist/brain-consolidate.js +335 -0
  7. package/dist/brain-context.js +42 -3
  8. package/dist/brain-final.js +15 -9
  9. package/dist/brain-link.js +73 -0
  10. package/dist/brain-metrics.js +277 -0
  11. package/dist/brain-new.js +402 -0
  12. package/dist/brain-pack.js +210 -0
  13. package/dist/brain-repair.js +280 -0
  14. package/dist/brain.js +3 -0
  15. package/dist/brand.js +4 -0
  16. package/dist/cli-args.js +47 -9
  17. package/dist/cli-option-values.js +1 -1
  18. package/dist/clipboard.js +65 -0
  19. package/dist/commands.js +98 -15
  20. package/dist/config.js +66 -34
  21. package/dist/context-pack.js +145 -0
  22. package/dist/cost.js +20 -0
  23. package/dist/dashboard/api-helpers.js +87 -0
  24. package/dist/dashboard/server.js +179 -0
  25. package/dist/dashboard/static/app.js +277 -0
  26. package/dist/dashboard/static/index.html +39 -0
  27. package/dist/dashboard/static/styles.css +85 -0
  28. package/dist/diff.js +10 -2
  29. package/dist/gateway/auth.js +14 -3
  30. package/dist/gateway/deliver.js +45 -3
  31. package/dist/gateway/doctor.js +456 -0
  32. package/dist/gateway/email.js +30 -1
  33. package/dist/gateway/ledger.js +20 -1
  34. package/dist/gateway/session.js +34 -11
  35. package/dist/hotkeys.js +21 -0
  36. package/dist/i18n/en.js +98 -0
  37. package/dist/i18n/index.js +19 -0
  38. package/dist/i18n/th.js +98 -0
  39. package/dist/i18n/types.js +1 -0
  40. package/dist/insights-args.js +24 -4
  41. package/dist/knowledge.js +55 -29
  42. package/dist/loop.js +65 -9
  43. package/dist/mcp-hub.js +33 -0
  44. package/dist/mcp-registry.js +153 -9
  45. package/dist/mcp-risk.js +71 -0
  46. package/dist/mcp.js +77 -5
  47. package/dist/memory-log.js +90 -0
  48. package/dist/memory-store.js +37 -1
  49. package/dist/memory.js +51 -7
  50. package/dist/model-picker.js +58 -0
  51. package/dist/orchestrate.js +7 -5
  52. package/dist/plan-handoff.js +17 -0
  53. package/dist/polyglot.js +162 -0
  54. package/dist/process-runner.js +96 -0
  55. package/dist/project-init.js +91 -0
  56. package/dist/project-registry.js +143 -0
  57. package/dist/project-scaffold.js +124 -0
  58. package/dist/prompt-size.js +155 -0
  59. package/dist/providers/codex-login.js +138 -0
  60. package/dist/providers/codex.js +20 -8
  61. package/dist/providers/keys.js +21 -0
  62. package/dist/providers/models.js +1 -1
  63. package/dist/providers/registry.js +11 -1
  64. package/dist/search/cli.js +9 -1
  65. package/dist/search/embedding-config.js +22 -0
  66. package/dist/search/engine.js +2 -13
  67. package/dist/search/indexer.js +10 -10
  68. package/dist/session-brain.js +103 -0
  69. package/dist/session-distill.js +84 -0
  70. package/dist/session.js +1 -11
  71. package/dist/skill-install.js +24 -1
  72. package/dist/skills.js +33 -0
  73. package/dist/slash-completion.js +155 -0
  74. package/dist/support-dump.js +31 -0
  75. package/dist/tool-catalog.js +59 -0
  76. package/dist/tools/index.js +5 -0
  77. package/dist/tools/permission.js +82 -16
  78. package/dist/tools/polyglot.js +126 -0
  79. package/dist/tools/sandbox.js +38 -13
  80. package/dist/tools/search.js +9 -2
  81. package/dist/tools/task.js +22 -2
  82. package/dist/tools/timeout.js +7 -5
  83. package/dist/tools/web-fetch-tool.js +33 -0
  84. package/dist/turn-retrieval.js +83 -0
  85. package/dist/ui/app.js +874 -35
  86. package/dist/ui/banner.js +78 -4
  87. package/dist/ui/markdown.js +122 -0
  88. package/dist/ui/overlay.js +496 -0
  89. package/dist/ui/queue.js +23 -0
  90. package/dist/ui/render.js +30 -2
  91. package/dist/ui/session-panel.js +115 -0
  92. package/dist/ui/setup-providers.js +40 -0
  93. package/dist/ui/setup.js +163 -50
  94. package/dist/ui/status.js +142 -0
  95. package/dist/ui/thinking-panel.js +36 -0
  96. package/dist/ui/tool-trail.js +97 -0
  97. package/dist/ui/transcript.js +26 -0
  98. package/dist/ui/useBusyElapsed.js +19 -0
  99. package/dist/ui/useEditor.js +144 -5
  100. package/dist/ui/useGitBranch.js +57 -0
  101. package/dist/update.js +32 -6
  102. package/dist/usage-cli.js +160 -0
  103. package/dist/usage-ledger.js +169 -0
  104. package/dist/web-fetch.js +637 -0
  105. package/dist/web-surface.js +190 -0
  106. package/package.json +4 -3
  107. package/scripts/postinstall.mjs +4 -4
  108. package/second-brain/Projects/_Index.md +17 -4
  109. package/second-brain/Projects/sanook-cli/_Index.md +7 -3
  110. package/second-brain/Projects/sanook-cli/context.md +35 -0
  111. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  112. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  113. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  114. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +52 -11
  115. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  116. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  117. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  118. package/second-brain/Research/_Index.md +2 -0
  119. package/second-brain/Shared/Operating-State/current-state.md +14 -23
  120. package/second-brain/Shared/Tech-Standards/_Index.md +2 -0
  121. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  122. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  123. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  124. package/second-brain/Templates/project-workspace/context.md +28 -0
  125. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  126. package/second-brain/Templates/project-workspace/overview.md +39 -0
  127. package/second-brain/Templates/project-workspace/repo.md +33 -0
@@ -5,8 +5,18 @@ import { join } from 'node:path';
5
5
  export function codexHome() {
6
6
  return process.env.CODEX_HOME?.trim() || join(homedir(), '.codex');
7
7
  }
8
+ async function readCodexLoggedIn() {
9
+ try {
10
+ const auth = JSON.parse(await readFile(join(codexHome(), 'auth.json'), 'utf8'));
11
+ return auth?.auth_mode === 'chatgpt' || Boolean(auth?.tokens?.access_token);
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
8
17
  /** เช็กว่า codex CLI ติดตั้ง + login ChatGPT แล้ว */
9
18
  export async function detectCodex() {
19
+ const loggedIn = await readCodexLoggedIn();
10
20
  const hasBinary = await new Promise((resolve) => {
11
21
  const p = spawn('codex', ['--version'], { shell: process.platform === 'win32' });
12
22
  // timeout: binary ค้าง (shim รอ stdin / Gatekeeper stall ตอนรันครั้งแรกบน macOS) → ไม่ให้ wizard ตัน
@@ -24,16 +34,18 @@ export async function detectCodex() {
24
34
  });
25
35
  });
26
36
  if (!hasBinary) {
27
- return { installed: false, loggedIn: false, reason: 'ไม่พบ codex CLI — ติดตั้ง: npm i -g @openai/codex' };
28
- }
29
- try {
30
- const auth = JSON.parse(await readFile(join(codexHome(), 'auth.json'), 'utf8'));
31
- const loggedIn = auth?.auth_mode === 'chatgpt' || Boolean(auth?.tokens?.access_token);
32
- return { installed: true, loggedIn, reason: loggedIn ? undefined : 'ยังไม่ได้ login — รัน: codex login' };
37
+ return {
38
+ installed: false,
39
+ loggedIn,
40
+ reason: loggedIn
41
+ ? 'login แล้ว แต่ยังไม่มี codex CLI ติดตั้ง: npm i -g @openai/codex'
42
+ : 'ไม่พบ codex CLI ติดตั้ง: npm i -g @openai/codex',
43
+ };
33
44
  }
34
- catch {
35
- return { installed: true, loggedIn: false, reason: 'ยังไม่ได้ login — รัน: codex login' };
45
+ if (loggedIn) {
46
+ return { installed: true, loggedIn: true, reason: undefined };
36
47
  }
48
+ return { installed: true, loggedIn: false, reason: 'ยังไม่ได้ login — รัน: codex login' };
37
49
  }
38
50
  /**
39
51
  * รัน `codex exec` แบบ non-interactive — ส่ง prompt ทาง stdin, parse JSONL events
@@ -36,3 +36,24 @@ export function assertDirectApiKey(policy, key) {
36
36
  export function redactKey(s) {
37
37
  return s.replace(/\b(AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9_-]{6,}|AIza[A-Za-z0-9_-]{10,}|xai-[A-Za-z0-9]{10,}|gsk_[A-Za-z0-9]{10,}|[A-Za-z0-9_-]{24,})\b/g, (m) => (m.length > 8 ? `${m.slice(0, 4)}…${m.slice(-2)}` : '…'));
38
38
  }
39
+ export function redactUnknown(value) {
40
+ const visiting = new WeakSet();
41
+ const visit = (current) => {
42
+ if (typeof current === 'string')
43
+ return redactKey(current);
44
+ if (!current || typeof current !== 'object')
45
+ return current;
46
+ if (visiting.has(current))
47
+ return '[Circular]';
48
+ visiting.add(current);
49
+ try {
50
+ if (Array.isArray(current))
51
+ return current.map(visit);
52
+ return Object.fromEntries(Object.entries(current).map(([k, v]) => [redactKey(k), visit(v)]));
53
+ }
54
+ finally {
55
+ visiting.delete(current);
56
+ }
57
+ };
58
+ return visit(value);
59
+ }
@@ -22,7 +22,7 @@ export async function listRemoteModels(cfg, key, timeoutMs = 6000) {
22
22
  .map((m) => (m.name ?? '').replace(/^models\//, ''))
23
23
  .filter(Boolean);
24
24
  }
25
- const base = process.env[`${cfg.id.toUpperCase()}_BASE_URL`] ?? cfg.baseURL;
25
+ const base = process.env[`${cfg.id.toUpperCase()}_BASE_URL`]?.trim() || cfg.baseURL?.trim();
26
26
  if (!base)
27
27
  return []; // ไม่มี baseURL = ดึงไม่ได้
28
28
  const headers = cfg.id === 'anthropic'
@@ -134,7 +134,17 @@ export const PROVIDERS = {
134
134
  requiresKey: false,
135
135
  localPlaceholderKey: 'codex',
136
136
  keyFormat: null,
137
- models: { default: 'gpt-5-codex', codex: 'gpt-5-codex' },
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
148
  create: () => {
139
149
  throw new Error('codex เป็น delegate provider — ใช้ผ่าน codex subprocess ไม่ใช่ Vercel AI SDK');
140
150
  },
@@ -19,7 +19,9 @@ function inlineSourceValue(value) {
19
19
  export function parseSearchArgs(args) {
20
20
  const queryParts = [];
21
21
  let mode = 'auto';
22
+ let modeSet = false;
22
23
  let limit = 8;
24
+ let limitSet = false;
23
25
  let sources;
24
26
  for (let i = 0; i < args.length; i++) {
25
27
  const a = args[i];
@@ -36,7 +38,10 @@ export function parseSearchArgs(args) {
36
38
  return { ok: false, message: `--mode ต้องระบุค่าเป็น ${SEARCH_MODES.join('|')}` };
37
39
  if (!isSearchMode(v))
38
40
  return { ok: false, message: `--mode ต้องเป็น ${SEARCH_MODES.join('|')}` };
41
+ if (modeSet)
42
+ return { ok: false, message: 'ใช้ --mode เพียงครั้งเดียว' };
39
43
  mode = v;
44
+ modeSet = true;
40
45
  }
41
46
  else if (a === '--limit' || a.startsWith('--limit=')) {
42
47
  const next = a === '--limit' ? takeValue(args, i) : undefined;
@@ -48,7 +53,10 @@ export function parseSearchArgs(args) {
48
53
  const n = parsePositiveInteger(raw);
49
54
  if (n === undefined)
50
55
  return { ok: false, message: '--limit ต้องเป็น integer บวก เช่น 8' };
56
+ if (limitSet)
57
+ return { ok: false, message: 'ใช้ --limit เพียงครั้งเดียว' };
51
58
  limit = n;
59
+ limitSet = true;
52
60
  }
53
61
  else if (a === '--source' || a === '--sources' || a.startsWith('--source=') || a.startsWith('--sources=')) {
54
62
  const next = a === '--source' || a === '--sources' ? takeValue(args, i) : undefined;
@@ -62,7 +70,7 @@ export function parseSearchArgs(args) {
62
70
  }
63
71
  if (bad.length)
64
72
  return { ok: false, message: `--source ต้องเป็น ${SEARCH_SOURCES.join(',')} (คั่นหลายค่าได้ด้วย comma)` };
65
- sources = [...new Set(requested)];
73
+ sources = [...new Set([...(sources ?? []), ...requested])];
66
74
  }
67
75
  else {
68
76
  queryParts.push(a);
@@ -0,0 +1,22 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { appHomePath } from '../brand.js';
3
+ const EMBEDDING_MODEL_ENV = 'SANOOK_EMBEDDING_MODEL';
4
+ export function cleanEmbeddingModelSpec(v) {
5
+ if (typeof v !== 'string')
6
+ return undefined;
7
+ const clean = v.trim();
8
+ return clean ? clean : undefined;
9
+ }
10
+ /** read an optional embeddingModel spec from ~/.sanook/config.json. */
11
+ export async function configEmbeddingModel() {
12
+ try {
13
+ const cfg = JSON.parse(await readFile(appHomePath('config.json'), 'utf8'));
14
+ return cleanEmbeddingModelSpec(cfg.embeddingModel);
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ export async function embeddingModelSpec(override) {
21
+ return cleanEmbeddingModelSpec(override) ?? cleanEmbeddingModelSpec(process.env[EMBEDDING_MODEL_ENV]) ?? (await configEmbeddingModel());
22
+ }
@@ -14,11 +14,10 @@
14
14
  // lazily, and on ANY embedding error degrades to BM25 with a `degraded` flag —
15
15
  // search must never throw at the floor.
16
16
  // ============================================================================
17
- import { readFile } from 'node:fs/promises';
18
- import { appHomePath } from '../brand.js';
19
17
  import { bm25Search, termList } from './index-core.js';
20
18
  import { rrfFuse } from './fuse.js';
21
19
  import { cosineTopK, embedQuery, getEmbedder, loadVectors, vectorsMtimeMs, } from './embed-store.js';
20
+ import { embeddingModelSpec } from './embedding-config.js';
22
21
  import { indexMtimeMs, loadIndex } from './store.js';
23
22
  const CAND = 60; // candidate pool depth per leg before fusion/limit
24
23
  const SNIPPET_WIDTH = 64;
@@ -138,16 +137,6 @@ async function cachedVectors() {
138
137
  }
139
138
  return vectorCache.vectors;
140
139
  }
141
- /** read an optional embeddingModel spec from ~/.sanook/config.json. */
142
- async function configEmbeddingModel() {
143
- try {
144
- const cfg = JSON.parse(await readFile(appHomePath('config.json'), 'utf8'));
145
- return cfg.embeddingModel;
146
- }
147
- catch {
148
- return undefined;
149
- }
150
- }
151
140
  /** drop in-process caches (tests + after a reindex in the same process). */
152
141
  export function resetSearchCaches() {
153
142
  indexCache = null;
@@ -165,7 +154,7 @@ export async function search(query, opts = {}) {
165
154
  const mode = opts.mode ?? 'auto';
166
155
  if (mode === 'fts')
167
156
  return rankSearch(index, query, opts);
168
- const spec = opts.embeddingModel ?? process.env.SANOOK_EMBEDDING_MODEL ?? (await configEmbeddingModel());
157
+ const spec = await embeddingModelSpec(opts.embeddingModel);
169
158
  const embedder = getEmbedder(spec);
170
159
  if (!embedder) {
171
160
  const res = rankSearch(index, query, opts);
@@ -24,6 +24,7 @@ import { loadSkills } from '../skills.js';
24
24
  import { activeFacts, effImportance, loadStore } from '../memory-store.js';
25
25
  import { chunkMarkdown } from './chunk.js';
26
26
  import { addDoc, removeDoc, removeSource } from './index-core.js';
27
+ import { embeddingModelSpec } from './embedding-config.js';
27
28
  import { loadIndex, saveIndex } from './store.js';
28
29
  import { buildVectorIndex, embedTexts, getEmbedder, invalidateVectors, saveVectors } from './embed-store.js';
29
30
  /** strip a .md path to a human title fallback when a chunk has no heading. */
@@ -38,6 +39,14 @@ export async function indexVaultFiles(index, manifest, fs) {
38
39
  const next = {};
39
40
  const diff = { added: 0, updated: 0, removed: 0, skipped: 0 };
40
41
  const paths = await fs.listMarkdown();
42
+ // Guard against a momentarily-unreadable vault (unmounted drive / perms blip / wrong cwd): walk()
43
+ // swallows readdir errors and returns [], which would otherwise evict the ENTIRE persisted index
44
+ // via the deletion sweep below. If a non-empty manifest just lost >50% of its files, treat it as a
45
+ // transient read failure and keep the existing index+manifest untouched (recovers on the next pass).
46
+ const manifestSize = Object.keys(manifest).length;
47
+ if (manifestSize > 0 && paths.length < Math.ceil(manifestSize * 0.5)) {
48
+ return { manifest, diff: { added: 0, updated: 0, removed: 0, skipped: manifestSize } };
49
+ }
41
50
  const seenExisting = new Set();
42
51
  for (const rel of paths) {
43
52
  const fp = await fs.fingerprint(rel);
@@ -192,15 +201,6 @@ export function nodeVaultFS(root) {
192
201
  };
193
202
  }
194
203
  const SESSIONS_DIR = appHomePath('sessions');
195
- async function configEmbeddingModel() {
196
- try {
197
- const cfg = JSON.parse(await readFile(appHomePath('config.json'), 'utf8'));
198
- return cfg.embeddingModel;
199
- }
200
- catch {
201
- return undefined;
202
- }
203
- }
204
204
  /** load first-user-message of the most recent sessions (bounded) for the session corpus. */
205
205
  export async function loadRecentSessions(limit = 60) {
206
206
  const out = [];
@@ -263,7 +263,7 @@ export async function reindex(now = Date.now()) {
263
263
  })));
264
264
  await saveIndex(index, nextManifest);
265
265
  let vectors = 0;
266
- const embedder = getEmbedder(process.env.SANOOK_EMBEDDING_MODEL ?? (await configEmbeddingModel()));
266
+ const embedder = getEmbedder(await embeddingModelSpec());
267
267
  if (!embedder) {
268
268
  await invalidateVectors().catch(() => { });
269
269
  }
@@ -0,0 +1,103 @@
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 { saveSession } from './session.js';
9
+ function transcriptFromTurns(turns) {
10
+ return turns
11
+ .filter((t) => t.role === 'user' || t.role === 'assistant')
12
+ .map((t) => `${t.role === 'user' ? 'User' : 'Assistant'}: ${t.text.trim()}`)
13
+ .filter((line) => line.length > 8)
14
+ .join('\n\n');
15
+ }
16
+ function sessionTitleFromHistory(history) {
17
+ const firstUser = history.find((t) => t.role === 'user')?.text.trim();
18
+ if (!firstUser)
19
+ return 'repl session';
20
+ const cleaned = firstUser.replace(/^\/\w+\s*/, '').trim();
21
+ return cleaned.split(/\s+/).slice(0, 8).join(' ').slice(0, 72) || 'repl session';
22
+ }
23
+ function injectSessionSummary(template, summary, facts) {
24
+ const summaryBlock = [summary.trim(), facts.length ? `\n### Key facts\n${facts.map((f) => `- ${f}`).join('\n')}` : '']
25
+ .filter(Boolean)
26
+ .join('\n');
27
+ if (/^## Summary\s*$/m.test(template)) {
28
+ return template.replace(/^## Summary\s*$/m, `## Summary\n\n${summaryBlock}`);
29
+ }
30
+ return `${template.trimEnd()}\n\n## Summary\n\n${summaryBlock}\n`;
31
+ }
32
+ async function summarizeSession(model, transcript, messages) {
33
+ const provider = parseSpec(model).provider;
34
+ if (PROVIDERS[provider]?.kind !== 'delegate' && transcript.trim().length > 40) {
35
+ try {
36
+ const text = await makeSummarizer(model)(transcript);
37
+ if (text.trim())
38
+ return text.trim();
39
+ }
40
+ catch {
41
+ // fall through to heuristic distill
42
+ }
43
+ }
44
+ const facts = distilledFactsFromMessages(messages);
45
+ if (facts.length)
46
+ return facts.map((f) => `- ${f}`).join('\n');
47
+ const lines = transcript.split('\n\n').slice(-6);
48
+ return lines.length ? lines.join('\n\n') : 'Session ended with no durable transcript.';
49
+ }
50
+ /** Persist REPL session + write a Sessions/ note in the configured second-brain vault. */
51
+ export async function finalizeReplSession(options) {
52
+ const hasConversation = options.messages.length > 0 || options.history.some((t) => t.role === 'user' || t.role === 'assistant');
53
+ if (!hasConversation || !persistenceEnabled()) {
54
+ return { sessionSaved: false };
55
+ }
56
+ const now = new Date().toISOString();
57
+ const session = {
58
+ id: options.sessionId,
59
+ title: sessionTitleFromHistory(options.history),
60
+ created: options.sessionCreated,
61
+ updated: now,
62
+ model: options.model,
63
+ cwd: options.cwd,
64
+ messages: options.messages,
65
+ };
66
+ await saveSession(session);
67
+ const brainPath = await getBrainPath();
68
+ if (!brainPath)
69
+ return { sessionSaved: true };
70
+ const transcript = transcriptFromTurns(options.history);
71
+ const summary = await summarizeSession(options.model, transcript, options.messages);
72
+ const title = sessionTitleFromHistory(options.history);
73
+ const slugSuffix = options.sessionId.slice(-6);
74
+ const today = now.slice(0, 10);
75
+ const output = `Sessions/${today}-${slugSuffix}-session.md`;
76
+ const report = await createBrainNote({
77
+ brainPath,
78
+ type: 'session',
79
+ title,
80
+ output,
81
+ force: true,
82
+ today,
83
+ });
84
+ if (!report.ok || !report.path)
85
+ return { sessionSaved: true };
86
+ const facts = distilledFactsFromMessages(options.messages);
87
+ const raw = await readFile(report.path, 'utf8');
88
+ const next = injectSessionSummary(raw, summary, facts.slice(0, 8));
89
+ await writeFile(report.path, next, 'utf8');
90
+ return {
91
+ sessionSaved: true,
92
+ brainNoteRel: report.relPath,
93
+ brainNotePath: report.path,
94
+ };
95
+ }
96
+ export function formatFinalizeMessage(result) {
97
+ if (!result.sessionSaved)
98
+ return undefined;
99
+ if (result.brainNoteRel) {
100
+ return `${BRAND.cliName}: session saved · second-brain → [[${result.brainNoteRel.replace(/\.md$/i, '')}]]`;
101
+ }
102
+ return `${BRAND.cliName}: session saved`;
103
+ }
@@ -0,0 +1,84 @@
1
+ // Session knowledge distiller — extracts DURABLE facts (decisions, gotchas, preferences,
2
+ // constraints) from a finished session transcript so they can be folded into the memory store
3
+ // WITHOUT the model voluntarily calling `remember`. Pure + deterministic (heuristic): the offline,
4
+ // zero-cost fallback. An LLM-based extractor can layer on top when a model is available.
5
+ // Signal patterns — a sentence is a candidate only if it matches one (keeps precision up).
6
+ const SIGNALS = [
7
+ { kind: 'decision', re: /\b(decided|we['’]?ll use|we will use|we use|going with|chose|switch(?:ed|ing)? to|standardiz(?:e|ed|ing) on|settled on|agreed to)\b/i },
8
+ { kind: 'preference', re: /\b(prefer(?:s|red)?|convention(?: is|:)|by convention|coding style|likes? to|always (?:use|run|prefer|name)|we name)\b/i },
9
+ { kind: 'constraint', re: /\b(must not|must|never|do ?n['’]?t|don['’]t|required to|is required|only (?:use|run|allow)|forbidden|not allowed|has to)\b/i },
10
+ { kind: 'gotcha', re: /\b(gotcha|caveat|watch out|the (?:bug|issue|problem|error) (?:was|is)|turned out|root cause|fix(?:ed)? (?:was|by|it by)|fails? (?:if|when|because)|breaks? (?:if|when)|broke because|because the|note that|important:|heads up)\b/i },
11
+ ];
12
+ // Strong "X not Y" / "X instead of Y" decision signal (e.g. "pnpm not npm", "tabs over spaces").
13
+ const X_NOT_Y = /\b[\w.@/+-]{2,}\s+(?:not|instead of|over|rather than)\s+[\w.@/+-]{2,}\b/i;
14
+ const MAX_CANDIDATES = 12;
15
+ const MIN_WORDS = 4;
16
+ const MAX_WORDS = 45;
17
+ function looksLikeCodeOrLog(s) {
18
+ if (/^\s*[$#>]/.test(s))
19
+ return true; // shell prompt / diff marker
20
+ if (/[{};=]\s*$/.test(s) && /[(){}\[\]=;]/.test(s))
21
+ return true; // code-ish line
22
+ if (/\b(at |Error:|Traceback|stack trace|node_modules\/)/.test(s) && /:\d+/.test(s))
23
+ return true; // stack trace
24
+ const symbolRatio = (s.replace(/[\w\s]/g, '').length || 0) / Math.max(1, s.length);
25
+ return symbolRatio > 0.3; // mostly punctuation/symbols
26
+ }
27
+ function splitSentences(text) {
28
+ return text
29
+ .split(/(?<=[.!?])\s+|\n+/)
30
+ .map((s) => s.trim())
31
+ .filter(Boolean);
32
+ }
33
+ function normalize(s) {
34
+ return s.replace(/\s+/g, ' ').replace(/^[-*•\d.)\s]+/, '').trim();
35
+ }
36
+ /**
37
+ * Extract durable-fact candidates from a transcript. Skips questions, chit-chat, code/log lines,
38
+ * and too-short/too-long sentences; requires a decision/gotcha/preference/constraint signal.
39
+ */
40
+ export function distillSession(messages) {
41
+ const out = [];
42
+ const seen = new Set();
43
+ for (const msg of messages) {
44
+ if (msg.role !== 'user' && msg.role !== 'assistant')
45
+ continue;
46
+ for (const raw of splitSentences(msg.text)) {
47
+ const s = normalize(raw);
48
+ const words = s.split(/\s+/).filter(Boolean);
49
+ if (words.length < MIN_WORDS || words.length > MAX_WORDS)
50
+ continue;
51
+ if (s.endsWith('?'))
52
+ continue; // questions aren't durable facts
53
+ if (looksLikeCodeOrLog(s))
54
+ continue;
55
+ const signal = SIGNALS.find((sig) => sig.re.test(s));
56
+ const kind = signal?.kind ?? (X_NOT_Y.test(s) ? 'decision' : undefined);
57
+ if (!kind)
58
+ continue;
59
+ const key = s.toLowerCase().replace(/[^a-z0-9 ]/g, '').trim();
60
+ if (!key || seen.has(key))
61
+ continue;
62
+ seen.add(key);
63
+ out.push({ text: s, kind });
64
+ if (out.length >= MAX_CANDIDATES)
65
+ return out;
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ /** flatten an AI-SDK ModelMessage content (string | parts[]) to its plain text. */
71
+ function messageText(content) {
72
+ if (typeof content === 'string')
73
+ return content;
74
+ if (!Array.isArray(content))
75
+ return '';
76
+ return content
77
+ .map((p) => (p && typeof p === 'object' && 'text' in p && typeof p.text === 'string' ? p.text : ''))
78
+ .join(' ')
79
+ .trim();
80
+ }
81
+ /** distill durable-fact texts from a finished conversation (ModelMessage[]-shaped). Pure. */
82
+ export function distilledFactsFromMessages(messages) {
83
+ return distillSession(messages.map((m) => ({ role: m.role, text: messageText(m.content) }))).map((c) => c.text);
84
+ }
package/dist/session.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { chmod, readFile, writeFile, mkdir, readdir, realpath, rm } from 'node:fs/promises';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { appHomePath, persistenceEnabled } from './brand.js';
4
- import { redactKey } from './providers/keys.js';
4
+ import { redactKey, redactUnknown } from './providers/keys.js';
5
5
  // session store — จำ conversation + ความคืบหน้า เพื่อ "ทำงานต่อได้" ไม่ลืมว่าทำถึงไหน
6
6
  const SESSION_DIR = appHomePath('sessions');
7
7
  function isRecord(value) {
@@ -45,16 +45,6 @@ function sessionFilePath(id) {
45
45
  }
46
46
  return join(SESSION_DIR, `${id}.json`);
47
47
  }
48
- function redactUnknown(value) {
49
- if (typeof value === 'string')
50
- return redactKey(value);
51
- if (Array.isArray(value))
52
- return value.map(redactUnknown);
53
- if (value && typeof value === 'object') {
54
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactUnknown(v)]));
55
- }
56
- return value;
57
- }
58
48
  function sanitizeSession(s) {
59
49
  return {
60
50
  ...s,
@@ -6,7 +6,7 @@ import { promisify } from 'node:util';
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { lookup } from 'node:dns/promises';
8
8
  import { isIP } from 'node:net';
9
- import { parseFrontmatter, isValidSkillName } from './skills.js';
9
+ import { parseFrontmatter, isValidSkillName, bundledSkillsDir, listBundledSkills } from './skills.js';
10
10
  import { appHomePath, BRAND } from './brand.js';
11
11
  const execFileAsync = promisify(execFile);
12
12
  const USER_SKILLS = appHomePath('skills');
@@ -172,6 +172,29 @@ async function fetchSkillMd(url) {
172
172
  throw new Error('SKILL.md ใหญ่เกิน 2MB');
173
173
  return text;
174
174
  }
175
+ function bundledCatalogHint(name) {
176
+ const sample = ['git-commit-pr', 'write-tests', 'debug-root-cause'];
177
+ return `ไม่เจอ bundled skill "${name}" — ลอง ${sample.join(', ')} หรือ ${BRAND.cliName} skill list`;
178
+ }
179
+ /**
180
+ * ติดตั้ง skill จาก bundled catalog (ชื่อ slug) · local path · URL · GitHub
181
+ * ⚠️ skill = instruction ที่ agent จะ trust (ไม่ใช่ data) — ติดตั้งจาก source ที่เชื่อถือเท่านั้น
182
+ */
183
+ export async function installNamedSkill(nameOrSource, onLog) {
184
+ if (await exists(nameOrSource))
185
+ return installFromLocal(nameOrSource, onLog);
186
+ if (isValidSkillName(nameOrSource)) {
187
+ const bundled = join(bundledSkillsDir(), nameOrSource);
188
+ if (await exists(join(bundled, 'SKILL.md')))
189
+ return [await installFromDir(bundled)];
190
+ const catalog = await listBundledSkills();
191
+ const match = catalog.find((skill) => skill.name === nameOrSource);
192
+ if (match)
193
+ return [await installFromDir(dirname(dirname(match.path)))];
194
+ throw new Error(bundledCatalogHint(nameOrSource));
195
+ }
196
+ return installSkill(nameOrSource, onLog);
197
+ }
175
198
  /**
176
199
  * ติดตั้ง skill จาก source — local path · URL ของ SKILL.md (https) · GitHub ("user/repo" หรือ "user/repo/sub/path")
177
200
  * ⚠️ skill = instruction ที่ agent จะ trust (ไม่ใช่ data) — ติดตั้งจาก source ที่เชื่อถือเท่านั้น
package/dist/skills.js CHANGED
@@ -9,6 +9,9 @@ import { projectConfigPathIfTrusted } from './trust.js';
9
9
  // 3 ชั้น: bundled (ship กับ CLI) → global (~/.sanook) → project (.sanook) — ชั้นหลัง override ชื่อซ้ำ
10
10
  const BUNDLED_SKILLS = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
11
11
  const GLOBAL_SKILLS = appHomePath('skills');
12
+ export function bundledSkillsDir() {
13
+ return BUNDLED_SKILLS;
14
+ }
12
15
  /** minimal frontmatter parser (key: value ใน --- block) — ไม่พึ่ง YAML dep */
13
16
  export function parseFrontmatter(content) {
14
17
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
@@ -33,6 +36,36 @@ export function parseFrontmatter(content) {
33
36
  export function isValidSkillName(name) {
34
37
  return /^[a-z0-9][a-z0-9-]{0,63}$/.test(name);
35
38
  }
39
+ /** list bundled skills only (sanook skill install <name> catalog) */
40
+ export async function listBundledSkills() {
41
+ const out = [];
42
+ let entries;
43
+ try {
44
+ entries = await readdir(BUNDLED_SKILLS, { withFileTypes: true });
45
+ }
46
+ catch {
47
+ return out;
48
+ }
49
+ for (const e of entries) {
50
+ if (!e.isDirectory() || !isValidSkillName(e.name))
51
+ continue;
52
+ const p = join(BUNDLED_SKILLS, e.name, 'SKILL.md');
53
+ try {
54
+ const { meta } = parseFrontmatter(await readFile(p, 'utf8'));
55
+ const name = meta.name && isValidSkillName(meta.name) ? meta.name : e.name;
56
+ out.push({
57
+ name,
58
+ description: meta.description ?? '',
59
+ whenToUse: meta.when_to_use,
60
+ path: p,
61
+ });
62
+ }
63
+ catch {
64
+ /* skip invalid entries */
65
+ }
66
+ }
67
+ return out.sort((a, b) => a.name.localeCompare(b.name));
68
+ }
36
69
  /** scan project + global skills → list (name+description เท่านั้น สำหรับ inject). project ทับ global ชื่อซ้ำ */
37
70
  export async function loadSkills(cwd = process.cwd()) {
38
71
  const out = new Map();