Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 14 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This is an AI-generated code review comment.

qmd models land in memory-backed /tmp

Search is on by default (PI_MEMORY_NO_SEARCH is only set when search === false), and with HOME=/tmp on Cloud Run's in-memory filesystem the first memory_search downloads ~2 GB of models into instance RAM — on top of inference memory — and again on every cold start. That's a real OOM risk.

Options: bake the models into the image, point qmd's cache at persistent storage, or default search: false for deployments until memory sizing is confirmed. Same applies to Dockerfile.fullstack.

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.
Expand All @@ -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

Expand Down
18 changes: 14 additions & 4 deletions Dockerfile.fullstack
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
31 changes: 28 additions & 3 deletions apps/server/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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({
Expand All @@ -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);',
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
35 changes: 30 additions & 5 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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/<id>`) 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.
Expand All @@ -51,8 +55,29 @@ export const AGENT_BUNDLES_ROOT =
* its own subdirectory (`SHARED_ROOT/<id>`) 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
Expand Down
26 changes: 19 additions & 7 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -278,7 +289,7 @@ void store
// Socket.IO room by the chat handlers.
const pi = new PiAgentManager(
agentHandlers,
memory,
memoryResolver,
runs,
hostResourcePreamble,
);
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading