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.
- package/CHANGELOG.md +112 -2
- package/README.md +15 -3
- package/README.th.md +8 -1
- package/dist/approval.js +7 -0
- package/dist/bin.js +637 -56
- package/dist/brain-consolidate.js +335 -0
- package/dist/brain-context.js +42 -3
- package/dist/brain-final.js +15 -9
- package/dist/brain-link.js +73 -0
- package/dist/brain-metrics.js +277 -0
- package/dist/brain-new.js +402 -0
- package/dist/brain-pack.js +210 -0
- package/dist/brain-repair.js +280 -0
- package/dist/brain.js +3 -0
- package/dist/brand.js +4 -0
- package/dist/cli-args.js +47 -9
- package/dist/cli-option-values.js +1 -1
- package/dist/clipboard.js +65 -0
- package/dist/commands.js +98 -15
- package/dist/config.js +66 -34
- package/dist/context-pack.js +145 -0
- package/dist/cost.js +20 -0
- package/dist/dashboard/api-helpers.js +87 -0
- package/dist/dashboard/server.js +179 -0
- package/dist/dashboard/static/app.js +277 -0
- package/dist/dashboard/static/index.html +39 -0
- package/dist/dashboard/static/styles.css +85 -0
- package/dist/diff.js +10 -2
- package/dist/gateway/auth.js +14 -3
- package/dist/gateway/deliver.js +45 -3
- package/dist/gateway/doctor.js +456 -0
- package/dist/gateway/email.js +30 -1
- package/dist/gateway/ledger.js +20 -1
- package/dist/gateway/session.js +34 -11
- package/dist/hotkeys.js +21 -0
- package/dist/i18n/en.js +98 -0
- package/dist/i18n/index.js +19 -0
- package/dist/i18n/th.js +98 -0
- package/dist/i18n/types.js +1 -0
- package/dist/insights-args.js +24 -4
- package/dist/knowledge.js +55 -29
- package/dist/loop.js +65 -9
- package/dist/mcp-hub.js +33 -0
- package/dist/mcp-registry.js +153 -9
- package/dist/mcp-risk.js +71 -0
- package/dist/mcp.js +77 -5
- package/dist/memory-log.js +90 -0
- package/dist/memory-store.js +37 -1
- package/dist/memory.js +51 -7
- package/dist/model-picker.js +58 -0
- package/dist/orchestrate.js +7 -5
- package/dist/plan-handoff.js +17 -0
- package/dist/polyglot.js +162 -0
- package/dist/process-runner.js +96 -0
- package/dist/project-init.js +91 -0
- package/dist/project-registry.js +143 -0
- package/dist/project-scaffold.js +124 -0
- package/dist/prompt-size.js +155 -0
- package/dist/providers/codex-login.js +138 -0
- package/dist/providers/codex.js +20 -8
- package/dist/providers/keys.js +21 -0
- package/dist/providers/models.js +1 -1
- package/dist/providers/registry.js +11 -1
- package/dist/search/cli.js +9 -1
- package/dist/search/embedding-config.js +22 -0
- package/dist/search/engine.js +2 -13
- package/dist/search/indexer.js +10 -10
- package/dist/session-brain.js +103 -0
- package/dist/session-distill.js +84 -0
- package/dist/session.js +1 -11
- package/dist/skill-install.js +24 -1
- package/dist/skills.js +33 -0
- package/dist/slash-completion.js +155 -0
- package/dist/support-dump.js +31 -0
- package/dist/tool-catalog.js +59 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/permission.js +82 -16
- package/dist/tools/polyglot.js +126 -0
- package/dist/tools/sandbox.js +38 -13
- package/dist/tools/search.js +9 -2
- package/dist/tools/task.js +22 -2
- package/dist/tools/timeout.js +7 -5
- package/dist/tools/web-fetch-tool.js +33 -0
- package/dist/turn-retrieval.js +83 -0
- package/dist/ui/app.js +874 -35
- package/dist/ui/banner.js +78 -4
- package/dist/ui/markdown.js +122 -0
- package/dist/ui/overlay.js +496 -0
- package/dist/ui/queue.js +23 -0
- package/dist/ui/render.js +30 -2
- package/dist/ui/session-panel.js +115 -0
- package/dist/ui/setup-providers.js +40 -0
- package/dist/ui/setup.js +163 -50
- package/dist/ui/status.js +142 -0
- package/dist/ui/thinking-panel.js +36 -0
- package/dist/ui/tool-trail.js +97 -0
- package/dist/ui/transcript.js +26 -0
- package/dist/ui/useBusyElapsed.js +19 -0
- package/dist/ui/useEditor.js +144 -5
- package/dist/ui/useGitBranch.js +57 -0
- package/dist/update.js +32 -6
- package/dist/usage-cli.js +160 -0
- package/dist/usage-ledger.js +169 -0
- package/dist/web-fetch.js +637 -0
- package/dist/web-surface.js +190 -0
- package/package.json +4 -3
- package/scripts/postinstall.mjs +4 -4
- package/second-brain/Projects/_Index.md +17 -4
- package/second-brain/Projects/sanook-cli/_Index.md +7 -3
- package/second-brain/Projects/sanook-cli/context.md +35 -0
- package/second-brain/Projects/sanook-cli/current-state.md +32 -0
- package/second-brain/Projects/sanook-cli/overview.md +41 -0
- package/second-brain/Projects/sanook-cli/repo.md +34 -0
- package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +52 -11
- package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
- package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
- package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
- package/second-brain/Research/_Index.md +2 -0
- package/second-brain/Shared/Operating-State/current-state.md +14 -23
- package/second-brain/Shared/Tech-Standards/_Index.md +2 -0
- package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
- package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
- package/second-brain/Templates/project-workspace/_Index.md +31 -0
- package/second-brain/Templates/project-workspace/context.md +28 -0
- package/second-brain/Templates/project-workspace/current-state.md +29 -0
- package/second-brain/Templates/project-workspace/overview.md +39 -0
- package/second-brain/Templates/project-workspace/repo.md +33 -0
package/dist/providers/codex.js
CHANGED
|
@@ -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 {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
return { installed: true, loggedIn:
|
|
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
|
package/dist/providers/keys.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/providers/models.js
CHANGED
|
@@ -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`]
|
|
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: {
|
|
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
|
},
|
package/dist/search/cli.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/search/engine.js
CHANGED
|
@@ -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
|
|
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);
|
package/dist/search/indexer.js
CHANGED
|
@@ -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(
|
|
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,
|
package/dist/skill-install.js
CHANGED
|
@@ -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();
|