summaryrefslogtreecommitdiffhomepage
path: root/packages/lsp/src/framing.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-11 21:12:03 +0900
committerAdam Malczewski <[email protected]>2026-06-11 21:12:03 +0900
commite7eada4802ceebd86c83bcd6e3eca70152e7f331 (patch)
tree447095fd60b43980358d1565506f3ae2430e5f29 /packages/lsp/src/framing.ts
parent35937cee7f838e414eb8147c67205e01d85a4da0 (diff)
downloaddispatch-e7eada4802ceebd86c83bcd6e3eca70152e7f331.tar.gz
dispatch-e7eada4802ceebd86c83bcd6e3eca70152e7f331.zip
feat(lsp,cwd): LSP integration + per-conversation cwd; fix cache-warming cache bust
LSP + per-conversation CWD feature: - new bundled `lsp` extension: hand-rolled JSON-RPC codec (framing/rpc), lazy one-server-per-(serverID,root), per-cwd config resolution, on-demand `lsp` tool - `conversation-store`: getCwd/setCwd (cwdKey); `session-orchestrator` defaults a turn's cwd from the store - `transport-http`: cwd + lsp status endpoints; wire types in transport-contract - host-bin: register lsp; config wiring Cache-warming fix (the warm read 0% on the first reheat after a message): - warm assembled tools under a different cwd than the real turn (a reheat sends no cwd, and the warm service had no store fallback). The skills filter rewrites the cwd-sensitive `load_skill` description, so the tools block — the first bytes of the prompt-cache prefix — diverged and the cache missed entirely. Warm now resolves cwd as opts.cwd ?? conversationStore.getCwd(), mirroring handleMessage. - capture warm sends as `provider.request` spans flagged `warm:true` (thread a child logger into providerOpts) so warm vs real bodies are diffable (obs §3.1). - kernel logger: span-close now merges child-bound attrs like span-open, so a `warm:true` query finds the closed span (with usage/status), not just the open. Tests: warm forwards a warm-flagged logger; warm falls back to stored cwd; logger open/close attr consistency. Full suite green (873).
Diffstat (limited to 'packages/lsp/src/framing.ts')
-rw-r--r--packages/lsp/src/framing.ts67
1 files changed, 67 insertions, 0 deletions
diff --git a/packages/lsp/src/framing.ts b/packages/lsp/src/framing.ts
new file mode 100644
index 0000000..3a8ab3a
--- /dev/null
+++ b/packages/lsp/src/framing.ts
@@ -0,0 +1,67 @@
+/**
+ * LSP Content-Length framing codec.
+ *
+ * The LSP base protocol uses Content-Length headers to frame JSON messages.
+ * `encode` wraps a JSON message with headers; `FrameDecoder` reassembles
+ * complete messages from streaming byte chunks (handles partial frames and
+ * multiple frames per chunk).
+ */
+
+const HEADER_SEP = "\r\n\r\n";
+const CONTENT_LENGTH_RE = /^Content-Length:\s*(\d+)/i;
+
+export function encode(msg: string): Uint8Array {
+ const body = new TextEncoder().encode(msg);
+ const header = `Content-Length: ${body.length}\r\n\r\n`;
+ const frame = new TextEncoder().encode(header);
+ const result = new Uint8Array(frame.length + body.length);
+ result.set(frame);
+ result.set(body, frame.length);
+ return result;
+}
+
+export class FrameDecoder {
+ private buffer = "";
+ private expectedLength: number | null = null;
+ private headerEnd = -1;
+
+ /**
+ * Feed raw bytes into the decoder. Returns all complete JSON messages
+ * that can be extracted from the accumulated buffer.
+ */
+ decode(chunk: Uint8Array): string[] {
+ this.buffer += new TextDecoder().decode(chunk);
+ const messages: string[] = [];
+
+ while (true) {
+ if (this.expectedLength === null) {
+ const headerEnd = this.buffer.indexOf(HEADER_SEP);
+ if (headerEnd === -1) break;
+
+ const headerPart = this.buffer.slice(0, headerEnd);
+ const match = CONTENT_LENGTH_RE.exec(headerPart);
+ if (!match?.[1]) {
+ this.buffer = this.buffer.slice(headerEnd + HEADER_SEP.length);
+ continue;
+ }
+ this.expectedLength = Number.parseInt(match[1], 10);
+ this.headerEnd = headerEnd;
+ }
+
+ const bodyStart = this.headerEnd + HEADER_SEP.length;
+ const available = this.buffer.length - bodyStart;
+
+ if (available >= this.expectedLength) {
+ const body = this.buffer.slice(bodyStart, bodyStart + this.expectedLength);
+ messages.push(body);
+ this.buffer = this.buffer.slice(bodyStart + this.expectedLength);
+ this.expectedLength = null;
+ this.headerEnd = -1;
+ } else {
+ break;
+ }
+ }
+
+ return messages;
+ }
+}