sanook-cli 0.4.0 → 0.5.1

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 (238) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +173 -0
  3. package/README.md +153 -20
  4. package/README.th.md +136 -0
  5. package/dist/agentContext.js +4 -0
  6. package/dist/approval.js +6 -0
  7. package/dist/bin.js +405 -57
  8. package/dist/brain.js +92 -59
  9. package/dist/brand.js +47 -0
  10. package/dist/checkpoint.js +37 -0
  11. package/dist/commands.js +86 -6
  12. package/dist/compaction.js +76 -5
  13. package/dist/config.js +100 -12
  14. package/dist/cost.js +60 -3
  15. package/dist/doctor.js +92 -0
  16. package/dist/gateway/auth.js +2 -2
  17. package/dist/gateway/ledger.js +2 -2
  18. package/dist/gateway/scheduler.js +1 -0
  19. package/dist/gateway/serve.js +6 -4
  20. package/dist/gateway/server.js +10 -2
  21. package/dist/git.js +11 -2
  22. package/dist/hooks.js +43 -17
  23. package/dist/knowledge.js +48 -49
  24. package/dist/loop.js +182 -66
  25. package/dist/lsp/client.js +173 -0
  26. package/dist/lsp/framing.js +56 -0
  27. package/dist/lsp/index.js +138 -0
  28. package/dist/lsp/servers.js +82 -0
  29. package/dist/mcp-server.js +244 -0
  30. package/dist/mcp.js +184 -29
  31. package/dist/memory-store.js +559 -0
  32. package/dist/memory.js +143 -29
  33. package/dist/orchestrate.js +150 -0
  34. package/dist/providers/codex.js +21 -7
  35. package/dist/providers/keys.js +3 -2
  36. package/dist/providers/models.js +22 -6
  37. package/dist/providers/registry.js +155 -1
  38. package/dist/repomap.js +93 -0
  39. package/dist/search/chunk.js +158 -0
  40. package/dist/search/embed-store.js +187 -0
  41. package/dist/search/engine.js +203 -0
  42. package/dist/search/fuse.js +35 -0
  43. package/dist/search/index-core.js +187 -0
  44. package/dist/search/indexer.js +241 -0
  45. package/dist/search/store.js +77 -0
  46. package/dist/session.js +42 -8
  47. package/dist/skill-install.js +10 -10
  48. package/dist/skills.js +12 -9
  49. package/dist/summarize.js +31 -0
  50. package/dist/tools/bash.js +21 -2
  51. package/dist/tools/diagnostics.js +41 -0
  52. package/dist/tools/edit.js +29 -7
  53. package/dist/tools/index.js +8 -1
  54. package/dist/tools/list.js +7 -2
  55. package/dist/tools/permission.js +90 -9
  56. package/dist/tools/read.js +23 -4
  57. package/dist/tools/remember.js +1 -1
  58. package/dist/tools/sandbox.js +61 -0
  59. package/dist/tools/search.js +105 -4
  60. package/dist/tools/task.js +195 -29
  61. package/dist/tools/timeout.js +35 -0
  62. package/dist/tools/util.js +10 -0
  63. package/dist/tools/write.js +6 -4
  64. package/dist/trust.js +89 -0
  65. package/dist/ui/app.js +228 -31
  66. package/dist/ui/banner.js +4 -9
  67. package/dist/ui/brain-wizard.js +2 -2
  68. package/dist/ui/history.js +30 -0
  69. package/dist/ui/mentions.js +44 -0
  70. package/dist/ui/render.js +55 -15
  71. package/dist/ui/setup.js +97 -12
  72. package/dist/ui/useEditor.js +83 -0
  73. package/dist/update.js +114 -0
  74. package/dist/worktree.js +173 -0
  75. package/package.json +11 -5
  76. package/scripts/postinstall.mjs +33 -0
  77. package/second-brain/.agents/_Index.md +30 -0
  78. package/second-brain/.agents/skills/_Index.md +30 -0
  79. package/second-brain/.agents/workflows/_Index.md +30 -0
  80. package/second-brain/AGENTS.md +4 -4
  81. package/second-brain/Acceptance/_Index.md +30 -0
  82. package/second-brain/Acceptance/golden-case-template.md +39 -0
  83. package/second-brain/Areas/_Index.md +30 -0
  84. package/second-brain/Bugs/System-OS/_Index.md +30 -0
  85. package/second-brain/Bugs/_Index.md +30 -0
  86. package/second-brain/CLAUDE.md +4 -1
  87. package/second-brain/Checklists/_Index.md +30 -0
  88. package/second-brain/Checklists/preflight-postflight-template.md +29 -0
  89. package/second-brain/Distillations/_Index.md +30 -0
  90. package/second-brain/Entities/_Index.md +30 -0
  91. package/second-brain/Entities/entity-template.md +33 -0
  92. package/second-brain/Evals/_Index.md +30 -0
  93. package/second-brain/Evals/correction-pairs.md +24 -0
  94. package/second-brain/Evals/failure-taxonomy.md +24 -0
  95. package/second-brain/Evals/golden-set.md +25 -0
  96. package/second-brain/Evals/quality-ledger.md +23 -0
  97. package/second-brain/Evals/self-eval-rubric.md +23 -0
  98. package/second-brain/GEMINI.md +4 -4
  99. package/second-brain/Goals/_Index.md +30 -0
  100. package/second-brain/Handoffs/_Index.md +30 -0
  101. package/second-brain/Home.md +7 -0
  102. package/second-brain/Intake/Raw Sources/_Index.md +30 -0
  103. package/second-brain/Intake/_Index.md +30 -0
  104. package/second-brain/Intake/_Quarantine/_Index.md +30 -0
  105. package/second-brain/Learning/_Index.md +30 -0
  106. package/second-brain/Playbooks/_Index.md +30 -0
  107. package/second-brain/Playbooks/playbook-template.md +23 -0
  108. package/second-brain/Projects/_Index.md +30 -0
  109. package/second-brain/Prompts/_Index.md +30 -0
  110. package/second-brain/README.md +2 -1
  111. package/second-brain/Research/_Index.md +30 -0
  112. package/second-brain/Retrospectives/_Index.md +30 -0
  113. package/second-brain/Reviews/_Index.md +30 -0
  114. package/second-brain/Runbooks/_Index.md +30 -0
  115. package/second-brain/Runbooks/eval-loop.md +24 -0
  116. package/second-brain/Sessions/_Index.md +30 -0
  117. package/second-brain/Shared/AI-Context-Index.md +20 -0
  118. package/second-brain/Shared/AI-Threads/_Index.md +30 -0
  119. package/second-brain/Shared/Archive/_Index.md +30 -0
  120. package/second-brain/Shared/Assets/_Index.md +30 -0
  121. package/second-brain/Shared/Context-Packs/_Index.md +30 -0
  122. package/second-brain/Shared/Context7-Docs/_Index.md +30 -0
  123. package/second-brain/Shared/Coordination/NOW.md +28 -0
  124. package/second-brain/Shared/Coordination/_Index.md +30 -0
  125. package/second-brain/Shared/Coordination/agent-registry.md +24 -0
  126. package/second-brain/Shared/Coordination/task-board/_Index.md +30 -0
  127. package/second-brain/Shared/Coordination/task-board/task-template.md +43 -0
  128. package/second-brain/Shared/Coordination/task-board.md +32 -0
  129. package/second-brain/Shared/Core-Facts/_Index.md +30 -0
  130. package/second-brain/Shared/Decision-Memory/_Index.md +30 -0
  131. package/second-brain/Shared/Glossary/_Index.md +30 -0
  132. package/second-brain/Shared/Memory-Inbox/_Index.md +30 -0
  133. package/second-brain/Shared/Operating-State/_Index.md +30 -0
  134. package/second-brain/Shared/Prompting/_Index.md +30 -0
  135. package/second-brain/Shared/Provenance/_Index.md +30 -0
  136. package/second-brain/Shared/Rules/_Index.md +30 -0
  137. package/second-brain/Shared/Rules/contextual-note-rule.md +30 -0
  138. package/second-brain/Shared/Rules/frontmatter-standard.md +10 -0
  139. package/second-brain/Shared/Rules/memory-write-protocol.md +28 -0
  140. package/second-brain/Shared/Rules/procedural-runbook-header.md +40 -0
  141. package/second-brain/Shared/Rules/review-and-staleness-policy.md +22 -0
  142. package/second-brain/Shared/Rules/rules-formatting.md +34 -0
  143. package/second-brain/Shared/Scripts/_Index.md +30 -0
  144. package/second-brain/Shared/Scripts-Archive/_Index.md +30 -0
  145. package/second-brain/Shared/Tech-Standards/_Index.md +30 -0
  146. package/second-brain/Shared/Tech-Standards/verification-standard.md +40 -0
  147. package/second-brain/Shared/User-Memory/_Index.md +30 -0
  148. package/second-brain/Shared/User-Persona/_Index.md +30 -0
  149. package/second-brain/Shared/User-Persona/owner-profile.md +25 -0
  150. package/second-brain/Shared/Working-Memory/_Index.md +30 -0
  151. package/second-brain/Shared/_Index.md +30 -0
  152. package/second-brain/Shared/mcp-servers/_Index.md +30 -0
  153. package/second-brain/Skills/_Index.md +30 -0
  154. package/second-brain/Templates/_Index.md +30 -0
  155. package/second-brain/Templates/bug.md +2 -0
  156. package/second-brain/Templates/handoff.md +2 -0
  157. package/second-brain/Templates/session.md +2 -0
  158. package/second-brain/Tools/_Index.md +30 -0
  159. package/second-brain/Traces/_Index.md +30 -0
  160. package/second-brain/Vault Structure Map.md +33 -1
  161. package/second-brain/copilot/_Index.md +30 -0
  162. package/skills/audit-license-compliance/SKILL.md +117 -0
  163. package/skills/author-codemod/SKILL.md +110 -0
  164. package/skills/build-audit-logging/SKILL.md +112 -0
  165. package/skills/build-cdc-streaming-pipeline/SKILL.md +123 -0
  166. package/skills/build-cli-tool/SKILL.md +108 -0
  167. package/skills/build-data-table/SKILL.md +141 -0
  168. package/skills/build-native-mobile-ui/SKILL.md +154 -0
  169. package/skills/build-offline-first-sync/SKILL.md +118 -0
  170. package/skills/build-realtime-channel/SKILL.md +122 -0
  171. package/skills/build-vector-search/SKILL.md +131 -0
  172. package/skills/compose-local-dev-stack/SKILL.md +149 -0
  173. package/skills/configure-bundler-build/SKILL.md +166 -0
  174. package/skills/configure-dns-tls/SKILL.md +142 -0
  175. package/skills/configure-reverse-proxy-lb/SKILL.md +129 -0
  176. package/skills/configure-security-headers-csp/SKILL.md +122 -0
  177. package/skills/contract-testing/SKILL.md +140 -0
  178. package/skills/datetime-timezone-correctness/SKILL.md +125 -0
  179. package/skills/debug-ci-pipeline-failure/SKILL.md +134 -0
  180. package/skills/debug-flaky-tests/SKILL.md +128 -0
  181. package/skills/defend-llm-prompt-injection/SKILL.md +110 -0
  182. package/skills/deliver-webhooks/SKILL.md +116 -0
  183. package/skills/design-api-pagination/SKILL.md +144 -0
  184. package/skills/design-authorization-model/SKILL.md +119 -0
  185. package/skills/design-backup-dr-recovery/SKILL.md +113 -0
  186. package/skills/design-event-sourcing-cqrs/SKILL.md +143 -0
  187. package/skills/design-multi-tenancy/SKILL.md +100 -0
  188. package/skills/design-protobuf-grpc-service/SKILL.md +146 -0
  189. package/skills/design-relational-schema/SKILL.md +129 -0
  190. package/skills/design-search-index-infra/SKILL.md +151 -0
  191. package/skills/design-state-machine/SKILL.md +108 -0
  192. package/skills/design-token-system/SKILL.md +109 -0
  193. package/skills/distributed-locks-leases/SKILL.md +120 -0
  194. package/skills/encrypt-sensitive-data/SKILL.md +148 -0
  195. package/skills/feature-flags-rollout/SKILL.md +130 -0
  196. package/skills/file-upload-object-storage/SKILL.md +107 -0
  197. package/skills/fuzz-dynamic-security-test/SKILL.md +111 -0
  198. package/skills/harden-llm-app-reliability/SKILL.md +126 -0
  199. package/skills/i18n-localization-setup/SKILL.md +113 -0
  200. package/skills/idempotency-keys/SKILL.md +107 -0
  201. package/skills/implement-push-notifications/SKILL.md +142 -0
  202. package/skills/ingest-webhook-secure/SKILL.md +120 -0
  203. package/skills/integrate-oauth-oidc/SKILL.md +126 -0
  204. package/skills/load-stress-test/SKILL.md +129 -0
  205. package/skills/map-privacy-data-gdpr/SKILL.md +146 -0
  206. package/skills/model-nosql-data/SKILL.md +118 -0
  207. package/skills/money-decimal-arithmetic/SKILL.md +123 -0
  208. package/skills/monitor-ml-drift/SKILL.md +109 -0
  209. package/skills/numeric-precision-units/SKILL.md +144 -0
  210. package/skills/optimize-llm-cost-latency/SKILL.md +103 -0
  211. package/skills/optimize-react-rerenders/SKILL.md +124 -0
  212. package/skills/orchestrate-agent-workflow/SKILL.md +100 -0
  213. package/skills/payments-billing-integration/SKILL.md +114 -0
  214. package/skills/pin-toolchain-versions/SKILL.md +116 -0
  215. package/skills/plan-strangler-migration/SKILL.md +95 -0
  216. package/skills/property-based-testing/SKILL.md +108 -0
  217. package/skills/publish-package-registry/SKILL.md +130 -0
  218. package/skills/recover-git-state/SKILL.md +119 -0
  219. package/skills/remediate-web-vulnerabilities/SKILL.md +125 -0
  220. package/skills/resilience-timeouts-retries/SKILL.md +104 -0
  221. package/skills/resolve-merge-rebase-conflict/SKILL.md +97 -0
  222. package/skills/rewrite-git-history/SKILL.md +109 -0
  223. package/skills/scaffold-cross-platform-app/SKILL.md +137 -0
  224. package/skills/schema-evolution-compatibility/SKILL.md +121 -0
  225. package/skills/send-transactional-email/SKILL.md +126 -0
  226. package/skills/serve-deploy-ml-model/SKILL.md +107 -0
  227. package/skills/setup-cdn-edge-waf/SKILL.md +107 -0
  228. package/skills/setup-devcontainer-env/SKILL.md +131 -0
  229. package/skills/setup-lint-format-precommit/SKILL.md +140 -0
  230. package/skills/setup-monorepo-tooling/SKILL.md +125 -0
  231. package/skills/ship-mobile-app-store-release/SKILL.md +137 -0
  232. package/skills/structured-output-llm/SKILL.md +86 -0
  233. package/skills/supply-chain-sbom-provenance/SKILL.md +120 -0
  234. package/skills/test-data-factories/SKILL.md +158 -0
  235. package/skills/threat-model-stride/SKILL.md +123 -0
  236. package/skills/train-evaluate-ml-model/SKILL.md +109 -0
  237. package/skills/unicode-text-correctness/SKILL.md +109 -0
  238. package/skills/visual-regression-testing/SKILL.md +120 -0
@@ -0,0 +1,56 @@
1
+ // ============================================================================
2
+ // src/lsp/framing.ts — LSP message framing (the wire codec).
3
+ //
4
+ // The Language Server Protocol speaks JSON-RPC 2.0 over a stream framed with HTTP-
5
+ // style headers: `Content-Length: <N>\r\n\r\n<N bytes of UTF-8 JSON>`, messages
6
+ // back to back. This differs from MCP's newline-delimited framing (src/mcp.ts),
7
+ // so it needs its own codec. Pure + dependency-free: encode() builds a frame,
8
+ // LspDecoder.push() accumulates bytes and yields whatever complete messages have
9
+ // arrived (headers and bodies may split across chunks). Fully unit-testable with
10
+ // zero process, zero server.
11
+ // ============================================================================
12
+ /** encode a JSON-RPC message as an LSP frame (Content-Length header + body). */
13
+ export function encode(msg) {
14
+ const body = Buffer.from(JSON.stringify(msg), 'utf8');
15
+ const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii');
16
+ return Buffer.concat([header, body]);
17
+ }
18
+ /**
19
+ * Streaming decoder: feed it bytes, get back complete parsed messages. Tolerant of
20
+ * messages split across chunks and of extra headers (e.g. Content-Type). A body
21
+ * that fails to JSON-parse is skipped (defensive — a malformed frame must not wedge
22
+ * the stream), and the byte length is counted via Content-Length, not characters,
23
+ * so multibyte UTF-8 is handled correctly.
24
+ */
25
+ export class LspDecoder {
26
+ buf = Buffer.alloc(0);
27
+ push(chunk) {
28
+ this.buf = Buffer.concat([this.buf, typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk]);
29
+ const out = [];
30
+ for (;;) {
31
+ const headerEnd = this.buf.indexOf('\r\n\r\n');
32
+ if (headerEnd === -1)
33
+ break; // headers not fully arrived yet
34
+ const header = this.buf.subarray(0, headerEnd).toString('ascii');
35
+ const m = /content-length:\s*(\d+)/i.exec(header);
36
+ if (!m) {
37
+ // no Content-Length in this header block — unrecoverable framing; drop it and resync
38
+ this.buf = this.buf.subarray(headerEnd + 4);
39
+ continue;
40
+ }
41
+ const len = Number(m[1]);
42
+ const bodyStart = headerEnd + 4;
43
+ if (this.buf.length < bodyStart + len)
44
+ break; // body not fully arrived yet
45
+ const body = this.buf.subarray(bodyStart, bodyStart + len).toString('utf8');
46
+ this.buf = this.buf.subarray(bodyStart + len);
47
+ try {
48
+ out.push(JSON.parse(body));
49
+ }
50
+ catch {
51
+ /* skip a malformed body rather than wedging the stream */
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+ }
@@ -0,0 +1,138 @@
1
+ // ============================================================================
2
+ // src/lsp/index.ts — diagnose(file): spawn the right LSP server, get diagnostics.
3
+ //
4
+ // Ties the pieces together: resolveServer() picks an installed server, a real
5
+ // Content-Length stdio transport drives an LspSession, and diagnostics come back
6
+ // converted + human-1-based. Servers are POOLED per (binary, workspace) and
7
+ // reused across calls — re-opening a file becomes a didChange, so the agent's
8
+ // repeated "edit → check" loop pays the (slow) server init only once. Graceful at
9
+ // every step: no server installed / spawn fails / silent server → a clear message,
10
+ // never a crash.
11
+ // ============================================================================
12
+ import { spawn } from 'node:child_process';
13
+ import { readFile } from 'node:fs/promises';
14
+ import { pathToFileURL } from 'node:url';
15
+ import { resolve as resolvePath } from 'node:path';
16
+ import { getRepoRoot } from '../worktree.js';
17
+ import { encode, LspDecoder } from './framing.js';
18
+ import { LspSession, waitForDiagnostics } from './client.js';
19
+ import { resolveServer } from './servers.js';
20
+ const SAFE_ENV_KEYS = ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'LANG', 'LC_ALL', 'USER', 'SHELL', 'TERM', 'NODE_PATH', 'NVM_DIR', 'APPDATA'];
21
+ function safeEnv() {
22
+ const out = {};
23
+ for (const k of SAFE_ENV_KEYS) {
24
+ const v = process.env[k];
25
+ if (v != null)
26
+ out[k] = v;
27
+ }
28
+ return out;
29
+ }
30
+ /** real stdio transport: spawn the server, frame with Content-Length both ways. */
31
+ function spawnTransport(binPath, args, cwd) {
32
+ const proc = spawn(binPath, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: safeEnv() });
33
+ const decoder = new LspDecoder();
34
+ let handler = null;
35
+ proc.stdout?.on('data', (buf) => {
36
+ for (const m of decoder.push(buf))
37
+ handler?.(m);
38
+ });
39
+ proc.stdout?.on('error', () => { });
40
+ proc.stderr?.on('data', () => { }); // swallow server logs (stdout is the protocol)
41
+ proc.stdin?.on('error', () => { }); // guard EPIPE if the server dies
42
+ const transport = {
43
+ send: (msg) => {
44
+ if (proc.stdin?.writable)
45
+ proc.stdin.write(encode(msg));
46
+ },
47
+ onMessage: (cb) => {
48
+ handler = cb;
49
+ },
50
+ close: () => {
51
+ try {
52
+ proc.kill();
53
+ }
54
+ catch {
55
+ /* already dead */
56
+ }
57
+ },
58
+ };
59
+ return { transport, proc };
60
+ }
61
+ const pool = new Map(); // key = binPath\0rootUri
62
+ let exitHooked = false;
63
+ function hookExitOnce() {
64
+ if (exitHooked)
65
+ return;
66
+ exitHooked = true;
67
+ process.on('exit', closeAllServers);
68
+ }
69
+ /**
70
+ * Get language-server diagnostics for a file. Returns {ok:false,reason} when no
71
+ * server is configured/installed or the server can't start; otherwise the
72
+ * (possibly empty) diagnostics list. Never throws.
73
+ */
74
+ export async function diagnose(filePath, opts = {}) {
75
+ const cwd = opts.cwd ?? process.cwd();
76
+ const abs = resolvePath(cwd, filePath);
77
+ const resolved = await resolveServer(abs, cwd);
78
+ if ('unavailable' in resolved)
79
+ return { ok: false, reason: resolved.unavailable };
80
+ const rootUri = pathToFileURL((await getRepoRoot(cwd)) ?? cwd).toString();
81
+ const key = `${resolved.binPath}\0${rootUri}`;
82
+ hookExitOnce();
83
+ let pooled = pool.get(key);
84
+ if (!pooled) {
85
+ try {
86
+ const { transport, proc } = spawnTransport(resolved.binPath, resolved.def.args, cwd);
87
+ const session = new LspSession(transport);
88
+ let died = false;
89
+ proc.on('exit', () => {
90
+ died = true;
91
+ pool.delete(key);
92
+ });
93
+ await session.initialize(rootUri);
94
+ if (died)
95
+ return { ok: false, reason: `${resolved.def.command} ออกก่อนเริ่มงาน (ติดตั้งครบไหม?)` };
96
+ pooled = { session, proc, opened: new Map() };
97
+ pool.set(key, pooled);
98
+ }
99
+ catch (e) {
100
+ pool.delete(key);
101
+ return { ok: false, reason: `เริ่ม ${resolved.def.command} ไม่สำเร็จ: ${e.message}` };
102
+ }
103
+ }
104
+ let text = opts.content;
105
+ if (text == null) {
106
+ try {
107
+ text = await readFile(abs, 'utf8');
108
+ }
109
+ catch (e) {
110
+ return { ok: false, reason: `อ่านไฟล์ไม่ได้: ${e.message}` };
111
+ }
112
+ }
113
+ const uri = pathToFileURL(abs).toString();
114
+ const waitOpts = { settleMs: opts.settleMs, timeoutMs: opts.timeoutMs };
115
+ // subscribe before sending open/change so we never miss an early publish
116
+ const wait = waitForDiagnostics(pooled.session, uri, waitOpts);
117
+ const prevVersion = pooled.opened.get(uri);
118
+ if (prevVersion == null) {
119
+ pooled.opened.set(uri, 1);
120
+ pooled.session.didOpen(uri, resolved.languageId, text);
121
+ }
122
+ else {
123
+ const version = prevVersion + 1;
124
+ pooled.opened.set(uri, version);
125
+ pooled.session.notify('textDocument/didChange', {
126
+ textDocument: { uri, version },
127
+ contentChanges: [{ text }], // full-document sync
128
+ });
129
+ }
130
+ const diagnostics = await wait;
131
+ return { ok: true, serverId: resolved.def.id, diagnostics };
132
+ }
133
+ /** shut down all pooled servers (called on process exit). */
134
+ export function closeAllServers() {
135
+ for (const p of pool.values())
136
+ p.session.close();
137
+ pool.clear();
138
+ }
@@ -0,0 +1,82 @@
1
+ // ============================================================================
2
+ // src/lsp/servers.ts — language → LSP server registry + availability detection.
3
+ //
4
+ // Zero-config floor: sanook does NOT bundle language servers (they're large and
5
+ // language-specific, like ripgrep). Instead it maps a file's extension to the
6
+ // conventional LSP server, detects whether that server is actually installed
7
+ // (PATH or node_modules/.bin), and degrades to a clear "install X" message when
8
+ // it isn't. Present server → real diagnostics; absent → graceful, never a crash.
9
+ // ============================================================================
10
+ import { access, constants } from 'node:fs/promises';
11
+ import { join, extname, delimiter } from 'node:path';
12
+ // conventional stdio language servers, by ecosystem. command is the binary NAME;
13
+ // resolveServer() finds it in node_modules/.bin or PATH.
14
+ export const SERVERS = [
15
+ {
16
+ id: 'typescript',
17
+ command: 'typescript-language-server',
18
+ args: ['--stdio'],
19
+ languages: { '.ts': 'typescript', '.tsx': 'typescriptreact', '.mts': 'typescript', '.cts': 'typescript', '.js': 'javascript', '.jsx': 'javascriptreact', '.mjs': 'javascript', '.cjs': 'javascript' },
20
+ install: 'npm i -g typescript-language-server typescript',
21
+ },
22
+ {
23
+ id: 'python',
24
+ command: 'pyright-langserver',
25
+ args: ['--stdio'],
26
+ languages: { '.py': 'python', '.pyi': 'python' },
27
+ install: 'npm i -g pyright',
28
+ },
29
+ { id: 'go', command: 'gopls', args: [], languages: { '.go': 'go' }, install: 'go install golang.org/x/tools/gopls@latest' },
30
+ { id: 'rust', command: 'rust-analyzer', args: [], languages: { '.rs': 'rust' }, install: 'rustup component add rust-analyzer' },
31
+ {
32
+ id: 'json',
33
+ command: 'vscode-json-language-server',
34
+ args: ['--stdio'],
35
+ languages: { '.json': 'json', '.jsonc': 'jsonc' },
36
+ install: 'npm i -g vscode-langservers-extracted',
37
+ },
38
+ { id: 'bash', command: 'bash-language-server', args: ['start'], languages: { '.sh': 'shellscript', '.bash': 'shellscript' }, install: 'npm i -g bash-language-server' },
39
+ ];
40
+ /** the server def + languageId for a file, or null if no server is configured for that extension. */
41
+ export function serverDefForFile(filePath) {
42
+ const ext = extname(filePath).toLowerCase();
43
+ for (const def of SERVERS) {
44
+ const languageId = def.languages[ext];
45
+ if (languageId)
46
+ return { def, languageId };
47
+ }
48
+ return null;
49
+ }
50
+ /** resolve a binary name to an absolute path: node_modules/.bin first (project-local), then PATH. */
51
+ export async function findBinary(command, cwd = process.cwd()) {
52
+ const candidates = [join(cwd, 'node_modules', '.bin', command)];
53
+ for (const dir of (process.env.PATH ?? '').split(delimiter).filter(Boolean)) {
54
+ candidates.push(join(dir, command));
55
+ if (process.platform === 'win32')
56
+ candidates.push(join(dir, `${command}.cmd`), join(dir, `${command}.exe`));
57
+ }
58
+ for (const c of candidates) {
59
+ try {
60
+ await access(c, constants.X_OK);
61
+ return c;
62
+ }
63
+ catch {
64
+ /* not here */
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+ /**
70
+ * Resolve an installed server for a file. Returns the server + its absolute binary
71
+ * path, or null with a `reason` (no server configured for the ext, or not installed).
72
+ */
73
+ export async function resolveServer(filePath, cwd = process.cwd()) {
74
+ const match = serverDefForFile(filePath);
75
+ if (!match)
76
+ return { unavailable: `ไม่มี language server ที่รองรับนามสกุล "${extname(filePath) || '(none)'}"` };
77
+ const binPath = await findBinary(match.def.command, cwd);
78
+ if (!binPath) {
79
+ return { unavailable: `ยังไม่ได้ติดตั้ง ${match.def.command} (สำหรับ ${match.def.id}) — ติดตั้ง: ${match.def.install}` };
80
+ }
81
+ return { def: match.def, languageId: match.languageId, binPath };
82
+ }
@@ -0,0 +1,244 @@
1
+ // ============================================================================
2
+ // src/mcp-server.ts — sanook's MCP SERVER (the strategic parity win).
3
+ //
4
+ // arra-oracle's entire value prop is "a queryable brain over MCP". sanook today
5
+ // is MCP CLIENT-ONLY (src/mcp.ts connects OUT to other servers). This module adds
6
+ // the server half over the SAME zero-dep JSON-RPC 2.0 newline framing and
7
+ // PROTOCOL_VERSION, so any MCP host (Claude Desktop, Cursor, another agent) can
8
+ // mount sanook's brain — BM25 over the second-brain vault + bi-temporal memory +
9
+ // sessions + skills, with optional BYOK semantic search — Node-native,
10
+ // Apache-2.0, no Bun, no SQLite, no native binary.
11
+ //
12
+ // STDOUT DISCIPLINE: stdout carries ONLY JSON-RPC frames. Every diagnostic goes
13
+ // to stderr (a stray stdout write corrupts the protocol stream). Launched by
14
+ // `sanook mcp serve`.
15
+ // ============================================================================
16
+ import { readFileSync } from 'node:fs';
17
+ import { PROTOCOL_VERSION } from './mcp.js';
18
+ import { BRAND } from './brand.js';
19
+ import { search, resetSearchCaches } from './search/engine.js';
20
+ import { reindex } from './search/indexer.js';
21
+ import { loadIndex } from './search/store.js';
22
+ import { loadVectors } from './search/embed-store.js';
23
+ import { indexStats } from './search/index-core.js';
24
+ import { recall } from './knowledge.js';
25
+ import { appendMemory } from './memory.js';
26
+ import { NOTE_TYPE } from './memory-store.js';
27
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
28
+ const SERVER_NAME = `${BRAND.cliName}-brain`;
29
+ const log = (msg) => void process.stderr.write(`[${SERVER_NAME}] ${msg}\n`);
30
+ // ---- tool surface ----------------------------------------------------------
31
+ const SOURCES = ['memory', 'vault', 'session', 'skill'];
32
+ export const TOOLS = [
33
+ {
34
+ name: 'sanook_search',
35
+ description: "Hybrid BM25 + optional semantic search across the user's second-brain vault, " +
36
+ 'bi-temporal memory, past sessions, and skills. Returns ranked, snippeted hits. ' +
37
+ "mode 'auto' uses semantic when a BYOK embeddings key is configured, else BM25.",
38
+ inputSchema: {
39
+ type: 'object',
40
+ properties: {
41
+ query: { type: 'string', description: 'natural-language or keyword query' },
42
+ mode: { type: 'string', enum: ['auto', 'fts', 'semantic', 'hybrid'], description: "default 'auto'" },
43
+ limit: { type: 'number', description: 'max hits (default 8)' },
44
+ sources: { type: 'array', items: { type: 'string', enum: SOURCES }, description: 'restrict to these corpora' },
45
+ },
46
+ required: ['query'],
47
+ },
48
+ },
49
+ {
50
+ name: 'sanook_recall',
51
+ description: 'Quick keyword recall across memory + vault + skills + sessions (BM25, no network). ' +
52
+ 'Use at the start of a task to reuse prior knowledge.',
53
+ inputSchema: {
54
+ type: 'object',
55
+ properties: { query: { type: 'string' } },
56
+ required: ['query'],
57
+ },
58
+ },
59
+ {
60
+ name: 'sanook_remember',
61
+ description: 'Persist an atomic fact/preference/decision across sessions (Merge-Don\'t-Append: ' +
62
+ 'dedups, supersedes contradictions, routes to the vault inbox).',
63
+ inputSchema: {
64
+ type: 'object',
65
+ properties: {
66
+ text: { type: 'string', description: 'one concise atomic claim' },
67
+ noteType: { type: 'string', enum: [...NOTE_TYPE], description: 'optional classification' },
68
+ },
69
+ required: ['text'],
70
+ },
71
+ },
72
+ {
73
+ name: 'sanook_index',
74
+ description: 'Incrementally (re)index the vault + live memory/sessions/skills into the search index. ' +
75
+ 'O(delta): only changed files are re-read. Run after editing the vault.',
76
+ inputSchema: { type: 'object', properties: {} },
77
+ },
78
+ {
79
+ name: 'sanook_stats',
80
+ description: 'Index health: document counts per source, term count, vault path, and vector/semantic status.',
81
+ inputSchema: { type: 'object', properties: {} },
82
+ },
83
+ ];
84
+ // ---- tool implementations (return plain text for the MCP content block) -----
85
+ function formatResult(res) {
86
+ const head = `${res.hits.length} hit(s) · mode=${res.mode}${res.degraded ? ` (degraded: ${res.degraded})` : ''}`;
87
+ if (!res.hits.length)
88
+ return `${head}\n(no matches)`;
89
+ const lines = res.hits.map((h) => {
90
+ const title = h.title.trim();
91
+ const body = title ? `${title} — ${h.snippet}` : h.snippet;
92
+ const where = h.path ? ` (${h.path})` : '';
93
+ return `[${h.source}] ${body}${where}`;
94
+ });
95
+ return `${head}\n${lines.join('\n')}`;
96
+ }
97
+ async function callTool(name, args) {
98
+ switch (name) {
99
+ case 'sanook_search': {
100
+ const query = String(args.query ?? '').trim();
101
+ if (!query)
102
+ return 'ERROR: query is required';
103
+ const res = await search(query, {
104
+ mode: args.mode ?? 'auto',
105
+ limit: typeof args.limit === 'number' ? args.limit : 8,
106
+ sources: Array.isArray(args.sources) ? args.sources : undefined,
107
+ });
108
+ return formatResult(res);
109
+ }
110
+ case 'sanook_recall': {
111
+ const query = String(args.query ?? '').trim();
112
+ if (!query)
113
+ return 'ERROR: query is required';
114
+ return recall(query);
115
+ }
116
+ case 'sanook_remember': {
117
+ const text = String(args.text ?? '').trim();
118
+ if (!text)
119
+ return 'ERROR: text is required';
120
+ const noteType = NOTE_TYPE.includes(String(args.noteType))
121
+ ? args.noteType
122
+ : undefined;
123
+ await appendMemory(text, noteType);
124
+ // keep the persisted search index fresh so the next sanook_search sees this fact
125
+ await reindex().catch((e) => log(`post-remember reindex failed: ${e.message}`));
126
+ resetSearchCaches();
127
+ return `OK: remembered — "${text}"`;
128
+ }
129
+ case 'sanook_index': {
130
+ const r = await reindex();
131
+ resetSearchCaches();
132
+ return (`indexed: +${r.added} ~${r.updated} -${r.removed} (skipped ${r.skipped}) · ` +
133
+ `memory=${r.memory} sessions=${r.sessions} skills=${r.skills} · vault=${r.vaultPath ?? '(none)'}`);
134
+ }
135
+ case 'sanook_stats': {
136
+ const { index } = await loadIndex();
137
+ const stats = indexStats(index);
138
+ const vectors = await loadVectors();
139
+ const bySrc = Object.entries(stats.bySource)
140
+ .map(([k, v]) => `${k}=${v}`)
141
+ .join(' ');
142
+ const vec = vectors.dim ? `${vectors.tag} (${vectors.ids.length} vecs, dim ${vectors.dim})` : 'none (BM25 only)';
143
+ return `docs=${stats.docs} terms=${stats.terms} avgdl=${stats.avgdl.toFixed(1)}\nbySource: ${bySrc || '(empty)'}\nvectors: ${vec}`;
144
+ }
145
+ default:
146
+ throw new Error(`unknown tool: ${name}`);
147
+ }
148
+ }
149
+ export async function handle(msg) {
150
+ switch (msg.method) {
151
+ case 'initialize':
152
+ return {
153
+ protocolVersion: PROTOCOL_VERSION,
154
+ capabilities: { tools: {} },
155
+ serverInfo: { name: SERVER_NAME, version: VERSION },
156
+ };
157
+ case 'notifications/initialized':
158
+ return undefined; // notification → no response
159
+ case 'ping':
160
+ return {};
161
+ case 'tools/list':
162
+ return { tools: TOOLS };
163
+ case 'tools/call': {
164
+ const name = String(msg.params?.name ?? '');
165
+ const args = msg.params?.arguments ?? {};
166
+ try {
167
+ const text = await callTool(name, args);
168
+ return { content: [{ type: 'text', text }], isError: text.startsWith('ERROR:') };
169
+ }
170
+ catch (e) {
171
+ return { content: [{ type: 'text', text: `error: ${e.message}` }], isError: true };
172
+ }
173
+ }
174
+ default:
175
+ throw rpcError(-32601, `method not found: ${msg.method}`);
176
+ }
177
+ }
178
+ function rpcError(code, message) {
179
+ return { code, message };
180
+ }
181
+ const MAX_LINE = 16 * 1024 * 1024; // cap an un-terminated stdin line so a runaway host can't grow memory unbounded
182
+ /** start the stdio MCP server loop. Resolves when stdin closes. */
183
+ export function runMcpServer() {
184
+ return new Promise((resolve) => {
185
+ let buf = '';
186
+ const write = (obj) => {
187
+ try {
188
+ process.stdout.write(`${JSON.stringify(obj)}\n`);
189
+ }
190
+ catch (e) {
191
+ log(`stdout write failed: ${e.message}`); // never let a write fault escape the handler
192
+ }
193
+ };
194
+ process.stdin.setEncoding('utf8');
195
+ process.stdin.on('data', (chunk) => {
196
+ buf += chunk;
197
+ if (buf.length > MAX_LINE && !buf.includes('\n')) {
198
+ log(`stdin line exceeded ${MAX_LINE} bytes with no newline — dropping`);
199
+ buf = '';
200
+ return;
201
+ }
202
+ let idx;
203
+ while ((idx = buf.indexOf('\n')) !== -1) {
204
+ const line = buf.slice(0, idx).trim();
205
+ buf = buf.slice(idx + 1);
206
+ if (!line)
207
+ continue;
208
+ let msg;
209
+ try {
210
+ msg = JSON.parse(line);
211
+ }
212
+ catch {
213
+ log(`dropping non-JSON line (${line.length} bytes)`);
214
+ continue;
215
+ }
216
+ const id = msg.id;
217
+ void handle(msg)
218
+ .then((result) => {
219
+ if (result === undefined || id == null)
220
+ return; // notification → silent
221
+ write({ jsonrpc: '2.0', id, result });
222
+ })
223
+ .catch((err) => {
224
+ if (id == null)
225
+ return;
226
+ const e = (err ?? {});
227
+ const code = typeof e.code === 'number' ? e.code : -32603;
228
+ const message = err instanceof Error ? err.message : typeof e.message === 'string' ? e.message : 'internal error';
229
+ write({ jsonrpc: '2.0', id, error: { code, message } });
230
+ });
231
+ }
232
+ });
233
+ // resolve (and stop the server) on stream end/close OR error — an unhandled stdin
234
+ // 'error' would otherwise crash the process AND leave this promise pending forever.
235
+ const done = () => resolve();
236
+ process.stdin.on('end', done);
237
+ process.stdin.on('close', done);
238
+ process.stdin.on('error', (e) => {
239
+ log(`stdin error: ${e.message}`);
240
+ resolve();
241
+ });
242
+ log(`ready · ${TOOLS.length} tools · protocol ${PROTOCOL_VERSION}`);
243
+ });
244
+ }