diff --git a/README.md b/README.md index e68f9385d..d9fff811b 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ To learn more about Pi: | Package | Description | |---------|-------------| +| **[@earendil-works/chord](packages/chord)** | Standalone application-composition runtime for services, replicated state, RPC, and plugins | | **[@earendil-works/pi-telemetry](packages/telemetry)** | Vendor-neutral telemetry contracts, reference adapter, conformance tests, and typed schemas | | **[@earendil-works/pi-ai](packages/ai)** | Unified multi-provider LLM API (OpenAI, Anthropic, Google, etc.) | | **[@earendil-works/pi-agent-core](packages/agent)** | Agent runtime with tool calling and state management | diff --git a/package-lock.json b/package-lock.json index b1ea489c3..a6f227bc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -718,6 +718,10 @@ "node": ">=20" } }, + "node_modules/@earendil-works/chord": { + "resolved": "packages/chord", + "link": true + }, "node_modules/@earendil-works/gondolin": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.12.0.tgz", @@ -5332,6 +5336,7 @@ "version": "0.84.4", "license": "MIT", "dependencies": { + "@earendil-works/chord": "^0.84.4", "@earendil-works/pi-ai": "^0.84.4", "@earendil-works/pi-telemetry": "^0.84.4", "diff": "8.0.4", @@ -5384,6 +5389,18 @@ "node": ">=22.19.0" } }, + "packages/chord": { + "name": "@earendil-works/chord", + "version": "0.84.4", + "license": "MIT", + "devDependencies": { + "shx": "0.4.0", + "vitest": "4.1.9" + }, + "engines": { + "node": ">=22.19.0" + } + }, "packages/client": { "name": "@earendil-works/pi-client", "version": "0.84.4", diff --git a/package.json b/package.json index f05205167..62e5ef813 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ ], "scripts": { "clean": "npm run clean --workspaces", - "build": "cd packages/tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", - "build:offline": "cd packages/tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", + "build": "cd packages/chord && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", + "build:offline": "cd packages/chord && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", "check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:entry-graphs && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke", "check:browser-smoke": "node scripts/check-browser-smoke.mjs", "check:pinned-deps": "node scripts/check-pinned-deps.mjs", diff --git a/packages/agent/package.json b/packages/agent/package.json index 00a486566..c20b62b39 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -55,6 +55,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { + "@earendil-works/chord": "^0.84.4", "@earendil-works/pi-ai": "^0.84.4", "@earendil-works/pi-telemetry": "^0.84.4", "diff": "8.0.4", diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index b94d8d128..065bada5d 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -13,7 +13,7 @@ import { uuidv7, } from "@earendil-works/pi-ai"; import type { AgentMessage, ThinkingLevel } from "../../types.ts"; -import type { Context } from "../context.ts"; +import { type Context, getTelemetryContext } from "../context.ts"; import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage } from "../messages.ts"; import { buildContextEntries, sessionEntryToContextMessages } from "../session/context.ts"; import type { CompactionEntry, Entry, JsonValue } from "../session/types.ts"; @@ -118,7 +118,7 @@ export function createSummaryRequestOptions(options: SimpleStreamOptions, contex return { ...options, signal: context.abortSignal, - telemetryContext: context.telemetryContext, + telemetryContext: getTelemetryContext(context), cacheRetention: "none", sessionId: options.sessionId ?? uuidv7(), }; diff --git a/packages/agent/src/harness/context.ts b/packages/agent/src/harness/context.ts index 03ecb7604..230cf1922 100644 --- a/packages/agent/src/harness/context.ts +++ b/packages/agent/src/harness/context.ts @@ -1,150 +1,27 @@ +import { type Context, createContextKey, withContextValue } from "@earendil-works/chord"; import { NOOP_TELEMETRY_CONTEXT, type TelemetryContext } from "@earendil-works/pi-telemetry"; -/** Typed identity for one value carried by a {@link Context}. */ -export interface ContextKey { - readonly token: symbol; - /** Type-only marker that prevents keys with different value types from being interchangeable. */ - readonly valueType?: (value: T) => T; -} +export { + awaitWithContext, + BACKGROUND_CONTEXT, + type Context, + type ContextKey, + createContextKey, + TODO_CONTEXT, + withAbortSignal, + withCancel, + withContextValue, + withoutAbortSignal, +} from "@earendil-works/chord"; -const ABORT_SIGNAL_CONTEXT_KEY: ContextKey = Object.freeze({ - token: Symbol("pi.abortSignal"), -}); -const TELEMETRY_CONTEXT_KEY: ContextKey = Object.freeze({ token: Symbol("pi.telemetryContext") }); +const TELEMETRY_CONTEXT_KEY = createContextKey("pi.telemetryContext"); -/** Immutable invocation-scoped values passed explicitly through harness operations. */ -export interface Context { - readonly abortSignal: AbortSignal | undefined; - readonly telemetryContext: TelemetryContext; - value(key: ContextKey): T | undefined; - toString(): string; -} - -abstract class BaseContext implements Context { - abstract value(key: ContextKey): T | undefined; - abstract toString(): string; - - get abortSignal(): AbortSignal | undefined { - return this.value(ABORT_SIGNAL_CONTEXT_KEY); - } - - get telemetryContext(): TelemetryContext { - return this.value(TELEMETRY_CONTEXT_KEY) ?? NOOP_TELEMETRY_CONTEXT; - } -} - -class EmptyContext extends BaseContext { - readonly #name: string; - - constructor(name: string) { - super(); - this.#name = name; - } - - value(_key: ContextKey): T | undefined { - return undefined; - } - - toString(): string { - return this.#name; - } -} - -/** One copy-on-write value layered over a parent context. */ -class ContextValue extends BaseContext { - readonly #parent: Context; - readonly #key: ContextKey; - readonly #value: T; - - constructor(parent: Context, key: ContextKey, value: T) { - super(); - this.#parent = parent; - this.#key = key; - this.#value = value; - } - - value(key: ContextKey): Value | undefined { - if (key.token === this.#key.token) return this.#value as unknown as Value; - return this.#parent.value(key); - } - - toString(): string { - return `${this.#parent}.WithValue(${this.#key.token.description ?? "anonymous"})`; - } -} - -/** Root context for work that has no caller. */ -export const BACKGROUND_CONTEXT: Context = new EmptyContext("[Context BACKGROUND_CONTEXT]"); - -/** Temporary root context marking a call site that still needs context propagation. */ -export const TODO_CONTEXT: Context = new EmptyContext("[Context TODO_CONTEXT]"); - -export function createContextKey(description: string): ContextKey { - return Object.freeze({ token: Symbol(description) }); -} - -/** Derive a context containing one additional or replaced value. */ -export function withContextValue(key: ContextKey, value: T, parent: Context): Context { - return new ContextValue(parent, key, value); -} - -/** - * Derive a context cancelled by either the parent signal or the supplied signal. - * The parent context remains unchanged. - */ -export function withAbortSignal(signal: AbortSignal, context: Context): Context { - const parentSignal = context.abortSignal; - const combined = parentSignal === undefined ? signal : AbortSignal.any([parentSignal, signal]); - return withContextValue(ABORT_SIGNAL_CONTEXT_KEY, combined, context); -} - -/** Derive a context retaining all values except caller cancellation. Intended for mandatory cleanup only. */ -export function withoutAbortSignal(context: Context): Context { - return withContextValue(ABORT_SIGNAL_CONTEXT_KEY, undefined, context); -} - -/** Derive an independently cancellable child context. */ -export function withCancel(context: Context): { - readonly context: Context; - readonly cancel: (reason?: unknown) => void; -} { - const controller = new AbortController(); - return { - context: withAbortSignal(controller.signal, context), - cancel: (reason?: unknown) => controller.abort(reason), - }; -} - -/** - * Observe a promise until it settles or the invocation is cancelled. - * Cancellation rejects only this waiter; it does not cancel the underlying promise. - */ -export function awaitWithContext(promise: Promise, context: Context): Promise { - const signal = context.abortSignal; - if (signal === undefined) return promise; - if (signal.aborted) return Promise.reject(abortError(signal)); - return new Promise((resolve, reject) => { - const onAbort = (): void => reject(abortError(signal)); - signal.addEventListener("abort", onAbort, { once: true }); - void promise.then( - (value) => { - signal.removeEventListener("abort", onAbort); - resolve(value); - }, - (error: unknown) => { - signal.removeEventListener("abort", onAbort); - reject(error); - }, - ); - }); +/** Return the telemetry parent attached to a context, or the shared no-op parent. */ +export function getTelemetryContext(context: Context): TelemetryContext { + return context.value(TELEMETRY_CONTEXT_KEY) ?? NOOP_TELEMETRY_CONTEXT; } /** Derive a context whose telemetry children use the supplied parent or active span. */ export function withTelemetryContext(telemetryContext: TelemetryContext, context: Context): Context { return withContextValue(TELEMETRY_CONTEXT_KEY, telemetryContext, context); } - -function abortError(signal: AbortSignal): Error { - const reason: unknown = signal.reason; - return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError"); -} diff --git a/packages/agent/src/harness/execution/assistant.ts b/packages/agent/src/harness/execution/assistant.ts index 62929cd3b..23c58e4b1 100644 --- a/packages/agent/src/harness/execution/assistant.ts +++ b/packages/agent/src/harness/execution/assistant.ts @@ -10,7 +10,7 @@ import type { Tool, } from "@earendil-works/pi-ai"; import type { AgentMessage, ThinkingLevel } from "../../types.ts"; -import type { Context } from "../context.ts"; +import { type Context, getTelemetryContext } from "../context.ts"; import type { SettledAssistantMessage } from "../session/types.ts"; import type { AgentHarnessStreamOptions } from "../types.ts"; import { AbortRequested } from "./effect-gate.ts"; @@ -79,7 +79,7 @@ function createRequestOptions( deferred: options.deferred, ...(config.thinkingLevel === "off" ? {} : { reasoning: config.thinkingLevel }), signal: context.abortSignal, - telemetryContext: context.telemetryContext, + telemetryContext: getTelemetryContext(context), onPayload: config.beforePayload === undefined ? undefined diff --git a/packages/agent/src/harness/runtime/drive/deferred.ts b/packages/agent/src/harness/runtime/drive/deferred.ts index 00cb8679b..1a11a5f8d 100644 --- a/packages/agent/src/harness/runtime/drive/deferred.ts +++ b/packages/agent/src/harness/runtime/drive/deferred.ts @@ -1,5 +1,5 @@ import type { Api, DeferredHandle, Model } from "@earendil-works/pi-ai"; -import { type Context, withAbortSignal } from "../../context.ts"; +import { type Context, getTelemetryContext, withAbortSignal } from "../../context.ts"; import { consumeAssistantStream } from "../../execution/assistant.ts"; import { applyStreamOptionsPatch } from "../../hooks.ts"; import { SessionInvariantError } from "../../session/session.ts"; @@ -196,7 +196,7 @@ async function performDeferredPoll( lane.models.streamDeferred(prepared.model, prepared.source, { wait: 0, signal: admitted.abortSignal, - telemetryContext: admitted.telemetryContext, + telemetryContext: getTelemetryContext(admitted), timeoutMs: prepared.streamOptions.timeoutMs, maxRetries: prepared.streamOptions.maxRetries, maxRetryDelayMs: prepared.streamOptions.maxRetryDelayMs, diff --git a/packages/agent/src/harness/runtime/drive/generation.ts b/packages/agent/src/harness/runtime/drive/generation.ts index ef95da0dd..15d305e69 100644 --- a/packages/agent/src/harness/runtime/drive/generation.ts +++ b/packages/agent/src/harness/runtime/drive/generation.ts @@ -1,6 +1,6 @@ import type { Api, Model, Tool } from "@earendil-works/pi-ai"; import type { AgentMessage } from "../../../types.ts"; -import { type Context, withAbortSignal } from "../../context.ts"; +import { type Context, getTelemetryContext, withAbortSignal } from "../../context.ts"; import { type HarnessAssistantStreamConfig, streamHarnessAssistant } from "../../execution/assistant.ts"; import { applyStreamOptionsPatch } from "../../hooks.ts"; import { SessionInvariantError } from "../../session/session.ts"; @@ -217,7 +217,7 @@ async function performGeneration( ...options, sessionId: `${lane.session.metadata.id}:${lane.name}`, signal: admitted.abortSignal, - telemetryContext: admitted.telemetryContext, + telemetryContext: getTelemetryContext(admitted), }), ); }, diff --git a/packages/agent/src/harness/runtime/drive/reconcile.ts b/packages/agent/src/harness/runtime/drive/reconcile.ts index 0d012b45e..8e99d10db 100644 --- a/packages/agent/src/harness/runtime/drive/reconcile.ts +++ b/packages/agent/src/harness/runtime/drive/reconcile.ts @@ -1,5 +1,6 @@ import type { DeferredHandle } from "@earendil-works/pi-ai"; import type { HarnessEvent } from "../../agent-harness.ts"; +import { getTelemetryContext } from "../../context.ts"; import { SessionInvariantError } from "../../session/session.ts"; import type { DeferredEffectPendingOperation, @@ -25,7 +26,7 @@ async function cancelDeferredBestEffort( try { await lane.models.cancelDeferred(model, handle, { signal: drive.closeSignal, - telemetryContext: drive.context.telemetryContext, + telemetryContext: getTelemetryContext(drive.context), timeoutMs: deferred.streamOptions.timeoutMs, maxRetries: deferred.streamOptions.maxRetries, maxRetryDelayMs: deferred.streamOptions.maxRetryDelayMs, diff --git a/packages/agent/src/harness/runtime/drive/structural.ts b/packages/agent/src/harness/runtime/drive/structural.ts index 4db644e86..ea687811c 100644 --- a/packages/agent/src/harness/runtime/drive/structural.ts +++ b/packages/agent/src/harness/runtime/drive/structural.ts @@ -10,7 +10,7 @@ import type { BranchPreparation, BranchSummaryResult } from "../../compaction/br import { generateBranchSummaryWithRequest } from "../../compaction/branch-summarization.ts"; import type { CompactionPreparation, CompactResult, SummaryRequest } from "../../compaction/compaction.ts"; import { compactWithRequest, prepareCompaction, shouldCompact } from "../../compaction/compaction.ts"; -import { type Context, withAbortSignal } from "../../context.ts"; +import { type Context, getTelemetryContext, withAbortSignal } from "../../context.ts"; import { AbortRequested } from "../../execution/effect-gate.ts"; import { applyStreamOptionsPatch } from "../../hooks.ts"; import { insertEntry, insertUsage } from "../../session/commit.ts"; @@ -787,7 +787,7 @@ function requestStreamOptions( cacheRetention: "none", deferred: false, signal: context.abortSignal, - telemetryContext: context.telemetryContext, + telemetryContext: getTelemetryContext(context), onPayload, }; } diff --git a/packages/agent/src/harness/telemetry.ts b/packages/agent/src/harness/telemetry.ts index dfbbbae66..1fcee7b65 100644 --- a/packages/agent/src/harness/telemetry.ts +++ b/packages/agent/src/harness/telemetry.ts @@ -10,7 +10,7 @@ import type { TelemetrySchemaSpanUnion, TelemetrySpan, } from "@earendil-works/pi-telemetry"; -import { type Context, withTelemetryContext } from "./context.ts"; +import { type Context, getTelemetryContext, withTelemetryContext } from "./context.ts"; export type { AttributeValue, @@ -141,7 +141,7 @@ export function startAiSpan, context: Context) => Result | Promise, context: Context, ): Promise { - return context.telemetryContext.startSpan({ name, attributes }, (span) => + return getTelemetryContext(context).startSpan({ name, attributes }, (span) => callback(span as AiTelemetrySpan, withTelemetryContext(span, context)), ); } @@ -629,7 +629,7 @@ export function startHarnessSpan< callback: (span: HarnessTelemetrySpan, context: Context) => Result | Promise, context: Context, ): Promise { - return context.telemetryContext.startSpan({ name, attributes }, (span: TelemetrySpan) => + return getTelemetryContext(context).startSpan({ name, attributes }, (span: TelemetrySpan) => callback(span as HarnessTelemetrySpan, withTelemetryContext(span, context)), ); } diff --git a/packages/agent/test/harness/context.test.ts b/packages/agent/test/harness/context.test.ts index 1e77a2d90..b540d8948 100644 --- a/packages/agent/test/harness/context.test.ts +++ b/packages/agent/test/harness/context.test.ts @@ -1,102 +1,25 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { - awaitWithContext, BACKGROUND_CONTEXT, - createContextKey, + getTelemetryContext, TODO_CONTEXT, - withAbortSignal, - withCancel, - withContextValue, - withoutAbortSignal, withTelemetryContext, } from "../../src/harness/context.ts"; -import { InMemoryTelemetryContext } from "../../src/index.ts"; +import { InMemoryTelemetryContext, NOOP_TELEMETRY_CONTEXT } from "../../src/index.ts"; -describe("Context", () => { - it("provides distinct empty root contexts", () => { - const key = createContextKey("value"); - - expect(TODO_CONTEXT).not.toBe(BACKGROUND_CONTEXT); - expect(TODO_CONTEXT.abortSignal).toBeUndefined(); - expect(TODO_CONTEXT.value(key)).toBeUndefined(); - expect(TODO_CONTEXT.telemetryContext).toBe(BACKGROUND_CONTEXT.telemetryContext); - expect(String(BACKGROUND_CONTEXT)).toBe("[Context BACKGROUND_CONTEXT]"); - expect(String(TODO_CONTEXT)).toBe("[Context TODO_CONTEXT]"); - }); - - it("layers typed values without modifying parents", () => { - const firstKey = createContextKey("first"); - const secondKey = createContextKey("second"); - const first = withContextValue(firstKey, "one", BACKGROUND_CONTEXT); - const second = withContextValue(secondKey, 2, first); - const replaced = withContextValue(firstKey, "updated", second); - - expect(BACKGROUND_CONTEXT.value(firstKey)).toBeUndefined(); - expect(first.value(firstKey)).toBe("one"); - expect(first.value(secondKey)).toBeUndefined(); - expect(second.value(firstKey)).toBe("one"); - expect(second.value(secondKey)).toBe(2); - expect(replaced.value(firstKey)).toBe("updated"); - expect(second.value(firstKey)).toBe("one"); - expect(String(replaced)).toBe("[Context BACKGROUND_CONTEXT].WithValue(first).WithValue(second).WithValue(first)"); - }); - - it("inherits parent cancellation and isolates child cancellation", () => { - const parentController = new AbortController(); - const parent = withAbortSignal(parentController.signal, BACKGROUND_CONTEXT); - const child = withCancel(parent); - const sibling = withCancel(parent); - const childListener = vi.fn(); - child.context.abortSignal?.addEventListener("abort", childListener); - - child.cancel("child"); - expect(child.context.abortSignal?.aborted).toBe(true); - expect(child.context.abortSignal?.reason).toBe("child"); - expect(sibling.context.abortSignal?.aborted).toBe(false); - expect(parent.abortSignal?.aborted).toBe(false); - expect(childListener).toHaveBeenCalledOnce(); - - parentController.abort("parent"); - expect(sibling.context.abortSignal?.aborted).toBe(true); - expect(sibling.context.abortSignal?.reason).toBe("parent"); - }); - - it("masks caller cancellation for mandatory cleanup", () => { - const controller = new AbortController(); - const key = createContextKey("value"); - const context = withContextValue(key, "preserved", withAbortSignal(controller.signal, BACKGROUND_CONTEXT)); - const cleanup = withoutAbortSignal(context); - - controller.abort(); - expect(context.abortSignal?.aborted).toBe(true); - expect(cleanup.abortSignal).toBeUndefined(); - expect(cleanup.value(key)).toBe("preserved"); - }); - - it("stops waiting when the invocation is cancelled", async () => { - const controller = new AbortController(); - const context = withAbortSignal(controller.signal, BACKGROUND_CONTEXT); - let resolveWork!: (value: string) => void; - const work = new Promise((resolve) => { - resolveWork = resolve; - }); - const waiting = awaitWithContext(work, context); - const cancellation = new Error("cancelled"); - - controller.abort(cancellation); - await expect(waiting).rejects.toBe(cancellation); - resolveWork("completed later"); - await expect(work).resolves.toBe("completed later"); - await expect(awaitWithContext(Promise.resolve("completed"), BACKGROUND_CONTEXT)).resolves.toBe("completed"); +describe("harness Context telemetry", () => { + it("uses no-op telemetry when none is attached", () => { + expect(getTelemetryContext(BACKGROUND_CONTEXT)).toBe(NOOP_TELEMETRY_CONTEXT); + expect(getTelemetryContext(TODO_CONTEXT)).toBe(NOOP_TELEMETRY_CONTEXT); }); it("carries telemetry as an ordinary context value", async () => { const telemetry = new InMemoryTelemetryContext(); const context = withTelemetryContext(telemetry, BACKGROUND_CONTEXT); - await context.telemetryContext.startSpan({ name: "parent" }, async (span) => { + await getTelemetryContext(context).startSpan({ name: "parent" }, async (span) => { const childContext = withTelemetryContext(span, context); - await childContext.telemetryContext.startSpan({ name: "child" }, () => undefined); + await getTelemetryContext(childContext).startSpan({ name: "child" }, () => undefined); }); const spans = telemetry.getSpans(); diff --git a/packages/agent/test/harness/execution-primitives.test.ts b/packages/agent/test/harness/execution-primitives.test.ts index 230a59fdf..1f2abd6ed 100644 --- a/packages/agent/test/harness/execution-primitives.test.ts +++ b/packages/agent/test/harness/execution-primitives.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { BACKGROUND_CONTEXT, createContextKey, + getTelemetryContext, withAbortSignal, withContextValue, withTelemetryContext, @@ -161,7 +162,7 @@ describe("HookRegistry", () => { let receivedValue: string | undefined; hooks.on("before_tool", async (_event, context) => { receivedValue = context.value(valueKey); - await context.telemetryContext.startSpan({ name: "handler.child" }, () => undefined); + await getTelemetryContext(context).startSpan({ name: "handler.child" }, () => undefined); return undefined; }); diff --git a/packages/agent/tsconfig.build.json b/packages/agent/tsconfig.build.json index 7761b01a1..e02014a57 100644 --- a/packages/agent/tsconfig.build.json +++ b/packages/agent/tsconfig.build.json @@ -3,6 +3,7 @@ "compilerOptions": { "outDir": "./dist", "paths": { + "@earendil-works/chord": ["../chord/dist/index.d.ts"], "@earendil-works/pi-telemetry": ["../telemetry/dist/index.d.ts"], "@earendil-works/pi-ai": ["../ai/dist/index.d.ts"], "@earendil-works/pi-ai/*": ["../ai/dist/*.d.ts", "../ai/dist/providers/*.d.ts"] diff --git a/packages/agent/vitest.config.ts b/packages/agent/vitest.config.ts index 39600da16..ad704d072 100644 --- a/packages/agent/vitest.config.ts +++ b/packages/agent/vitest.config.ts @@ -1,6 +1,7 @@ import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; +const chordSrcIndex = fileURLToPath(new URL("../chord/src/index.ts", import.meta.url)); const telemetrySrcIndex = fileURLToPath(new URL("../telemetry/src/index.ts", import.meta.url)); const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url)); const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url)); @@ -16,6 +17,7 @@ export default defineConfig({ }, resolve: { alias: [ + { find: /^@earendil-works\/chord$/, replacement: chordSrcIndex }, { find: /^@earendil-works\/pi-telemetry$/, replacement: telemetrySrcIndex }, { find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, diff --git a/packages/agent/vitest.harness.config.ts b/packages/agent/vitest.harness.config.ts index 964c59a48..e80c27bbe 100644 --- a/packages/agent/vitest.harness.config.ts +++ b/packages/agent/vitest.harness.config.ts @@ -1,6 +1,7 @@ import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; +const chordSrcIndex = fileURLToPath(new URL("../chord/src/index.ts", import.meta.url)); const telemetrySrcIndex = fileURLToPath(new URL("../telemetry/src/index.ts", import.meta.url)); const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url)); const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url)); @@ -23,6 +24,7 @@ export default defineConfig({ }, resolve: { alias: [ + { find: /^@earendil-works\/chord$/, replacement: chordSrcIndex }, { find: /^@earendil-works\/pi-telemetry$/, replacement: telemetrySrcIndex }, { find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, diff --git a/packages/chord/PLANNING.md b/packages/chord/PLANNING.md new file mode 100644 index 000000000..497b5e015 --- /dev/null +++ b/packages/chord/PLANNING.md @@ -0,0 +1,859 @@ +# Chord implementation plan + +> **Status:** Planning document. This describes the intended scope and implementation sequence; it is not a public API contract yet. + +## 1. Goal + +Chord will be the application-neutral foundation for: + +1. loading, composing, unloading, reloading, and bundling plugins; +2. declaring and consuming local or remote services; +3. transporting service calls and subscriptions over symmetric RPC plumbing; and +4. replicating authoritative latest-value state to local and remote consumers. + +The current Pi experiments prove many required behaviors, but Chord will be implemented from scratch. Existing source may be used as test and design evidence, not copied into this package. Compatibility with experimental APIs or wire messages is not a requirement. + +## 2. Dependency boundary + +The dependency direction is strict: + +```text +@earendil-works/chord + ↑ +Pi agent, protocol, server, coding agent, TUI, and future applications +``` + +Chord must: + +- have no dependency on another Pi workspace package; +- contain no imports from `@earendil-works/pi-*` or relative paths outside `packages/chord`; +- use application-neutral vocabulary in source, errors, tests, and examples; +- own any generic runtime types required by its public API, including strict JSON values and invocation cancellation context; +- keep Node-specific loading and bundling separate from the platform-neutral runtime; and +- be buildable, testable, packable, and usable without resolving another Pi package. + +A generic third-party dependency is not prohibited, but each dependency must be justified. The runtime should initially prefer standard JavaScript APIs. The bundler may use one pinned implementation behind a Chord-owned adapter. + +The following terms must not become Chord concepts: Session, Harness, AgentLane, server, client, attachment, TUI, model, tool, hook, provider credential, or workspace. Those belong to consumers. + +## 3. Architectural model + +### 3.1 Working vocabulary + +The names below are provisional, but the distinctions are required. + +- **Plugin**: one independently activated composition unit with a stable ID and synchronous setup function. +- **Plugin module**: a JavaScript module exporting one or more plugins for one application-selected entry. +- **Loaded generation**: plugins plus the resources owned by loading their module generation. +- **Host**: one assembled plugin and service graph. +- **Service token**: a stable runtime service ID plus type information and locality policy. +- **Provider**: the owner of one singleton service or one keyed service collection. +- **Connection**: a transport-neutral source of services outside the current host. +- **Peer**: one endpoint of a symmetric RPC channel. Either peer may provide and consume services. +- **Replicated state**: initialized mutable source state with read-only local or remote replicas. + +A product feature may ship multiple plugin-module entries for different application environments. Chord does not group those entries into a cross-process runtime object and does not interpret their entry names. + +### 3.2 Layering + +The implementation should be divided into these layers: + +```text +plugin loader and bundler + ↓ +plugin host, lifecycle, and dependency graph + ↓ +service tokens, providers, facades, and keyed instances + ↓ +replicated state and service subscriptions + ↓ +symmetric RPC peer and transport adapter + ↓ +strict JSON, invocation context, cancellation, and errors +``` + +The local service path must not require RPC serialization. The remote path must use the same service semantics through a strict wire boundary. + +## 4. Invocation context and strict JSON + +Current experiments depend on Pi's Harness `Context`, which Chord cannot import. Chord therefore needs a small neutral invocation context. + +The initial context should provide: + +- an optional `AbortSignal` for cancellation of one invocation or observation; +- immutable typed local values through Chord-owned context keys; +- a background root; +- child derivation, cancellation, and cancellation-aware waiting helpers; and +- no built-in telemetry, identity, authentication, or application values. + +Applications may define their own context keys. A Pi adapter can carry telemetry and authenticated identity through such keys without Chord knowing their types. + +A context object never crosses RPC as a business value. The calling peer sends cancellation control and, if configured, an opaque strict-JSON metadata carrier. The receiving adapter constructs a fresh local context. The adapter, not remote business arguments, installs authenticated local identity. + +Chord will own a strict `JsonValue` type and runtime check. Remote arguments, results, errors, snapshots, updates, catalogues, and RPC envelopes must be finite strict JSON: + +- finite numbers only; +- no `undefined`, sparse arrays, symbols, prototypes, cycles, classes, functions, `Map`, or `Set`; and +- `null`, rather than `undefined`, for business-level absence. + +Application schema validation remains the application's responsibility. Chord validates generic envelopes and the strict-JSON boundary, not every application's DTO shape. + +## 5. Plugins and lifecycle + +### 5.1 Plugin shape + +The intended author model is equivalent to: + +```ts +interface Plugin { + readonly id: string; + setup(environment: PluginEnvironment): void; +} +``` + +Setup is synchronous declaration. It may: + +- provide a singleton service; +- declare ownership of a keyed service; +- acquire a singleton service handle; +- declare an observation of keyed service instances; +- create replicated state; +- register an activation callback; +- register owned resource cleanup; and +- register final deactivation work. + +Setup must not: + +- invoke a service or read replicated state through an acquired facade; +- perform asynchronous work; +- introduce a new service dependency later from an event handler or activation callback; or +- mutate the active host generation. + +The host records setup calls in a private generation ledger. Plugin authors do not maintain a parallel `requires`/`provides` manifest. + +### 5.2 Assembly + +After every plugin has completed setup, the host must: + +1. collect local provisions and catalogues from configured connections; +2. resolve every hard requirement to exactly one local or connected provision; +3. reject missing providers, duplicate providers, ambiguous connection offers, and singleton/keyed mismatches; +4. reject duplicate plugin IDs; +5. derive provider-to-consumer lifecycle edges; +6. reject dependency cycles; +7. construct local and remote service bindings while handles are still inaccessible; +8. hydrate required connected services; and +9. activate providers before consumers. + +A plugin may provide and consume the same token without creating a self-cycle. Optional dependencies are not part of the first contract; they require a distinct acquisition API later. + +### 5.3 Resource ownership + +Every plugin generation owns: + +- activation callbacks; +- explicit cleanup functions; +- keyed instances added through its provider handle; +- keyed observations and their tasks; and +- service provisions. + +Disposal is idempotent. Consumers deactivate before providers, and each plugin's resources dispose in reverse registration order. Cleanup continues after individual failures and reports one error or an `AggregateError` after all cleanup attempts finish. + +Keyed observation handlers receive a fresh cancellable context. Closing or replacing the instance aborts only that handler task. A handler failure is reported through host policy unless its context was cancelled as normal cleanup. + +### 5.4 Loaded module generations + +Module loading and plugin activation are separate ownership domains: + +```ts +interface LoadedPlugins { + readonly plugins: readonly Plugin[]; + dispose(): Promise; +} + +interface PluginLoader { + load(): Promise; +} +``` + +Required loaders: + +- a static loader for built-ins and tests; +- an ordered combined loader with reverse disposal and startup-failure cleanup; and +- a bundle/module loader for one selected manifest entry. + +The loader owns one source generation. The host owns active plugin lifecycles. A coordinator must deactivate a retired generation before disposing its `LoadedPlugins`. + +JavaScript ESM cannot be physically unloaded. Chord's unload contract means deactivation, removal of service reachability, cleanup, and release of loader-owned references/resources. Reloaded code must use a new content-addressed module URL or other loader generation identity instead of relying on the ESM cache. + +## 6. Loading, unloading, and reload + +### 6.1 Host updates + +Host updates are serialized and have two forms. + +#### Shape-preserving replacement + +A targeted replacement is shape-preserving when each replaced plugin keeps the same: + +- plugin ID; +- required service IDs and modes; and +- provided service IDs and modes. + +The sequence is: + +```text +load replacement module generation +→ run replacement setup +→ validate replacement shape and remote implementations +→ withdraw replaced providers +→ deactivate replaced plugins in reverse dependency order +→ activate replacements in dependency order +→ reconnect local provider slots +→ publish complete remote singleton replacement snapshots +→ dispose the retired loaded generation +``` + +Properties: + +- existing local and remote singleton facades keep object identity; +- captured local methods dispatch to the replacement; +- captured remote methods and replicated-state facades address the replacement; +- while withdrawn, calls fail rather than queue and replicated state is unhydrated; +- keyed instances from the old plugin close; instances reconstructed by the replacement use fresh generations; and +- setup or shape-validation failure leaves the active generation unchanged. + +There is no rollback guarantee after old-plugin deactivation begins. Committed application effects are outside the reload transaction. + +#### Structural replacement + +Adding or removing plugins, changing service shape, or changing connection selection is structural. The first implementation should replace the complete host generation rather than attempt a partial affected-subgraph update: + +```text +load and synchronously set up the complete desired generation +→ resolve and validate its graph without activation effects +→ begin cutover +→ withdraw old providers and deactivate the old graph +→ install and activate the new graph +→ dispose the old loaded generation +``` + +Rules: + +- validation failure before cutover leaves the old graph active; +- after cutover starts, failure may leave the host degraded and is not described as rollback; +- removing a provider while retaining a hard consumer is rejected during candidate validation; +- service facades owned outside plugin lifecycles remain stable when service ID and mode survive the replacement; +- removed services become permanently disconnected for the retired generation; and +- a future optimization may retain unaffected plugins, but it is not required for the initial structural update. + +This full-generation path supplies actual plugin load and unload semantics without first implementing a complex partial-graph transaction. + +### 6.2 Calls during unload + +Provider withdrawal rejects new calls. The runtime must generation-fence admitted calls and state publication so old completions cannot update a replacement provider. + +The initial implementation must explicitly choose and test one admitted-call drain policy before module-loader disposal: + +1. wait for admitted calls to settle while signalling plugin deactivation separately; or +2. cancel them through a provider-generation signal, then wait for settlement. + +Silently abandoning admitted promises is not acceptable. Transport disconnect still cancels only connection-owned invocations; it does not imply application-level cancellation or rollback. + +### 6.3 Update failure reporting + +An update result must distinguish: + +- load failure; +- setup or graph validation failure before cutover; +- old-generation deactivation failure; +- replacement activation failure; +- service rebinding failure; and +- retired loader disposal failure. + +Multiple failures are aggregated without hiding the first transition failure. Concurrent update, unload, and host-dispose requests are serialized or rejected with a stable lifecycle error. + +## 7. Services + +### 7.1 Tokens and modes + +A service token has: + +- a non-empty stable string ID; +- a TypeScript contract type; +- a locality policy: remotely exposable by default or explicitly process-local; and +- no provider instance of its own. + +Chord reserves a prefix for control-plane IDs. Duplicate IDs in one catalogue are invalid. + +Two modes are required: + +- **singleton**: one provider, many consumers; and +- **keyed**: one collection owner, dynamic instances, many observers. + +One token has one mode in one host graph. Mixing singleton and keyed use is an assembly or protocol error. + +### 7.2 Local services + +Process-local services: + +- may expose arbitrary objects, synchronous methods, classes, functions, native handles, or non-JSON values; +- are never included in a remote catalogue; +- use the same graph ordering, stable handles, keyed generations, and lifecycle behavior as remote services; and +- are trusted composition, not a security boundary. + +A local singleton consumer receives a stable lazy facade, not the provider object. This removes setup-order dependence and lets provider replacement update captured methods. + +### 7.3 Remote service contracts + +A remotely exposable implementation may contain only own data properties classified as: + +- asynchronous methods whose business arguments and result are strict JSON, with one Chord invocation context in the declared position; or +- branded replicated-state values. + +The provider derives a runtime member table from the implementation. Plugin authors do not maintain a second method/state descriptor. Unsupported accessors, fields, or member kinds are rejected before publication. + +Type-level checks should reject obviously invalid remote contracts. Runtime checks remain mandatory because types do not authenticate peers or survive JavaScript consumption. + +Remote methods: + +- return promises; +- return strict JSON or `void`; +- receive a fresh local invocation context; +- map caller cancellation to exactly one request; +- do not queue while disconnected; and +- expose stable error codes and sanitized messages across the wire. + +Arbitrary returned object references, callbacks, function serialization, and general object-graph remoting are outside the initial scope. + +### 7.4 Stable singleton facades + +`use(token)` returns one source-independent facade per host service slot. Member slots are created lazily on property access and validated against provider metadata when bound. + +Required behavior: + +- inaccessible during setup; +- directly bound after local graph assembly or remote hydration; +- stable across provider withdrawal and replacement; +- method invocation fails while disconnected; +- state reads return `undefined` while unhydrated; +- replacement cannot change an already-observed member between method and state; and +- a provider omitting an already-accessed member is a binding error. + +### 7.5 Keyed services + +`provideMany(token)` declares one keyed owner during setup and returns a generation-owned collection handle. While active, `add(key, implementation)`: + +- requires a non-empty key unique among live instances; +- creates a host-owned monotonically increasing generation for that key; +- publishes methods and initial state atomically; +- returns an idempotent close function; and +- is automatically closed during plugin disposal. + +An instance address is `(service ID, key, generation)`. Reusing a closed key creates a new generation. Stale facades cannot call the replacement. + +`observe(token, handler)`: + +- reconciles a complete initial instance directory; +- starts one task per instance only after all initial state members hydrate; +- preserves ordered additions, replacements, and removals; +- aborts the instance task on close, replacement, disconnect, or observation disposal; and +- never represents service facades inside replicated JSON state. + +## 8. Replicated state + +Replicated state is authoritative one-writer latest-value replication. + +The source API has an initialized value, `set(value, context)`, and subscriptions. A remote or disconnected replica has `value === undefined` until hydration. + +Required semantics: + +1. The source is always initialized. +2. Source and replica values are strict JSON for remotely exposable state. +3. Subscribing to initialized local or hydrated replica state immediately delivers the current value with a fresh delivery context. +4. Subscribing to cold state registers without immediate delivery. +5. Subscription establishment installs update capture before taking the snapshot. +6. Updates racing the snapshot are buffered and delivered after the snapshot with no gap. +7. Updates carry a provider-binding-local sequence and are applied only in order. +8. A sequence gap clears readiness and triggers complete resubscription or reports a terminal binding error; stale state must not continue as current silently. +9. Disconnect, provider withdrawal, route change, and replacement clear replica readiness. +10. Reconnect or replacement installs a complete fresh snapshot in the existing state facade before later updates. +11. Listener exceptions are isolated and reported through host policy. +12. Values are borrowed immutable data. Chord does not defensively clone local reads, local writes, or local listener delivery. Wire encoding naturally detaches remote data, but callers must not depend on identity. + +State identity is structural: + +```text +provider binding + service ID + optional keyed address + member name +``` + +There is no separate state ID. + +Explicit non-goals: + +- durability or reconstruction after process restart; +- event history; +- patch or delta replication in the initial API; +- CRDT merging or multiple writers; +- offline mutation replay; +- automatic unchanged-value suppression; and +- high-frequency stream transport. + +A later delta primitive requires a concrete feature and a separate snapshot/delta/gap-recovery design. + +## 9. Symmetric RPC plumbing + +### 9.1 No client/server mode + +Chord must not expose `Client`, `Server`, `SessionConnection`, or similar topology classes. It exposes a symmetric peer over an application-supplied duplex channel. Either endpoint may register handlers, provide services, call methods, or subscribe to state. + +Application adapters own: + +- sockets, pipes, WebSockets, workers, or loopback delivery; +- framing and reconnect policy; +- authentication and authorization; +- routing and target selection; +- process ownership; +- attachment or selection state; and +- application protocol envelopes surrounding a Chord message. + +Chord sees one connected peer and already-decoded strict-JSON messages. + +### 9.2 RPC layer + +The generic RPC peer should own: + +- request ID allocation scoped to one peer connection; +- request/response correlation; +- handler registration; +- cancellation frames and one request-local `AbortController`; +- disconnect rejection and cleanup; +- stable serializable error envelopes; +- ordered notification delivery; +- malformed-message rejection; and +- optional context metadata hooks. + +The channel contract should be small: send one strict-JSON message, receive messages in order, observe close, and close. `send()` should be awaitable so adapters can provide backpressure. + +A peer disconnect: + +- rejects outbound requests; +- aborts inbound request contexts; +- closes service subscriptions and keyed observation tasks; and +- does not cancel application-owned jobs or perform durable business mutations. + +### 9.3 Service protocol over RPC + +The service layer adds transport-neutral operations for: + +- catalogue discovery; +- singleton and keyed subscription open/close; +- complete subscription snapshots; +- method invocation; +- invocation cancellation; +- replicated-state updates; +- singleton unavailable/replaced events; and +- keyed instance spawned/closed events. + +The wire protocol contains no server ID, session ID, attachment ID, route, user identity, or host kind. A Pi router may wrap or forward Chord envelopes using its own control fields without parsing service business payloads. + +Provider catalogue and subscription state must come from actual service provisions, not a handwritten application inventory. + +### 9.4 Wire errors and validation + +Chord needs stable generic error codes for: + +- service not allowed or absent; +- mode mismatch; +- member absent or used with the wrong kind; +- keyed instance absent or stale; +- invalid strict-JSON value; +- cancellation; +- malformed RPC message; +- peer disconnected; and +- internal provider failure. + +Unexpected provider exceptions become a sanitized internal error by default. Stack traces and arbitrary exception fields do not cross the wire. Applications may register or map additional stable error codes through an adapter, but Chord does not own application error taxonomies. + +### 9.5 Protocol evolution + +The first implementation must version Chord's RPC/service envelope independently of any application protocol. Version negotiation can be a peer handshake or an adapter-guaranteed constructor parameter, but incompatible peers must fail before service calls are admitted. + +Member and DTO compatibility across plugin-generation skew is an application responsibility. Chord guarantees only its generic envelope semantics. + +## 10. Bundling + +### 10.1 Purpose + +The bundler turns one or more application-declared plugin entries into independently loadable Node ESM artifacts. It is not a package manager or plugin registry. + +Each entry is built independently. Chord does not assume names such as `server`, `session`, `tui`, or `web`; entry names are opaque application data. + +### 10.2 Initial input and output + +The initial bundler should accept: + +- plugin identity and optional version metadata; +- a mapping of opaque entry names to TypeScript or JavaScript source files; +- an output directory; +- an application-supplied external-module allowlist; +- source-map and minification options; and +- optional define/platform settings needed for Node ESM. + +It should emit: + +- one content-addressed ESM file per entry; +- source maps when enabled; +- a versioned strict-JSON manifest; +- content hashes or integrity values; +- declared external imports; and +- enough metadata for diagnostics and cache-busting generation loads. + +Writes should use a temporary output directory followed by an atomic rename so a loader never sees a half-written generation. + +### 10.3 Bundle rules + +- `@earendil-works/chord` must be externalized so a plugin uses the host's one runtime and branding symbols. +- Other dependencies are bundled by default unless the application explicitly externalizes them. +- Built-in module use may be allowed for Node entries but is not a trust or sandbox policy. +- Dynamic imports and unresolved externals must be reported deterministically. +- Bundle output must not depend on Pi's repository path aliases. +- Rebuilding unchanged inputs should produce stable content except for documented metadata. +- Diagnostics must identify the entry and original source location. + +The concrete bundler engine is an implementation detail. Select it with a spike covering TypeScript, ESM, source maps, externals, content hashing, and programmatic diagnostics before adding a dependency. + +### 10.4 Bundle loading + +The bundle loader must: + +1. parse and validate the manifest; +2. select one application-requested entry; +3. verify integrity before activation when integrity is present; +4. load it under a fresh generation identity; +5. validate that its exports are plugins with unique non-empty IDs; +6. return `LoadedPlugins`; and +7. provide idempotent loader disposal. + +Plugin discovery, installation, version resolution, download, signature trust, and update policy remain application responsibilities. + +## 11. Pi migration boundary + +The following existing files describe behavior that should become Chord responsibility through a rewrite: + +| Existing area | Chord responsibility | +|---|---| +| `packages/agent/src/plugins/services/types.ts` | service tokens, modes, remote contract checks, strict JSON, snapshots, updates, connection interfaces | +| `packages/agent/src/plugins/services/replicated-state.ts` | authoritative replicated state and delivery semantics | +| `packages/agent/src/plugins/services/provider.ts` | provider classification, calls, singleton replacement, keyed generations, snapshots | +| `packages/agent/src/plugins/services/namespace.ts` | stable remote facades, hydration, state updates, keyed observation | +| `packages/coding-agent/src/experimental/facets.ts` | plugin environment, dependency ledger, lifecycle graph, host, reload | +| `packages/coding-agent/src/experimental/facet-loader.ts` | static and combined loaders plus loaded-generation ownership | +| generic service sections of `packages/protocol/src/protocol.ts` | Chord-owned versioned service/RPC envelope | + +The following must remain outside Chord: + +| Existing area | Downstream responsibility | +|---|---| +| `packages/coding-agent/src/experimental/services/connection.ts` | Pi connection state, selected-session attachment, route rebinding, Pi client adapter | +| `packages/coding-agent/src/experimental/services/server.ts` | server-wide session directory and management implementations | +| `packages/coding-agent/src/experimental/services/worker.ts` | Session worker host construction and Pi protocol publication adapter | +| `packages/server`, `packages/client`, and process managers | framing, routing, authentication, attachment, process lifecycle, reconnect policy | +| slash-command, model, account, transcript, TUI, and agent-controller services | application contracts and plugin implementations | +| `source-resolver.ts` and Pi internal process entrypoints | Pi source execution and process policy | + +`packages/agent/docs/plugins.md`, `packages/agent/docs/rpc.md`, the experimental service tests, and the remote plugin fixture are behavioral input. They are not normative Chord APIs. Once migration finishes, generic semantics should be documented in Chord and Pi documents should cover only their host-specific contracts and adapters. + +Migration should happen only after Chord passes its standalone conformance suite: + +1. implement Chord without changing Pi callers; +2. add thin Pi adapters and migrate generic service imports; +3. migrate the experimental plugin host and loaders; +4. adapt Pi's routed protocol to carry Chord envelopes; +5. run local, loopback, framed, keyed-generation, reload, and TUI integration tests; and +6. delete duplicate experimental generic implementations only after all consumers use Chord. + +No compatibility shim is required unless separately requested. + +## 12. Proposed source layout + +This is a planning aid, not a requirement to create all files immediately. + +```text +packages/chord/ + src/ + index.ts platform-neutral public API + context.ts neutral invocation context and cancellation + json.ts strict JSON types and validation + errors.ts lifecycle, service, and RPC errors + state.ts source and replica state primitives + services/ + types.ts tokens, contracts, modes, snapshots + provider.ts singleton/keyed provider runtime + facade.ts stable local and remote facades + host-bindings.ts graph-facing service slots + rpc/ + peer.ts symmetric request/cancel plumbing + protocol.ts versioned generic envelopes and parsing + services.ts service protocol over a peer + loopback.ts deterministic in-memory duplex transport + plugins/ + types.ts plugin and environment types + lifecycle.ts activation and resource ownership + graph.ts validation and ordering + host.ts start, update, reload, dispose + loader.ts static and combined loaders + node/ + bundle.ts Node ESM bundler + bundle-loader.ts manifest validation and generation loading + test/ + ... + test-fixtures/ + bundled-plugin/ + README.md + PLANNING.md +``` + +If Node-only APIs are exported, they should use a separate package export such as `@earendil-works/chord/node` or `@earendil-works/chord/bundler`; importing the main runtime must not load Node-only modules. + +## 13. Work packages + +### WP0 — Contract decisions and test harness + +Deliver: + +- settle public vocabulary and the invocation-context shape; +- settle the admitted-call unload policy; +- settle protocol version negotiation; +- choose whether remote context position is fixed and trailing; +- create deterministic in-memory duplex and controllable race test helpers; +- add package-boundary checks preventing Pi imports; and +- add compile-only fixtures for valid and invalid remote contracts. + +Exit condition: every later work package can target explicit behavior without importing experimental implementations. + +### WP1 — Foundations and replicated state + +Deliver: + +- strict JSON type/checking; +- neutral context, cancellation, and cancellation-aware waiting; +- mutable authoritative state; +- cold replica state; +- snapshot hydration, ordered updates, clear, and rehydrate; +- borrowed immutable value contract; and +- listener error reporting. + +Tests include cold/hydrated subscriptions, immediate delivery, source update order, snapshot/update races, sequence gaps, cancellation, invalid JSON, and listener failure isolation. + +### WP2 — Symmetric RPC peer + +Deliver: + +- versioned peer envelopes and validation; +- request/response correlation; +- inbound handler dispatch; +- cancellation and disconnect behavior; +- serializable sanitized errors; +- ordered notifications; and +- loopback transport. + +Tests include requests initiated from both peers, crossed concurrent requests, duplicate/unknown IDs, pre-aborted requests, cancellation isolation, malformed messages, send failure, disconnect during calls, and handler exceptions. + +### WP3 — Service provider and facades + +Deliver: + +- service tokens and locality policy; +- singleton and keyed provider registration; +- implementation member classification; +- local service slots and stable facades; +- remote method invocation over `RpcPeer`; +- catalogues generated from provisions; +- keyed generations and stale-call fencing; and +- service error mapping. + +Tests include local and loopback paths, captured method stability, provider withdrawal/replacement, mode errors, unsupported members, strict-JSON argument/result checks, local-only isolation, keyed reuse, and concurrent callers. + +### WP4 — Remote state and service subscriptions + +Deliver: + +- complete singleton and keyed subscription snapshots; +- atomic snapshot/update buffering; +- state update publication; +- singleton unavailable/replaced events; +- keyed directory hydration and ordered reconciliation; +- observer task cancellation; and +- clear/rehydrate on connection replacement. + +Tests include late subscribers, updates racing hydration, multiple state members, provider replacement, keyed hydration before handlers, disconnect, reconnect, sequence gaps, stale frames, and observer errors. + +### WP5 — Plugin host and graph + +Deliver: + +- plugin/environment APIs; +- setup-derived ledger; +- graph validation and topological ordering; +- local and connected service resolution; +- lifecycle ownership; +- startup failure cleanup; +- host disposal; and +- static loader integration. + +Tests include consumer-before-provider setup, missing/duplicate/ambiguous providers, mode mismatch, cycles, asynchronous setup rejection, activation order, reverse cleanup, cleanup aggregation, connection hydration failure, and service access guards. + +### WP6 — Generation loading, unload, and reload + +Deliver: + +- combined loaders; +- complete structural generation replacement; +- shape-preserving targeted reload; +- stable surviving service slots; +- keyed instance retirement; +- generation fencing for late calls and updates; +- admitted-call drain/cancel policy; and +- precise failure reporting and loader disposal order. + +Tests include load failure cleanup, setup failure with old generation retained, unload with retained hard consumer rejection, provider gaps, captured local and remote methods, replacement snapshots, activation failure after cutover, disposal failure aggregation, concurrent update/dispose, and old-generation late publication. + +### WP7 — Node ESM bundler and bundle loader + +Deliver: + +- bundler-engine spike and decision; +- programmatic bundler API; +- versioned manifest; +- independent content-addressed entries; +- source maps and diagnostics; +- Chord externalization; +- atomic output replacement; +- manifest/integrity validation; and +- fresh-generation loading. + +Tests bundle an application-neutral fixture with two opaque entries and a third-party dependency, load each independently, activate it, reload changed source, prove unchanged source hashes are stable, reject corrupt manifests/integrity, and prove output resolves no Pi packages. + +### WP8 — Pi adoption + +This work is downstream of Chord rather than an implementation dependency. + +Deliver: + +- Pi adapters for context metadata and routed RPC transport; +- migration of experimental service and plugin host consumers; +- preservation of selected-session and TUI behavior outside Chord; +- framed integration and reload coverage; and +- removal of superseded generic experimental code. + +Exit condition: Pi depends on Chord, while Chord remains independently packable and contains no Pi imports. + +## 14. Required conformance matrix + +The standalone suite must cover at least: + +### Plugin graph and lifecycle + +- declaration only during setup; +- setup service access rejection; +- deterministic graph ordering; +- missing, duplicate, ambiguous, and cyclic dependencies; +- activation and reverse disposal; +- startup and disposal failure aggregation; +- local and connected providers in one graph; +- complete structural load/unload; and +- shape-preserving reload. + +### Services + +- singleton and keyed modes; +- unrestricted local contracts; +- remote member classification; +- strict JSON and `void` results; +- stable local and remote facades; +- provider withdrawal and replacement; +- keyed generation fencing; +- per-call cancellation; and +- connection and host access guards. + +### Replication + +- initialized source and cold replica; +- immediate hydrated subscription delivery; +- snapshot/update race freedom; +- ordered updates and gap handling; +- disconnect clearing; +- replacement and reconnect rehydration; +- keyed state before observer startup; and +- listener/handler cleanup. + +### RPC + +- symmetric calls in both directions; +- request correlation and cancellation isolation; +- malformed and oversized-message policy; +- sanitized errors; +- disconnect cleanup; +- subscription cleanup; +- protocol-version mismatch; and +- deterministic in-memory and framed-adapter tests. + +### Loading and bundling + +- loader order and reverse disposal; +- generation cache busting; +- manifest validation and integrity; +- independent entries; +- dependency bundling and explicit externals; +- source maps and diagnostics; +- atomic output; and +- packed-package execution outside the monorepo. + +Race tests should control exact points rather than use timing: subscription capture versus state update, request admission versus cancellation, provider withdrawal versus invocation, instance close versus call, reload cutover versus update publication, and host dispose versus activation. + +## 15. Non-goals for the initial implementation + +- Pi host APIs or built-in Pi services; +- plugin discovery, installation, download, registry, or package resolution; +- trust policy, code signing, sandboxing, or capability security; +- network listeners, socket framing, reconnect loops, routing, or authentication; +- server/client roles or fixed process topologies; +- durable state, database integration, migrations, or transactional application writes; +- CRDTs, offline writes, mutation replay, or generic delta replication; +- arbitrary object remoting, callbacks, remote references, or garbage collection of references; +- serialized UI trees, remote tools, or remote hooks; +- generic contribution registries; applications can expose these as process-local services; +- automatic retries of mutating calls after uncertain disconnects; and +- backward compatibility with experimental Pi APIs or wire formats. + +## 16. Decisions required before implementation + +The following decisions should be recorded in this document or small ADRs before WP1/WP2/WP7 begins: + +1. Final public names: `Plugin` versus `Facet`, `Host`, `Peer`, and `Connection`. +2. The exact neutral context API and remote method context position. +3. Whether context metadata propagation is in the first RPC envelope or added with a compatible optional field. +4. Wait-versus-cancel policy for admitted provider calls during unload. +5. Peer protocol negotiation and maximum message-size ownership. +6. State sequence-gap recovery: automatic resubscribe versus terminal binding failure. +7. Bundler engine and its runtime/development dependency placement. +8. Manifest schema, integrity algorithm, and external-module resolution contract. +9. Whether browser-compatible core behavior is an immediate tested requirement or only an architectural constraint. + +None of these decisions should introduce Pi concepts into Chord. + +## 17. Definition of done + +Chord's initial scope is complete when: + +- a standalone application unrelated to Pi can define local and remote singleton/keyed services; +- two symmetric peers can call each other, cancel calls, subscribe, disconnect, and rehydrate; +- replicated state satisfies the snapshot/update and replacement semantics above; +- plugins activate from a validated graph and own all registered resources; +- a running host can load, unload, structurally replace, and shape-preservingly reload plugin generations; +- stable service facades survive provider replacement as specified; +- the Node bundler emits and reloads independent content-addressed plugin entries; +- all race and lifecycle conformance tests pass; +- the packed package works outside the Pi monorepo; and +- an automated boundary check proves that Chord has no imports or dependencies on the rest of Pi. diff --git a/packages/chord/README.md b/packages/chord/README.md new file mode 100644 index 000000000..7b50cdb0d --- /dev/null +++ b/packages/chord/README.md @@ -0,0 +1,52 @@ +# @earendil-works/chord + +Chord is an application-composition runtime for systems assembled from +plugins/extensions. It is built on services, replicated state, and automatic +RPC. It is being developed as a standalone package in the Pi monorepo, but it +is not a Pi package: it does not depend on any other Pi workspace package and is +built to also be usable by unrelated applications. + +## What Chord is for + +A single application feature may need to run in several environments: for +example, an agent worker, a terminal UI, and a remote WebUI. Chord provides the +generic machinery to write such extensions in a way that is both delightful for +humans as well as agents. + +The design has a few connected pieces: + +- **Plugins** are synchronous setup units that declare the services they provide + and require. After every plugin has declared its shape, a host validates the + complete dependency graph, binds services, activates providers before consumers, + and disposes resources in reverse dependency order. These units are called + *facets*. + +- **Facets** are parts of a plugin. Each facet is bundled up separately and runs + in the process or environment where it's supposed to run. You can use facets + to split a plugin into separate pieces that need to be loaded into different + processes and environments (think backend, browser, TUI etc.) + +- **Services** are typed, stable tokens with either one provider (**singleton**) + or dynamic keyed instances (**keyed**). A service can be process-local, with + an unrestricted JavaScript contract, or remotely exposable. Consumers retain a + stable facade while a provider disconnects or is replaced. + +- **Replicated state** exposes authoritative state to local and remote + connected consumers. Replicas become ready from a complete snapshot, apply + ordered updates, and become unready on disconnect or replacement until they + are rehydrated. + +- **RPC** connects two symmetric peers over an application-supplied duplex + transport. Either peer may provide and consume services. Remote calls, + state snapshots, updates, catalogues, and control messages use strict JSON and + support cancellation. + +- **Context** Chord provides a Go-like context system for cancellation and + invocation-scoped application values. Applications can carry permissions or + telemetry through those values without Chord depending on either. + +Chord bundles application-declared plugin-module facets into independently +loadable Node ESM artifacts. + +See [PLANNING.md](PLANNING.md) for the architecture, implementation order, +migration boundary, and conformance criteria. diff --git a/packages/chord/package.json b/packages/chord/package.json new file mode 100644 index 000000000..3eb369a79 --- /dev/null +++ b/packages/chord/package.json @@ -0,0 +1,47 @@ +{ + "name": "@earendil-works/chord", + "version": "0.84.4", + "description": "Application composition runtime for services, replicated state, RPC, and plugins", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "clean": "shx rm -rf dist", + "build": "tsgo -p tsconfig.build.json", + "test": "vitest --run", + "prepublishOnly": "npm run clean && npm run build" + }, + "keywords": [ + "application", + "plugins", + "rpc", + "services", + "state" + ], + "author": "Earendil Works", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/earendil-works/pi.git", + "directory": "packages/chord" + }, + "engines": { + "node": ">=22.19.0" + }, + "devDependencies": { + "shx": "0.4.0", + "vitest": "4.1.9" + } +} diff --git a/packages/chord/src/context.ts b/packages/chord/src/context.ts new file mode 100644 index 000000000..99688ba7b --- /dev/null +++ b/packages/chord/src/context.ts @@ -0,0 +1,137 @@ +/** Typed identity for one value carried by a {@link Context}. */ +export interface ContextKey { + readonly token: symbol; + /** Type-only marker that prevents keys with different value types from being interchangeable. */ + readonly valueType?: (value: T) => T; +} + +const ABORT_SIGNAL_CONTEXT_KEY: ContextKey = Object.freeze({ + token: Symbol("chord.abortSignal"), +}); + +/** Immutable invocation-scoped values passed explicitly through operations. */ +export interface Context { + readonly abortSignal: AbortSignal | undefined; + value(key: ContextKey): T | undefined; + toString(): string; +} + +abstract class BaseContext implements Context { + abstract value(key: ContextKey): T | undefined; + abstract toString(): string; + + get abortSignal(): AbortSignal | undefined { + return this.value(ABORT_SIGNAL_CONTEXT_KEY); + } +} + +class EmptyContext extends BaseContext { + readonly #name: string; + + constructor(name: string) { + super(); + this.#name = name; + } + + value(_key: ContextKey): T | undefined { + return undefined; + } + + toString(): string { + return this.#name; + } +} + +/** One copy-on-write value layered over a parent context. */ +class ContextValue extends BaseContext { + readonly #parent: Context; + readonly #key: ContextKey; + readonly #value: T; + + constructor(parent: Context, key: ContextKey, value: T) { + super(); + this.#parent = parent; + this.#key = key; + this.#value = value; + } + + value(key: ContextKey): Value | undefined { + if (key.token === this.#key.token) return this.#value as unknown as Value; + return this.#parent.value(key); + } + + toString(): string { + return `${this.#parent}.WithValue(${this.#key.token.description ?? "anonymous"})`; + } +} + +/** Root context for work that has no caller. */ +export const BACKGROUND_CONTEXT: Context = new EmptyContext("[Context BACKGROUND_CONTEXT]"); + +/** Temporary root context marking a call site that still needs context propagation. */ +export const TODO_CONTEXT: Context = new EmptyContext("[Context TODO_CONTEXT]"); + +export function createContextKey(description: string): ContextKey { + return Object.freeze({ token: Symbol(description) }); +} + +/** Derive a context containing one additional or replaced value. */ +export function withContextValue(key: ContextKey, value: T, parent: Context): Context { + return new ContextValue(parent, key, value); +} + +/** + * Derive a context cancelled by either the parent signal or the supplied signal. + * The parent context remains unchanged. + */ +export function withAbortSignal(signal: AbortSignal, context: Context): Context { + const parentSignal = context.abortSignal; + const combined = parentSignal === undefined ? signal : AbortSignal.any([parentSignal, signal]); + return withContextValue(ABORT_SIGNAL_CONTEXT_KEY, combined, context); +} + +/** Derive a context retaining all values except caller cancellation. Intended for mandatory cleanup only. */ +export function withoutAbortSignal(context: Context): Context { + return withContextValue(ABORT_SIGNAL_CONTEXT_KEY, undefined, context); +} + +/** Derive an independently cancellable child context. */ +export function withCancel(context: Context): { + readonly context: Context; + readonly cancel: (reason?: unknown) => void; +} { + const controller = new AbortController(); + return { + context: withAbortSignal(controller.signal, context), + cancel: (reason?: unknown) => controller.abort(reason), + }; +} + +/** + * Observe a promise until it settles or the invocation is cancelled. + * Cancellation rejects only this waiter; it does not cancel the underlying promise. + */ +export function awaitWithContext(promise: Promise, context: Context): Promise { + const signal = context.abortSignal; + if (signal === undefined) return promise; + if (signal.aborted) return Promise.reject(abortError(signal)); + return new Promise((resolve, reject) => { + const onAbort = (): void => reject(abortError(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + void promise.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); +} + +function abortError(signal: AbortSignal): Error { + const reason: unknown = signal.reason; + return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError"); +} diff --git a/packages/chord/src/index.ts b/packages/chord/src/index.ts new file mode 100644 index 000000000..6fd72b3f8 --- /dev/null +++ b/packages/chord/src/index.ts @@ -0,0 +1,6 @@ +/** + * Chord is a standalone application-composition runtime. + * + * Its public API is under development. + */ +export * from "./context.ts"; diff --git a/packages/chord/test/context.test.ts b/packages/chord/test/context.test.ts new file mode 100644 index 000000000..7360a04ee --- /dev/null +++ b/packages/chord/test/context.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; +import { + awaitWithContext, + BACKGROUND_CONTEXT, + createContextKey, + TODO_CONTEXT, + withAbortSignal, + withCancel, + withContextValue, + withoutAbortSignal, +} from "../src/index.ts"; + +describe("Context", () => { + it("provides distinct empty root contexts", () => { + const key = createContextKey("value"); + + expect(TODO_CONTEXT).not.toBe(BACKGROUND_CONTEXT); + expect(TODO_CONTEXT.abortSignal).toBeUndefined(); + expect(TODO_CONTEXT.value(key)).toBeUndefined(); + expect(String(BACKGROUND_CONTEXT)).toBe("[Context BACKGROUND_CONTEXT]"); + expect(String(TODO_CONTEXT)).toBe("[Context TODO_CONTEXT]"); + }); + + it("layers typed values without modifying parents", () => { + const firstKey = createContextKey("first"); + const secondKey = createContextKey("second"); + const first = withContextValue(firstKey, "one", BACKGROUND_CONTEXT); + const second = withContextValue(secondKey, 2, first); + const replaced = withContextValue(firstKey, "updated", second); + + expect(BACKGROUND_CONTEXT.value(firstKey)).toBeUndefined(); + expect(first.value(firstKey)).toBe("one"); + expect(first.value(secondKey)).toBeUndefined(); + expect(second.value(firstKey)).toBe("one"); + expect(second.value(secondKey)).toBe(2); + expect(replaced.value(firstKey)).toBe("updated"); + expect(second.value(firstKey)).toBe("one"); + expect(String(replaced)).toBe("[Context BACKGROUND_CONTEXT].WithValue(first).WithValue(second).WithValue(first)"); + }); + + it("inherits parent cancellation and isolates child cancellation", () => { + const parentController = new AbortController(); + const parent = withAbortSignal(parentController.signal, BACKGROUND_CONTEXT); + const child = withCancel(parent); + const sibling = withCancel(parent); + const childListener = vi.fn(); + child.context.abortSignal?.addEventListener("abort", childListener); + + child.cancel("child"); + expect(child.context.abortSignal?.aborted).toBe(true); + expect(child.context.abortSignal?.reason).toBe("child"); + expect(sibling.context.abortSignal?.aborted).toBe(false); + expect(parent.abortSignal?.aborted).toBe(false); + expect(childListener).toHaveBeenCalledOnce(); + + parentController.abort("parent"); + expect(sibling.context.abortSignal?.aborted).toBe(true); + expect(sibling.context.abortSignal?.reason).toBe("parent"); + }); + + it("masks caller cancellation for mandatory cleanup", () => { + const controller = new AbortController(); + const key = createContextKey("value"); + const context = withContextValue(key, "preserved", withAbortSignal(controller.signal, BACKGROUND_CONTEXT)); + const cleanup = withoutAbortSignal(context); + + controller.abort(); + expect(context.abortSignal?.aborted).toBe(true); + expect(cleanup.abortSignal).toBeUndefined(); + expect(cleanup.value(key)).toBe("preserved"); + }); + + it("stops waiting when the invocation is cancelled", async () => { + const controller = new AbortController(); + const context = withAbortSignal(controller.signal, BACKGROUND_CONTEXT); + let resolveWork!: (value: string) => void; + const work = new Promise((resolve) => { + resolveWork = resolve; + }); + const waiting = awaitWithContext(work, context); + const cancellation = new Error("cancelled"); + + controller.abort(cancellation); + await expect(waiting).rejects.toBe(cancellation); + resolveWork("completed later"); + await expect(work).resolves.toBe("completed later"); + await expect(awaitWithContext(Promise.resolve("completed"), BACKGROUND_CONTEXT)).resolves.toBe("completed"); + }); +}); diff --git a/packages/chord/tsconfig.build.json b/packages/chord/tsconfig.build.json new file mode 100644 index 000000000..695dd9ad2 --- /dev/null +++ b/packages/chord/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] +} diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 4c48ca4c7..fb02912c8 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -449,11 +449,20 @@ "node": ">=6.9.0" } }, + "node_modules/@earendil-works/chord": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.84.4.tgz", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@earendil-works/pi-agent-core": { "version": "0.84.4", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.4.tgz", "license": "MIT", "dependencies": { + "@earendil-works/chord": "^0.84.4", "@earendil-works/pi-ai": "^0.84.4", "@earendil-works/pi-telemetry": "^0.84.4", "diff": "8.0.4", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index cb2f59077..fa62decfb 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -475,11 +475,20 @@ "node": ">=6.9.0" } }, + "node_modules/@earendil-works/chord": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.84.4.tgz", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@earendil-works/pi-agent-core": { "version": "0.84.4", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.4.tgz", "license": "MIT", "dependencies": { + "@earendil-works/chord": "^0.84.4", "@earendil-works/pi-ai": "^0.84.4", "@earendil-works/pi-telemetry": "^0.84.4", "diff": "8.0.4", diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index 4d9ef2747..af687ad2d 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ }, resolve: { alias: [ + { find: /^@earendil-works\/chord$/, replacement: src("../chord/src/index.ts") }, { find: /^@earendil-works\/pi-agent-core$/, replacement: src("../agent/src/index.ts") }, { find: /^@earendil-works\/pi-agent-core\/(.+)$/, replacement: `${src("../agent/src/")}$1.ts` }, { find: /^@earendil-works\/pi-ai$/, replacement: src("../ai/src/index.ts") }, diff --git a/packages/session-backends/sqlite-node/vitest.benchmark.config.ts b/packages/session-backends/sqlite-node/vitest.benchmark.config.ts index 5b4c29a8b..865b45198 100644 --- a/packages/session-backends/sqlite-node/vitest.benchmark.config.ts +++ b/packages/session-backends/sqlite-node/vitest.benchmark.config.ts @@ -1,6 +1,7 @@ import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; +const chordIndex = fileURLToPath(new URL("../../chord/src/index.ts", import.meta.url)); const telemetryIndex = fileURLToPath(new URL("../../telemetry/src/index.ts", import.meta.url)); const aiIndex = fileURLToPath(new URL("../../ai/src/index.ts", import.meta.url)); const agentIndex = fileURLToPath(new URL("../../agent/src/index.ts", import.meta.url)); @@ -18,6 +19,7 @@ export default defineConfig({ }, resolve: { alias: [ + { find: /^@earendil-works\/chord$/, replacement: chordIndex }, { find: /^@earendil-works\/pi-telemetry$/, replacement: telemetryIndex }, { find: /^@earendil-works\/pi-agent-core\/session\/testing$/, replacement: agentSessionTesting }, { find: /^@earendil-works\/pi-agent-core$/, replacement: agentIndex }, diff --git a/packages/session-backends/sqlite-node/vitest.config.ts b/packages/session-backends/sqlite-node/vitest.config.ts index d8b141aae..0ea697e5d 100644 --- a/packages/session-backends/sqlite-node/vitest.config.ts +++ b/packages/session-backends/sqlite-node/vitest.config.ts @@ -1,6 +1,7 @@ import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; +const chordIndex = fileURLToPath(new URL("../../chord/src/index.ts", import.meta.url)); const telemetryIndex = fileURLToPath(new URL("../../telemetry/src/index.ts", import.meta.url)); const aiIndex = fileURLToPath(new URL("../../ai/src/index.ts", import.meta.url)); const agentIndex = fileURLToPath(new URL("../../agent/src/index.ts", import.meta.url)); @@ -25,6 +26,7 @@ export default defineConfig({ }, resolve: { alias: [ + { find: /^@earendil-works\/chord$/, replacement: chordIndex }, { find: /^@earendil-works\/pi-telemetry$/, replacement: telemetryIndex }, { find: /^@earendil-works\/pi-agent-core\/node$/, replacement: agentNode }, { find: /^@earendil-works\/pi-agent-core\/session\/testing$/, replacement: agentSessionTesting }, diff --git a/scripts/check-entry-graphs.mjs b/scripts/check-entry-graphs.mjs index 4eb2df09e..dc6f640c4 100644 --- a/scripts/check-entry-graphs.mjs +++ b/scripts/check-entry-graphs.mjs @@ -19,6 +19,7 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); /** Workspace package name -> its source root, so cross-package imports are followed. */ const WORKSPACE = { + "@earendil-works/chord": "packages/chord/src", "@earendil-works/pi-ai": "packages/ai/src", "@earendil-works/pi-agent-core": "packages/agent/src", "@earendil-works/pi-telemetry": "packages/telemetry/src", diff --git a/scripts/check-pinned-deps.mjs b/scripts/check-pinned-deps.mjs index a2b389424..991697205 100644 --- a/scripts/check-pinned-deps.mjs +++ b/scripts/check-pinned-deps.mjs @@ -4,6 +4,7 @@ import { join } from "node:path"; const dependencySections = ["dependencies", "devDependencies", "optionalDependencies"]; const exactVersionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const ignoredDirectories = new Set([".git", "dist", "node_modules"]); +const internalPackageNames = new Set(["@earendil-works/chord"]); const packageJsonFiles = []; function collectPackageJsonFiles(directory) { @@ -22,7 +23,7 @@ function collectPackageJsonFiles(directory) { } function isInternalWorkspaceDependency(name) { - return name.startsWith("@earendil-works/pi-"); + return name.startsWith("@earendil-works/pi-") || internalPackageNames.has(name); } function isNonRegistrySpecifier(specifier) { diff --git a/scripts/generate-coding-agent-install-lock.mjs b/scripts/generate-coding-agent-install-lock.mjs index 881205d2d..57ecb1334 100644 --- a/scripts/generate-coding-agent-install-lock.mjs +++ b/scripts/generate-coding-agent-install-lock.mjs @@ -12,6 +12,7 @@ const rootLockfilePath = join(repoRoot, "package-lock.json"); const outputPackageJsonPath = join(outputDir, "package.json"); const outputLockfilePath = join(outputDir, "package-lock.json"); const internalPackagePrefix = "@earendil-works/pi-"; +const internalPackageNames = new Set(["@earendil-works/chord"]); const installPackageName = "@earendil-works/pi-coding-agent-install"; const allowedInstallScriptPackages = new Map([ ["@google/genai@1.52.0", "preinstall is a no-op in the published package"], @@ -143,7 +144,7 @@ function getInternalWorkspaces(lockPackages) { if (!lockPath.startsWith("packages/") || lockPath.includes("/node_modules/") || !entry.name || !entry.version) { continue; } - if (!entry.name.startsWith(internalPackagePrefix)) { + if (!entry.name.startsWith(internalPackagePrefix) && !internalPackageNames.has(entry.name)) { continue; } @@ -294,7 +295,11 @@ function validateGeneratedFiles(installerPackageJson, installLock, internalNames if (entry.dev || entry.devOptional || entry.extraneous) { errors.push(`${lockPath || "root"} contains dev/extraneous metadata`); } - if (packageName?.startsWith(internalPackagePrefix) && entry.version !== installerPackageJson.version) { + if ( + packageName !== undefined && + (packageName.startsWith(internalPackagePrefix) || internalPackageNames.has(packageName)) && + entry.version !== installerPackageJson.version + ) { errors.push(`${lockPath} internal package version ${entry.version} does not match ${installerPackageJson.version}`); } if (entry.hasInstallScript) { diff --git a/scripts/generate-coding-agent-shrinkwrap.mjs b/scripts/generate-coding-agent-shrinkwrap.mjs index 06b1a2283..b25008bd8 100644 --- a/scripts/generate-coding-agent-shrinkwrap.mjs +++ b/scripts/generate-coding-agent-shrinkwrap.mjs @@ -10,6 +10,7 @@ const codingAgentDir = join(repoRoot, "packages/coding-agent"); const rootLockfilePath = join(repoRoot, "package-lock.json"); const shrinkwrapPath = join(codingAgentDir, "npm-shrinkwrap.json"); const internalPackagePrefix = "@earendil-works/pi-"; +const internalPackageNames = new Set(["@earendil-works/chord"]); const allowedInstallScriptPackages = new Map([ ["@google/genai@1.52.0", "preinstall is a no-op in the published package"], ["protobufjs@7.6.5", "postinstall only warns about protobufjs version scheme mismatches"], @@ -136,7 +137,7 @@ function getInternalWorkspaces(lockPackages) { if (!lockPath.startsWith("packages/") || lockPath.includes("/node_modules/") || !entry.name || !entry.version) { continue; } - if (!entry.name.startsWith(internalPackagePrefix)) { + if (!entry.name.startsWith(internalPackagePrefix) && !internalPackageNames.has(entry.name)) { continue; } diff --git a/scripts/local-release.mjs b/scripts/local-release.mjs index 739bffd15..f1382de3e 100644 --- a/scripts/local-release.mjs +++ b/scripts/local-release.mjs @@ -6,6 +6,7 @@ import { isAbsolute, join, relative, resolve } from "node:path"; import { spawnSync } from "node:child_process"; const packages = [ + { directory: "packages/chord", name: "@earendil-works/chord" }, { directory: "packages/telemetry", name: "@earendil-works/pi-telemetry" }, { directory: "packages/ai", name: "@earendil-works/pi-ai" }, { directory: "packages/tui", name: "@earendil-works/pi-tui" }, diff --git a/tsconfig.json b/tsconfig.json index e110a3bfd..cbed84a3f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "moduleResolution": "NodeNext", "paths": { "*": ["./*"], + "@earendil-works/chord": ["./packages/chord/src/index.ts"], "@earendil-works/pi-telemetry": ["./packages/telemetry/src/index.ts"], "@earendil-works/pi-telemetry/testing": ["./packages/telemetry/src/testing/index.ts"], "@earendil-works/pi-ai": ["./packages/ai/src/index.ts"], diff --git a/vitest.base.ts b/vitest.base.ts index 7592113ea..425dc1f76 100644 --- a/vitest.base.ts +++ b/vitest.base.ts @@ -2,6 +2,7 @@ import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; export const workspaceSourcePaths = { + chordIndex: fileURLToPath(new URL("./packages/chord/src/index.ts", import.meta.url)), telemetryIndex: fileURLToPath(new URL("./packages/telemetry/src/index.ts", import.meta.url)), telemetryTesting: fileURLToPath(new URL("./packages/telemetry/src/testing/index.ts", import.meta.url)), aiIndex: fileURLToPath(new URL("./packages/ai/src/index.ts", import.meta.url)), @@ -22,6 +23,7 @@ export const workspaceSourcePaths = { export default defineConfig({ resolve: { alias: [ + { find: /^@earendil-works\/chord$/, replacement: workspaceSourcePaths.chordIndex }, { find: /^@earendil-works\/pi-telemetry$/, replacement: workspaceSourcePaths.telemetryIndex }, { find: /^@earendil-works\/pi-telemetry\/testing$/, replacement: workspaceSourcePaths.telemetryTesting }, { find: /^@earendil-works\/pi-ai$/, replacement: workspaceSourcePaths.aiIndex },