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
@@ -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
+ }
@@ -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 && (await exists(AUTO_MEMORY_FILE))) {
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
- return [idx, currentState, inbox];
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
+ }
@@ -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(e);
12
+ const json = JSON.stringify(safe);
11
13
  if (json)
12
14
  return json;
13
15
  }
14
16
  catch {
15
- return inspect(e, { breakLength: Infinity, depth: 2 });
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
+ }
@@ -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
+ }