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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// `sanook memory log` — a read-only viewer over the BI-TEMPORAL memory store: see how a belief about
|
|
2
|
+
// the project evolved over time (what was true, when it was superseded, and by what). The store keeps
|
|
3
|
+
// superseded/archived facts with validFrom/invalidatedAt/supersededBy/supersedes edges — most coding
|
|
4
|
+
// CLIs overwrite memory, so this "decision evolution" view is genuinely differentiated. Pure +
|
|
5
|
+
// deterministic (no disk/clock of its own) → fully testable.
|
|
6
|
+
import { tokens } from './memory-store.js';
|
|
7
|
+
function relevance(query, fact) {
|
|
8
|
+
const q = [...tokens(query)];
|
|
9
|
+
if (!q.length)
|
|
10
|
+
return 0;
|
|
11
|
+
const ft = [...tokens(fact.text)];
|
|
12
|
+
// forgiving match for a human-facing viewer: exact OR a shared prefix (deploy↔deploys↔deployment)
|
|
13
|
+
const matches = (t) => ft.some((w) => w === t || (t.length >= 3 && (w.startsWith(t) || t.startsWith(w))));
|
|
14
|
+
let overlap = 0;
|
|
15
|
+
for (const t of q)
|
|
16
|
+
if (matches(t))
|
|
17
|
+
overlap++;
|
|
18
|
+
return overlap;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Facts matching `query` across ALL statuses (active + superseded + archived), each with its
|
|
22
|
+
* evolution edges resolved. Empty query → the most recently CHANGED facts (superseded/archived first)
|
|
23
|
+
* so `sanook memory log` with no args surfaces "what beliefs changed recently".
|
|
24
|
+
*/
|
|
25
|
+
export function memoryLog(store, query = '', limit = 12) {
|
|
26
|
+
const byId = new Map(store.facts.map((f) => [f.id, f]));
|
|
27
|
+
const q = query.trim();
|
|
28
|
+
const ranked = q
|
|
29
|
+
? store.facts
|
|
30
|
+
.map((f) => ({ f, score: relevance(q, f) }))
|
|
31
|
+
.filter((x) => x.score > 0)
|
|
32
|
+
.sort((a, b) => b.score - a.score || b.f.updated - a.f.updated)
|
|
33
|
+
.map((x) => x.f)
|
|
34
|
+
: [...store.facts]
|
|
35
|
+
.filter((f) => f.status !== 'active') // no query → highlight what CHANGED
|
|
36
|
+
.sort((a, b) => (b.invalidatedAt ?? b.updated) - (a.invalidatedAt ?? a.updated));
|
|
37
|
+
return ranked.slice(0, limit).map((f) => ({
|
|
38
|
+
fact: f,
|
|
39
|
+
supersededBy: f.supersededBy ? byId.get(f.supersededBy) : undefined,
|
|
40
|
+
supersedes: f.supersedes.map((id) => byId.get(id)).filter((x) => !!x),
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
export function memoryStats(store) {
|
|
44
|
+
const byTier = {};
|
|
45
|
+
let active = 0, superseded = 0, archived = 0;
|
|
46
|
+
for (const f of store.facts) {
|
|
47
|
+
byTier[f.tier] = (byTier[f.tier] ?? 0) + 1;
|
|
48
|
+
if (f.status === 'active')
|
|
49
|
+
active++;
|
|
50
|
+
else if (f.status === 'superseded')
|
|
51
|
+
superseded++;
|
|
52
|
+
else if (f.status === 'archived')
|
|
53
|
+
archived++;
|
|
54
|
+
}
|
|
55
|
+
return { total: store.facts.length, active, superseded, archived, byTier };
|
|
56
|
+
}
|
|
57
|
+
function day(ms) {
|
|
58
|
+
try {
|
|
59
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return '?';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const BADGE = { active: '● active', superseded: '↻ superseded', archived: '⌁ archived' };
|
|
66
|
+
export function renderMemoryLog(entries, query = '') {
|
|
67
|
+
if (!entries.length) {
|
|
68
|
+
return query ? `ไม่เจอ fact ที่ตรงกับ "${query}" ใน memory (รวม superseded/archived)` : 'ยังไม่มี belief ที่เปลี่ยน (superseded/archived) — memory ยังนิ่ง';
|
|
69
|
+
}
|
|
70
|
+
const lines = [query ? `memory log — "${query}" (${entries.length})` : `memory log — recent changes (${entries.length})`];
|
|
71
|
+
for (const e of entries) {
|
|
72
|
+
const f = e.fact;
|
|
73
|
+
const when = f.invalidatedAt ? `${day(f.validFrom)} → ${day(f.invalidatedAt)}` : `since ${day(f.validFrom)}`;
|
|
74
|
+
lines.push('', `${BADGE[f.status] ?? f.status} [${f.noteType}/${f.tier}] ${when}`);
|
|
75
|
+
lines.push(` ${f.text}`);
|
|
76
|
+
if (e.supersededBy)
|
|
77
|
+
lines.push(` ↳ superseded by: ${e.supersededBy.text} (${day(e.supersededBy.validFrom)})`);
|
|
78
|
+
for (const s of e.supersedes)
|
|
79
|
+
lines.push(` ↳ supersedes: ${s.text}`);
|
|
80
|
+
}
|
|
81
|
+
return lines.join('\n');
|
|
82
|
+
}
|
|
83
|
+
export function renderMemoryStats(s) {
|
|
84
|
+
const tiers = Object.entries(s.byTier).map(([t, n]) => `${t}:${n}`).join(' · ') || '(none)';
|
|
85
|
+
return [
|
|
86
|
+
`memory: ${s.total} fact(s)`,
|
|
87
|
+
` ● active ${s.active} · ↻ superseded ${s.superseded} · ⌁ archived ${s.archived}`,
|
|
88
|
+
` tiers: ${tiers}`,
|
|
89
|
+
].join('\n');
|
|
90
|
+
}
|
package/dist/memory-store.js
CHANGED
|
@@ -503,6 +503,8 @@ export async function loadStore(now = Date.now()) {
|
|
|
503
503
|
const parsed = StoreSchema.safeParse(JSON.parse(await readFile(MEMORY_JSON, 'utf8')));
|
|
504
504
|
if (parsed.success)
|
|
505
505
|
return parsed.data;
|
|
506
|
+
// parseable but schema-invalid (version bump / partial write): DON'T lose it — saveStore
|
|
507
|
+
// preserves the original to a .corrupt backup before the next overwrite (loadStore stays pure).
|
|
506
508
|
}
|
|
507
509
|
catch {
|
|
508
510
|
/* no json yet, or malformed → fall through */
|
|
@@ -536,12 +538,46 @@ async function writeSecure(path, content) {
|
|
|
536
538
|
* Both files are 0o600. On the very first json write, the legacy MEMORY.md is backed up
|
|
537
539
|
* to MEMORY.md.bak so raw legacy text is never destroyed. No-op when persistence is disabled.
|
|
538
540
|
*/
|
|
541
|
+
/**
|
|
542
|
+
* If an existing memory.json cannot be validated (schema bump / corruption / partial write),
|
|
543
|
+
* copy it verbatim to memory.json.<ts>.corrupt before it gets overwritten — so a single schema
|
|
544
|
+
* mismatch never silently destroys the entire auto-memory. Best-effort, idempotent per `now`.
|
|
545
|
+
*/
|
|
546
|
+
async function preserveUnvalidatableStore(now) {
|
|
547
|
+
let raw;
|
|
548
|
+
try {
|
|
549
|
+
raw = await readFile(MEMORY_JSON, 'utf8');
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
if (StoreSchema.safeParse(JSON.parse(raw)).success)
|
|
556
|
+
return; // valid → nothing to rescue
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
/* unparseable → preserve below */
|
|
560
|
+
}
|
|
561
|
+
const backup = `${MEMORY_JSON}.${now}.corrupt`;
|
|
562
|
+
if (await exists(backup))
|
|
563
|
+
return;
|
|
564
|
+
try {
|
|
565
|
+
await writeFile(backup, raw, { mode: 0o600 });
|
|
566
|
+
await chmod(backup, 0o600).catch(() => { });
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
/* best-effort */
|
|
570
|
+
}
|
|
571
|
+
}
|
|
539
572
|
export async function saveStore(store, now = Date.now()) {
|
|
540
573
|
if (!persistenceEnabled())
|
|
541
574
|
return;
|
|
542
575
|
await mkdir(MEMORY_DIR, { recursive: true });
|
|
543
576
|
const firstJson = !(await exists(MEMORY_JSON));
|
|
544
|
-
if (firstJson
|
|
577
|
+
if (!firstJson) {
|
|
578
|
+
await preserveUnvalidatableStore(now); // data-loss guard before overwriting an unvalidatable store
|
|
579
|
+
}
|
|
580
|
+
else if (await exists(AUTO_MEMORY_FILE)) {
|
|
545
581
|
await copyFile(AUTO_MEMORY_FILE, MEMORY_BAK).catch(() => { });
|
|
546
582
|
await chmod(MEMORY_BAK, 0o600).catch(() => { });
|
|
547
583
|
}
|
package/dist/memory.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { readFile, writeFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { join, dirname, resolve } from 'node:path';
|
|
3
|
+
import { buildContextPackBlock, listContextPacks, readContextPackExcerpt, selectContextPack } from './context-pack.js';
|
|
4
|
+
import { buildProjectContextBlock, resolveVaultProject } from './project-registry.js';
|
|
3
5
|
import { appHomePath, BRAND, persistenceEnabled, worklogEnabled } from './brand.js';
|
|
4
6
|
import { redactKey } from './providers/keys.js';
|
|
5
7
|
import { loadStore, saveStore, mergeFact, maybeConsolidate, consolidate, renderPromptBlock } from './memory-store.js';
|
|
@@ -77,12 +79,12 @@ export async function loadAutoMemory() {
|
|
|
77
79
|
* "รู้จัก" vault: inject Shared/AI-Context-Index.md (ไฟล์ที่ vault บอกให้อ่านก่อน) เข้า system prompt
|
|
78
80
|
* brainPath มาจาก ~/.sanook/config.json · ไม่มี/ไฟล์หาย → คืน '' (เงียบ)
|
|
79
81
|
*/
|
|
80
|
-
export async function loadBrainContext() {
|
|
82
|
+
export async function loadBrainContext(cwd = process.cwd()) {
|
|
81
83
|
const brainPath = await getBrainPath();
|
|
82
|
-
return brainPath ? buildBrainContext(brainPath) : '';
|
|
84
|
+
return brainPath ? buildBrainContext(brainPath, { cwd }) : '';
|
|
83
85
|
}
|
|
84
86
|
/** ประกอบ source parts ชุดเดียวกับที่ inject เข้า prompt จริง — ให้ CLI inspect ได้โดยไม่ drift */
|
|
85
|
-
export async function buildBrainContextParts(brainPath) {
|
|
87
|
+
export async function buildBrainContextParts(brainPath, options = {}) {
|
|
86
88
|
const idx = await readTrimmedPart({
|
|
87
89
|
id: 'ai-context-index',
|
|
88
90
|
label: 'AI Context Index',
|
|
@@ -99,7 +101,47 @@ export async function buildBrainContextParts(brainPath) {
|
|
|
99
101
|
wrap: (content) => `## current-state\n${content}`,
|
|
100
102
|
});
|
|
101
103
|
const inbox = await readInboxPart(brainPath, 'Shared/Memory-Inbox/memory-inbox.md', 1200);
|
|
102
|
-
|
|
104
|
+
const parts = [idx, currentState, inbox];
|
|
105
|
+
const project = await resolveVaultProject({
|
|
106
|
+
brainPath,
|
|
107
|
+
cwd: options.cwd,
|
|
108
|
+
slug: options.projectSlug,
|
|
109
|
+
});
|
|
110
|
+
if (project) {
|
|
111
|
+
const block = await buildProjectContextBlock(brainPath, project);
|
|
112
|
+
parts.push({
|
|
113
|
+
id: 'project-workspace',
|
|
114
|
+
label: `Project (${project.slug})`,
|
|
115
|
+
relPath: `${project.relDir}/`,
|
|
116
|
+
path: join(brainPath, project.relDir),
|
|
117
|
+
content: block,
|
|
118
|
+
chars: block.length,
|
|
119
|
+
maxChars: 3500,
|
|
120
|
+
status: block ? 'present' : 'empty',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const taskQuery = options.taskQuery?.trim();
|
|
124
|
+
if (taskQuery) {
|
|
125
|
+
const packs = await listContextPacks(brainPath);
|
|
126
|
+
const selected = selectContextPack(taskQuery, packs);
|
|
127
|
+
if (selected) {
|
|
128
|
+
const relPath = selected.pack.relPath;
|
|
129
|
+
const path = join(brainPath, relPath);
|
|
130
|
+
const maxChars = 1200;
|
|
131
|
+
const excerpt = await readContextPackExcerpt(brainPath, selected.pack, maxChars);
|
|
132
|
+
parts.push({
|
|
133
|
+
id: 'context-pack',
|
|
134
|
+
label: `Context Pack (${selected.pack.slug})`,
|
|
135
|
+
relPath,
|
|
136
|
+
path,
|
|
137
|
+
content: excerpt,
|
|
138
|
+
chars: excerpt.length,
|
|
139
|
+
maxChars,
|
|
140
|
+
status: excerpt ? 'present' : 'empty',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return parts;
|
|
103
145
|
}
|
|
104
146
|
export function renderBrainContext(brainPath, parts) {
|
|
105
147
|
const content = parts.map((part) => part.content).filter(Boolean);
|
|
@@ -107,10 +149,12 @@ export function renderBrainContext(brainPath, parts) {
|
|
|
107
149
|
return '';
|
|
108
150
|
return `<brain_vault path="${brainPath}" note="second-brain ของ user — สิ่งที่จำไว้/state ปัจจุบันอยู่ใน block นี้; route โน้ตตาม Vault Structure Map; อ่าน/เขียนไฟล์ใน vault ด้วย absolute path ได้">\n${content.join('\n\n')}\n</brain_vault>`;
|
|
109
151
|
}
|
|
110
|
-
/** ประกอบ brain context จาก vault path (pure → testable) — entry + current-state + remembered facts */
|
|
111
|
-
export async function buildBrainContext(brainPath) {
|
|
112
|
-
return renderBrainContext(brainPath, await buildBrainContextParts(brainPath));
|
|
152
|
+
/** ประกอบ brain context จาก vault path (pure → testable) — entry + current-state + remembered facts + optional context pack */
|
|
153
|
+
export async function buildBrainContext(brainPath, options = {}) {
|
|
154
|
+
return renderBrainContext(brainPath, await buildBrainContextParts(brainPath, options));
|
|
113
155
|
}
|
|
156
|
+
/** Build a standalone context-pack block for per-turn injection (turn-retrieval path). */
|
|
157
|
+
export { buildContextPackBlock };
|
|
114
158
|
async function readTrimmedPart(input) {
|
|
115
159
|
const p = join(input.brainPath, input.relPath);
|
|
116
160
|
try {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { canonicalSpec, hasUsableEnvKey, PROVIDERS, parseSpec } from './providers/registry.js';
|
|
2
|
+
function statusFor(provider) {
|
|
3
|
+
const cfg = PROVIDERS[provider];
|
|
4
|
+
if (cfg.kind === 'delegate')
|
|
5
|
+
return 'delegate';
|
|
6
|
+
if (!cfg.requiresKey)
|
|
7
|
+
return 'local';
|
|
8
|
+
return hasUsableEnvKey(provider) ? 'ready' : 'needs-key';
|
|
9
|
+
}
|
|
10
|
+
function statusLabel(status) {
|
|
11
|
+
if (status === 'needs-key')
|
|
12
|
+
return 'needs key';
|
|
13
|
+
return status;
|
|
14
|
+
}
|
|
15
|
+
export function modelPickerOptions(current) {
|
|
16
|
+
const currentSpec = canonicalSpec(current);
|
|
17
|
+
return Object.entries(PROVIDERS).flatMap(([provider, cfg]) => {
|
|
18
|
+
const grouped = new Map();
|
|
19
|
+
for (const [alias, model] of Object.entries(cfg.models)) {
|
|
20
|
+
const aliases = grouped.get(model) ?? [];
|
|
21
|
+
aliases.push(alias);
|
|
22
|
+
grouped.set(model, aliases);
|
|
23
|
+
}
|
|
24
|
+
const status = statusFor(provider);
|
|
25
|
+
return [...grouped.entries()].map(([model, aliases]) => {
|
|
26
|
+
const nonDefaultAliases = aliases.filter((alias) => alias !== 'default');
|
|
27
|
+
const displayAliases = nonDefaultAliases.length ? nonDefaultAliases.join('/') : 'default';
|
|
28
|
+
const spec = `${provider}:${model}`;
|
|
29
|
+
return {
|
|
30
|
+
aliases: displayAliases,
|
|
31
|
+
current: spec === currentSpec,
|
|
32
|
+
label: `${provider}:${displayAliases}`,
|
|
33
|
+
meta: `${cfg.label} · ${statusLabel(status)}`,
|
|
34
|
+
model,
|
|
35
|
+
provider,
|
|
36
|
+
spec,
|
|
37
|
+
status,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export function initialModelPickerIndex(options) {
|
|
43
|
+
const current = options.findIndex((option) => option.current);
|
|
44
|
+
return current === -1 ? 0 : current;
|
|
45
|
+
}
|
|
46
|
+
export function modelProviderEntries() {
|
|
47
|
+
return Object.entries(PROVIDERS).map(([id, cfg]) => ({
|
|
48
|
+
id,
|
|
49
|
+
label: cfg.label,
|
|
50
|
+
status: statusFor(id),
|
|
51
|
+
modelCount: new Set(Object.values(cfg.models)).size,
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
export function filterModelPickerOptions(options, providerId) {
|
|
55
|
+
if (!providerId)
|
|
56
|
+
return options;
|
|
57
|
+
return options.filter((option) => option.provider === providerId);
|
|
58
|
+
}
|
package/dist/orchestrate.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
import { inspect } from 'node:util';
|
|
2
|
+
import { redactKey, redactUnknown } from './providers/keys.js';
|
|
2
3
|
export function formatSubagentError(e) {
|
|
3
4
|
if (e instanceof Error)
|
|
4
|
-
return e.message || e.name;
|
|
5
|
+
return redactKey(e.message || e.name);
|
|
5
6
|
if (typeof e === 'string')
|
|
6
|
-
return e;
|
|
7
|
+
return redactKey(e);
|
|
7
8
|
if (e == null)
|
|
8
9
|
return String(e);
|
|
10
|
+
const safe = redactUnknown(e);
|
|
9
11
|
try {
|
|
10
|
-
const json = JSON.stringify(
|
|
12
|
+
const json = JSON.stringify(safe);
|
|
11
13
|
if (json)
|
|
12
14
|
return json;
|
|
13
15
|
}
|
|
14
16
|
catch {
|
|
15
|
-
return inspect(
|
|
17
|
+
return inspect(safe, { breakLength: Infinity, depth: 2 });
|
|
16
18
|
}
|
|
17
|
-
return String(e);
|
|
19
|
+
return redactKey(String(e));
|
|
18
20
|
}
|
|
19
21
|
const DEFAULT_CONCURRENCY = 5;
|
|
20
22
|
const DEFAULT_GLOBAL_SUBAGENT_CONCURRENCY = 16;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { BRAND } from './brand.js';
|
|
2
|
+
/** Shell-safe double-quoted string for handoff hints (task may contain quotes/newlines). */
|
|
3
|
+
export function shellQuoteDouble(value) {
|
|
4
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n')}"`;
|
|
5
|
+
}
|
|
6
|
+
/** Hint printed after plan mode completes — stderr so stdout stays pipe-friendly. */
|
|
7
|
+
export function formatPlanExecuteHandoff(originalTask) {
|
|
8
|
+
const task = originalTask.trim();
|
|
9
|
+
const quoted = task ? shellQuoteDouble(task) : '<task>';
|
|
10
|
+
return [
|
|
11
|
+
'---',
|
|
12
|
+
'Plan complete. Execute with:',
|
|
13
|
+
` ${BRAND.cliName} --yes ${quoted}`,
|
|
14
|
+
` ${BRAND.cliName} plan ${quoted} | ${BRAND.cliName} --yes ${shellQuoteDouble('Execute this plan:')}`,
|
|
15
|
+
` (plan text on stdout → pipe into ${BRAND.cliName} --yes "Execute this plan:" with stdin)`,
|
|
16
|
+
].join('\n');
|
|
17
|
+
}
|
package/dist/polyglot.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { BRAND } from './brand.js';
|
|
4
|
+
import { findBinary } from './lsp/servers.js';
|
|
5
|
+
import { safeProcessEnv } from './process-runner.js';
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const MAX_VERSION_TEXT = 160;
|
|
8
|
+
const RUNTIME_SPECS = [
|
|
9
|
+
{
|
|
10
|
+
id: 'python',
|
|
11
|
+
label: 'Python',
|
|
12
|
+
candidates: ['python3', 'python'],
|
|
13
|
+
versionArgs: ['--version'],
|
|
14
|
+
role: 'data/doc/ML glue, JSON/CSV transforms, OCR/transcription helpers, one-off research scripts via run_python',
|
|
15
|
+
install: 'Install Python 3.11+ (python.org, Homebrew, pyenv, or uv).',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'uv',
|
|
19
|
+
label: 'uv',
|
|
20
|
+
candidates: ['uv'],
|
|
21
|
+
versionArgs: ['--version'],
|
|
22
|
+
role: 'fast Python project/env management when Sanook grows optional Python packs',
|
|
23
|
+
install: 'Install uv: https://docs.astral.sh/uv/',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'rustc',
|
|
27
|
+
label: 'Rust compiler',
|
|
28
|
+
candidates: ['rustc'],
|
|
29
|
+
versionArgs: ['--version'],
|
|
30
|
+
role: 'compile small high-speed/safe helpers and future native accelerators via run_rust',
|
|
31
|
+
install: 'Install Rust via rustup: https://rustup.rs/',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'cargo',
|
|
35
|
+
label: 'Cargo',
|
|
36
|
+
candidates: ['cargo'],
|
|
37
|
+
versionArgs: ['--version'],
|
|
38
|
+
role: 'build/test packaged Rust helpers when a native crate becomes worth shipping',
|
|
39
|
+
install: 'Install Rust via rustup: https://rustup.rs/',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'pyright',
|
|
43
|
+
label: 'Pyright LSP',
|
|
44
|
+
candidates: ['pyright-langserver'],
|
|
45
|
+
versionArgs: ['--version'],
|
|
46
|
+
role: 'Python diagnostics through Sanook diagnostics tool',
|
|
47
|
+
install: 'npm i -g pyright',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'rust-analyzer',
|
|
51
|
+
label: 'rust-analyzer LSP',
|
|
52
|
+
candidates: ['rust-analyzer'],
|
|
53
|
+
versionArgs: ['--version'],
|
|
54
|
+
role: 'Rust diagnostics through Sanook diagnostics tool',
|
|
55
|
+
install: 'rustup component add rust-analyzer',
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
async function defaultVersion(command, args, cwd) {
|
|
59
|
+
const { stdout, stderr } = await execFileAsync(command, args, { cwd, env: safeProcessEnv(), timeout: 5_000, maxBuffer: 256 * 1024 });
|
|
60
|
+
return stdout.trim() ? stdout : stderr;
|
|
61
|
+
}
|
|
62
|
+
function normalizeVersionText(version) {
|
|
63
|
+
const firstLine = version.trim().split(/\r?\n/)[0]?.trim() || '(version unavailable)';
|
|
64
|
+
if (firstLine.length <= MAX_VERSION_TEXT)
|
|
65
|
+
return firstLine;
|
|
66
|
+
return `${firstLine.slice(0, MAX_VERSION_TEXT - '... [truncated]'.length)}... [truncated]`;
|
|
67
|
+
}
|
|
68
|
+
async function detectSpec(spec, cwd, findBinaryImpl, versionImpl) {
|
|
69
|
+
for (const candidate of spec.candidates) {
|
|
70
|
+
const command = await findBinaryImpl(candidate, cwd);
|
|
71
|
+
if (!command)
|
|
72
|
+
continue;
|
|
73
|
+
let version;
|
|
74
|
+
try {
|
|
75
|
+
version = normalizeVersionText(await versionImpl(command, [...spec.versionArgs], cwd));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
version = '(installed; version probe failed)';
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
id: spec.id,
|
|
82
|
+
label: spec.label,
|
|
83
|
+
status: 'ready',
|
|
84
|
+
command,
|
|
85
|
+
version,
|
|
86
|
+
role: spec.role,
|
|
87
|
+
install: spec.install,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
id: spec.id,
|
|
92
|
+
label: spec.label,
|
|
93
|
+
status: 'missing',
|
|
94
|
+
role: spec.role,
|
|
95
|
+
install: spec.install,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export async function inspectPolyglotRuntimes(options = {}) {
|
|
99
|
+
const cwd = options.cwd ?? process.cwd();
|
|
100
|
+
const findBinaryImpl = options.findBinaryImpl ?? findBinary;
|
|
101
|
+
const versionImpl = options.versionImpl ?? defaultVersion;
|
|
102
|
+
const optional = await Promise.all(RUNTIME_SPECS.map((spec) => detectSpec(spec, cwd, findBinaryImpl, versionImpl)));
|
|
103
|
+
return {
|
|
104
|
+
cwd,
|
|
105
|
+
runtimes: [
|
|
106
|
+
{
|
|
107
|
+
id: 'typescript',
|
|
108
|
+
label: 'TypeScript / Node.js',
|
|
109
|
+
status: 'core',
|
|
110
|
+
command: process.execPath,
|
|
111
|
+
version: `node ${process.versions.node}`,
|
|
112
|
+
role: 'core Sanook runtime: agent loop, TUI, gateway, MCP, second-brain, packaging',
|
|
113
|
+
},
|
|
114
|
+
...optional,
|
|
115
|
+
],
|
|
116
|
+
strategy: [
|
|
117
|
+
'TypeScript stays the control plane and npm-distributed default.',
|
|
118
|
+
'Python is the optional analysis/data plane: scripts, data wrangling, document/ML/OCR workflows, and research helpers.',
|
|
119
|
+
'Rust is the optional performance/safety plane: single-binary helpers, high-throughput parsing/search, and future native accelerators.',
|
|
120
|
+
'Optional runtimes must degrade gracefully; missing Python/Rust should never break basic Sanook install or chat.',
|
|
121
|
+
],
|
|
122
|
+
notes: [
|
|
123
|
+
'`run_python` and `run_rust` are approval-gated tools because arbitrary code can mutate files.',
|
|
124
|
+
'The diagnostics tool already understands Python and Rust when Pyright/rust-analyzer are installed.',
|
|
125
|
+
'Use `sanook mcp list --tools` for external runtime capabilities exposed through MCP servers.',
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function fmtStatus(status) {
|
|
130
|
+
if (status === 'core')
|
|
131
|
+
return 'CORE ';
|
|
132
|
+
if (status === 'ready')
|
|
133
|
+
return 'READY';
|
|
134
|
+
return 'MISS ';
|
|
135
|
+
}
|
|
136
|
+
export function renderPolyglotReport(report) {
|
|
137
|
+
const missingRuntimes = report.runtimes.filter((runtime) => runtime.status === 'missing');
|
|
138
|
+
const lines = [
|
|
139
|
+
`${BRAND.productName} runtimes`,
|
|
140
|
+
`cwd: ${report.cwd}`,
|
|
141
|
+
'',
|
|
142
|
+
'Runtime surface:',
|
|
143
|
+
...report.runtimes.map((runtime) => {
|
|
144
|
+
const version = runtime.version ? ` — ${runtime.version}` : '';
|
|
145
|
+
const command = runtime.command ? ` (${runtime.command})` : '';
|
|
146
|
+
return ` [${fmtStatus(runtime.status)}] ${runtime.label}${version}${command}`;
|
|
147
|
+
}),
|
|
148
|
+
'',
|
|
149
|
+
'Role map:',
|
|
150
|
+
...report.runtimes.map((runtime) => ` - ${runtime.label}: ${runtime.role}`),
|
|
151
|
+
'',
|
|
152
|
+
'Strategy:',
|
|
153
|
+
...report.strategy.map((item) => ` - ${item}`),
|
|
154
|
+
'',
|
|
155
|
+
'Missing install hints:',
|
|
156
|
+
...(missingRuntimes.length > 0 ? missingRuntimes.map((runtime) => ` - ${runtime.label}: ${runtime.install}`) : [' - None']),
|
|
157
|
+
'',
|
|
158
|
+
'Notes:',
|
|
159
|
+
...report.notes.map((note) => ` - ${note}`),
|
|
160
|
+
];
|
|
161
|
+
return `${lines.join('\n')}\n`;
|
|
162
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { clamp } from './tools/util.js';
|
|
3
|
+
const SAFE_ENV_KEYS = ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL', 'USER', 'SHELL', 'TERM', 'NODE_PATH', 'NVM_DIR', 'APPDATA'];
|
|
4
|
+
const DEFAULT_MAX_BUFFER = 10 * 1024 * 1024;
|
|
5
|
+
export function safeProcessEnv(env = process.env) {
|
|
6
|
+
const out = {};
|
|
7
|
+
for (const key of SAFE_ENV_KEYS) {
|
|
8
|
+
const value = env[key];
|
|
9
|
+
if (value != null)
|
|
10
|
+
out[key] = value;
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
function appendChunk(chunks, chunk, state, maxBuffer) {
|
|
15
|
+
if (state.bytes >= maxBuffer) {
|
|
16
|
+
state.truncated = true;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const remaining = maxBuffer - state.bytes;
|
|
20
|
+
const kept = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
|
|
21
|
+
chunks.push(kept);
|
|
22
|
+
state.bytes += kept.length;
|
|
23
|
+
if (kept.length < chunk.length)
|
|
24
|
+
state.truncated = true;
|
|
25
|
+
}
|
|
26
|
+
export function runProcess(file, args, options = {}) {
|
|
27
|
+
const timeoutMs = Math.max(1, Math.min(options.timeoutMs ?? 120_000, 300_000));
|
|
28
|
+
const maxBuffer = Math.max(1, options.maxBuffer ?? DEFAULT_MAX_BUFFER);
|
|
29
|
+
const stdoutChunks = [];
|
|
30
|
+
const stderrChunks = [];
|
|
31
|
+
const stdoutState = { bytes: 0, truncated: false };
|
|
32
|
+
const stderrState = { bytes: 0, truncated: false };
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
let timedOut = false;
|
|
35
|
+
let settled = false;
|
|
36
|
+
const child = spawn(file, args, {
|
|
37
|
+
cwd: options.cwd,
|
|
38
|
+
env: safeProcessEnv(),
|
|
39
|
+
shell: false,
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
});
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
timedOut = true;
|
|
44
|
+
child.kill('SIGTERM');
|
|
45
|
+
setTimeout(() => {
|
|
46
|
+
if (!settled)
|
|
47
|
+
child.kill('SIGKILL');
|
|
48
|
+
}, 1_000).unref();
|
|
49
|
+
}, timeoutMs);
|
|
50
|
+
child.stdout.on('data', (chunk) => appendChunk(stdoutChunks, chunk, stdoutState, maxBuffer));
|
|
51
|
+
child.stderr.on('data', (chunk) => appendChunk(stderrChunks, chunk, stderrState, maxBuffer));
|
|
52
|
+
child.on('error', (err) => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
settled = true;
|
|
55
|
+
resolve({
|
|
56
|
+
ok: false,
|
|
57
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
|
|
58
|
+
stderr: Buffer.concat(stderrChunks).toString('utf8'),
|
|
59
|
+
code: null,
|
|
60
|
+
signal: null,
|
|
61
|
+
timedOut,
|
|
62
|
+
truncated: stdoutState.truncated || stderrState.truncated,
|
|
63
|
+
error: err.message,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
child.on('close', (code, signal) => {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
settled = true;
|
|
69
|
+
resolve({
|
|
70
|
+
ok: code === 0 && !timedOut,
|
|
71
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
|
|
72
|
+
stderr: Buffer.concat(stderrChunks).toString('utf8'),
|
|
73
|
+
code,
|
|
74
|
+
signal,
|
|
75
|
+
timedOut,
|
|
76
|
+
truncated: stdoutState.truncated || stderrState.truncated,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
if (options.input != null)
|
|
80
|
+
child.stdin.end(options.input);
|
|
81
|
+
else
|
|
82
|
+
child.stdin.end();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
export function formatProcessResult(result) {
|
|
86
|
+
const body = (result.stdout + (result.stderr ? `\n[stderr]\n${result.stderr}` : '')).trim();
|
|
87
|
+
const truncated = result.truncated ? '\n... [process output truncated]' : '';
|
|
88
|
+
if (result.ok)
|
|
89
|
+
return clamp(`${body}${truncated}`.trim()) || '(no output)';
|
|
90
|
+
const status = result.timedOut
|
|
91
|
+
? 'timeout'
|
|
92
|
+
: result.error
|
|
93
|
+
? result.error
|
|
94
|
+
: `exit ${result.code ?? 'unknown'}${result.signal ? ` (${result.signal})` : ''}`;
|
|
95
|
+
return clamp(`ERROR: process failed — ${status}${body ? `\n${body}` : ''}${truncated}`);
|
|
96
|
+
}
|