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.
- package/.env.example +19 -0
- package/CHANGELOG.md +173 -0
- package/README.md +153 -20
- package/README.th.md +136 -0
- package/dist/agentContext.js +4 -0
- package/dist/approval.js +6 -0
- package/dist/bin.js +405 -57
- package/dist/brain.js +92 -59
- package/dist/brand.js +47 -0
- package/dist/checkpoint.js +37 -0
- package/dist/commands.js +86 -6
- package/dist/compaction.js +76 -5
- package/dist/config.js +100 -12
- package/dist/cost.js +60 -3
- package/dist/doctor.js +92 -0
- package/dist/gateway/auth.js +2 -2
- package/dist/gateway/ledger.js +2 -2
- package/dist/gateway/scheduler.js +1 -0
- package/dist/gateway/serve.js +6 -4
- package/dist/gateway/server.js +10 -2
- package/dist/git.js +11 -2
- package/dist/hooks.js +43 -17
- package/dist/knowledge.js +48 -49
- package/dist/loop.js +182 -66
- package/dist/lsp/client.js +173 -0
- package/dist/lsp/framing.js +56 -0
- package/dist/lsp/index.js +138 -0
- package/dist/lsp/servers.js +82 -0
- package/dist/mcp-server.js +244 -0
- package/dist/mcp.js +184 -29
- package/dist/memory-store.js +559 -0
- package/dist/memory.js +143 -29
- package/dist/orchestrate.js +150 -0
- package/dist/providers/codex.js +21 -7
- package/dist/providers/keys.js +3 -2
- package/dist/providers/models.js +22 -6
- package/dist/providers/registry.js +155 -1
- package/dist/repomap.js +93 -0
- package/dist/search/chunk.js +158 -0
- package/dist/search/embed-store.js +187 -0
- package/dist/search/engine.js +203 -0
- package/dist/search/fuse.js +35 -0
- package/dist/search/index-core.js +187 -0
- package/dist/search/indexer.js +241 -0
- package/dist/search/store.js +77 -0
- package/dist/session.js +42 -8
- package/dist/skill-install.js +10 -10
- package/dist/skills.js +12 -9
- package/dist/summarize.js +31 -0
- package/dist/tools/bash.js +21 -2
- package/dist/tools/diagnostics.js +41 -0
- package/dist/tools/edit.js +29 -7
- package/dist/tools/index.js +8 -1
- package/dist/tools/list.js +7 -2
- package/dist/tools/permission.js +90 -9
- package/dist/tools/read.js +23 -4
- package/dist/tools/remember.js +1 -1
- package/dist/tools/sandbox.js +61 -0
- package/dist/tools/search.js +105 -4
- package/dist/tools/task.js +195 -29
- package/dist/tools/timeout.js +35 -0
- package/dist/tools/util.js +10 -0
- package/dist/tools/write.js +6 -4
- package/dist/trust.js +89 -0
- package/dist/ui/app.js +228 -31
- package/dist/ui/banner.js +4 -9
- package/dist/ui/brain-wizard.js +2 -2
- package/dist/ui/history.js +30 -0
- package/dist/ui/mentions.js +44 -0
- package/dist/ui/render.js +55 -15
- package/dist/ui/setup.js +97 -12
- package/dist/ui/useEditor.js +83 -0
- package/dist/update.js +114 -0
- package/dist/worktree.js +173 -0
- package/package.json +11 -5
- package/scripts/postinstall.mjs +33 -0
- package/second-brain/.agents/_Index.md +30 -0
- package/second-brain/.agents/skills/_Index.md +30 -0
- package/second-brain/.agents/workflows/_Index.md +30 -0
- package/second-brain/AGENTS.md +4 -4
- package/second-brain/Acceptance/_Index.md +30 -0
- package/second-brain/Acceptance/golden-case-template.md +39 -0
- package/second-brain/Areas/_Index.md +30 -0
- package/second-brain/Bugs/System-OS/_Index.md +30 -0
- package/second-brain/Bugs/_Index.md +30 -0
- package/second-brain/CLAUDE.md +4 -1
- package/second-brain/Checklists/_Index.md +30 -0
- package/second-brain/Checklists/preflight-postflight-template.md +29 -0
- package/second-brain/Distillations/_Index.md +30 -0
- package/second-brain/Entities/_Index.md +30 -0
- package/second-brain/Entities/entity-template.md +33 -0
- package/second-brain/Evals/_Index.md +30 -0
- package/second-brain/Evals/correction-pairs.md +24 -0
- package/second-brain/Evals/failure-taxonomy.md +24 -0
- package/second-brain/Evals/golden-set.md +25 -0
- package/second-brain/Evals/quality-ledger.md +23 -0
- package/second-brain/Evals/self-eval-rubric.md +23 -0
- package/second-brain/GEMINI.md +4 -4
- package/second-brain/Goals/_Index.md +30 -0
- package/second-brain/Handoffs/_Index.md +30 -0
- package/second-brain/Home.md +7 -0
- package/second-brain/Intake/Raw Sources/_Index.md +30 -0
- package/second-brain/Intake/_Index.md +30 -0
- package/second-brain/Intake/_Quarantine/_Index.md +30 -0
- package/second-brain/Learning/_Index.md +30 -0
- package/second-brain/Playbooks/_Index.md +30 -0
- package/second-brain/Playbooks/playbook-template.md +23 -0
- package/second-brain/Projects/_Index.md +30 -0
- package/second-brain/Prompts/_Index.md +30 -0
- package/second-brain/README.md +2 -1
- package/second-brain/Research/_Index.md +30 -0
- package/second-brain/Retrospectives/_Index.md +30 -0
- package/second-brain/Reviews/_Index.md +30 -0
- package/second-brain/Runbooks/_Index.md +30 -0
- package/second-brain/Runbooks/eval-loop.md +24 -0
- package/second-brain/Sessions/_Index.md +30 -0
- package/second-brain/Shared/AI-Context-Index.md +20 -0
- package/second-brain/Shared/AI-Threads/_Index.md +30 -0
- package/second-brain/Shared/Archive/_Index.md +30 -0
- package/second-brain/Shared/Assets/_Index.md +30 -0
- package/second-brain/Shared/Context-Packs/_Index.md +30 -0
- package/second-brain/Shared/Context7-Docs/_Index.md +30 -0
- package/second-brain/Shared/Coordination/NOW.md +28 -0
- package/second-brain/Shared/Coordination/_Index.md +30 -0
- package/second-brain/Shared/Coordination/agent-registry.md +24 -0
- package/second-brain/Shared/Coordination/task-board/_Index.md +30 -0
- package/second-brain/Shared/Coordination/task-board/task-template.md +43 -0
- package/second-brain/Shared/Coordination/task-board.md +32 -0
- package/second-brain/Shared/Core-Facts/_Index.md +30 -0
- package/second-brain/Shared/Decision-Memory/_Index.md +30 -0
- package/second-brain/Shared/Glossary/_Index.md +30 -0
- package/second-brain/Shared/Memory-Inbox/_Index.md +30 -0
- package/second-brain/Shared/Operating-State/_Index.md +30 -0
- package/second-brain/Shared/Prompting/_Index.md +30 -0
- package/second-brain/Shared/Provenance/_Index.md +30 -0
- package/second-brain/Shared/Rules/_Index.md +30 -0
- package/second-brain/Shared/Rules/contextual-note-rule.md +30 -0
- package/second-brain/Shared/Rules/frontmatter-standard.md +10 -0
- package/second-brain/Shared/Rules/memory-write-protocol.md +28 -0
- package/second-brain/Shared/Rules/procedural-runbook-header.md +40 -0
- package/second-brain/Shared/Rules/review-and-staleness-policy.md +22 -0
- package/second-brain/Shared/Rules/rules-formatting.md +34 -0
- package/second-brain/Shared/Scripts/_Index.md +30 -0
- package/second-brain/Shared/Scripts-Archive/_Index.md +30 -0
- package/second-brain/Shared/Tech-Standards/_Index.md +30 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +40 -0
- package/second-brain/Shared/User-Memory/_Index.md +30 -0
- package/second-brain/Shared/User-Persona/_Index.md +30 -0
- package/second-brain/Shared/User-Persona/owner-profile.md +25 -0
- package/second-brain/Shared/Working-Memory/_Index.md +30 -0
- package/second-brain/Shared/_Index.md +30 -0
- package/second-brain/Shared/mcp-servers/_Index.md +30 -0
- package/second-brain/Skills/_Index.md +30 -0
- package/second-brain/Templates/_Index.md +30 -0
- package/second-brain/Templates/bug.md +2 -0
- package/second-brain/Templates/handoff.md +2 -0
- package/second-brain/Templates/session.md +2 -0
- package/second-brain/Tools/_Index.md +30 -0
- package/second-brain/Traces/_Index.md +30 -0
- package/second-brain/Vault Structure Map.md +33 -1
- package/second-brain/copilot/_Index.md +30 -0
- package/skills/audit-license-compliance/SKILL.md +117 -0
- package/skills/author-codemod/SKILL.md +110 -0
- package/skills/build-audit-logging/SKILL.md +112 -0
- package/skills/build-cdc-streaming-pipeline/SKILL.md +123 -0
- package/skills/build-cli-tool/SKILL.md +108 -0
- package/skills/build-data-table/SKILL.md +141 -0
- package/skills/build-native-mobile-ui/SKILL.md +154 -0
- package/skills/build-offline-first-sync/SKILL.md +118 -0
- package/skills/build-realtime-channel/SKILL.md +122 -0
- package/skills/build-vector-search/SKILL.md +131 -0
- package/skills/compose-local-dev-stack/SKILL.md +149 -0
- package/skills/configure-bundler-build/SKILL.md +166 -0
- package/skills/configure-dns-tls/SKILL.md +142 -0
- package/skills/configure-reverse-proxy-lb/SKILL.md +129 -0
- package/skills/configure-security-headers-csp/SKILL.md +122 -0
- package/skills/contract-testing/SKILL.md +140 -0
- package/skills/datetime-timezone-correctness/SKILL.md +125 -0
- package/skills/debug-ci-pipeline-failure/SKILL.md +134 -0
- package/skills/debug-flaky-tests/SKILL.md +128 -0
- package/skills/defend-llm-prompt-injection/SKILL.md +110 -0
- package/skills/deliver-webhooks/SKILL.md +116 -0
- package/skills/design-api-pagination/SKILL.md +144 -0
- package/skills/design-authorization-model/SKILL.md +119 -0
- package/skills/design-backup-dr-recovery/SKILL.md +113 -0
- package/skills/design-event-sourcing-cqrs/SKILL.md +143 -0
- package/skills/design-multi-tenancy/SKILL.md +100 -0
- package/skills/design-protobuf-grpc-service/SKILL.md +146 -0
- package/skills/design-relational-schema/SKILL.md +129 -0
- package/skills/design-search-index-infra/SKILL.md +151 -0
- package/skills/design-state-machine/SKILL.md +108 -0
- package/skills/design-token-system/SKILL.md +109 -0
- package/skills/distributed-locks-leases/SKILL.md +120 -0
- package/skills/encrypt-sensitive-data/SKILL.md +148 -0
- package/skills/feature-flags-rollout/SKILL.md +130 -0
- package/skills/file-upload-object-storage/SKILL.md +107 -0
- package/skills/fuzz-dynamic-security-test/SKILL.md +111 -0
- package/skills/harden-llm-app-reliability/SKILL.md +126 -0
- package/skills/i18n-localization-setup/SKILL.md +113 -0
- package/skills/idempotency-keys/SKILL.md +107 -0
- package/skills/implement-push-notifications/SKILL.md +142 -0
- package/skills/ingest-webhook-secure/SKILL.md +120 -0
- package/skills/integrate-oauth-oidc/SKILL.md +126 -0
- package/skills/load-stress-test/SKILL.md +129 -0
- package/skills/map-privacy-data-gdpr/SKILL.md +146 -0
- package/skills/model-nosql-data/SKILL.md +118 -0
- package/skills/money-decimal-arithmetic/SKILL.md +123 -0
- package/skills/monitor-ml-drift/SKILL.md +109 -0
- package/skills/numeric-precision-units/SKILL.md +144 -0
- package/skills/optimize-llm-cost-latency/SKILL.md +103 -0
- package/skills/optimize-react-rerenders/SKILL.md +124 -0
- package/skills/orchestrate-agent-workflow/SKILL.md +100 -0
- package/skills/payments-billing-integration/SKILL.md +114 -0
- package/skills/pin-toolchain-versions/SKILL.md +116 -0
- package/skills/plan-strangler-migration/SKILL.md +95 -0
- package/skills/property-based-testing/SKILL.md +108 -0
- package/skills/publish-package-registry/SKILL.md +130 -0
- package/skills/recover-git-state/SKILL.md +119 -0
- package/skills/remediate-web-vulnerabilities/SKILL.md +125 -0
- package/skills/resilience-timeouts-retries/SKILL.md +104 -0
- package/skills/resolve-merge-rebase-conflict/SKILL.md +97 -0
- package/skills/rewrite-git-history/SKILL.md +109 -0
- package/skills/scaffold-cross-platform-app/SKILL.md +137 -0
- package/skills/schema-evolution-compatibility/SKILL.md +121 -0
- package/skills/send-transactional-email/SKILL.md +126 -0
- package/skills/serve-deploy-ml-model/SKILL.md +107 -0
- package/skills/setup-cdn-edge-waf/SKILL.md +107 -0
- package/skills/setup-devcontainer-env/SKILL.md +131 -0
- package/skills/setup-lint-format-precommit/SKILL.md +140 -0
- package/skills/setup-monorepo-tooling/SKILL.md +125 -0
- package/skills/ship-mobile-app-store-release/SKILL.md +137 -0
- package/skills/structured-output-llm/SKILL.md +86 -0
- package/skills/supply-chain-sbom-provenance/SKILL.md +120 -0
- package/skills/test-data-factories/SKILL.md +158 -0
- package/skills/threat-model-stride/SKILL.md +123 -0
- package/skills/train-evaluate-ml-model/SKILL.md +109 -0
- package/skills/unicode-text-correctness/SKILL.md +109 -0
- 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
|
+
}
|