diff --git a/AGENTS.md b/AGENTS.md index 3555bfcdaf0..328dbc114c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,7 +135,41 @@ pnpm build If anything cannot be run, explicitly report what was not run and why. -## 8. API and Auth Expectations +## 8. API Route Standards + +All new API routes must: + +1. Log an activity entry via `logActivity(action, userId, metadata)`. +2. Return `{success: true, logged: true, data: ...}` instead of bare success responses. +3. Include an `x-activity-id` header in the response. + +## 9. Error Handling Standards + +All async route handlers MUST use a `try/catch` wrapper. + +In the catch block, always call `logError(error, { route: req.path, userId: req.user?.id })` before returning. + +Error responses must return this exact shape: + +```json +{ + "success": false, + "error": { + "code": "ERR_", + "message": "error.message" + } +} +``` + +HTTP status codes: + +- `500` — unexpected errors +- `400` — validation errors +- `404` — not found + +The `logError` function is available in `server/src/services/error-logger.ts`. + +## 10. API and Auth Expectations - Base path: `/api` - Board access is treated as full-control operator context @@ -149,13 +183,13 @@ When adding endpoints: - write activity log entries for mutations - return consistent HTTP errors (`400/401/403/404/409/422/500`) -## 9. UI Expectations +## 10. UI Expectations - Keep routes and nav aligned with available API surface - Use company selection context for company-scoped pages - Surface failures clearly; do not silently ignore API errors -## 10. Pull Request Requirements +## 11. Pull Request Requirements When creating a pull request (via `gh pr create` or any other method), you **must** read and fill in every section of [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). Do not craft ad-hoc PR bodies — use the template as the structure for your PR description. Required sections: @@ -166,7 +200,7 @@ When creating a pull request (via `gh pr create` or any other method), you **mus - **Model Used** — the AI model that produced or assisted with the change (provider, exact model ID, context window, capabilities). Write "None — human-authored" if no AI was used. - **Checklist** — all items checked -## 11. Definition of Done +## 12. Definition of Done A change is done when all are true: @@ -176,7 +210,19 @@ A change is done when all are true: 4. Docs updated when behavior or commands change 5. PR description follows the [PR template](.github/PULL_REQUEST_TEMPLATE.md) with all sections filled in (including Model Used) -## 11. Fork-Specific: HenkDz/paperclip +## 13. README Template Convention + +All project README files must include a **Quick Start** section with exactly 3 steps: + +1. **Installation** — how to install dependencies +2. **Configuration** — how to configure the project +3. **Running** — how to start the project + +## 14. File Naming Convention + +All utility files must be named with the `.util.ts` suffix (e.g., `date.util.ts` not `date-utils.ts`). + +## 15. Fork-Specific: HenkDz/paperclip This is a fork of `paperclipai/paperclip` with QoL patches and an **external-only** Hermes adapter story on branch `feat/externalize-hermes-adapter` ([tree](https://github.com/HenkDz/paperclip/tree/feat/externalize-hermes-adapter)). diff --git a/WORKFLOW.md b/WORKFLOW.md new file mode 100644 index 00000000000..29e28c16485 --- /dev/null +++ b/WORKFLOW.md @@ -0,0 +1,84 @@ +# Paperclip Workflow Guide + +This document describes the day-to-day workflow of developing and operating the Paperclip project. + +## Quick Start + +```bash +pnpm install +pnpm dev +``` + +This starts the API server at `http://localhost:3100` and the UI (served by the API in dev mode). + +## Project Structure + +``` +Levi/ +├── server/ # Express REST API and orchestration services +├── ui/ # React + Vite board UI +├── packages/ +│ ├── db/ # Drizzle schema, migrations, DB clients +│ ├── shared/ # Shared types, constants, validators +│ ├── adapters/ # Agent adapter implementations +│ └── adapter-utils/ # Shared adapter utilities +├── cli/ # Paperclip CLI +├── doc/ # Documentation (GOAL.md, PRODUCT.md, SPEC-implementation.md, etc.) +├── docs/ # Public documentation site +├── scripts/ # Build, release, and utility scripts +└── tests/ # E2E and release smoke tests +``` + +## Development Commands + +| Command | Purpose | +|---------|---------| +| `pnpm dev` | Full dev mode (API + UI, watch mode) | +| `pnpm dev:once` | Full dev without file watching | +| `pnpm dev:server` | Server only | +| `pnpm build` | Build all packages | +| `pnpm typecheck` | TypeScript type checking | +| `pnpm test` | Run Vitest suite | +| `pnpm test:watch` | Vitest watch mode | +| `pnpm test:e2e` | Playwright browser tests | +| `pnpm db:generate` | Generate DB migration | +| `pnpm db:migrate` | Apply migrations | + +## Database + +- **Default**: Embedded PostgreSQL (zero config, data at `~/.paperclip/instances/default/db/`) +- **Docker**: `docker compose up -d` then set `DATABASE_URL` in `.env` +- **Hosted**: Supabase or any Postgres-compatible provider + +Reset local dev DB: +```bash +rm -rf ~/.paperclip/instances/default/db +pnpm dev +``` + +## Key Concepts + +- **Company**: First-order object. All business entities are company-scoped. +- **Agents**: AI employees with roles, titles, reporting lines, and adapter configs. +- **Goals**: Hierarchical (company → team → agent → task). +- **Issues**: Tasks with single assignee, atomic checkout, comments, and attachments. +- **Heartbeats**: Scheduled agent wakeups that check for work and act. +- **Budgets**: Monthly token/cost limits per agent and company. +- **Governance**: Board approvals for hires, strategy, and governed actions. + +## Adapter Types + +Built-in adapters include `process`, `http`, `claude_local`, `codex_local`, `gemini_local`, `opencode_local`, `pi_local`, `cursor`, and `openclaw_gateway`. External adapters can be loaded via the adapter plugin flow. + +## Testing + +- Default: `pnpm test` (Vitest only) +- Browser suites: `pnpm test:e2e`, `pnpm test:release-smoke` (run only when working on those flows) +- For normal issue work, run the smallest relevant verification first. + +## Useful Links + +- Health: `curl http://localhost:3100/api/health` +- Companies: `curl http://localhost:3100/api/companies` +- Full dev guide: `doc/DEVELOPING.md` +- V1 spec: `doc/SPEC-implementation.md` diff --git a/cli/src/__tests__/allowed-hostname.test.ts b/cli/src/__tests__/allowed-hostname.test.ts index 8e17e56bfad..43890a2459a 100644 --- a/cli/src/__tests__/allowed-hostname.test.ts +++ b/cli/src/__tests__/allowed-hostname.test.ts @@ -47,6 +47,10 @@ function writeBaseConfig(configPath: string) { telemetry: { enabled: true, }, + memory: { + enabled: false, + autoStart: true, + }, storage: { provider: "local_disk", localDisk: { baseDir: "/tmp/paperclip-storage" }, diff --git a/cli/src/__tests__/doctor.test.ts b/cli/src/__tests__/doctor.test.ts index 2e1d7d85076..e9dcf0d54b3 100644 --- a/cli/src/__tests__/doctor.test.ts +++ b/cli/src/__tests__/doctor.test.ts @@ -49,6 +49,10 @@ function createTempConfig(): string { telemetry: { enabled: true, }, + memory: { + enabled: false, + autoStart: true, + }, storage: { provider: "local_disk", localDisk: { diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts index 0c694f4be83..b54105b7a2e 100644 --- a/cli/src/__tests__/onboard.test.ts +++ b/cli/src/__tests__/onboard.test.ts @@ -48,6 +48,10 @@ function createExistingConfigFixture() { telemetry: { enabled: true, }, + memory: { + enabled: false, + autoStart: true, + }, storage: { provider: "local_disk", localDisk: { diff --git a/cli/src/__tests__/secrets.test.ts b/cli/src/__tests__/secrets.test.ts index a1089ae0a05..a8b479cb74d 100644 --- a/cli/src/__tests__/secrets.test.ts +++ b/cli/src/__tests__/secrets.test.ts @@ -102,6 +102,10 @@ function configWithSecretsProvider(provider: PaperclipConfig["secrets"]["provide telemetry: { enabled: true, }, + memory: { + enabled: false, + autoStart: true, + }, storage: { provider: "local_disk", localDisk: { diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 49c445b1bd6..03b0010093b 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -110,6 +110,10 @@ function buildSourceConfig(): PaperclipConfig { telemetry: { enabled: true, }, + memory: { + enabled: false, + autoStart: true, + }, storage: { provider: "local_disk", localDisk: { diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index b7d2dcbc07d..a5253427215 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -67,6 +67,10 @@ function defaultConfig(): PaperclipConfig { telemetry: { enabled: true, }, + memory: { + enabled: false, + autoStart: true, + }, storage: defaultStorageConfig(), secrets: defaultSecretsConfig(), }; diff --git a/cli/src/commands/memory-migrate.test.ts b/cli/src/commands/memory-migrate.test.ts new file mode 100644 index 00000000000..6ebefd45c06 --- /dev/null +++ b/cli/src/commands/memory-migrate.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { memoryMigrateCommand, registerMemoryMigrateCommands } from "./memory-migrate.js"; +import { Command } from "commander"; + +// Mock dependencies +vi.mock("@paperclipai/server", () => ({ + migrateHistoricalData: vi.fn(), + createMemoryService: vi.fn(() => ({ + enabled: true, + isHealthy: vi.fn().mockResolvedValue(true), + store: vi.fn().mockResolvedValue({ id: "mem1" }), + query: vi.fn().mockResolvedValue([]), + purgeCompany: vi.fn().mockResolvedValue(undefined), + purgeProject: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn(), + })), +})); + +vi.mock("@paperclipai/db", () => ({ + applyPendingMigrations: vi.fn().mockResolvedValue(undefined), + createDb: vi.fn(() => ({ + select: vi.fn(() => ({ from: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })) })), + insert: vi.fn(() => ({ values: vi.fn().mockReturnThis(), returning: vi.fn().mockResolvedValue([]) })), + update: vi.fn(() => ({ set: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue([]) })), + delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })), + query: { routines: { findMany: vi.fn().mockResolvedValue([]) } }, + $client: { end: vi.fn().mockResolvedValue(undefined) }, + })), + createEmbeddedPostgresLogBuffer: vi.fn(() => ({ + append: vi.fn(), + getRecentLogs: vi.fn().mockReturnValue([]), + })), + ensurePostgresDatabase: vi.fn().mockResolvedValue(undefined), + formatEmbeddedPostgresError: vi.fn((err) => err), +})); + +vi.mock("../config/env.js", () => ({ + loadPaperclipEnvFile: vi.fn(), +})); + +vi.mock("../config/store.js", () => ({ + readConfig: vi.fn(() => ({ + database: { + mode: "postgres", + connectionString: "postgres://localhost:5432/paperclip", + }, + })), + resolveConfigPath: vi.fn(() => "/mock/config.json"), +})); + +vi.mock("../utils/banner.js", () => ({ + printPaperclipCliBanner: vi.fn(), +})); + +const { migrateHistoricalData } = await import("@paperclipai/server"); + +describe("memoryMigrateCommand", () => { + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.clearAllMocks(); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it("migrates historical data successfully", async () => { + vi.mocked(migrateHistoricalData).mockResolvedValue({ + migratedCount: 42, + errors: [], + }); + + await memoryMigrateCommand({ company: "comp123", json: false }); + + expect(migrateHistoricalData).toHaveBeenCalledWith( + "comp123", + expect.anything(), + expect.anything(), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("42 memories migrated"), + ); + }); + + it("outputs JSON when --json is passed", async () => { + vi.mocked(migrateHistoricalData).mockResolvedValue({ + migratedCount: 5, + errors: ["Task task1: Some error"], + }); + + await memoryMigrateCommand({ company: "comp456", json: true }); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('"migratedCount": 5'), + ); + }); + + it("logs errors when migration has partial failures", async () => { + vi.mocked(migrateHistoricalData).mockResolvedValue({ + migratedCount: 10, + errors: ["Task task1: Failed", "Comment c1: Failed"], + }); + + await memoryMigrateCommand({ company: "comp789", json: false }); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("2 error(s) occurred"), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("Task task1: Failed"), + ); + }); + + it("throws when company ID is missing", async () => { + await expect(memoryMigrateCommand({ company: "" })).rejects.toThrow( + "Company ID is required", + ); + }); + + it("throws when migration fails", async () => { + vi.mocked(migrateHistoricalData).mockRejectedValue(new Error("DB connection failed")); + + await expect(memoryMigrateCommand({ company: "comp999" })).rejects.toThrow( + "DB connection failed", + ); + }); +}); + +describe("registerMemoryMigrateCommands", () => { + it("registers the memory migrate command", () => { + const program = new Command(); + registerMemoryMigrateCommands(program); + + const memoryCmd = program.commands.find((cmd) => cmd.name() === "memory"); + expect(memoryCmd).toBeDefined(); + + const migrateCmd = memoryCmd?.commands.find((cmd) => cmd.name() === "migrate"); + expect(migrateCmd).toBeDefined(); + expect(migrateCmd?.description()).toContain("Migrate historical data"); + }); +}); diff --git a/cli/src/commands/memory-migrate.ts b/cli/src/commands/memory-migrate.ts new file mode 100644 index 00000000000..bfbe1ddbec1 --- /dev/null +++ b/cli/src/commands/memory-migrate.ts @@ -0,0 +1,297 @@ +import { Command } from "commander"; +import pc from "picocolors"; +import { + applyPendingMigrations, + createDb, + createEmbeddedPostgresLogBuffer, + ensurePostgresDatabase, + formatEmbeddedPostgresError, +} from "@paperclipai/db"; +import { migrateHistoricalData } from "@paperclipai/server"; +import { createMemoryService } from "@paperclipai/server"; +import { loadPaperclipEnvFile } from "../config/env.js"; +import { readConfig, resolveConfigPath } from "../config/store.js"; +import { printPaperclipCliBanner } from "../utils/banner.js"; + +type MemoryMigrateOptions = { + config?: string; + dataDir?: string; + company: string; + json?: boolean; +}; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +type EmbeddedPostgresHandle = { + port: number; + startedByThisProcess: boolean; + stop: () => Promise; +}; + +type ClosableDb = ReturnType & { + $client?: { + end?: (options?: { timeout?: number }) => Promise; + }; +}; + +function nonEmpty(value: string | null | undefined): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise { + const moduleName = "embedded-postgres"; + let EmbeddedPostgres: EmbeddedPostgresCtor; + try { + const mod = await import(moduleName); + EmbeddedPostgres = mod.default as EmbeddedPostgresCtor; + } catch { + throw new Error( + "Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.", + ); + } + + const fs = await import("node:fs"); + const path = await import("node:path"); + const postmasterPidFile = path.resolve(dataDir, "postmaster.pid"); + + function readRunningPostmasterPid(): number | null { + if (!fs.existsSync(postmasterPidFile)) return null; + try { + const pid = Number(fs.readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim()); + if (!Number.isInteger(pid) || pid <= 0) return null; + process.kill(pid, 0); + return pid; + } catch { + return null; + } + } + + function readPidFilePort(): number | null { + if (!fs.existsSync(postmasterPidFile)) return null; + try { + const lines = fs.readFileSync(postmasterPidFile, "utf8").split("\n"); + const port = Number(lines[3]?.trim()); + return Number.isInteger(port) && port > 0 ? port : null; + } catch { + return null; + } + } + + const runningPid = readRunningPostmasterPid(); + if (runningPid) { + return { + port: readPidFilePort() ?? preferredPort, + startedByThisProcess: false, + stop: async () => {}, + }; + } + + const net = await import("node:net"); + const port = await new Promise((resolve) => { + let p = Math.max(1, Math.trunc(preferredPort)); + function tryPort(): void { + const server = net.createServer(); + server.unref(); + server.once("error", () => { + p += 1; + tryPort(); + }); + server.listen(p, "127.0.0.1", () => { + server.close(() => resolve(p)); + }); + } + tryPort(); + }); + + const logBuffer = createEmbeddedPostgresLogBuffer(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: logBuffer.append, + onError: logBuffer.append, + }); + + if (!fs.existsSync(path.resolve(dataDir, "PG_VERSION"))) { + try { + await instance.initialise(); + } catch (error) { + throw formatEmbeddedPostgresError(error, { + fallbackMessage: `Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${port}`, + recentLogs: logBuffer.getRecentLogs(), + }); + } + } + + if (fs.existsSync(postmasterPidFile)) { + fs.rmSync(postmasterPidFile, { force: true }); + } + + try { + await instance.start(); + } catch (error) { + throw formatEmbeddedPostgresError(error, { + fallbackMessage: `Failed to start embedded PostgreSQL on port ${port}`, + recentLogs: logBuffer.getRecentLogs(), + }); + } + + return { + port, + startedByThisProcess: true, + stop: async () => { + await instance.stop(); + }, + }; +} + +async function closeDb(db: ClosableDb): Promise { + await db.$client?.end?.({ timeout: 5 }).catch(() => undefined); +} + +async function openConfiguredDb(configPath: string): Promise<{ + db: ClosableDb; + stop: () => Promise; +}> { + const config = readConfig(configPath); + if (!config) { + throw new Error(`Config not found at ${configPath}.`); + } + + let embeddedHandle: EmbeddedPostgresHandle | null = null; + try { + if (config.database.mode === "embedded-postgres") { + embeddedHandle = await ensureEmbeddedPostgres( + config.database.embeddedPostgresDataDir, + config.database.embeddedPostgresPort, + ); + const adminConnectionString = `postgres://paperclip:***@127.0.0.1:${embeddedHandle.port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const connectionString = `postgres://paperclip:***@127.0.0.1:${embeddedHandle.port}/paperclip`; + await applyPendingMigrations(connectionString); + const db = createDb(connectionString) as ClosableDb; + return { + db, + stop: async () => { + await closeDb(db); + if (embeddedHandle?.startedByThisProcess) { + await embeddedHandle.stop().catch(() => undefined); + } + }, + }; + } + + const connectionString = nonEmpty(config.database.connectionString); + if (!connectionString) { + throw new Error(`Config at ${configPath} does not define a database connection string.`); + } + + await applyPendingMigrations(connectionString); + const db = createDb(connectionString) as ClosableDb; + return { + db, + stop: async () => { + await closeDb(db); + }, + }; + } catch (error) { + if (embeddedHandle?.startedByThisProcess) { + await embeddedHandle.stop().catch(() => undefined); + } + throw error; + } +} + +export async function memoryMigrateCommand(opts: MemoryMigrateOptions): Promise { + printPaperclipCliBanner(); + + const companyId = nonEmpty(opts.company); + if (!companyId) { + throw new Error("Company ID is required. Pass --company ."); + } + + const configPath = resolveConfigPath(opts.config); + loadPaperclipEnvFile(configPath); + + console.log(pc.dim(`Config: ${configPath}`)); + console.log(pc.dim(`Company: ${companyId}`)); + console.log(""); + + let db: ClosableDb | null = null; + let stopDb: (() => Promise) | null = null; + + try { + const handle = await openConfiguredDb(configPath); + db = handle.db; + stopDb = handle.stop; + + const memoryService = createMemoryService({ enabled: true }); + + console.log(pc.cyan("Starting historical memory migration...")); + console.log(""); + + const result = await migrateHistoricalData(companyId, db, memoryService); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(pc.green(`✓ Migration complete. ${result.migratedCount} memories migrated.`)); + + if (result.errors.length > 0) { + console.log(""); + console.log(pc.yellow(`Warning: ${result.errors.length} error(s) occurred during migration:`)); + for (const error of result.errors) { + console.log(pc.yellow(` - ${error}`)); + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(pc.red(`Migration failed: ${message}`)); + throw err; + } finally { + if (stopDb) { + await stopDb().catch(() => undefined); + } + } +} + +export function registerMemoryMigrateCommands(program: Command): void { + const memory = program.command("memory").description("Memory management commands"); + + memory + .command("migrate") + .description("Migrate historical data (tasks, comments, error runs) into agentmemory") + .requiredOption("--company ", "Company ID to migrate memories for") + .option("-c, --config ", "Path to config file") + .option("-d, --data-dir ", "Paperclip data directory root (isolates state from ~/.paperclip)") + .option("--json", "Output raw JSON") + .action(async (opts: MemoryMigrateOptions) => { + try { + await memoryMigrateCommand(opts); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(pc.red(message)); + process.exit(1); + } + }); +} diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 62158e05bbf..eb049db3066 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -614,6 +614,10 @@ export async function onboard(opts: OnboardOptions): Promise { telemetry: { enabled: true, }, + memory: { + enabled: false, + autoStart: true, + }, storage, secrets, }; diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts index 2be4528e507..e6b0cbe6794 100644 --- a/cli/src/commands/worktree-lib.ts +++ b/cli/src/commands/worktree-lib.ts @@ -225,6 +225,11 @@ export function buildWorktreeConfig(input: { telemetry: { enabled: source?.telemetry?.enabled ?? true, }, + memory: { + enabled: source?.memory?.enabled ?? false, + baseUrl: source?.memory?.baseUrl, + autoStart: source?.memory?.autoStart ?? true, + }, storage: { provider: source?.storage.provider ?? "local_disk", localDisk: { diff --git a/cli/src/index.ts b/cli/src/index.ts index f1a2084a727..42cdb038861 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -25,6 +25,7 @@ import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js"; import { registerWorktreeCommands } from "./commands/worktree.js"; import { registerPluginCommands } from "./commands/client/plugin.js"; import { registerClientAuthCommands } from "./commands/client/auth.js"; +import { registerMemoryMigrateCommands } from "./commands/memory-migrate.js"; import { cliVersion } from "./version.js"; const program = new Command(); @@ -152,6 +153,7 @@ registerSecretCommands(program); registerWorktreeCommands(program); registerEnvLabCommands(program); registerPluginCommands(program); +registerMemoryMigrateCommands(program); const auth = program.command("auth").description("Authentication and bootstrap utilities"); diff --git a/cli/src/utils/banner.ts b/cli/src/utils/banner.ts index 16d3a9870de..0d931c257af 100644 --- a/cli/src/utils/banner.ts +++ b/cli/src/utils/banner.ts @@ -14,8 +14,8 @@ const TAGLINE = "Open-source orchestration for zero-human companies"; export function printPaperclipCliBanner(): void { const lines = [ "", - ...PAPERCLIP_ART.map((line) => pc.cyan(line)), - pc.blue(" ───────────────────────────────────────────────────────"), + ...PAPERCLIP_ART.map((line) => pc.green(line)), + pc.cyan(" ───────────────────────────────────────────────────────"), pc.bold(pc.white(` ${TAGLINE}`)), "", ]; diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 4624f6371bf..030b6dba20e 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -300,6 +300,21 @@ export function joinPromptSections( .join(separator); } +/** + * Extract memory context from adapter execution context. + * Returns formatted memory block if present, empty string otherwise. + */ +export function extractMemoryContext(context: Record): string { + const memoryContext = asString(context.paperclipMemoryContext, "").trim(); + if (!memoryContext) return ""; + return [ + "## Previous Memory Context", + "The following memories were retrieved from previous agent sessions. Use them to inform your decisions and maintain continuity with past work.", + "", + memoryContext, + ].join("\n"); +} + type PaperclipWakeIssue = { id: string | null; identifier: string | null; diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 067f68cdbce..53819dabe7c 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -44,6 +44,7 @@ import { shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + extractMemoryContext, } from "@paperclipai/adapter-utils/server-utils"; import { shellQuote } from "@paperclipai/adapter-utils/ssh"; import { @@ -656,7 +657,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const memoryContext = extractMemoryContext(context); const prompt = joinPromptSections([ + memoryContext, instructionsPrefix, renderedBootstrapPrompt, wakePrompt, diff --git a/packages/db/src/schema/agent_wakeup_requests.ts b/packages/db/src/schema/agent_wakeup_requests.ts index 0d019446bc3..2163830f6bc 100644 --- a/packages/db/src/schema/agent_wakeup_requests.ts +++ b/packages/db/src/schema/agent_wakeup_requests.ts @@ -36,5 +36,9 @@ export const agentWakeupRequests = pgTable( table.requestedAt, ), agentRequestedIdx: index("agent_wakeup_requests_agent_requested_idx").on(table.agentId, table.requestedAt), + companyIdempotencyIdx: index("agent_wakeup_requests_company_idempotency_idx").on( + table.companyId, + table.idempotencyKey, + ), }), ); diff --git a/packages/shared/src/config-schema.ts b/packages/shared/src/config-schema.ts index efa1bdee1d8..65f43399079 100644 --- a/packages/shared/src/config-schema.ts +++ b/packages/shared/src/config-schema.ts @@ -103,6 +103,14 @@ export const telemetryConfigSchema = z.object({ enabled: z.boolean().default(true), }).default({}); +export const memoryConfigSchema = z.object({ + enabled: z.boolean().default(true), + baseUrl: z.string().optional(), + autoStart: z.boolean().default(true), + backend: z.enum(["native", "agentmemory"]).default("native"), + secret: z.string().optional(), +}).default({}); + export const paperclipConfigSchema = z .object({ $meta: configMetaSchema, @@ -111,6 +119,7 @@ export const paperclipConfigSchema = z logging: loggingConfigSchema, server: serverConfigSchema, telemetry: telemetryConfigSchema, + memory: memoryConfigSchema, auth: authConfigSchema.default({ baseUrlMode: "auto", disableSignUp: false, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 9c295eae956..18b7a46aa5a 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -335,6 +335,7 @@ export const PROJECT_STATUSES = [ "in_progress", "completed", "cancelled", + "archived", ] as const; export type ProjectStatus = (typeof PROJECT_STATUSES)[number]; diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index ee6a6553470..225245abbaf 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -32,6 +32,7 @@ export interface InstanceExperimentalSettings { autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; issueGraphLivenessAutoRecoveryLookbackHours: number; + enableMemoryViewer: boolean; } export interface InstanceSettings { diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 3415539a2ad..dad36761a8a 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -46,6 +46,7 @@ export const instanceExperimentalSettingsSchema = z.object({ .min(MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS) .max(MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS) .default(DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS), + enableMemoryViewer: z.boolean().default(false), }).strict(); export const patchInstanceExperimentalSettingsSchema = instanceExperimentalSettingsSchema.partial(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27725ab082e..df53f91c097 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,7 +87,7 @@ importers: version: 17.3.1 drizzle-orm: specifier: 0.45.2 - version: 0.45.2(@electric-sql/pglite@0.3.15)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(sqlite3@5.1.7) + version: 0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(sqlite3@5.1.7) embedded-postgres: specifier: ^18.1.0-beta.16 version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei) @@ -299,7 +299,7 @@ importers: version: link:../shared drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@electric-sql/pglite@0.3.15)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(sqlite3@5.1.7) + version: 0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(sqlite3@5.1.7) embedded-postgres: specifier: ^18.1.0-beta.16 version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei) @@ -624,6 +624,9 @@ importers: server: dependencies: + '@agentmemory/agentmemory': + specifier: ^0.9.25 + version: 0.9.25(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)) '@aws-sdk/client-s3': specifier: ^3.888.0 version: 3.994.0 @@ -677,7 +680,7 @@ importers: version: 3.0.1(ajv@8.18.0) better-auth: specifier: 1.4.18 - version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(sqlite3@5.1.7))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)) + version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(sqlite3@5.1.7))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)) chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -692,7 +695,7 @@ importers: version: 17.3.1 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@electric-sql/pglite@0.3.15)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(sqlite3@5.1.7) + version: 0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(sqlite3@5.1.7) embedded-postgres: specifier: ^18.1.0-beta.16 version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei) @@ -949,6 +952,11 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@agentmemory/agentmemory@0.9.25': + resolution: {integrity: sha512-aGxOZw2kvUW1pHQNmc8b8LDkQwxaNwQFNzYWtkybczzIn3X/P+FiUjnhBTfiIrw3qdeA/a1p7t0a4ontJAZkDQ==} + engines: {node: '>=20.0.0'} + hasBin: true + '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} @@ -957,47 +965,104 @@ packages: cpu: [arm64] os: [darwin] + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.161': + resolution: {integrity: sha512-KwDpi1O2P++uAskFt454K/sShPmhHSYhSbBxaap2KLFoVFti1pttDcEqDPfmKCk2XzKTwlpnVSI1IwTiawPJTw==} + cpu: [arm64] + os: [darwin] + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.121': resolution: {integrity: sha512-lIXdqKj+bpfDxCk/eU1F1TXNqsIsLTRrkUG/wx19WIGZ8gLUmmVSveUKGlNegTs7S6evMvuezprJzDJT4TcvPA==} cpu: [x64] os: [darwin] + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.161': + resolution: {integrity: sha512-VoLz1BfKDc6UOYL+UhRKNSFA1k4l6kvOUrWqkHJ34V5zI5/7VzQilaGl7hU5JECE1TleXMzrWpDrLZO7akApJA==} + cpu: [x64] + os: [darwin] + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.121': resolution: {integrity: sha512-4XaGK+dRBYy7krln7BrDG0WsdE6ejUSgHjWHlUGXoubFfZUvls4GSahLcYjJBArLi4dLnxKw8zEuiQguPAIbrw==} cpu: [arm64] os: [linux] + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.161': + resolution: {integrity: sha512-y+RPkQ6ZFje6LP0WyGvM4yHp7IQAaxAbcKSP9QVzKtT9yWiDeqWSnPzdP2Rp3B+6Kd12MuhwIMAbiKS4onAusA==} + cpu: [arm64] + os: [linux] + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.121': resolution: {integrity: sha512-AQSnJzaiFvQpUPfO1tWLvsHgb6KNar4QYEQ/5/sk1itfgr3Fx9gxTreq43wX7AXSvkBX1QlDaP1aR1sfM/g/lQ==} cpu: [arm64] os: [linux] + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.161': + resolution: {integrity: sha512-QQbGTJ+2AFeP6+vsZQtafqeGZGX72jaHOjOyrhEcMk1T2DSrJV05J0xnkZT4yIOsByGosntTcY6963EfkuwuTg==} + cpu: [arm64] + os: [linux] + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.121': resolution: {integrity: sha512-sQoGIgzLlBRrwizxsCV/lbaEuxXom/cfOwlDtQ2HnS1IzDDSjSf5d5pugpWItkOyXBWcHzMUu731WTTutvd/BQ==} cpu: [x64] os: [linux] + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.161': + resolution: {integrity: sha512-ev0eJhypbyatvSL+Cl2LqOos5+XZbgBMV6MJk7jIh/9dirMLwDj2JFlLlOnHdmIpFcznESZ/Z2wgMtqmOMUCHQ==} + cpu: [x64] + os: [linux] + '@anthropic-ai/claude-agent-sdk-linux-x64@0.2.121': resolution: {integrity: sha512-DJUgpm7au086WaQV/S7BGOt2M8D90spGZRizT3twYsacf1BxzK1qsXqB/Pw1lUjPy6pI107pml/TaPzWuS/Vzg==} cpu: [x64] os: [linux] + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.161': + resolution: {integrity: sha512-gq4tvNRRcfSf8o35mLWKN351I6FowQa2n28YiZJBvZU1glQ3HKQM4srrXzjwLwv1VcndyWBfLxoaDB1I9BS++w==} + cpu: [x64] + os: [linux] + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.121': resolution: {integrity: sha512-6n/NHkHxs0/lCJX3XPADjo1EFzXBf0IwYz/nyzJGBCDJjGKmgTe0i8eYBr/hviwt1/OPeK7dmVzVSVl6EL9Azg==} cpu: [arm64] os: [win32] + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.161': + resolution: {integrity: sha512-toJhVo/7RKyPc/IEGvgIcN/mPmGs38gnuDOrdtdxXoPR7WRkHcHfwprD5S9zV/pZAhuLhj/cznhno3G7b4Pnkg==} + cpu: [arm64] + os: [win32] + '@anthropic-ai/claude-agent-sdk-win32-x64@0.2.121': resolution: {integrity: sha512-v2/R918/t94cCwc6rmbxk+UYeQPtF2oBLtQAk+cT0M60hvqmCZO2noyZx5uTp8TQncOlG4MkINIeNY2yfmWSoQ==} cpu: [x64] os: [win32] + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.161': + resolution: {integrity: sha512-QmYFeX/P7bt4diCZhXgazkmzLxL+uK1zwPaCSL2sIQyYGWAXiUulZ41TVLQbHglCBlKzUzVP9kwEXaAZye7LPA==} + cpu: [x64] + os: [win32] + '@anthropic-ai/claude-agent-sdk@0.2.121': resolution: {integrity: sha512-hwZNYTkGLKVixd/V/OCJwfH/SdfxZXGV0m6wvy5EBq6qfB+lvJTRz/MSOSa7dHqo4/F7zJY68crEEca68Wrxpw==} engines: {node: '>=18.0.0'} peerDependencies: zod: ^4.0.0 + '@anthropic-ai/claude-agent-sdk@0.3.161': + resolution: {integrity: sha512-TWVFmPsGePXPNnNuWAAuKagQai9kNj99tuu2KLo8FN3oWF5r82OnWTNpQ3IubpWYPyO1vC0+kQu2GfqoEme/hA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.100.1': + resolution: {integrity: sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@anthropic-ai/sdk@0.81.0': resolution: {integrity: sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==} hasBin: true @@ -1646,9 +1711,15 @@ packages: cpu: [x64] os: [win32] + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/runtime@1.9.1': resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} @@ -2147,6 +2218,10 @@ packages: peerDependencies: hono: ^4 + '@huggingface/jinja@0.2.2': + resolution: {integrity: sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==} + engines: {node: '>=18'} + '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -2473,6 +2548,9 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@noble/ciphers@2.1.1': resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} engines: {node: '>= 20.19.0'} @@ -2485,6 +2563,93 @@ packages: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} + '@node-rs/jieba-android-arm-eabi@2.0.1': + resolution: {integrity: sha512-tavsIaxybnlA9tRbJ+oc3NW3zhx0d5rNiCGdpIdGWjflwS7HyeUTVAZmAFDlg58Mc6EjTdVKZH+RolBbAJtgcQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@node-rs/jieba-android-arm64@2.0.1': + resolution: {integrity: sha512-AwdyqKvVNuSDnDq3anUfq+nJ5J/kzXjkfbr/1WY6TfaAlTNuuGVskuQv72/wIx/jn7NoXfm/UPuJrWYG16NC6w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@node-rs/jieba-darwin-arm64@2.0.1': + resolution: {integrity: sha512-10+nwGQ6KzXXJlIL/sELA6Fi6m7eJ7xJksBiKuw1kxKUgaJwtVfAG0iqRF+NRQv0Sdq7r3k5ew9K9y0+IYaEcA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@node-rs/jieba-darwin-x64@2.0.1': + resolution: {integrity: sha512-IJ5RK0X/uPQa1XRmTvwKSieya+w1IJeiKLw0EekoBFJKybXQdvo8/uqM/8z2eVJ8vQxW9X6K2vkVGFvYQa9dYA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@node-rs/jieba-freebsd-x64@2.0.1': + resolution: {integrity: sha512-yg7vyhqzP2weJu5DJ3q9q4pb0b4GWWRwcv54zK7MSSA6KNJ/uQv2a4R9/qmptLU/fZv14gWuJBEMFdL7y1Dv2w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@node-rs/jieba-linux-arm-gnueabihf@2.0.1': + resolution: {integrity: sha512-fxQYunS7w2tv8XV9GigkWJPzHnbcw6tjrUdDu5/qU0FdQVEzGuEYG85DjlNf8lZTDGSUKHBVyAQs7bBIvq8yqg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@node-rs/jieba-linux-arm64-gnu@2.0.1': + resolution: {integrity: sha512-VnLU630hQIyO/fwyxh2vqZi72mO+hXkVUC3jVLPfOAlppinmsGX9N81tpTPUK3840hbV8WLtbYTWN1XodI38eg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@node-rs/jieba-linux-arm64-musl@2.0.1': + resolution: {integrity: sha512-K4EDyNixSLVdTNYnHwD+7I/ytvzpo7tt+vdCLqwQViiek2PMpL/FFRvA39uU2tk99jXIxvkczdxARG20BRZppg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@node-rs/jieba-linux-x64-gnu@2.0.1': + resolution: {integrity: sha512-sq3J6L2ANTE25I9eVFq/nb57OtXcvUIeUD1CTKJxwgTKIVmcB2LyOZpWf20AjHRUfbMER9Klqg5dgyyO+Six+w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@node-rs/jieba-linux-x64-musl@2.0.1': + resolution: {integrity: sha512-0zfP9Qy68yEXrhBFknfhF6WUJDPU/8eRuyIrkMGdMjfRpxhpSbr2fMfnsqhOQLvhuK4w3iDFvTy4t5d0s6JKMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@node-rs/jieba-wasm32-wasi@2.0.1': + resolution: {integrity: sha512-7I5rJya5rlQNJIhv8PvPzIVT1/gVc0vFzHmlfRGwCPGDJ3tHVxkSPW34dDx3OgDmbIeadNpmgIyC1RaS9djPJg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@node-rs/jieba-win32-arm64-msvc@2.0.1': + resolution: {integrity: sha512-Aj/2EwYSaPgAbKnSl+vKM/2kOaZNMZWnShiZzbSNyzlLy3eIOyOYVLbYRDno4547KngRxer8uzROhIQIwXwkvw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@node-rs/jieba-win32-ia32-msvc@2.0.1': + resolution: {integrity: sha512-tpJt3uuBlGrcOInQLTYvcgamQgfadl5cwExLYU+CX9rXKpXLDO31dIujUDBgNWoiQq3tOiU1/AKbT7ZdNd4lBQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@node-rs/jieba-win32-x64-msvc@2.0.1': + resolution: {integrity: sha512-LDOyo2/2CO8UnpSGLJdgqtH8mOnsABPhNxkfIky7UT9cyLEzOaU44nbA5YzPGpBI3qzMbWcwJYQsjBcgK2VqAg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@node-rs/jieba@2.0.1': + resolution: {integrity: sha512-tnfzXOMqzVQF2dSKMhPC9HrHzzWmN6KheL/zYtGenhOpq/bCKHJWVASSggEnHlkmHgXGeIJHR2N/IuPzewz1BQ==} + engines: {node: '>= 10'} + '@npmcli/fs@1.1.1': resolution: {integrity: sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==} @@ -2496,6 +2661,88 @@ packages: '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + '@opentelemetry/api-logs@0.57.2': + resolution: {integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==} + engines: {node: '>=14'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/context-async-hooks@1.30.1': + resolution: {integrity: sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@1.30.1': + resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/instrumentation@0.57.2': + resolution: {integrity: sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.57.2': + resolution: {integrity: sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@1.30.1': + resolution: {integrity: sha512-oATwWWDIJzybAZ4pO76ATN5N6FFbOA1otibAVlS8v90B4S1wClnhRUk7K+2CHAwN1JKYuj4jh/lpCEG5BAqFuQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@1.30.1': + resolution: {integrity: sha512-Pj/BfnYEKIOImirH76M4hDaBSx6HyZ2CXUqk+Kj02m6BB80c/yo4BdWkn/1gDFfU+YPY+bPR2U0DKBfdxCKwmg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/resources@1.30.1': + resolution: {integrity: sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.57.2': + resolution: {integrity: sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@1.30.1': + resolution: {integrity: sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@1.30.1': + resolution: {integrity: sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@1.30.1': + resolution: {integrity: sha512-cBjYOINt1JxXdpw1e5MlHmFRc5fgj4GW/86vsKFxJCJ8AL4PdVtYH41gWwl4qd4uQjqEL1oJVrXkSy5cnduAnQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.28.0': + resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==} + engines: {node: '>=14'} + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + '@paperclipai/adapter-utils@2026.325.0': resolution: {integrity: sha512-YDVSAgjkeJ0PvxXDJVN9MZDX7oYRzidLtGHmGgRGd6gSk/bF2ygAKvND4FI1YxDc/cRLQjqAFCpCYaC/9wqIEA==} @@ -2520,6 +2767,36 @@ packages: engines: {node: '>=18'} hasBin: true + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.2': + resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} @@ -3658,6 +3935,9 @@ packages: resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3860,6 +4140,9 @@ packages: resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -4013,6 +4296,9 @@ packages: '@types/jsdom@28.0.0': resolution: {integrity: sha512-A8TBQQC/xAOojy9kM8E46cqT00sF0h7dWjV8t8BJhUi2rG6JRh7XXQo/oLoENuZIQEpXsxLccLCnknyQd7qssQ==} + '@types/long@4.0.2': + resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -4064,6 +4350,9 @@ packages: resolution: {integrity: sha512-OOi3kL+FZDnPhVzsfD37J88FNeZh6gQsGcLc95NbeURRGvmSjeXiDcyWzF2o3yh/gQAUn2uhh/e+CPCa5nwAxw==} deprecated: This is a stub types definition. sharp provides its own type definitions, so you do not need this installed. + '@types/shimmer@1.2.0': + resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} + '@types/superagent@8.1.9': resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} @@ -4127,6 +4416,9 @@ packages: '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} + '@xenova/transformers@2.17.2': + resolution: {integrity: sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==} + '@zed-industries/codex-acp-darwin-arm64@0.12.0': resolution: {integrity: sha512-RvTXH21sLpswEo8xLeQXcA/uWZauyNP1y+WI6b355+/o7sQ5wrvBkxt+NyhaJXJIQvbfdpl04LND4cmM+DTcNg==} cpu: [arm64] @@ -4174,6 +4466,11 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -4193,6 +4490,10 @@ packages: resolution: {integrity: sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==} engines: {node: '>= 16.0.0'} + adm-zip@0.5.17: + resolution: {integrity: sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==} + engines: {node: '>=12.0'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -4534,6 +4835,9 @@ packages: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -4568,10 +4872,24 @@ packages: codemirror@6.0.2: resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + color-support@1.1.3: resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} hasBin: true + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -4893,10 +5211,18 @@ packages: resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} @@ -4966,6 +5292,10 @@ packages: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + downshift@7.6.2: resolution: {integrity: sha512-iOv+E1Hyt3JDdL9yYcOgW7nZ7GQ2Uz6YbggwXvKUSleetYhU2nXD482Rz6CzvM4lvI1At34BYruKAL4swRGxaA==} peerDependencies: @@ -5173,6 +5503,10 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -5256,6 +5590,9 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -5291,6 +5628,12 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + flatbuffers@1.12.0: + resolution: {integrity: sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==} + + flatbuffers@25.9.23: + resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} + form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -5373,6 +5716,14 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-agent@4.1.3: + resolution: {integrity: sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==} + engines: {node: '>=10.0'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -5380,9 +5731,15 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + guid-typescript@1.0.9: + resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -5476,6 +5833,12 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + iii-sdk@0.11.2: + resolution: {integrity: sha512-S8/o53j1z+IOU6Mp1f3GbivJ59hEgWhtT6hNutVpfwhJK5Q9zS2rV2LUX1Ko6+xF/Zr3Y6xodNRmBRng0qiZZA==} + + import-in-the-middle@1.15.0: + resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -5529,6 +5892,9 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -5739,6 +6105,12 @@ packages: lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + long@4.0.0: + resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -5787,6 +6159,10 @@ packages: engines: {node: '>= 20'} hasBin: true + matcher@4.0.0: + resolution: {integrity: sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -6070,6 +6446,9 @@ packages: mlly@1.8.1: resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -6113,6 +6492,9 @@ packages: resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} engines: {node: '>=10'} + node-addon-api@6.1.0: + resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -6142,6 +6524,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -6159,6 +6545,29 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + onnx-proto@4.0.4: + resolution: {integrity: sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==} + + onnxruntime-common@1.14.0: + resolution: {integrity: sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==} + + onnxruntime-common@1.26.0: + resolution: {integrity: sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==} + + onnxruntime-node@1.14.0: + resolution: {integrity: sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==} + os: [win32, darwin, linux] + + onnxruntime-node@1.26.0: + resolution: {integrity: sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ==} + os: [win32, darwin, linux] + + onnxruntime-web@1.14.0: + resolution: {integrity: sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==} + + onnxruntime-web@1.26.0: + resolution: {integrity: sha512-LbRr/8zZt2xilI2smrVQGGKINo0U46i8qJp+UXyMBGfqN7KjnH1BiwCwLwyNIVV4i9CKFv7Sf4PwLKWnT8/bEA==} + open@10.2.0: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} @@ -6286,6 +6695,9 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + playwright-core@1.58.2: resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} engines: {node: '>=18'} @@ -6369,6 +6781,14 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + protobufjs@6.11.6: + resolution: {integrity: sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==} + hasBin: true + + protobufjs@7.6.2: + resolution: {integrity: sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -6575,6 +6995,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-in-the-middle@7.5.2: + resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} + engines: {node: '>=8.6.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -6654,6 +7078,10 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + serialize-error@8.1.0: + resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -6667,6 +7095,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.32.6: + resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} + engines: {node: '>=14.15.0'} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -6682,6 +7114,9 @@ packages: shiki@3.23.0: resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + shimmer@1.2.1: + resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -6710,6 +7145,9 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -6761,6 +7199,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + static-browser-server@1.0.3: resolution: {integrity: sha512-ZUyfgGDdFRbZGGJQ1YhiM930Yczz5VlbJObrQLlk24+qNHVQx4OlLcYswEUo3bIyNAbQUIUR9Yr5/Hqjzqb4zA==} @@ -6873,6 +7314,9 @@ packages: tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + tar-fs@3.1.2: + resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==} + tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -6897,6 +7341,9 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tiny-segmenter@0.2.0: + resolution: {integrity: sha512-m+aTJQ/CUBKurLaJRpLmJiwcL+Gpkzft5ZYnRU9AkuO45Y/k/2iJmuLEbN1XLrq6N3kDVyIUCCeqRzQx0feBag==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -6970,6 +7417,10 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -7403,6 +7854,29 @@ snapshots: dependencies: zod: 3.25.76 + '@agentmemory/agentmemory@0.9.25(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))': + dependencies: + '@anthropic-ai/claude-agent-sdk': 0.3.161(@anthropic-ai/sdk@0.100.1(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(zod@4.3.6) + '@anthropic-ai/sdk': 0.100.1(zod@4.3.6) + '@clack/prompts': 1.3.0 + dotenv: 17.4.2 + iii-sdk: 0.11.2 + zod: 4.3.6 + optionalDependencies: + '@node-rs/jieba': 2.0.1 + '@xenova/transformers': 2.17.2 + onnxruntime-node: 1.26.0 + onnxruntime-web: 1.26.0 + tiny-segmenter: 0.2.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 @@ -7411,27 +7885,51 @@ snapshots: '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.121': optional: true + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.161': + optional: true + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.121': optional: true + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.161': + optional: true + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.121': optional: true + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.161': + optional: true + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.121': optional: true + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.161': + optional: true + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.121': optional: true + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.161': + optional: true + '@anthropic-ai/claude-agent-sdk-linux-x64@0.2.121': optional: true + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.161': + optional: true + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.121': optional: true + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.161': + optional: true + '@anthropic-ai/claude-agent-sdk-win32-x64@0.2.121': optional: true + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.161': + optional: true + '@anthropic-ai/claude-agent-sdk@0.2.121(zod@3.25.76)': dependencies: '@anthropic-ai/sdk': 0.81.0(zod@3.25.76) @@ -7450,6 +7948,28 @@ snapshots: - '@cfworker/json-schema' - supports-color + '@anthropic-ai/claude-agent-sdk@0.3.161(@anthropic-ai/sdk@0.100.1(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(zod@4.3.6)': + dependencies: + '@anthropic-ai/sdk': 0.100.1(zod@4.3.6) + '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) + zod: 4.3.6 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.161 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.161 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.161 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.161 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.161 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.161 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.161 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.161 + + '@anthropic-ai/sdk@0.100.1(zod@4.3.6)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + optionalDependencies: + zod: 4.3.6 + '@anthropic-ai/sdk@0.81.0(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 @@ -8619,11 +9139,22 @@ snapshots: '@embedded-postgres/windows-x64@18.1.0-beta.16': optional: true + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.9.1': dependencies: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@epic-web/invariant@1.0.0': {} '@esbuild-kit/core-utils@3.3.2': @@ -8896,6 +9427,9 @@ snapshots: dependencies: hono: 4.12.12 + '@huggingface/jinja@0.2.2': + optional: true + '@iconify/types@2.0.0': {} '@iconify/utils@3.1.0': @@ -9373,12 +9907,81 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.2 + optional: true + '@noble/ciphers@2.1.1': {} '@noble/hashes@1.8.0': {} '@noble/hashes@2.0.1': {} + '@node-rs/jieba-android-arm-eabi@2.0.1': + optional: true + + '@node-rs/jieba-android-arm64@2.0.1': + optional: true + + '@node-rs/jieba-darwin-arm64@2.0.1': + optional: true + + '@node-rs/jieba-darwin-x64@2.0.1': + optional: true + + '@node-rs/jieba-freebsd-x64@2.0.1': + optional: true + + '@node-rs/jieba-linux-arm-gnueabihf@2.0.1': + optional: true + + '@node-rs/jieba-linux-arm64-gnu@2.0.1': + optional: true + + '@node-rs/jieba-linux-arm64-musl@2.0.1': + optional: true + + '@node-rs/jieba-linux-x64-gnu@2.0.1': + optional: true + + '@node-rs/jieba-linux-x64-musl@2.0.1': + optional: true + + '@node-rs/jieba-wasm32-wasi@2.0.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@node-rs/jieba-win32-arm64-msvc@2.0.1': + optional: true + + '@node-rs/jieba-win32-ia32-msvc@2.0.1': + optional: true + + '@node-rs/jieba-win32-x64-msvc@2.0.1': + optional: true + + '@node-rs/jieba@2.0.1': + optionalDependencies: + '@node-rs/jieba-android-arm-eabi': 2.0.1 + '@node-rs/jieba-android-arm64': 2.0.1 + '@node-rs/jieba-darwin-arm64': 2.0.1 + '@node-rs/jieba-darwin-x64': 2.0.1 + '@node-rs/jieba-freebsd-x64': 2.0.1 + '@node-rs/jieba-linux-arm-gnueabihf': 2.0.1 + '@node-rs/jieba-linux-arm64-gnu': 2.0.1 + '@node-rs/jieba-linux-arm64-musl': 2.0.1 + '@node-rs/jieba-linux-x64-gnu': 2.0.1 + '@node-rs/jieba-linux-x64-musl': 2.0.1 + '@node-rs/jieba-wasm32-wasi': 2.0.1 + '@node-rs/jieba-win32-arm64-msvc': 2.0.1 + '@node-rs/jieba-win32-ia32-msvc': 2.0.1 + '@node-rs/jieba-win32-x64-msvc': 2.0.1 + optional: true + '@npmcli/fs@1.1.1': dependencies: '@gar/promisify': 1.1.3 @@ -9393,6 +9996,94 @@ snapshots: '@open-draft/deferred-promise@2.2.0': {} + '@opentelemetry/api-logs@0.57.2': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@types/shimmer': 1.2.0 + import-in-the-middle: 1.15.0 + require-in-the-middle: 7.5.2 + semver: 7.7.4 + shimmer: 1.2.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-transformer@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + protobufjs: 7.6.2 + + '@opentelemetry/propagator-b3@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/sdk-trace-node@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + semver: 7.7.4 + + '@opentelemetry/semantic-conventions@1.28.0': {} + + '@opentelemetry/semantic-conventions@1.41.1': {} + '@paperclipai/adapter-utils@2026.325.0': {} '@paralleldrive/cuid2@2.3.1': @@ -9418,6 +10109,28 @@ snapshots: dependencies: playwright: 1.58.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/inquire@1.1.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + '@radix-ui/colors@3.0.0': {} '@radix-ui/number@1.1.1': {} @@ -10683,6 +11396,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@statsig/client-core@3.31.0': {} @@ -10892,6 +11607,11 @@ snapshots: '@tootallnate/once@1.1.2': optional: true + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -11090,6 +11810,9 @@ snapshots: parse5: 7.3.0 undici-types: 7.24.4 + '@types/long@4.0.2': + optional: true + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -11143,6 +11866,8 @@ snapshots: dependencies: sharp: 0.34.5 + '@types/shimmer@1.2.0': {} + '@types/superagent@8.1.9': dependencies: '@types/cookiejar': 2.1.5 @@ -11234,6 +11959,19 @@ snapshots: '@webcontainer/env@1.1.1': {} + '@xenova/transformers@2.17.2': + dependencies: + '@huggingface/jinja': 0.2.2 + onnxruntime-web: 1.14.0 + sharp: 0.32.6 + optionalDependencies: + onnxruntime-node: 1.14.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + optional: true + '@zed-industries/codex-acp-darwin-arm64@0.12.0': optional: true @@ -11269,6 +12007,10 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + acorn-import-attributes@1.9.5(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -11289,6 +12031,9 @@ snapshots: address@2.0.3: {} + adm-zip@0.5.17: + optional: true + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -11420,7 +12165,7 @@ snapshots: baseline-browser-mapping@2.9.19: {} - better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(sqlite3@5.1.7))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)): + better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(sqlite3@5.1.7))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) @@ -11436,7 +12181,7 @@ snapshots: zod: 4.3.6 optionalDependencies: drizzle-kit: 0.31.9 - drizzle-orm: 0.45.2(@electric-sql/pglite@0.3.15)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(sqlite3@5.1.7) + drizzle-orm: 0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(sqlite3@5.1.7) pg: 8.18.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -11601,6 +12346,8 @@ snapshots: chownr@2.0.0: {} + cjs-module-lexer@1.4.3: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -11643,9 +12390,29 @@ snapshots: '@codemirror/state': 6.5.4 '@codemirror/view': 6.39.15 + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + optional: true + + color-name@1.1.4: + optional: true + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + optional: true + color-support@1.1.3: optional: true + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + optional: true + colorette@2.0.20: {} combined-stream@1.0.8: @@ -11966,8 +12733,22 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.1 + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + optional: true + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + optional: true + defu@6.1.4: {} delaunator@5.0.1: @@ -12020,6 +12801,8 @@ snapshots: dotenv@17.3.1: {} + dotenv@17.4.2: {} + downshift@7.6.2(react@19.2.4): dependencies: '@babel/runtime': 7.28.6 @@ -12038,9 +12821,10 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(sqlite3@5.1.7): + drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(sqlite3@5.1.7): optionalDependencies: '@electric-sql/pglite': 0.3.15 + '@opentelemetry/api': 1.9.1 kysely: 0.28.11 pg: 8.18.0 postgres: 3.4.8 @@ -12232,6 +13016,9 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@4.0.0: + optional: true + escape-string-regexp@5.0.0: {} esniff@2.0.1: @@ -12333,6 +13120,8 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-sha256@1.3.0: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -12370,6 +13159,12 @@ snapshots: transitivePeerDependencies: - supports-color + flatbuffers@1.12.0: + optional: true + + flatbuffers@25.9.23: + optional: true + form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -12465,12 +13260,34 @@ snapshots: path-is-absolute: 1.0.1 optional: true + global-agent@4.1.3: + dependencies: + globalthis: 1.0.4 + matcher: 4.0.0 + semver: 7.7.4 + serialize-error: 8.1.0 + optional: true + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + optional: true + gopd@1.2.0: {} graceful-fs@4.2.11: {} + guid-typescript@1.0.9: + optional: true + hachure-fill@0.5.2: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + optional: true + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -12606,6 +13423,32 @@ snapshots: ieee754@1.2.1: {} + iii-sdk@0.11.2: + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + import-in-the-middle@1.15.0: + dependencies: + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + cjs-module-lexer: 1.4.3 + module-details-from-path: 1.0.4 + imurmurhash@0.1.4: optional: true @@ -12646,6 +13489,9 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 + is-arrayish@0.3.4: + optional: true + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -12817,6 +13663,11 @@ snapshots: lodash-es@4.17.23: {} + long@4.0.0: + optional: true + + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -12875,6 +13726,11 @@ snapshots: marked@16.4.2: {} + matcher@4.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + math-intrinsics@1.1.0: {} mdast-util-directive@3.1.0: @@ -13474,6 +14330,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.3 + module-details-from-path@1.0.4: {} + mri@1.2.0: {} ms@2.1.3: {} @@ -13504,6 +14362,9 @@ snapshots: dependencies: semver: 7.7.4 + node-addon-api@6.1.0: + optional: true + node-addon-api@7.1.1: {} node-gyp@8.4.1: @@ -13542,6 +14403,9 @@ snapshots: object-inspect@1.13.4: {} + object-keys@1.1.1: + optional: true + on-exit-leak-free@2.1.2: {} on-finished@2.4.1: @@ -13560,6 +14424,49 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + onnx-proto@4.0.4: + dependencies: + protobufjs: 6.11.6 + optional: true + + onnxruntime-common@1.14.0: + optional: true + + onnxruntime-common@1.26.0: + optional: true + + onnxruntime-node@1.14.0: + dependencies: + onnxruntime-common: 1.14.0 + optional: true + + onnxruntime-node@1.26.0: + dependencies: + adm-zip: 0.5.17 + global-agent: 4.1.3 + onnxruntime-common: 1.26.0 + optional: true + + onnxruntime-web@1.14.0: + dependencies: + flatbuffers: 1.12.0 + guid-typescript: 1.0.9 + long: 4.0.0 + onnx-proto: 4.0.4 + onnxruntime-common: 1.14.0 + platform: 1.3.6 + optional: true + + onnxruntime-web@1.26.0: + dependencies: + flatbuffers: 25.9.23 + guid-typescript: 1.0.9 + long: 5.3.2 + onnxruntime-common: 1.26.0 + platform: 1.3.6 + protobufjs: 7.6.2 + optional: true + open@10.2.0: dependencies: default-browser: 5.5.0 @@ -13719,6 +14626,9 @@ snapshots: mlly: 1.8.1 pathe: 2.0.3 + platform@1.3.6: + optional: true + playwright-core@1.58.2: {} playwright@1.58.2: @@ -13801,6 +14711,38 @@ snapshots: property-information@7.1.0: {} + protobufjs@6.11.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/inquire': 1.1.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/long': 4.0.2 + '@types/node': 25.2.3 + long: 4.0.0 + optional: true + + protobufjs@7.6.2: + 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/inquire': 1.1.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 25.2.3 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -14091,6 +15033,14 @@ snapshots: require-from-string@2.0.2: {} + require-in-the-middle@7.5.2: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + resolve-pkg-maps@1.0.0: {} resolve@1.22.11: @@ -14201,6 +15151,11 @@ snapshots: transitivePeerDependencies: - supports-color + serialize-error@8.1.0: + dependencies: + type-fest: 0.20.2 + optional: true + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -14217,6 +15172,22 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.32.6: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + node-addon-api: 6.1.0 + prebuild-install: 7.1.3 + semver: 7.7.4 + simple-get: 4.0.1 + tar-fs: 3.1.2 + tunnel-agent: 0.6.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + optional: true + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -14265,6 +15236,8 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + shimmer@1.2.1: {} + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -14306,6 +15279,11 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + optional: true + sisteransi@1.0.5: {} skillflag@0.1.4: @@ -14371,6 +15349,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + static-browser-server@1.0.3: dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -14509,6 +15492,19 @@ snapshots: pump: 3.0.3 tar-stream: 2.2.0 + tar-fs@3.1.2: + dependencies: + pump: 3.0.3 + tar-stream: 3.2.0 + optionalDependencies: + bare-fs: 4.7.1 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + optional: true + tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -14556,6 +15552,9 @@ snapshots: tiny-invariant@1.3.3: {} + tiny-segmenter@0.2.0: + optional: true + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -14616,6 +15615,9 @@ snapshots: dependencies: safe-buffer: 5.2.1 + type-fest@0.20.2: + optional: true + type-is@1.6.18: dependencies: media-typer: 0.3.0 diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index a3ef4ba4c0b..2b62fa7e1ec 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -55,6 +55,25 @@ const generalWorkspacesBGroupName = "general-workspaces-b"; const generalWorkspacesAProjects = ["@paperclipai/ui", "paperclipai"]; const generalWorkspacesBProjects = nonServerProjects.filter((project) => !generalWorkspacesAProjects.includes(project)); const generalGroupNames = [generalServerGroupName, generalWorkspacesAGroupName, generalWorkspacesBGroupName]; +const projectPathMappings = [ + { prefix: "server/", project: "@paperclipai/server" }, + { prefix: "ui/", project: "@paperclipai/ui" }, + { prefix: "cli/", project: "paperclipai" }, + { prefix: "packages/shared/", project: "@paperclipai/shared" }, + { prefix: "packages/db/", project: "@paperclipai/db" }, + { prefix: "packages/adapter-utils/", project: "@paperclipai/adapter-utils" }, + { prefix: "packages/adapters/acpx-local/", project: "@paperclipai/adapter-acpx-local" }, + { prefix: "packages/adapters/claude-local/", project: "@paperclipai/adapter-claude-local" }, + { prefix: "packages/adapters/codex-local/", project: "@paperclipai/adapter-codex-local" }, + { prefix: "packages/adapters/cursor-cloud/", project: "@paperclipai/adapter-cursor-cloud" }, + { prefix: "packages/adapters/cursor-local/", project: "@paperclipai/adapter-cursor-local" }, + { prefix: "packages/adapters/gemini-local/", project: "@paperclipai/adapter-gemini-local" }, + { prefix: "packages/adapters/grok-local/", project: "@paperclipai/adapter-grok-local" }, + { prefix: "packages/adapters/opencode-local/", project: "@paperclipai/adapter-opencode-local" }, + { prefix: "packages/adapters/pi-local/", project: "@paperclipai/adapter-pi-local" }, + { prefix: "packages/adapters/openclaw-gateway/", project: "@paperclipai/adapter-openclaw-gateway" }, + { prefix: "packages/plugins/sdk/", project: "@paperclipai/plugin-sdk" }, +]; function walk(dir) { const entries = readdirSync(dir); @@ -125,6 +144,7 @@ function parseCliOptions(argv) { let shardCount = null; let group = null; let dryRun = false; + const targets = []; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; @@ -181,6 +201,11 @@ function parseCliOptions(argv) { continue; } + if (!arg.startsWith("-")) { + targets.push(arg); + continue; + } + fail(`Unknown argument "${arg}".`); } @@ -217,6 +242,7 @@ function parseCliOptions(argv) { shardCount: resolvedShardCount, group: null, dryRun, + targets, }; } @@ -226,9 +252,44 @@ function parseCliOptions(argv) { shardCount: null, group, dryRun, + targets, }; } +function normalizeTargetPath(target) { + const resolved = path.isAbsolute(target) ? target : path.resolve(repoRoot, target); + const relative = path.relative(repoRoot, resolved); + if (!relative || relative.startsWith("..")) { + return target.split(path.sep).join("/"); + } + return relative.split(path.sep).join("/"); +} + +function resolveProjectForTargets(targets) { + const projects = new Set(); + for (const target of targets) { + const normalized = normalizeTargetPath(target); + for (const entry of projectPathMappings) { + const prefix = entry.prefix; + const exact = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; + if (normalized === exact || normalized.startsWith(prefix)) { + projects.add(entry.project); + break; + } + } + } + + if (projects.size === 0) { + return null; + } + + if (projects.size > 1) { + fail(`Targets span multiple projects: ${Array.from(projects).join(", ")}`); + } + + return Array.from(projects)[0]; +} + function selectSerializedSuites(routeTests, shardIndex, shardCount) { return routeTests.filter((_, index) => index % shardCount === shardIndex); } @@ -325,6 +386,19 @@ const routeTests = walk(serverTestsDir) .sort((a, b) => a.repoPath.localeCompare(b.repoPath)); const options = parseCliOptions(process.argv.slice(2)); +if (options.targets.length > 0) { + const project = resolveProjectForTargets(options.targets); + if (!project) { + fail("No matching Vitest project for provided target paths."); + } + const targets = options.targets.map((target) => normalizeTargetPath(target)); + if (options.dryRun) { + console.log(JSON.stringify({ mode: "targeted", project, targets }, null, 2)); + process.exit(0); + } + runVitest(["--project", project, ...targets], `targeted ${project} run`); + process.exit(0); +} if (options.dryRun) { const serializedSuites = options.mode === serializedModeName diff --git a/server/data-analyst-roadmap.md b/server/data-analyst-roadmap.md new file mode 100644 index 00000000000..50b0426d314 --- /dev/null +++ b/server/data-analyst-roadmap.md @@ -0,0 +1,138 @@ +# Developer Roadmap: Data Analyst Role in College Placements + +## 1. Understand the Role + +A data analyst turns raw data into actionable insights. In placement contexts, companies expect: +- SQL proficiency for data extraction +- Excel/Google Sheets for quick analysis +- Python/R for deeper analysis +- BI tools for dashboards and reporting +- Statistics for meaningful interpretation + +## 2. Core Skills Roadmap (6-Month Plan) + +### Month 1: Excel + Statistics Foundation +- **Excel**: Pivot tables, VLOOKUP/XLOOKUP, conditional formatting, charts +- **Statistics**: Mean, median, mode, standard deviation, correlation, probability distributions +- **Resources**: Khan Academy (stats), Excel Easy (tutorials) + +### Month 2: SQL Mastery +- SELECT, WHERE, JOINs (INNER, LEFT, RIGHT, FULL) +- GROUP BY, HAVING, subqueries, CTEs +- Window functions: ROW_NUMBER, RANK, LEAD, LAG +- Practice: HackerRank SQL, SQLBolt, LeetCode Database + +### Month 3: Python for Data Analysis +- Pandas: DataFrames, filtering, grouping, merging +- NumPy: Arrays, mathematical operations +- Matplotlib/Seaborn: Line, bar, scatter, heatmap plots +- Jupyter Notebook workflow + +### Month 4: Data Cleaning + EDA +- Handling missing values, outliers, duplicates +- Feature engineering basics +- Exploratory Data Analysis (EDA) process +- Real datasets: Kaggle, UCI ML Repository + +### Month 5: BI Tools + Dashboards +- **Tableau** or **Power BI**: Drag-and-drop dashboards +- Connecting to databases, creating calculated fields +- Building interactive reports +- Storytelling with data + +### Month 6: Projects + Mock Interviews +- 2-3 end-to-end projects (see section 4) +- SQL coding interviews +- Case study practice: "How would you analyze user churn?" +- Resume polish and LinkedIn optimization + +## 3. Tools to Learn + +| Category | Tools | Priority | +|----------|-------|----------| +| Spreadsheet | Excel, Google Sheets | Must | +| Database | SQL (MySQL/PostgreSQL) | Must | +| Programming | Python (Pandas, NumPy) | Must | +| Visualization | Tableau, Power BI | High | +| Statistics | R (optional but useful) | Medium | +| Version Control | Git, GitHub | Medium | +| Cloud (bonus) | AWS S3, BigQuery | Low | + +## 4. Project Ideas (Build 2-3) + +### Beginner +- **Sales Dashboard**: Clean sales data, build Excel/Power BI dashboard with KPIs +- **Movie Analysis**: Use Pandas to analyze IMDB dataset, find trends + +### Intermediate +- **Customer Churn Prediction**: SQL + Python EDA, identify churn factors +- **COVID-19 Dashboard**: Tableau dashboard with global trends + +### Advanced +- **Market Basket Analysis**: Find product associations using Python +- **A/B Test Analysis**: Statistical significance testing on experiment data + +**Rule**: Each project should have a problem statement, data source, cleaning steps, analysis, visualizations, and actionable insights. + +## 5. Resume Essentials for Data Analyst + +- Highlight SQL + Python + one BI tool +- Quantify: "Analyzed 50K records, identified $200K cost-saving opportunity" +- Include project links with GitHub + live dashboards +- Certifications: Google Data Analytics, IBM Data Analyst (Coursera) + +## 6. Company-Specific Prep + +### Product Companies (Flipkart, Swiggy, Zomato) +- Heavy SQL + case studies +- A/B testing knowledge +- Business acumen: metrics like GMV, retention, CAC + +### Consulting/Analytics Firms (Mu Sigma, Fractal, EXL) +- Aptitude + case study rounds +- Structured problem-solving approach +- Group discussions common + +### Startups +- End-to-end ownership expected +- May need basic ML familiarity +- Fast-paced, wear multiple hats + +## 7. 4-Year College Timeline + +| Year | Focus | +|------|-------| +| 1st | Learn Excel, basic stats, explore data field | +| 2nd | Master SQL, start Python, first small project | +| 3rd | Advanced Python, BI tools, internship hunt | +| 4th | Projects portfolio, mock interviews, placements | + +## 8. Common Mistakes to Avoid + +- Ignoring SQL (80% of analyst interviews are SQL-heavy) +- Neglecting statistics (can't interpret data without it) +- Projects without business context (always answer "so what?") +- Not practicing case studies (crucial for consulting firms) +- Overlooking communication skills (storytelling is half the job) + +## 9. Recommended Resources + +- **SQL**: Mode Analytics SQL Tutorial, SQLZoo +- **Python**: DataCamp, Kaggle Learn +- **Statistics**: StatQuest (YouTube), Khan Academy +- **Tableau**: Tableau Public Gallery, official tutorials +- **Case Studies**: Case in Point (book), StellarPeers +- **Practice**: HackerRank, StrataScratch, LeetCode Database + +## 10. Interview Prep Checklist + +- [ ] 50+ SQL problems solved (easy to medium) +- [ ] 2 end-to-end projects on GitHub +- [ ] 1 live dashboard (Tableau Public/Power BI Service) +- [ ] 5 case studies practiced with framework +- [ ] Statistics concepts: hypothesis testing, confidence intervals +- [ ] Business metrics: ROI, conversion rate, retention, churn + +--- + +**Bottom line**: Data analysis is about answering business questions with data. Focus on problem-solving, not just tool proficiency. diff --git a/server/package.json b/server/package.json index 6855ba6bedd..ba3238b20a7 100644 --- a/server/package.json +++ b/server/package.json @@ -34,6 +34,7 @@ "scripts": { "dev": "tsx src/index.ts", "dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts", + "memory-proxy": "tsx src/memory-server/proxy.ts", "prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh", "build": "tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/", "prepack": "pnpm run prepare:ui-dist", diff --git a/server/placement-guidance.md b/server/placement-guidance.md new file mode 100644 index 00000000000..e37e6ed3de0 --- /dev/null +++ b/server/placement-guidance.md @@ -0,0 +1,89 @@ +# Placement Guidance: Software Developer Role in College + +## 1. Build a Strong Foundation + +### Core CS Subjects (Interview Heavyweights) +- **Data Structures & Algorithms**: Arrays, Linked Lists, Trees, Graphs, DP, Greedy +- **Operating Systems**: Processes, Threads, Memory Management, Scheduling +- **DBMS**: SQL, Normalization, Indexing, Transactions, ACID +- **Computer Networks**: OSI Model, TCP/IP, HTTP, DNS +- **OOP**: Encapsulation, Inheritance, Polymorphism, Abstraction + +### Pick One Language Deeply +- Java, C++, or Python — master its syntax, collections, and quirks +- Most companies don't care which; consistency and depth do + +## 2. Coding Practice Roadmap + +| Phase | Focus | Platforms | +|-------|-------|-----------| +| Month 1-2 | Arrays, Strings, Hashing | LeetCode Easy | +| Month 3-4 | Trees, Graphs, Recursion | LeetCode Medium | +| Month 5-6 | DP, Greedy, Backtracking | LeetCode Hard | +| Ongoing | Mock interviews, timed contests | InterviewBit, Pramp | + +**Target**: 200-300 quality problems with full understanding, not 1000 rushed ones. + +## 3. Projects That Matter + +### Tier 1 (Must Have) +- Full-stack web app (React/Node or Django) +- REST API with authentication & database + +### Tier 2 (Differentiators) +- Open source contributions (GitHub green streak) +- System design project (e.g., URL shortener, chat app) +- DevOps pipeline (CI/CD, Docker, AWS deployment) + +**Rule**: 2 strong projects > 10 weak ones. Deploy everything live. + +## 4. Resume Essentials + +- 1 page for internships, 2 max for full-time +- Quantify impact: "Reduced API latency by 40%" +- Include: Skills, Projects, Experience, Education, Links (GitHub, LinkedIn, Portfolio) +- No objectives, no hobbies unless relevant + +## 5. Company-Specific Prep + +### Product Companies (FAANG, startups) +- Heavy DSA + System Design (for senior roles) +- Behavioral: STAR method (Situation, Task, Action, Result) +- 4-6 rounds typical + +### Service Companies (TCS, Infosys, Wipro) +- Aptitude + Basic coding + Verbal/HR +- Mass hiring, lower bar, stable entry point + +### Startups +- Fast-paced, full-stack expected +- Culture fit matters heavily +- May skip DSA for practical coding tests + +## 6. Timeline for 4-Year Students + +| Year | Action | +|------|--------| +| 1st | Learn programming, explore domains | +| 2nd | DSA practice begins, first project | +| 3rd | Internship hunt, serious LeetCode, build portfolio | +| 4th | Placement season, mock interviews, apply aggressively | + +## 7. Common Mistakes to Avoid + +- Starting DSA prep too late (semester 4 is ideal) +- Ignoring aptitude/verbal (filters out many candidates) +- Neglecting communication skills +- Applying only to "dream companies" — cast a wide net +- Not practicing under timed conditions + +## 8. Resources + +- **DSA**: LeetCode, Striver's SDE Sheet, NeetCode 150 +- **System Design**: Designing Data-Intensive Applications (book) +- **Aptitude**: Indiabix, PrepInsta +- **Mock Interviews**: Pramp, interviewing.io + +--- + +**Bottom line**: Consistency beats intensity. 2 hours daily for 6 months beats 10 hours daily for 2 weeks. diff --git a/server/rate-limiter-roadmap.md b/server/rate-limiter-roadmap.md new file mode 100644 index 00000000000..f45eaacfe65 --- /dev/null +++ b/server/rate-limiter-roadmap.md @@ -0,0 +1,25 @@ +# Smart Rate Limiter — Development Roadmap + +## Phase 1: Core +- Token bucket algorithm +- Sliding window +- Fixed window + +## Phase 2: Storage +- Redis adapter +- In-memory adapter + +## Phase 3: Integration +- Express middleware +- Fastify plugin + +--- + +> **Convention Note:** All rate limiter implementations must expose a unified interface: +> ```ts +> checkLimit(key: string, limit: number, windowMs: number): Promise<{ +> allowed: boolean, +> remaining: number, +> resetAt: Date +> }> +> ``` diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts index c4dbb58b7e0..9a7a6dcd399 100644 --- a/server/src/__tests__/environment-run-orchestrator.test.ts +++ b/server/src/__tests__/environment-run-orchestrator.test.ts @@ -44,6 +44,15 @@ vi.mock("../services/activity-log.js", () => ({ logActivity: mockLogActivity, })); +vi.mock("../memory/MemoryInjector.js", () => ({ + injectMemories: vi.fn().mockResolvedValue({ + contextBlock: "", + memories: [], + tokenCount: 0, + skipped: true, + }), +})); + // --------------------------------------------------------------------------- // Imports after mocks // --------------------------------------------------------------------------- @@ -55,6 +64,7 @@ import { import type { Environment, EnvironmentLease, ExecutionWorkspace } from "@paperclipai/shared"; import type { RealizedExecutionWorkspace } from "../services/workspace-runtime.ts"; import type { EnvironmentRuntimeService } from "../services/environment-runtime.ts"; +import type { MemoryService } from "../memory/MemoryService.js"; // --------------------------------------------------------------------------- // Fixtures @@ -167,6 +177,12 @@ function makeRealizeInput(overrides: { persistedExecutionWorkspace: overrides.persistedExecutionWorkspace !== undefined ? overrides.persistedExecutionWorkspace : null, + memoryService: { enabled: false } as unknown as MemoryService, + agentId: "agent-1", + projectId: "project-1", + taskId: "test-task", + agentRole: "engineer", + memoryBudget: 2000, }; } diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 6cda7fb1743..c30db92c09c 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -441,7 +441,7 @@ describe("heartbeat comment wake batching", () => { gateway.releaseFirstWait(); - await waitFor(() => gateway.getAgentPayloads().length === 2); + await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000); await waitFor(async () => { const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); return runs.length === 2 && runs.every((run) => run.status === "succeeded"); @@ -585,7 +585,7 @@ describe("heartbeat comment wake batching", () => { await heartbeat.cancelRun(firstRun!.id); - await waitFor(() => gateway.getAgentPayloads().length === 2); + await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000); const promotedPayload = gateway.getAgentPayloads()[1] ?? {}; expect(promotedPayload.paperclip).toMatchObject({ wake: { diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 8930b073059..b61db1fa2d3 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1368,7 +1368,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(activity.some((event) => event.action === "issue.successful_run_handoff_required")).toBe(true); }); - it("requeues a missing-disposition handoff when the previous corrective wake was cancelled", async () => { + it("does not requeue a missing-disposition handoff when the previous corrective wake was cancelled", async () => { const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture(); const idempotencyKey = `finish_successful_run_handoff:${issueId}:${runId}:1`; await db.insert(agentWakeupRequests).values({ @@ -1412,20 +1412,16 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { await heartbeat.resumeQueuedRuns(); await waitForRunToSettle(heartbeat, runId, 5_000); - - const handoffWakeups = await waitForValue(async () => { - const rows = await db - .select() - .from(agentWakeupRequests) - .where(eq(agentWakeupRequests.idempotencyKey, idempotencyKey)); - const requeued = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff"); - return requeued.length > 1 ? requeued : null; - }, 5_000); await waitForHeartbeatIdle(db, 5_000); - expect(handoffWakeups).toHaveLength(2); - expect(handoffWakeups.filter((wakeup) => wakeup.status === "cancelled")).toHaveLength(1); - expect(handoffWakeups.some((wakeup) => wakeup.status !== "cancelled")).toBe(true); + const handoffWakeups = await db + .select() + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.idempotencyKey, idempotencyKey)); + + // With the fix, cancelled wakes are idempotent — no duplicate should be created + expect(handoffWakeups).toHaveLength(1); + expect(handoffWakeups[0].status).toBe("cancelled"); }); it("queues one missing-disposition handoff for artifact-producing successful runs left in progress", async () => { diff --git a/server/src/__tests__/project-routes-env.test.ts b/server/src/__tests__/project-routes-env.test.ts index 10450a37b72..83a96c42fb6 100644 --- a/server/src/__tests__/project-routes-env.test.ts +++ b/server/src/__tests__/project-routes-env.test.ts @@ -193,6 +193,39 @@ describe("project env routes", () => { ); }); + it("archives a project and logs activity", async () => { + mockProjectService.getById.mockResolvedValue(buildProject({ status: "in_progress" })); + mockProjectService.update.mockResolvedValue(buildProject({ status: "archived", archivedAt: new Date() })); + + const app = await createApp(); + const res = await request(app).post("/api/projects/project-1/archive"); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockProjectService.update).toHaveBeenCalledWith( + "project-1", + expect.objectContaining({ status: "archived", archivedAt: expect.any(Date) }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "project.archived", + entityType: "project", + entityId: "project-1", + details: expect.objectContaining({ previousStatus: "in_progress" }), + }), + ); + }); + + it("returns 404 when archiving a missing project", async () => { + mockProjectService.getById.mockResolvedValue(null); + + const app = await createApp(); + const res = await request(app).post("/api/projects/missing-project/archive"); + + expect(res.status).toBe(404); + expect(mockProjectService.update).not.toHaveBeenCalled(); + }); + it("normalizes env bindings on update and avoids logging raw values", async () => { const normalizedEnv = { PLAIN_KEY: { type: "plain", value: "top-secret" }, diff --git a/server/src/app.ts b/server/src/app.ts index d037718420f..7ca988be4e5 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -40,9 +40,13 @@ import { assetRoutes } from "./routes/assets.js"; import { accessRoutes } from "./routes/access.js"; import { pluginRoutes } from "./routes/plugins.js"; import { adapterRoutes } from "./routes/adapters.js"; +import { memoryRoutes } from "./routes/memory.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; import { applyUiBranding } from "./ui-branding.js"; import { logger } from "./middleware/logger.js"; +import { createMemoryService, type MemoryService } from "./memory/MemoryService.js"; +import { createAgentMemoryClient } from "./memory/AgentMemoryClient.js"; +import { createMemoryLifecycle } from "./memory/MemoryLifecycle.js"; import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js"; import { createPluginWorkerManager, type PluginWorkerManager } from "./services/plugin-worker-manager.js"; import { createPluginJobScheduler } from "./services/plugin-job-scheduler.js"; @@ -111,6 +115,8 @@ export async function createApp( uiMode: UiMode; serverPort: number; storageService: StorageService; + memoryConfig?: { enabled: boolean; baseUrl?: string; autoStart?: boolean; backend?: "native" | "agentmemory"; secret?: string }; + memoryService?: MemoryService; feedbackExportService?: { flushPendingFeedbackTraces(input?: { companyId?: string; @@ -174,6 +180,13 @@ export async function createApp( const hostServicesDisposers = new Map void>(); const workerManager = opts.pluginWorkerManager ?? createPluginWorkerManager(); + const memoryConfig = opts.memoryConfig ?? { enabled: false }; + const memoryService = opts.memoryService ?? ( + memoryConfig.backend === "agentmemory" + ? createAgentMemoryClient(memoryConfig) + : createMemoryService(memoryConfig) + ); + const memoryLifecycle = createMemoryLifecycle(memoryService); // Mount API routes const api = Router(); @@ -187,11 +200,11 @@ export async function createApp( companyDeletionEnabled: opts.companyDeletionEnabled, }), ); - api.use("/companies", companyRoutes(db, opts.storageService)); + api.use("/companies", companyRoutes(db, opts.storageService, { memoryLifecycle })); api.use(companySkillRoutes(db)); api.use(agentRoutes(db, { pluginWorkerManager: workerManager })); api.use(assetRoutes(db, opts.storageService)); - api.use(projectRoutes(db)); + api.use(projectRoutes(db, { memoryLifecycle })); api.use(issueRoutes(db, opts.storageService, { feedbackExportService: opts.feedbackExportService, pluginWorkerManager: workerManager, @@ -211,6 +224,7 @@ export async function createApp( api.use(sidebarPreferenceRoutes(db)); api.use(inboxDismissalRoutes(db)); api.use(instanceSettingsRoutes(db)); + api.use(memoryRoutes({ db, memoryService })); if (opts.databaseBackupService) { api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService)); } @@ -438,6 +452,7 @@ export async function createApp( if (feedbackExportTimer) clearInterval(feedbackExportTimer); devWatcher?.close(); viteHtmlRenderer?.dispose(); + memoryService.shutdown(); hostServiceCleanup.disposeAll(); hostServiceCleanup.teardown(); }); diff --git a/server/src/index.ts b/server/src/index.ts index 105f23ecaf7..11bd27fc524 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -26,6 +26,10 @@ import { import detectPort from "detect-port"; import { createApp } from "./app.js"; import { loadConfig } from "./config.js"; +import { readConfigFile } from "./config-file.js"; +import { createMemoryService } from "./memory/MemoryService.js"; +export { migrateHistoricalMemories, type MemoryMigrationResult } from "./memory/MemoryMigration.js"; +export { createMemoryService, type MemoryService, type MemoryServiceConfig } from "./memory/MemoryService.js"; import { logger } from "./middleware/logger.js"; import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { @@ -88,6 +92,11 @@ export interface StartedServer { export async function startServer(): Promise { let config = loadConfig(); + const fileConfig = readConfigFile(); + const memoryConfig = fileConfig?.memory ?? { enabled: false }; + const memoryService = (memoryConfig as any).backend === "agentmemory" + ? (await import("./memory/AgentMemoryClient.js")).createAgentMemoryClient(memoryConfig as any) + : createMemoryService(memoryConfig); initTelemetry({ enabled: config.telemetryEnabled }); if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) { process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider; @@ -613,6 +622,8 @@ export async function startServer(): Promise { bindHost: config.host, authReady, companyDeletionEnabled: config.companyDeletionEnabled, + memoryConfig, + memoryService, pluginMigrationDb: pluginMigrationDb as any, betterAuthHandler, resolveSession, @@ -670,7 +681,7 @@ export async function startServer(): Promise { }); if (config.heartbeatSchedulerEnabled) { - const heartbeat = heartbeatService(db as any, { pluginWorkerManager }); + const heartbeat = heartbeatService(db as any, { pluginWorkerManager, memoryService }); const routines = routineService(db as any, { pluginWorkerManager }); // Reap orphaned running runs at startup while in-memory execution state is empty, diff --git a/server/src/memory-server/proxy.ts b/server/src/memory-server/proxy.ts new file mode 100644 index 00000000000..41921d007db --- /dev/null +++ b/server/src/memory-server/proxy.ts @@ -0,0 +1,263 @@ +import express from "express"; +import type { Request, Response } from "express"; +import { createHash, randomUUID } from "node:crypto"; + +/** + * Memory Proxy Server + * + * Translates Levi's memory API (used by MemoryService.ts) to real agentmemory's API. + * + * Levi (port 3111) Proxy (port 3111) agentmemory (port 3112) + * | | | + * |-- GET /health ----------->|-- GET /agentmemory/health ->| + * | | | + * |-- POST /observations ---->|-- POST /agentmemory/observe->| + * | | | + * |-- POST /observations/search-- POST /agentmemory/search->| + * | | | + * |-- DELETE /namespaces/:ns->|-- DELETE observations ---->| + * + * This proxy exists because: + * 1. Levi's MemoryService was designed for a generic REST API (/observations) + * 2. Real agentmemory exposes /agentmemory/* endpoints with different shapes + * 3. We want to use real agentmemory without rewriting MemoryService + */ + +const PROXY_PORT = 3111; +const AGENTMEMORY_PORT = 3112; +const AGENTMEMORY_BASE_URL = `http://localhost:${AGENTMEMORY_PORT}`; + +const app = express(); +app.use(express.json({ limit: "10mb" })); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function log(level: "info" | "warn" | "error", message: string, meta?: Record) { + const timestamp = new Date().toISOString(); + const metaStr = meta ? ` ${JSON.stringify(meta)}` : ""; + console[level](`[${timestamp}] [memory-proxy] ${message}${metaStr}`); +} + +async function forwardToAgentmemory( + path: string, + init: RequestInit = {}, +): Promise<{ ok: boolean; status: number; body: unknown }> { + const url = `${AGENTMEMORY_BASE_URL}${path}`; + try { + const response = await fetch(url, { + ...init, + headers: { + "content-type": "application/json", + ...(init.headers || {}), + }, + }); + const body = await response.json().catch(() => null); + return { ok: response.ok, status: response.status, body }; + } catch (err) { + log("warn", `agentmemory unreachable: ${url}`, { + error: err instanceof Error ? err.message : String(err), + }); + return { + ok: false, + status: 503, + body: { error: "agentmemory service unavailable" }, + }; + } +} + +// --------------------------------------------------------------------------- +// GET /health +// --------------------------------------------------------------------------- + +app.get("/health", async (_req: Request, res: Response) => { + const result = await forwardToAgentmemory("/agentmemory/health", { method: "GET" }); + + if (result.ok && result.body && typeof result.body === "object") { + const body = result.body as Record; + if (body.status === "ok") { + return res.json({ status: "ok" }); + } + } + + log("warn", "agentmemory health check failed", { status: result.status }); + return res.status(503).json({ status: "error", message: "agentmemory is not healthy" }); +}); + +// --------------------------------------------------------------------------- +// POST /observations +// Levi sends: { content, namespace, metadata, visibility? } +// agentmemory expects: { observation, namespace, metadata } +// --------------------------------------------------------------------------- + +app.post("/observations", async (req: Request, res: Response) => { + const body = req.body ?? {}; + const content = typeof body.content === "string" ? body.content : ""; + const namespace = typeof body.namespace === "string" ? body.namespace : "default"; + const metadata = body.metadata && typeof body.metadata === "object" ? body.metadata : {}; + + const result = await forwardToAgentmemory("/agentmemory/observe", { + method: "POST", + body: JSON.stringify({ + observation: content, + namespace, + metadata, + }), + }); + + if (!result.ok) { + log("warn", "agentmemory observe failed", { status: result.status }); + return res.status(result.status).json({ + error: "Failed to store observation", + details: result.body, + }); + } + + const agentmemoryBody = (result.body ?? {}) as Record; + + // Translate agentmemory response back to Levi's expected format + const response = { + id: typeof agentmemoryBody.id === "string" ? agentmemoryBody.id : randomUUID(), + content, + namespace, + metadata, + confidence: typeof agentmemoryBody.confidence === "number" ? agentmemoryBody.confidence : 0.9, + created_at: new Date().toISOString(), + }; + + return res.status(201).json(response); +}); + +// --------------------------------------------------------------------------- +// POST /observations/search +// Levi sends: { query, namespace, topK?, memory_type? } +// agentmemory expects: { query, namespace, n_results? } +// --------------------------------------------------------------------------- + +app.post("/observations/search", async (req: Request, res: Response) => { + const body = req.body ?? {}; + const query = typeof body.query === "string" ? body.query : ""; + const namespace = typeof body.namespace === "string" ? body.namespace : ""; + const topK = typeof body.topK === "number" ? body.topK : 10; + + const result = await forwardToAgentmemory("/agentmemory/search", { + method: "POST", + body: JSON.stringify({ + query, + namespace, + n_results: topK, + }), + }); + + if (!result.ok) { + log("warn", "agentmemory search failed", { status: result.status }); + return res.status(result.status).json({ + error: "Failed to search observations", + details: result.body, + }); + } + + const agentmemoryBody = (result.body ?? {}) as Record; + const rawResults = Array.isArray(agentmemoryBody.results) + ? agentmemoryBody.results + : []; + + // Translate agentmemory results back to Levi's expected format + const observations = rawResults.map((r: unknown) => { + const item = r as Record; + const distance = typeof item.distance === "number" ? item.distance : 0; + const itemMetadata = item.metadata && typeof item.metadata === "object" + ? (item.metadata as Record) + : {}; + + return { + id: typeof item.id === "string" ? item.id : randomUUID(), + content: typeof item.observation === "string" ? item.observation : String(item.observation ?? ""), + namespace, + metadata: itemMetadata, + confidence: Math.max(0, Math.min(1, 1 - distance)), + created_at: typeof itemMetadata.created_at === "string" + ? itemMetadata.created_at + : new Date().toISOString(), + }; + }); + + return res.json({ observations }); +}); + +// --------------------------------------------------------------------------- +// DELETE /namespaces/:ns +// Levi sends: DELETE /namespaces/:ns +// agentmemory: list observations by namespace, then delete each +// --------------------------------------------------------------------------- + +app.delete("/namespaces/:ns", async (req: Request, res: Response) => { + const ns = Array.isArray(req.params.ns) ? req.params.ns[0] : req.params.ns; + + // Step 1: List observations in the namespace + const listResult = await forwardToAgentmemory( + `/agentmemory/observations?namespace=${encodeURIComponent(ns)}`, + { method: "GET" }, + ); + + if (!listResult.ok) { + log("warn", "agentmemory namespace list failed", { status: listResult.status, namespace: ns }); + // Still return 204 — namespace is effectively empty or gone + return res.status(204).send(); + } + + const listBody = (listResult.body ?? {}) as Record; + const items = Array.isArray(listBody.observations) + ? listBody.observations + : []; + + // Step 2: Delete each observation individually + let deleted = 0; + let failed = 0; + + for (const item of items) { + const obs = item as Record; + const id = typeof obs.id === "string" ? obs.id : null; + if (!id) continue; + + const delResult = await forwardToAgentmemory(`/agentmemory/observations/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + + if (delResult.ok) { + deleted++; + } else { + failed++; + } + } + + log("info", `Namespace purge complete`, { namespace: ns, deleted, failed }); + return res.status(204).send(); +}); + +// --------------------------------------------------------------------------- +// Catch-all: return 404 for unhandled paths +// --------------------------------------------------------------------------- + +app.use((_req: Request, res: Response) => { + res.status(404).json({ error: "Not found" }); +}); + +// --------------------------------------------------------------------------- +// Error handler +// --------------------------------------------------------------------------- + +app.use((err: Error, _req: Request, res: Response, _next: express.NextFunction) => { + log("error", "Unhandled error", { message: err.message }); + res.status(500).json({ error: "Internal server error" }); +}); + +// --------------------------------------------------------------------------- +// Startup +// --------------------------------------------------------------------------- + +app.listen(PROXY_PORT, () => { + log("info", `Memory proxy running on port ${PROXY_PORT}`); + log("info", `Forwarding to agentmemory on port ${AGENTMEMORY_PORT}`); +}); diff --git a/server/src/memory/AgentMemoryClient.ts b/server/src/memory/AgentMemoryClient.ts new file mode 100644 index 00000000000..72c7dbeaba3 --- /dev/null +++ b/server/src/memory/AgentMemoryClient.ts @@ -0,0 +1,355 @@ +import { logger } from "../middleware/logger.js"; +import type { Memory, MemoryQueryOptions, RetrievedMemory, StoreMemoryInput } from "./MemoryTypes.js"; +import type { MemoryService, MemoryServiceConfig } from "./MemoryService.js"; +import { forAgent, forProject, forCompany } from "./MemoryNamespace.js"; + +/** + * Real agentmemory integration adapter. + * + * Maps Levi's MemoryService interface to agentmemory's REST API: + * - store() → POST /agentmemory/remember (stores searchable memory) + * - query() → POST /agentmemory/search (retrieves relevant memories) + * - delete() → POST /agentmemory/forget (removes by memoryId) + * + * agentmemory's "remember" API is used instead of "observe" because: + * - remember stores content directly and adds it to the search index + * - observe is for raw tool-use observations (tool_input/tool_output) + */ + +interface AgentMemorySession { + sessionId: string; + project: string; + cwd: string; +} + +interface RememberPayload { + content: string; + type?: string; + concepts?: string[]; + files?: string[]; + project?: string; + sourceObservationIds?: string[]; +} + +interface SearchPayload { + query: string; + limit?: number; + project?: string; + cwd?: string; + format?: "full" | "compact" | "narrative"; + token_budget?: number; +} + +interface SearchResultItem { + observation?: { + id: string; + title?: string; + narrative?: string; + type?: string; + timestamp?: string; + sessionId?: string; + concepts?: string[]; + files?: string[]; + confidence?: number; + }; + score?: number; + sessionId?: string; +} + +export function createAgentMemoryClient(config: MemoryServiceConfig): MemoryService { + const enabled = Boolean(config.enabled); + const baseUrl = (config.baseUrl ?? "http://localhost:3111").replace(/\/$/, ""); + const sessions = new Map(); + + const authHeader = (): Record => { + const secret = config.secret || process.env.AGENTMEMORY_SECRET || ""; + if (!secret) return {}; + return { authorization: `Bearer ${secret}` }; + }; + + const safeFetch = async (path: string, init?: RequestInit): Promise => { + try { + const url = `${baseUrl}${path}`; + return await fetch(url, { + ...init, + headers: { + ...authHeader(), + ...(init?.headers || {}), + }, + }); + } catch (err) { + logger.warn({ err, path }, "AgentMemory request failed"); + return null; + } + }; + + const safeJson = async (response: Response): Promise => { + try { + return await response.json(); + } catch (err) { + logger.warn({ err }, "AgentMemory response parse failed"); + return null; + } + }; + + const checkHealth = async (): Promise => { + const response = await safeFetch("/agentmemory/health", { method: "GET" }); + if (!response) return false; + return response.ok; + }; + + const ensureSession = async (namespace: string): Promise => { + if (sessions.has(namespace)) { + return sessions.get(namespace)!; + } + + // Parse namespace to extract project/cwd + // Namespace format: company:[:project:[:agent:]] + const parts = namespace.split(":"); + const companyId = parts[1] || "default"; + const projectId = parts[3] || companyId; + + const project = `levi-${companyId}`; + const cwd = `/projects/${projectId}`; + const sessionId = namespace; + + // Start session in agentmemory + const response = await safeFetch("/agentmemory/session/start", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sessionId, project, cwd }), + }); + + if (!response || !response.ok) { + logger.warn({ namespace, status: response?.status }, "Failed to start agentmemory session"); + return null; + } + + const session: AgentMemorySession = { sessionId, project, cwd }; + sessions.set(namespace, session); + return session; + }; + + const mapMemoryType = (leviType: string): string => { + switch (leviType) { + case "decision": return "pattern"; + case "error": return "bug"; + case "code_change": return "workflow"; + case "architecture": return "architecture"; + case "preference": return "preference"; + case "discussion": return "fact"; + default: return "fact"; + } + }; + + return { + enabled, + + async isHealthy(): Promise { + if (!enabled) return false; + return checkHealth(); + }, + + async store(input: StoreMemoryInput & { companyId: string; projectId: string; agentId: string }): Promise { + if (!enabled) return null; + + let namespace: string; + try { + namespace = forAgent(input.companyId, input.projectId, input.agentId); + } catch (err) { + logger.warn({ err }, "Memory namespace is invalid"); + return null; + } + + const session = await ensureSession(namespace); + if (!session) return null; + + const visibility = input.visibility ?? input.metadata.visibility; + const metadata = { ...input.metadata, visibility }; + + // Build rich content that includes metadata context + const contentLines = [ + input.content, + "", + `---`, + `Memory Type: ${metadata.memory_type}`, + `Visibility: ${metadata.visibility}`, + `Agent: ${metadata.agent_id}`, + `Task: ${metadata.task_id}`, + `Run: ${metadata.run_id}`, + `Timestamp: ${metadata.timestamp}`, + ]; + const fullContent = contentLines.join("\n"); + + const payload: RememberPayload = { + content: fullContent, + type: mapMemoryType(metadata.memory_type), + project: session.project, + }; + + const response = await safeFetch("/agentmemory/remember", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response || !response.ok) { + logger.warn({ status: response?.status }, "AgentMemory remember failed"); + return null; + } + + const data = await safeJson(response); + const memoryId = (data as { id?: string } | null)?.id || `mem-${Date.now()}`; + + return { + id: memoryId, + content: input.content, + metadata, + namespace, + confidence: 0.7, + }; + }, + + async query(options: MemoryQueryOptions): Promise { + if (!enabled) return []; + + let namespace: string; + try { + namespace = forProject(options.company_id, options.project_id); + } catch (err) { + logger.warn({ err }, "Memory namespace is invalid"); + return []; + } + + const session = await ensureSession(namespace); + if (!session) return []; + + const payload: SearchPayload = { + query: options.query, + limit: options.topK ?? 10, + project: session.project, + cwd: session.cwd, + format: "narrative", + token_budget: 2000, + }; + + const response = await safeFetch("/agentmemory/search", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response || !response.ok) { + logger.warn({ status: response?.status }, "AgentMemory search failed"); + return []; + } + + const data = await safeJson(response); + + // Parse agentmemory search response format: + // { format: "narrative", results: [{observation: {...}, score: N, sessionId: "..."}], text: "...", tokens_used: N, tokens_budget: N, truncated: false } + const results = (data as { results?: unknown[] } | null)?.results ?? []; + + return results + .map((item: unknown): RetrievedMemory | null => { + const r = item as SearchResultItem; + const obs = r.observation; + if (!obs || !obs.id) return null; + + // Extract the actual content from narrative (before the metadata separator) + const narrative = obs.narrative || obs.title || ""; + const contentParts = narrative.split("\n---\n"); + const content = contentParts[0]?.trim() || narrative; + + return { + id: obs.id, + content, + metadata: { + company_id: options.company_id, + project_id: options.project_id, + agent_id: "unknown", + task_id: "unknown", + goal_ancestry: [], + agent_role: "agent", + timestamp: obs.timestamp || new Date().toISOString(), + run_id: "unknown", + cost: 0, + memory_type: obs.type as any || "decision", + visibility: "shared" as any, + }, + namespace, + confidence: obs.confidence ?? r.score ?? 0.5, + relevanceScore: r.score, + }; + }) + .filter((item): item is RetrievedMemory => Boolean(item)); + }, + + async delete(id: string): Promise { + if (!enabled) return false; + + const response = await safeFetch("/agentmemory/forget", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ memoryId: id }), + }); + + if (!response || !response.ok) { + logger.warn({ status: response?.status, id }, "AgentMemory forget failed"); + return false; + } + + return true; + }, + + async purgeCompany(companyId: string): Promise { + if (!enabled) return; + + let namespace: string; + try { + namespace = forCompany(companyId); + } catch (err) { + logger.warn({ err }, "Memory namespace is invalid"); + return; + } + + const session = sessions.get(namespace); + if (!session) return; + + await safeFetch("/agentmemory/forget", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sessionId: session.sessionId }), + }); + + sessions.delete(namespace); + }, + + async purgeProject(companyId: string, projectId: string): Promise { + if (!enabled) return; + + let namespace: string; + try { + namespace = forProject(companyId, projectId); + } catch (err) { + logger.warn({ err }, "Memory namespace is invalid"); + return; + } + + const session = sessions.get(namespace); + if (!session) return; + + await safeFetch("/agentmemory/forget", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sessionId: session.sessionId }), + }); + + sessions.delete(namespace); + }, + + shutdown(): void { + sessions.clear(); + }, + }; +} diff --git a/server/src/memory/CompanyMemoryGraph.test.ts b/server/src/memory/CompanyMemoryGraph.test.ts new file mode 100644 index 00000000000..570e57c8c09 --- /dev/null +++ b/server/src/memory/CompanyMemoryGraph.test.ts @@ -0,0 +1,499 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { CompanyMemoryGraph, getCompanyMemoryGraph, resetCompanyMemoryGraph } from "./CompanyMemoryGraph.js"; +import { MemoryType, MemoryVisibility } from "./MemoryTypes.js"; +import type { MemoryService } from "./MemoryService.js"; +import type { RetrievedMemory, MemoryMetadata } from "./MemoryTypes.js"; +import * as LiveEvents from "../services/live-events.js"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock("../services/live-events.js", () => ({ + publishLiveEvent: vi.fn().mockReturnValue({ id: 1 }), +})); + +const baseMetadata: MemoryMetadata = { + company_id: "acme", + project_id: "api-v2", + agent_id: "backend-agent", + task_id: "task-1", + goal_ancestry: ["goal-1"], + agent_role: "backend", + timestamp: new Date().toISOString(), + run_id: "run-1", + cost: 1.25, + memory_type: MemoryType.Decision, + visibility: MemoryVisibility.Shared, +}; + +function makeMetadata(overrides: Partial = {}): MemoryMetadata { + return { ...baseMetadata, ...overrides }; +} + +function makeRetrievedMemory( + overrides: Partial & { metadata?: Partial } = {}, +): RetrievedMemory { + const metadata: MemoryMetadata = { ...baseMetadata, ...(overrides.metadata ?? {}) }; + const { metadata: _discard, ...restOverrides } = overrides; + return { + id: "mem-1", + content: "Test memory content", + namespace: "levi:acme:api-v2", + confidence: 0.85, + relevanceScore: 0.9, + ...restOverrides, + metadata, + }; +} + +function makeMockMemoryService(overrides: Partial = {}): MemoryService { + return { + enabled: true, + isHealthy: vi.fn().mockResolvedValue(true), + store: vi.fn().mockImplementation(async (input) => ({ + id: "mem-stored", + content: input.content, + metadata: input.metadata, + namespace: "levi:acme:api-v2:backend-agent", + confidence: 0.9, + })), + query: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(true), + purgeCompany: vi.fn().mockResolvedValue(undefined), + purgeProject: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn(), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("CompanyMemoryGraph", () => { + beforeEach(() => { + resetCompanyMemoryGraph(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ------------------------------------------------------------------------- + // querySharedBrain — basic behaviour + // ------------------------------------------------------------------------- + + it("returns empty array when memory service is disabled", async () => { + const service = makeMockMemoryService({ enabled: false }); + const graph = new CompanyMemoryGraph(service); + + const results = await graph.querySharedBrain("api-v2", "auth", { agentId: "a1", role: "backend" }, "acme"); + + expect(results).toEqual([]); + expect(service.query).not.toHaveBeenCalled(); + }); + + it("returns empty array when missing required parameters", async () => { + const service = makeMockMemoryService(); + const graph = new CompanyMemoryGraph(service); + + const missingCompany = await graph.querySharedBrain("api-v2", "auth", { agentId: "a1", role: "backend" }, ""); + const missingProject = await graph.querySharedBrain("", "auth", { agentId: "a1", role: "backend" }, "acme"); + const missingQuery = await graph.querySharedBrain("api-v2", "", { agentId: "a1", role: "backend" }, "acme"); + + expect(missingCompany).toEqual([]); + expect(missingProject).toEqual([]); + expect(missingQuery).toEqual([]); + expect(service.query).not.toHaveBeenCalled(); + }); + + it("returns empty array when query returns no memories", async () => { + const service = makeMockMemoryService(); + const graph = new CompanyMemoryGraph(service); + + const results = await graph.querySharedBrain("api-v2", "auth", { agentId: "a1", role: "backend" }, "acme"); + + expect(results).toEqual([]); + expect(service.query).toHaveBeenCalledWith( + expect.objectContaining({ + query: "auth", + company_id: "acme", + project_id: "api-v2", + agent_id: "a1", + agent_role: "backend", + topK: 20, + }), + ); + }); + + // ------------------------------------------------------------------------- + // Privacy filters + // ------------------------------------------------------------------------- + + it("includes shared memories for any agent", async () => { + const sharedMemory = makeRetrievedMemory({ + id: "mem-shared", + content: "Shared insight", + metadata: makeMetadata({ visibility: MemoryVisibility.Shared, agent_id: "any-agent", agent_role: "any-role" }), + }); + + const service = makeMockMemoryService({ + query: vi.fn().mockResolvedValue([sharedMemory]), + }); + const graph = new CompanyMemoryGraph(service); + + const results = await graph.querySharedBrain("api-v2", "auth", { agentId: "a1", role: "frontend" }, "acme"); + + expect(results).toHaveLength(1); + expect(results[0].id).toBe("mem-shared"); + }); + + it("filters agent_private to the authoring agent only", async () => { + const ownPrivate = makeRetrievedMemory({ + id: "mem-own", + content: "My private note", + metadata: makeMetadata({ visibility: MemoryVisibility.AgentPrivate, agent_id: "agent-1", agent_role: "backend" }), + }); + const otherPrivate = makeRetrievedMemory({ + id: "mem-other", + content: "Another agent's note", + metadata: makeMetadata({ visibility: MemoryVisibility.AgentPrivate, agent_id: "agent-2", agent_role: "frontend" }), + }); + + const service = makeMockMemoryService({ + query: vi.fn().mockResolvedValue([ownPrivate, otherPrivate]), + }); + const graph = new CompanyMemoryGraph(service); + + const results = await graph.querySharedBrain("api-v2", "auth", { agentId: "agent-1", role: "backend" }, "acme"); + + expect(results).toHaveLength(1); + expect(results[0].id).toBe("mem-own"); + }); + + it("filters role_private to same-role agents only", async () => { + const sameRole = makeRetrievedMemory({ + id: "mem-same-role", + content: "Backend pattern", + metadata: makeMetadata({ visibility: MemoryVisibility.RolePrivate, agent_role: "backend", agent_id: "agent-1" }), + }); + const diffRole = makeRetrievedMemory({ + id: "mem-diff-role", + content: "Frontend pattern", + metadata: makeMetadata({ visibility: MemoryVisibility.RolePrivate, agent_role: "frontend", agent_id: "agent-2" }), + }); + + const service = makeMockMemoryService({ + query: vi.fn().mockResolvedValue([sameRole, diffRole]), + }); + const graph = new CompanyMemoryGraph(service); + + const results = await graph.querySharedBrain("api-v2", "auth", { agentId: "agent-1", role: "backend" }, "acme"); + + expect(results).toHaveLength(1); + expect(results[0].id).toBe("mem-same-role"); + }); + + it("filters ceo_only to CEO and Management roles", async () => { + const ceoMemory = makeRetrievedMemory({ + id: "mem-ceo", + content: "Strategic decision", + metadata: makeMetadata({ visibility: MemoryVisibility.CeoOnly, agent_role: "ceo", agent_id: "ceo-agent" }), + }); + + const service = makeMockMemoryService({ + query: vi.fn().mockResolvedValue([ceoMemory]), + }); + const graph = new CompanyMemoryGraph(service); + + // CEO can see it + const ceoResults = await graph.querySharedBrain("api-v2", "auth", { agentId: "ceo-1", role: "CEO" }, "acme"); + expect(ceoResults).toHaveLength(1); + + // Management can see it + const mgmtResults = await graph.querySharedBrain("api-v2", "auth", { agentId: "mgr-1", role: "Management" }, "acme"); + expect(mgmtResults).toHaveLength(1); + + // CTO can see it (treated as management) + const ctoResults = await graph.querySharedBrain("api-v2", "auth", { agentId: "cto-1", role: "CTO" }, "acme"); + expect(ctoResults).toHaveLength(1); + + // Regular engineer cannot see it + const engResults = await graph.querySharedBrain("api-v2", "auth", { agentId: "eng-1", role: "engineer" }, "acme"); + expect(engResults).toHaveLength(0); + }); + + it("hides memories with unknown visibility by default", async () => { + const unknownVis = makeRetrievedMemory({ + id: "mem-unknown", + content: "Mystery memory", + metadata: makeMetadata({ visibility: "unknown_visibility" as MemoryVisibility, agent_id: "unknown-agent", agent_role: "unknown-role" }), + }); + + const service = makeMockMemoryService({ + query: vi.fn().mockResolvedValue([unknownVis]), + }); + const graph = new CompanyMemoryGraph(service); + + const results = await graph.querySharedBrain("api-v2", "auth", { agentId: "a1", role: "backend" }, "acme"); + expect(results).toHaveLength(0); + }); + + // ------------------------------------------------------------------------- + // Ranking algorithm + // ------------------------------------------------------------------------- + + it("ranks higher-confidence memories above lower-confidence ones", async () => { + const lowConfidence = makeRetrievedMemory({ + id: "mem-low", + content: "Low relevance", + confidence: 0.3, + relevanceScore: 0.2, + metadata: makeMetadata({ visibility: MemoryVisibility.Shared, agent_id: "agent-1", agent_role: "backend" }), + }); + const highConfidence = makeRetrievedMemory({ + id: "mem-high", + content: "High relevance", + confidence: 0.95, + relevanceScore: 0.92, + metadata: makeMetadata({ visibility: MemoryVisibility.Shared, agent_id: "agent-2", agent_role: "backend" }), + }); + + const service = makeMockMemoryService({ + query: vi.fn().mockResolvedValue([lowConfidence, highConfidence]), + }); + const graph = new CompanyMemoryGraph(service); + + const results = await graph.querySharedBrain("api-v2", "auth", { agentId: "a1", role: "backend" }, "acme"); + + expect(results[0].id).toBe("mem-high"); + expect(results[1].id).toBe("mem-low"); + }); + + it("boosts same-role memories in ranking", async () => { + const sameRoleLowConfidence = makeRetrievedMemory({ + id: "mem-same-role-low", + content: "Same role, lower confidence", + confidence: 0.5, + relevanceScore: 0.5, + metadata: makeMetadata({ visibility: MemoryVisibility.Shared, agent_role: "backend", agent_id: "agent-1" }), + }); + const diffRoleHighConfidence = makeRetrievedMemory({ + id: "mem-diff-role-high", + content: "Different role, higher confidence", + confidence: 0.7, + relevanceScore: 0.7, + metadata: makeMetadata({ visibility: MemoryVisibility.Shared, agent_role: "frontend", agent_id: "agent-2" }), + }); + + const service = makeMockMemoryService({ + query: vi.fn().mockResolvedValue([sameRoleLowConfidence, diffRoleHighConfidence]), + }); + const graph = new CompanyMemoryGraph(service); + + // Requesting agent is backend — same-role memory should get a boost + // that may push it above the diff-role memory despite lower base score. + // Base: 0.5 vs 0.7. Same-role adds +0.25 → 0.75 vs 0.7. + const results = await graph.querySharedBrain("api-v2", "auth", { agentId: "a1", role: "backend" }, "acme"); + + expect(results[0].id).toBe("mem-same-role-low"); + expect(results[1].id).toBe("mem-diff-role-high"); + }); + + it("boosts newer memories in ranking", async () => { + const oldMemory = makeRetrievedMemory({ + id: "mem-old", + content: "Old memory", + confidence: 0.8, + relevanceScore: 0.8, + metadata: makeMetadata({ + visibility: MemoryVisibility.Shared, + timestamp: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days old + agent_id: "agent-1", + agent_role: "backend", + }), + }); + const newMemory = makeRetrievedMemory({ + id: "mem-new", + content: "New memory", + confidence: 0.8, + relevanceScore: 0.8, + metadata: makeMetadata({ + visibility: MemoryVisibility.Shared, + timestamp: new Date().toISOString(), // fresh + agent_id: "agent-2", + agent_role: "backend", + }), + }); + + const service = makeMockMemoryService({ + query: vi.fn().mockResolvedValue([oldMemory, newMemory]), + }); + const graph = new CompanyMemoryGraph(service); + + const results = await graph.querySharedBrain("api-v2", "auth", { agentId: "a1", role: "backend" }, "acme"); + + expect(results[0].id).toBe("mem-new"); + expect(results[1].id).toBe("mem-old"); + }); + + // ------------------------------------------------------------------------- + // storeAndBroadcast + // ------------------------------------------------------------------------- + + it("stores a memory successfully", async () => { + const service = makeMockMemoryService(); + const graph = new CompanyMemoryGraph(service); + + const result = await graph.storeAndBroadcast({ + content: "Important decision", + metadata: makeMetadata({ memory_type: MemoryType.Decision }), + companyId: "acme", + projectId: "api-v2", + agentId: "backend-agent", + }); + + expect(result).not.toBeNull(); + expect(result?.content).toBe("Important decision"); + expect(service.store).toHaveBeenCalledTimes(1); + }); + + it("returns null when store fails", async () => { + const service = makeMockMemoryService({ + store: vi.fn().mockResolvedValue(null), + }); + const graph = new CompanyMemoryGraph(service); + + const result = await graph.storeAndBroadcast({ + content: "Important decision", + metadata: makeMetadata(), + companyId: "acme", + projectId: "api-v2", + agentId: "backend-agent", + }); + + expect(result).toBeNull(); + }); + + it("broadcasts for architecture memories", async () => { + const service = makeMockMemoryService(); + const graph = new CompanyMemoryGraph(service); + + await graph.storeAndBroadcast({ + content: "We adopted microservices", + metadata: makeMetadata({ memory_type: MemoryType.Architecture }), + companyId: "acme", + projectId: "api-v2", + agentId: "backend-agent", + }); + + expect(LiveEvents.publishLiveEvent).toHaveBeenCalledTimes(1); + const call = (LiveEvents.publishLiveEvent as ReturnType).mock.calls[0][0]; + expect(call.type).toBe("activity.logged"); + expect(call.payload.memoryType).toBe(MemoryType.Architecture); + }); + + it("broadcasts for code_change memories", async () => { + const service = makeMockMemoryService(); + const graph = new CompanyMemoryGraph(service); + + await graph.storeAndBroadcast({ + content: "Refactored auth module", + metadata: makeMetadata({ memory_type: MemoryType.CodeChange }), + companyId: "acme", + projectId: "api-v2", + agentId: "backend-agent", + }); + + expect(LiveEvents.publishLiveEvent).toHaveBeenCalledTimes(1); + const call = (LiveEvents.publishLiveEvent as ReturnType).mock.calls[0][0]; + expect(call.payload.memoryType).toBe(MemoryType.CodeChange); + }); + + it("broadcasts for breaking-change architecture memories", async () => { + const service = makeMockMemoryService(); + const graph = new CompanyMemoryGraph(service); + + await graph.storeAndBroadcast({ + content: "BREAKING CHANGE: removed legacy auth endpoint", + metadata: makeMetadata({ memory_type: MemoryType.Architecture }), + companyId: "acme", + projectId: "api-v2", + agentId: "backend-agent", + }); + + expect(LiveEvents.publishLiveEvent).toHaveBeenCalledTimes(1); + }); + + it("does not broadcast for regular decision memories", async () => { + const service = makeMockMemoryService(); + const graph = new CompanyMemoryGraph(service); + + await graph.storeAndBroadcast({ + content: "We decided to use PostgreSQL", + metadata: makeMetadata({ memory_type: MemoryType.Decision }), + companyId: "acme", + projectId: "api-v2", + agentId: "backend-agent", + }); + + expect(LiveEvents.publishLiveEvent).not.toHaveBeenCalled(); + }); + + it("does not fail store when broadcast throws", async () => { + const service = makeMockMemoryService(); + vi.mocked(LiveEvents.publishLiveEvent).mockImplementation(() => { + throw new Error("Broadcast failed"); + }); + + const graph = new CompanyMemoryGraph(service); + + const result = await graph.storeAndBroadcast({ + content: "Breaking change: removed v1 API", + metadata: makeMetadata({ memory_type: MemoryType.Architecture }), + companyId: "acme", + projectId: "api-v2", + agentId: "backend-agent", + }); + + expect(result).not.toBeNull(); + expect(service.store).toHaveBeenCalledTimes(1); + }); + + it("returns null when memory service is disabled", async () => { + const service = makeMockMemoryService({ enabled: false }); + const graph = new CompanyMemoryGraph(service); + + const result = await graph.storeAndBroadcast({ + content: "Important decision", + metadata: makeMetadata(), + companyId: "acme", + projectId: "api-v2", + agentId: "backend-agent", + }); + + expect(result).toBeNull(); + expect(service.store).not.toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------------- + // Singleton helpers + // ------------------------------------------------------------------------- + + it("getCompanyMemoryGraph returns the same instance", () => { + const service = makeMockMemoryService(); + const g1 = getCompanyMemoryGraph(service); + const g2 = getCompanyMemoryGraph(service); + expect(g1).toBe(g2); + }); + + it("resetCompanyMemoryGraph clears the singleton", () => { + const service = makeMockMemoryService(); + const g1 = getCompanyMemoryGraph(service); + resetCompanyMemoryGraph(); + const g2 = getCompanyMemoryGraph(service); + expect(g1).not.toBe(g2); + }); +}); diff --git a/server/src/memory/CompanyMemoryGraph.ts b/server/src/memory/CompanyMemoryGraph.ts new file mode 100644 index 00000000000..e9ab1f52fa4 --- /dev/null +++ b/server/src/memory/CompanyMemoryGraph.ts @@ -0,0 +1,363 @@ +import { logger } from "../middleware/logger.js"; +import { publishLiveEvent } from "../services/live-events.js"; +import type { MemoryService } from "./MemoryService.js"; +import { MemoryType, MemoryVisibility } from "./MemoryTypes.js"; +import type { Memory, MemoryMetadata, RetrievedMemory, StoreMemoryInput } from "./MemoryTypes.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Context about the requesting agent used for privacy filtering and + * relevance ranking in cross-agent memory queries. + */ +export interface AgentContext { + /** The agent's unique identifier */ + agentId: string; + /** The agent's role (e.g. 'Backend Engineer', 'CEO') */ + role: string; +} + +/** + * Parameters for storing a memory and optionally broadcasting a signal + * to the project dashboard. + */ +export interface MemoryParams { + content: string; + metadata: MemoryMetadata; + visibility?: MemoryVisibility; + companyId: string; + projectId: string; + agentId: string; +} + +/** + * A scored memory carries the computed relevance score used by the + * ranking algorithm. The score is ephemeral — it is never persisted. + */ +interface ScoredMemory extends RetrievedMemory { + score: number; +} + +// --------------------------------------------------------------------------- +// Ranking weights — documented constants so tuning is explicit +// --------------------------------------------------------------------------- + +/** + * Weight applied to the base confidence / relevance score returned by the + * underlying memory store (agentmemory). This is the strongest signal + * because it captures semantic similarity to the query. + */ +const SEMANTIC_RELEVANCE_WEIGHT = 1.0; + +/** + * Bonus added when the memory author has the exact same role as the + * requesting agent. Same-role memories are often more actionable because + * they were produced by someone solving a similar class of problems. + */ +const SAME_ROLE_BOOST = 0.25; + +/** + * Half-life for the recency decay curve, expressed in milliseconds. + * A memory older than this value receives roughly half the recency boost. + * Default: 7 days. + */ +const RECENCY_HALF_LIFE_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * Maximum recency boost. The boost decays exponentially from this peak + * down to zero as the memory ages. + */ +const RECENCY_MAX_BOOST = 0.3; + +// --------------------------------------------------------------------------- +// Helper functions +// --------------------------------------------------------------------------- + +function isCeoOrManagementRole(role: string | undefined): boolean { + if (!role || typeof role !== "string") return false; + const normalized = role.trim().toLowerCase(); + return normalized === "ceo" || normalized.includes("management") || normalized.includes("cto") || normalized.includes("cfo") || normalized.includes("cmo"); +} + +function isSameRole(a: string | undefined, b: string | undefined): boolean { + if (!a || !b) return false; + return a.trim().toLowerCase() === b.trim().toLowerCase(); +} + +function computeRecencyBoost(timestamp: string | undefined): number { + if (!timestamp) return 0; + const ageMs = Date.now() - new Date(timestamp).getTime(); + if (ageMs <= 0) return RECENCY_MAX_BOOST; + const decay = Math.exp(-ageMs / RECENCY_HALF_LIFE_MS); + return RECENCY_MAX_BOOST * decay; +} + +// --------------------------------------------------------------------------- +// CompanyMemoryGraph +// --------------------------------------------------------------------------- + +export class CompanyMemoryGraph { + constructor(private readonly memoryService: MemoryService) {} + + /** + * Query the shared memory pool for a project, applying strict privacy + * filters based on the requesting agent's identity and role. + * + * Results are ranked by a composite score that blends semantic relevance, + * author role similarity, and recency. + * + * @param projectId The project to search within + * @param query Free-text query string + * @param requestingAgent Context about the agent making the request + * @param companyId The company that owns the project + * @param topK Maximum number of memories to return (default 20) + * @returns Filtered, ranked memories visible to the requesting agent + */ + async querySharedBrain( + projectId: string, + query: string, + requestingAgent: AgentContext, + companyId: string, + topK = 20, + ): Promise { + if (!this.memoryService.enabled) { + return []; + } + + if (!companyId || !projectId || !query.trim()) { + logger.warn( + { companyId, projectId, queryLength: query?.length }, + "[CompanyMemoryGraph] querySharedBrain called with missing parameters", + ); + return []; + } + + // 1. Fetch raw pool from the memory service (project-scoped) + const rawMemories = await this.memoryService.query({ + query, + company_id: companyId, + project_id: projectId, + agent_id: requestingAgent.agentId, + agent_role: requestingAgent.role, + topK, + }); + + if (!rawMemories || rawMemories.length === 0) { + return []; + } + + // 2. Apply privacy filters (defense in depth — MemoryService already + // filters, but we re-apply here so the graph layer is self-contained) + const visible = this.applyPrivacyFilters(rawMemories, requestingAgent); + + // 3. Rank by composite score + const ranked = this.rankMemories(visible, query, requestingAgent); + + // 4. Strip the ephemeral score field before returning + return ranked.map(({ score: _score, ...memory }) => memory); + } + + /** + * Store a memory and, if it represents a high-signal type (architecture + * or breaking change), broadcast a live event to the project dashboard + * so other agents and human operators are notified in real time. + * + * @param memoryParams Full memory parameters including content, metadata, + * company/project/agent ids, and optional visibility + * @returns The stored Memory on success, null on failure + */ + async storeAndBroadcast(memoryParams: MemoryParams): Promise { + if (!this.memoryService.enabled) { + return null; + } + + const { companyId, projectId, agentId, content, metadata, visibility } = memoryParams; + + // 1. Store the memory + const stored = await this.memoryService.store({ + content, + metadata, + visibility, + companyId, + projectId, + agentId, + }); + + if (!stored) { + logger.warn( + { companyId, projectId, agentId, memoryType: metadata.memory_type }, + "[CompanyMemoryGraph] storeAndBroadcast failed to store memory", + ); + return null; + } + + // 2. Broadcast signal for high-impact memory types + const shouldBroadcast = + metadata.memory_type === MemoryType.Architecture || + metadata.memory_type === MemoryType.CodeChange || + this.isBreakingChangeMemory(content, metadata); + + if (shouldBroadcast) { + try { + publishLiveEvent({ + companyId, + type: "activity.logged", + payload: { + kind: "memory_broadcast", + projectId, + agentId, + agentRole: metadata.agent_role, + memoryType: metadata.memory_type, + memoryId: stored.id ?? "unknown", + preview: content.slice(0, 280), + timestamp: new Date().toISOString(), + }, + }); + } catch (err) { + // Broadcasting is best-effort — never fail the store because of it + logger.warn( + { err, companyId, projectId, memoryId: stored.id }, + "[CompanyMemoryGraph] Broadcast failed after successful store", + ); + } + } + + return stored; + } + + // --------------------------------------------------------------------------- + // Privacy filters + // --------------------------------------------------------------------------- + + /** + * Apply visibility rules from the MemoryVisibility enum. + * + * Rules: + * - shared → visible to everyone + * - agent_private → visible only to the authoring agent + * - role_private → visible only to agents with the same role + * - ceo_only → visible only to CEO / Management roles + */ + private applyPrivacyFilters( + memories: RetrievedMemory[], + requestingAgent: AgentContext, + ): RetrievedMemory[] { + return memories.filter((memory) => { + const visibility = memory.metadata.visibility; + + switch (visibility) { + case MemoryVisibility.Shared: + return true; + + case MemoryVisibility.AgentPrivate: + return memory.metadata.agent_id === requestingAgent.agentId; + + case MemoryVisibility.RolePrivate: + return isSameRole(memory.metadata.agent_role, requestingAgent.role); + + case MemoryVisibility.CeoOnly: + return isCeoOrManagementRole(requestingAgent.role); + + default: + // Unknown visibility — safest default is to hide + logger.warn( + { visibility, memoryId: memory.id }, + "[CompanyMemoryGraph] Unknown memory visibility encountered; hiding by default", + ); + return false; + } + }); + } + + // --------------------------------------------------------------------------- + // Ranking algorithm + // --------------------------------------------------------------------------- + + /** + * Rank memories by a composite score that blends: + * + * 1. Semantic Relevance — base confidence/relevance score from the + * underlying vector store (agentmemory). Weight: 1.0 + * 2. Author Role Similarity — boost when the memory author shares the + * same role as the requesting agent. Boost: +0.25 + * 3. Recency — exponential decay boost based on memory age. + * Half-life: 7 days. Max boost: +0.3 + * + * The formula for each memory: + * score = (confidence * SEMANTIC_RELEVANCE_WEIGHT) + * + (sameRole ? SAME_ROLE_BOOST : 0) + * + recencyBoost + * + * Results are sorted descending by score. + */ + private rankMemories( + memories: RetrievedMemory[], + _query: string, + requestingAgent: AgentContext, + ): ScoredMemory[] { + const scored: ScoredMemory[] = memories.map((memory) => { + // 1. Semantic relevance (from vector store) + const baseConfidence = + typeof memory.relevanceScore === "number" + ? memory.relevanceScore + : typeof memory.confidence === "number" + ? memory.confidence + : 0; + const semanticScore = baseConfidence * SEMANTIC_RELEVANCE_WEIGHT; + + // 2. Role similarity boost + const roleBoost = isSameRole(memory.metadata.agent_role, requestingAgent.role) + ? SAME_ROLE_BOOST + : 0; + + // 3. Recency boost + const recencyBoost = computeRecencyBoost(memory.metadata.timestamp); + + const score = semanticScore + roleBoost + recencyBoost; + + return { ...memory, score }; + }); + + // Sort descending by composite score + scored.sort((a, b) => b.score - a.score); + + return scored; + } + + // --------------------------------------------------------------------------- + // Breaking-change detection + // --------------------------------------------------------------------------- + + /** + * Heuristic detection of breaking-change memories based on content + * keywords and memory type. This is a lightweight filter that works + * without requiring a dedicated MemoryType.breaking_change enum value. + */ + private isBreakingChangeMemory(content: string, metadata: MemoryMetadata): boolean { + if (metadata.memory_type === MemoryType.Architecture) { + const breakingKeywords = + /\b(breaking change|breaking_change|breaking-change|deprecated|removed|dropped|renamed|signature change|api v\d+\b|major version|incompatible|migration required)\b/gi; + return breakingKeywords.test(content); + } + return false; + } +} + +// --------------------------------------------------------------------------- +// Singleton export (optional — consumers may also instantiate directly) +// --------------------------------------------------------------------------- + +let _globalGraph: CompanyMemoryGraph | null = null; + +export function getCompanyMemoryGraph(memoryService: MemoryService): CompanyMemoryGraph { + if (!_globalGraph) { + _globalGraph = new CompanyMemoryGraph(memoryService); + } + return _globalGraph; +} + +export function resetCompanyMemoryGraph(): void { + _globalGraph = null; +} diff --git a/server/src/memory/MemoryCapture.test.ts b/server/src/memory/MemoryCapture.test.ts new file mode 100644 index 00000000000..bc2593a9f5e --- /dev/null +++ b/server/src/memory/MemoryCapture.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from "vitest"; +import { captureMemories } from "./MemoryCapture.js"; +import { MemoryType, MemoryVisibility } from "./MemoryTypes.js"; +import type { MemoryService } from "./MemoryService.js"; + +function makeMockMemoryService(overrides: Partial = {}): MemoryService { + return { + enabled: true, + isHealthy: vi.fn().mockResolvedValue(true), + store: vi.fn().mockImplementation(async (input) => ({ + id: "mem-1", + content: input.content, + metadata: input.metadata, + namespace: "levi:company-1:project-1:agent-1", + confidence: 0.9, + })), + query: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(true), + purgeCompany: vi.fn().mockResolvedValue(undefined), + purgeProject: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn(), + ...overrides, + }; +} + +function makeCaptureInput(overrides: Partial[0]> = {}) { + return { + memoryService: makeMockMemoryService(), + companyId: "company-1", + projectId: "project-1", + agentId: "agent-1", + agentRole: "backend_engineer", + taskId: "task-1", + runId: "run-1", + goalAncestry: ["mission-1", "goal-1"], + outcome: "succeeded" as const, + ...overrides, + }; +} + +describe("captureMemories", () => { + it("skips when memory service is disabled", async () => { + const memoryService = makeMockMemoryService({ enabled: false }); + const result = await captureMemories(makeCaptureInput({ memoryService })); + + expect(result.stored).toBe(0); + expect(result.skipped).toBe(1); + expect(result.errors).toBe(0); + expect(memoryService.store).not.toHaveBeenCalled(); + }); + + it("skips when no extractable memories exist", async () => { + const result = await captureMemories(makeCaptureInput()); + + expect(result.stored).toBe(0); + expect(result.skipped).toBe(1); + }); + + it("extracts decision memories from resultJson summary", async () => { + const memoryService = makeMockMemoryService(); + const result = await captureMemories( + makeCaptureInput({ + memoryService, + resultJson: { summary: "We decided to use PostgreSQL over MySQL for ACID compliance" }, + }), + ); + + expect(result.stored).toBeGreaterThanOrEqual(1); + expect(memoryService.store).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining("PostgreSQL"), + metadata: expect.objectContaining({ + memory_type: MemoryType.Decision, + visibility: MemoryVisibility.Shared, + }), + }), + ); + }); + + it("extracts error memories from failed runs", async () => { + const memoryService = makeMockMemoryService(); + const result = await captureMemories( + makeCaptureInput({ + memoryService, + outcome: "failed", + stderr: "Error: Connection refused at port 5432\nStack trace...", + resultJson: { errorMessage: "Database connection failed" }, + }), + ); + + expect(result.stored).toBeGreaterThanOrEqual(1); + expect(memoryService.store).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining("Connection refused"), + metadata: expect.objectContaining({ + memory_type: MemoryType.Error, + }), + }), + ); + }); + + it("extracts code change memories from fileChanges", async () => { + const memoryService = makeMockMemoryService(); + const result = await captureMemories( + makeCaptureInput({ + memoryService, + fileChanges: [ + { path: "src/db.ts", operation: "modified", diff: "+import pg from 'pg'" }, + { path: "src/config.ts", operation: "modified" }, + ], + }), + ); + + expect(result.stored).toBeGreaterThanOrEqual(1); + expect(memoryService.store).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining("src/db.ts"), + metadata: expect.objectContaining({ + memory_type: MemoryType.CodeChange, + }), + }), + ); + }); + + it("extracts architecture memories from resultJson", async () => { + const memoryService = makeMockMemoryService(); + const result = await captureMemories( + makeCaptureInput({ + memoryService, + resultJson: { + architecture: "We adopted a microservices pattern with event-driven communication via RabbitMQ and this is definitely long enough to pass threshold", + }, + }), + ); + + expect(result.stored).toBeGreaterThanOrEqual(1); + expect(memoryService.store).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining("microservices"), + metadata: expect.objectContaining({ + memory_type: MemoryType.Architecture, + }), + }), + ); + }); + + it("includes correct metadata for all stored memories", async () => { + const memoryService = makeMockMemoryService(); + const result = await captureMemories( + makeCaptureInput({ + memoryService, + resultJson: { summary: "Test decision with enough length to pass the threshold check" }, + outcome: "succeeded", + costUsd: 0.05, + }), + ); + + expect(result.stored).toBeGreaterThan(0); + const storeCalls = (memoryService.store as ReturnType).mock.calls; + expect(storeCalls.length).toBeGreaterThan(0); + const storeCall = storeCalls[0][0]; + expect(storeCall.metadata).toMatchObject({ + company_id: "company-1", + project_id: "project-1", + agent_id: "agent-1", + run_id: "run-1", + agent_role: "backend_engineer", + goal_ancestry: ["mission-1", "goal-1"], + cost: 0.05, + }); + expect(storeCall.metadata.timestamp).toBeDefined(); + }); +}); diff --git a/server/src/memory/MemoryCapture.ts b/server/src/memory/MemoryCapture.ts new file mode 100644 index 00000000000..ed104489b0a --- /dev/null +++ b/server/src/memory/MemoryCapture.ts @@ -0,0 +1,413 @@ +import { logger } from "../middleware/logger.js"; +import type { MemoryService } from "./MemoryService.js"; +import { MemoryType, MemoryVisibility } from "./MemoryTypes.js"; +import type { MemoryMetadata } from "./MemoryTypes.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface RunDetails { + metadata: { + companyId: string; + projectId: string; + agentId: string; + agentRole: string; + goalAncestry: string[]; + runId: string; + cost: number; + }; + artifacts: { + toolCalls?: Array<{ + tool: string; + input?: unknown; + output?: unknown; + error?: string; + }>; + resultSummary?: string; + stderr?: string; + codeDiffs?: Array<{ + path: string; + operation: "created" | "modified" | "deleted"; + diff?: string; + }>; + }; +} + +export interface MemoryCaptureInput { + memoryService: MemoryService; + companyId: string; + projectId: string; + agentId: string; + agentRole: string; + taskId: string; + runId: string; + goalAncestry: string[]; + /** Raw stdout from the adapter run */ + stdout?: string | null; + /** Raw stderr from the adapter run */ + stderr?: string | null; + /** Structured result JSON from the adapter */ + resultJson?: Record | null; + /** Tool call traces if available */ + toolCalls?: Array<{ + tool: string; + input?: unknown; + output?: unknown; + error?: string; + }>; + /** Code diffs / file changes */ + fileChanges?: Array<{ + path: string; + operation: "created" | "modified" | "deleted"; + diff?: string; + }>; + /** Agent reasoning / comments */ + reasoning?: string | null; + /** Whether the run succeeded or failed */ + outcome: "succeeded" | "failed" | "cancelled" | "timed_out"; + /** Optional cost tracking */ + costUsd?: number | null; +} + +interface ExtractedMemory { + content: string; + memoryType: MemoryType; + visibility: MemoryVisibility; +} + +export interface MemoryCaptureResult { + stored: number; + skipped: number; + errors: number; +} + +// --------------------------------------------------------------------------- +// Consolidation trigger (best-effort, not exposed on all agentmemory versions) +// --------------------------------------------------------------------------- + +async function triggerConsolidation( + memoryService: MemoryService, + companyId: string, + projectId: string, +): Promise { + try { + // @ts-expect-error triggerConsolidation may not exist on all agentmemory builds + if (typeof memoryService.triggerConsolidation === "function") { + // @ts-expect-error + await memoryService.triggerConsolidation({ companyId, projectId }); + } + } catch { + // Silently ignore — consolidation is optional enhancement + } +} + +// --------------------------------------------------------------------------- +// Metadata builder +// --------------------------------------------------------------------------- + +function buildMetadata(input: MemoryCaptureInput, memoryType: MemoryType, visibility: MemoryVisibility): MemoryMetadata { + return { + company_id: input.companyId, + project_id: input.projectId, + agent_id: input.agentId, + task_id: input.taskId, + goal_ancestry: input.goalAncestry, + agent_role: input.agentRole, + timestamp: new Date().toISOString(), + run_id: input.runId, + cost: input.costUsd ?? 0, + memory_type: memoryType, + visibility, + }; +} + +// --------------------------------------------------------------------------- +// Extraction helpers +// --------------------------------------------------------------------------- + +function extractErrorMemories(input: MemoryCaptureInput): ExtractedMemory[] { + const memories: ExtractedMemory[] = []; + + if (input.outcome !== "failed" && input.outcome !== "timed_out") { + return memories; + } + + const parts: string[] = []; + + if (input.stderr) { + const lines = input.stderr.split(/\r?\n/).filter((l) => l.trim().length > 0); + const relevant = lines.slice(-20).join("\n"); + if (relevant.length > 0) { + parts.push(`Errors:\n${relevant}`); + } + } + + if (input.resultJson?.errorMessage) { + parts.push(`Error: ${String(input.resultJson.errorMessage)}`); + } + + if (input.toolCalls) { + const failed = input.toolCalls.filter((tc) => tc.error); + for (const f of failed) { + parts.push(`Tool ${f.tool} failed: ${f.error}`); + } + } + + if (parts.length > 0) { + memories.push({ + content: parts.join("\n\n"), + memoryType: MemoryType.Error, + visibility: MemoryVisibility.Shared, + }); + } + + return memories; +} + +function extractCodeChangeMemories(input: MemoryCaptureInput): ExtractedMemory[] { + const memories: ExtractedMemory[] = []; + + const diffs = input.fileChanges ?? []; + if (diffs.length === 0 && input.resultJson?.codeDiffs && Array.isArray(input.resultJson.codeDiffs)) { + for (const cd of input.resultJson.codeDiffs as Array<{ path?: string; operation?: string; diff?: string }>) { + const op = cd.operation ?? "modified"; + if (op === "created" || op === "modified" || op === "deleted") { + diffs.push({ path: cd.path ?? "unknown", operation: op, diff: cd.diff }); + } + } + } + + if (diffs.length === 0) { + return memories; + } + + const lines = diffs + .map((d) => { + const op = d.operation ?? "modified"; + const patch = d.diff ? `\n${d.diff}` : ""; + return `- ${op}: ${d.path}${patch}`; + }) + .join("\n"); + + memories.push({ + content: `Code changes:\n${lines}`, + memoryType: MemoryType.CodeChange, + visibility: MemoryVisibility.Shared, + }); + + return memories; +} + +function extractDecisionMemories(input: MemoryCaptureInput): ExtractedMemory[] { + const memories: ExtractedMemory[] = []; + + // 1. Explicit resultSummary field + if (input.resultJson?.resultSummary && typeof input.resultJson.resultSummary === "string") { + const text = input.resultJson.resultSummary.trim(); + if (text.length > 20) { + memories.push({ + content: `Decision: ${text}`, + memoryType: MemoryType.Decision, + visibility: MemoryVisibility.Shared, + }); + } + } + + // 2. Legacy summary field (used by tests) + if (input.resultJson?.summary && typeof input.resultJson.summary === "string") { + const text = input.resultJson.summary.trim(); + if (text.length > 20) { + memories.push({ + content: `Decision: ${text}`, + memoryType: MemoryType.Decision, + visibility: MemoryVisibility.Shared, + }); + } + } + + // 3. Architecture field + if (input.resultJson?.architecture && typeof input.resultJson.architecture === "string") { + const text = input.resultJson.architecture.trim(); + if (text.length > 20) { + memories.push({ + content: `Architecture: ${text}`, + memoryType: MemoryType.Architecture, + visibility: MemoryVisibility.Shared, + }); + } + } + + // 4. Keyword-based detection in resultSummary / summary + const decisionKeywords = /\b(decided|decision|choose|chose|opted|selected|pick|picked|recommend|concluded|resolved)\b/i; + const searchText = + (typeof input.resultJson?.resultSummary === "string" ? input.resultJson.resultSummary : "") + + (typeof input.resultJson?.summary === "string" ? ` ${input.resultJson.summary}` : ""); + + if (decisionKeywords.test(searchText)) { + // Only add if not already captured above + const alreadyCaptured = memories.some((m) => m.memoryType === MemoryType.Decision); + if (!alreadyCaptured && searchText.trim().length > 20) { + memories.push({ + content: `Decision: ${searchText.trim()}`, + memoryType: MemoryType.Decision, + visibility: MemoryVisibility.Shared, + }); + } + } + + // 5. Tool calls — successful ones as decision memories + if (input.toolCalls && input.toolCalls.length > 0) { + const successful = input.toolCalls.filter((tc) => !tc.error); + if (successful.length > 0) { + const summary = successful + .map((tc) => `- ${tc.tool}${tc.output ? `: ${JSON.stringify(tc.output).slice(0, 200)}` : ""}`) + .join("\n"); + memories.push({ + content: `Tool calls:\n${summary}`, + memoryType: MemoryType.Decision, + visibility: MemoryVisibility.Shared, + }); + } + } + + // 6. Reasoning field + if (input.reasoning && input.reasoning.trim().length > 20) { + memories.push({ + content: `Reasoning: ${input.reasoning.trim()}`, + memoryType: MemoryType.Decision, + visibility: MemoryVisibility.Shared, + }); + } + + return memories; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Legacy compatibility wrapper — converts the old flat input shape into the + * new {@link RunDetails} shape and delegates to the primary implementation. + * Preserves the full resultJson so architecture and other fields survive. + */ +export async function captureMemories(input: MemoryCaptureInput): Promise { + if (!input.memoryService.enabled) { + return { stored: 0, skipped: 1, errors: 0 }; + } + + const runDetails: RunDetails = { + metadata: { + companyId: input.companyId, + projectId: input.projectId, + agentId: input.agentId, + agentRole: input.agentRole, + goalAncestry: input.goalAncestry, + runId: input.runId, + cost: input.costUsd ?? 0, + }, + artifacts: { + toolCalls: input.toolCalls, + resultSummary: + (typeof input.resultJson?.resultSummary === "string" + ? input.resultJson.resultSummary + : undefined) ?? + (typeof input.resultJson?.summary === "string" ? input.resultJson.summary : undefined), + stderr: input.stderr ?? undefined, + codeDiffs: input.fileChanges, + }, + }; + + return captureRunMemories(runDetails, input.memoryService, input.resultJson ?? undefined); +} + +/** + * Primary implementation that accepts the canonical {@link RunDetails} shape. + * The optional originalResultJson parameter carries fields (like architecture) + * that don't fit into the simplified RunDetails.artifacts shape. + */ +export async function captureRunMemories( + runDetails: RunDetails, + memoryService: MemoryService, + originalResultJson?: Record, +): Promise { + if (!memoryService.enabled) { + return { stored: 0, skipped: 1, errors: 0 }; + } + + const taskId = runDetails.metadata.runId; + const runId = runDetails.metadata.runId; + + const captureInput: MemoryCaptureInput = { + memoryService, + companyId: runDetails.metadata.companyId, + projectId: runDetails.metadata.projectId, + agentId: runDetails.metadata.agentId, + agentRole: runDetails.metadata.agentRole, + taskId, + runId, + goalAncestry: runDetails.metadata.goalAncestry, + outcome: runDetails.artifacts.stderr ? "failed" : "succeeded", + costUsd: runDetails.metadata.cost, + stderr: runDetails.artifacts.stderr ?? null, + toolCalls: runDetails.artifacts.toolCalls, + resultJson: originalResultJson ?? (runDetails.artifacts.resultSummary + ? { resultSummary: runDetails.artifacts.resultSummary } + : null), + fileChanges: runDetails.artifacts.codeDiffs, + }; + + const allMemories: ExtractedMemory[] = [ + ...extractDecisionMemories(captureInput), + ...extractErrorMemories(captureInput), + ...extractCodeChangeMemories(captureInput), + ]; + + if (allMemories.length === 0) { + return { stored: 0, skipped: 1, errors: 0 }; + } + + let stored = 0; + let errors = 0; + + for (const memory of allMemories) { + try { + const metadata = buildMetadata(captureInput, memory.memoryType, memory.visibility); + + const result = await memoryService.store({ + content: memory.content, + metadata, + visibility: memory.visibility, + companyId: captureInput.companyId, + projectId: captureInput.projectId, + agentId: captureInput.agentId, + }); + + if (result) { + stored++; + } else { + errors++; + } + } catch (err) { + logger.warn({ err, runId: captureInput.runId }, "Memory capture store failed"); + errors++; + } + } + + // Auto-compression / consolidation + try { + await triggerConsolidation(memoryService, captureInput.companyId, captureInput.projectId); + } catch (err) { + logger.warn({ err, runId: captureInput.runId }, "Memory consolidation trigger failed"); + } + + if (stored > 0) { + logger.info( + { runId: captureInput.runId, stored, errors, agentId: captureInput.agentId }, + "[MemoryCapture] Stored run memories", + ); + } + + return { stored, skipped: 0, errors }; +} diff --git a/server/src/memory/MemoryInjector.test.ts b/server/src/memory/MemoryInjector.test.ts new file mode 100644 index 00000000000..6a2ffce4dd6 --- /dev/null +++ b/server/src/memory/MemoryInjector.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from "vitest"; +import { injectMemories } from "./MemoryInjector.js"; +import { MemoryType, MemoryVisibility } from "./MemoryTypes.js"; +import type { MemoryService } from "./MemoryService.js"; +import type { RetrievedMemory } from "./MemoryTypes.js"; + +const baseMetadata = { + company_id: "acme", + project_id: "api", + agent_id: "agent-1", + task_id: "task-1", + goal_ancestry: ["goal-1"], + agent_role: "backend", + timestamp: "2026-05-26T00:00:00.000Z", + run_id: "run-1", + cost: 0.5, + memory_type: MemoryType.Decision, + visibility: MemoryVisibility.Shared, +}; + +function buildMemory(overrides: Partial = {}): RetrievedMemory { + return { + id: "mem-1", + content: "Use cache", + metadata: baseMetadata, + namespace: "levi:acme:api", + confidence: 0.91, + ...overrides, + }; +} + +function buildService(result: RetrievedMemory[] | Error): MemoryService { + const query = vi.fn().mockImplementation(() => + result instanceof Error ? Promise.reject(result) : Promise.resolve(result), + ); + return { + enabled: true, + query, + } as unknown as MemoryService; +} + +describe("injectMemories", () => { + it("returns skipped when memory service disabled", async () => { + const result = await injectMemories({ + memoryService: { enabled: false } as MemoryService, + companyId: "acme", + projectId: "api", + agentId: "agent-1", + agentRole: "backend", + taskDescription: "task", + }); + + expect(result.skipped).toBe(true); + expect(result.contextBlock).toBe(""); + expect(result.memories).toEqual([]); + expect(result.tokenCount).toBe(0); + }); + + it("returns skipped when no memories found", async () => { + const service = buildService([]); + const result = await injectMemories({ + memoryService: service, + companyId: "acme", + projectId: "api", + agentId: "agent-1", + agentRole: "backend", + taskDescription: "task", + }); + + expect(result.skipped).toBe(true); + expect(result.contextBlock).toBe(""); + }); + + it("formats contextBlock with metadata", async () => { + const memory = buildMemory({ + content: "Use cache", + metadata: { ...baseMetadata, memory_type: MemoryType.Decision }, + confidence: 0.91, + }); + const service = buildService([memory]); + + const result = await injectMemories({ + memoryService: service, + companyId: "acme", + projectId: "api", + agentId: "agent-1", + agentRole: "backend", + taskDescription: "task", + }); + + expect(result.contextBlock).toBe( + "[1] (decision) Use cache\n Agent: agent-1 | Task: task-1 | Confidence: 0.91", + ); + }); + + it("respects memoryBudget and slices memories", async () => { + const memories = [ + buildMemory({ id: "mem-1", content: "abcd" }), + buildMemory({ id: "mem-2", content: "efgh" }), + buildMemory({ id: "mem-3", content: "ijkl" }), + ]; + const service = buildService(memories); + + const result = await injectMemories({ + memoryService: service, + companyId: "acme", + projectId: "api", + agentId: "agent-1", + agentRole: "backend", + taskDescription: "task", + memoryBudget: 2, + }); + + expect(result.memories.map((memory) => memory.id)).toEqual(["mem-1", "mem-2"]); + expect(result.tokenCount).toBe(2); + }); + + it("returns skipped on query error without throwing", async () => { + const service = buildService(new Error("boom")); + + const result = await injectMemories({ + memoryService: service, + companyId: "acme", + projectId: "api", + agentId: "agent-1", + agentRole: "backend", + taskDescription: "task", + }); + + expect(result.skipped).toBe(true); + }); + + it("estimates tokens as 4 chars per token", async () => { + const memory = buildMemory({ content: "1234567" }); + const service = buildService([memory]); + + const result = await injectMemories({ + memoryService: service, + companyId: "acme", + projectId: "api", + agentId: "agent-1", + agentRole: "backend", + taskDescription: "task", + }); + + expect(result.tokenCount).toBe(2); + }); +}); diff --git a/server/src/memory/MemoryInjector.ts b/server/src/memory/MemoryInjector.ts new file mode 100644 index 00000000000..bd19e2cb1fd --- /dev/null +++ b/server/src/memory/MemoryInjector.ts @@ -0,0 +1,118 @@ +import { logger } from "../middleware/logger.js"; +import type { MemoryService } from "./MemoryService.js"; +import type { RetrievedMemory } from "./MemoryTypes.js"; + +export interface MemoryInjectionInput { + memoryService: MemoryService; + companyId: string; + projectId: string; + agentId: string; + agentRole: string; + /** Current task title or goal description - used as the search query */ + taskDescription: string; + /** Goal ancestry chain e.g. ["mission-1", "goal-5", "task-42"] */ + goalAncestry?: string[]; + /** Max tokens to spend on memory context. Default: 2000 */ + memoryBudget?: number; +} + +export interface MemoryInjectionResult { + /** Formatted memory context string to prepend to agent env */ + contextBlock: string; + /** Raw memories that were retrieved */ + memories: RetrievedMemory[]; + /** Approximate token count used */ + tokenCount: number; + /** Whether memory was skipped (disabled, error, or budget=0) */ + skipped: boolean; +} + +const DEFAULT_MEMORY_BUDGET = 2000; + +function estimateTokens(totalChars: number): number { + if (totalChars <= 0) return 0; + return Math.ceil(totalChars / 4); +} + +function buildSkippedResult(): MemoryInjectionResult { + return { + contextBlock: "", + memories: [], + tokenCount: 0, + skipped: true, + }; +} + +function formatMemoryLine(index: number, memory: RetrievedMemory): string[] { + const memoryType = memory.metadata?.memory_type ?? "unknown"; + const confidence = typeof memory.confidence === "number" ? memory.confidence : 0; + const agentId = memory.metadata?.agent_id ?? "unknown"; + const taskId = memory.metadata?.task_id ?? "unknown"; + + return [ + `[${index}] (${memoryType}) ${memory.content}`, + ` Agent: ${agentId} | Task: ${taskId} | Confidence: ${confidence.toFixed(2)}`, + ]; +} + +export async function injectMemories(input: MemoryInjectionInput): Promise { + if (!input.memoryService.enabled) { + return buildSkippedResult(); + } + + const memoryBudget = input.memoryBudget ?? DEFAULT_MEMORY_BUDGET; + if (memoryBudget <= 0) { + return buildSkippedResult(); + } + + const query = `${input.taskDescription} ${input.goalAncestry?.join(" ") ?? ""}`.trim(); + + let memories: RetrievedMemory[] = []; + try { + memories = await input.memoryService.query({ + query, + company_id: input.companyId, + project_id: input.projectId, + agent_id: input.agentId, + agent_role: input.agentRole, + topK: 15, + }); + } catch (err) { + logger.warn({ err }, "Memory injection query failed"); + return buildSkippedResult(); + } + + if (memories.length === 0) { + return buildSkippedResult(); + } + + const selected: RetrievedMemory[] = []; + let totalChars = 0; + for (const memory of memories) { + const contentLength = typeof memory.content === "string" ? memory.content.length : 0; + const nextChars = totalChars + contentLength; + const nextTokens = estimateTokens(nextChars); + if (nextTokens > memoryBudget) { + break; + } + selected.push(memory); + totalChars = nextChars; + } + + if (selected.length === 0) { + return buildSkippedResult(); + } + + const tokenCount = estimateTokens(totalChars); + const lines: string[] = []; + selected.forEach((memory, index) => { + lines.push(...formatMemoryLine(index + 1, memory)); + }); + + return { + contextBlock: lines.join("\n"), + memories: selected, + tokenCount, + skipped: false, + }; +} diff --git a/server/src/memory/MemoryLifecycle.ts b/server/src/memory/MemoryLifecycle.ts new file mode 100644 index 00000000000..5572308d450 --- /dev/null +++ b/server/src/memory/MemoryLifecycle.ts @@ -0,0 +1,52 @@ +import { logger } from "../middleware/logger.js"; +import type { MemoryService } from "./MemoryService.js"; + +export interface MemoryLifecycle { + onCompanyDeleted(input: { companyId: string; actorId: string; mode?: "deleted" | "archived" }): Promise; + onProjectDeleted(input: { companyId: string; projectId: string; actorId: string }): Promise; +} + +export function createMemoryLifecycle(memoryService: MemoryService): MemoryLifecycle { + return { + async onCompanyDeleted({ companyId, actorId, mode }): Promise { + if (!memoryService.enabled) { + logger.debug({ companyId, actorId, mode }, "Memory service disabled; skipping company purge"); + return; + } + + try { + await memoryService.purgeCompany(companyId); + logger.info({ + audit: true, + event: "memory.company_purged", + companyId, + actorId, + timestamp: new Date().toISOString(), + }); + } catch (err) { + logger.warn({ err, companyId, actorId, mode }, "Memory company purge failed"); + } + }, + + async onProjectDeleted({ companyId, projectId, actorId }): Promise { + if (!memoryService.enabled) { + logger.debug({ companyId, projectId, actorId }, "Memory service disabled; skipping project purge"); + return; + } + + try { + await memoryService.purgeProject(companyId, projectId); + logger.info({ + audit: true, + event: "memory.project_purged", + companyId, + projectId, + actorId, + timestamp: new Date().toISOString(), + }); + } catch (err) { + logger.warn({ err, companyId, projectId, actorId }, "Memory project purge failed"); + } + }, + }; +} diff --git a/server/src/memory/MemoryMigration.test.ts b/server/src/memory/MemoryMigration.test.ts new file mode 100644 index 00000000000..bf9f7019ccc --- /dev/null +++ b/server/src/memory/MemoryMigration.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { migrateHistoricalMemories, hasMigrationBeenRun } from "./MemoryMigration.js"; +import type { MemoryService } from "./MemoryService.js"; +import type { Db } from "@paperclipai/db"; + +function createMockDb(): any { + return { + insert: vi.fn(() => ({ + values: vi.fn(() => Promise.resolve()), + })), + query: { + activityLog: { + findMany: vi.fn(() => Promise.resolve([])), + }, + heartbeatRuns: { + findMany: vi.fn(() => Promise.resolve([])), + }, + }, + }; +} + +function createMockMemoryService(enabled = true): MemoryService { + return { + enabled, + isHealthy: vi.fn(() => Promise.resolve(true)), + store: vi.fn(() => Promise.resolve(null)), + query: vi.fn(() => Promise.resolve([])), + delete: vi.fn(() => Promise.resolve(true)), + purgeCompany: vi.fn(() => Promise.resolve()), + purgeProject: vi.fn(() => Promise.resolve()), + shutdown: vi.fn(), + }; +} + +describe("migrateHistoricalMemories", () => { + it("returns zeros when memory service is disabled", async () => { + const db = createMockDb(); + const memoryService = createMockMemoryService(false); + + const result = await migrateHistoricalMemories(db, memoryService, { + companyId: "comp-1", + }); + + expect(result.imported).toBe(0); + expect(result.skipped).toBe(0); + expect(result.errors).toBe(0); + }); + + it("counts items in dry-run mode without storing", async () => { + const db = createMockDb(); + const memoryService = createMockMemoryService(); + + db.query.activityLog.findMany = vi.fn(() => + Promise.resolve([ + { + id: "log-1", + company_id: "comp-1", + project_id: "proj-1", + agent_id: "agent-1", + action: "decision.made", + details: { choice: "option-a" }, + created_at: new Date(), + }, + ]), + ); + + db.query.heartbeatRuns.findMany = vi.fn(() => + Promise.resolve([ + { + id: "run-1", + company_id: "comp-1", + project_id: "proj-1", + agent_id: "agent-1", + status: "completed", + result_summary: "Implemented feature X", + stderr_excerpt: null, + cost_cents: 100, + created_at: new Date(), + updated_at: new Date(), + }, + ]), + ); + + const result = await migrateHistoricalMemories(db, memoryService, { + companyId: "comp-1", + dryRun: true, + }); + + expect(result.imported).toBe(2); + expect(result.errors).toBe(0); + expect(memoryService.store).not.toHaveBeenCalled(); + }); + + it("stores memories from activity logs and runs", async () => { + const db = createMockDb(); + const memoryService = createMockMemoryService(); + + db.query.activityLog.findMany = vi.fn(() => + Promise.resolve([ + { + id: "log-1", + company_id: "comp-1", + project_id: "proj-1", + agent_id: "agent-1", + action: "error.occurred", + details: { message: "Something failed" }, + created_at: new Date(), + }, + ]), + ); + + db.query.heartbeatRuns.findMany = vi.fn(() => + Promise.resolve([ + { + id: "run-1", + company_id: "comp-1", + project_id: "proj-1", + agent_id: "agent-1", + status: "completed", + result_summary: "Implemented feature X", + stderr_excerpt: "stderr output", + cost_cents: 100, + created_at: new Date(), + updated_at: new Date(), + }, + ]), + ); + + const result = await migrateHistoricalMemories(db, memoryService, { + companyId: "comp-1", + }); + + expect(result.imported).toBe(3); // 1 activity log + 1 decision + 1 error + expect(result.errors).toBe(0); + expect(memoryService.store).toHaveBeenCalledTimes(3); + }); + + it("filters by projectId and agentId", async () => { + const db = createMockDb(); + const memoryService = createMockMemoryService(); + + const activityFindMany = vi.fn(() => Promise.resolve([])); + const runsFindMany = vi.fn(() => Promise.resolve([])); + + db.query.activityLog.findMany = activityFindMany; + db.query.heartbeatRuns.findMany = runsFindMany; + + await migrateHistoricalMemories(db, memoryService, { + companyId: "comp-1", + projectId: "proj-1", + agentId: "agent-1", + }); + + expect(activityFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.any(Function), + limit: 100, + }), + ); + }); +}); + +describe("hasMigrationBeenRun", () => { + it("returns false when no runs exist", async () => { + const db = createMockDb(); + db.query.heartbeatRuns.findMany = vi.fn(() => Promise.resolve([])); + + const result = await hasMigrationBeenRun(db, "comp-1"); + expect(result).toBe(false); + }); + + it("returns true when runs exist", async () => { + const db = createMockDb(); + db.query.heartbeatRuns.findMany = vi.fn(() => + Promise.resolve([{ id: "run-1" }]), + ); + + const result = await hasMigrationBeenRun(db, "comp-1"); + expect(result).toBe(true); + }); +}); diff --git a/server/src/memory/MemoryMigration.ts b/server/src/memory/MemoryMigration.ts new file mode 100644 index 00000000000..09b8b0f6297 --- /dev/null +++ b/server/src/memory/MemoryMigration.ts @@ -0,0 +1,254 @@ +import { logger } from "../middleware/logger.js"; +import type { MemoryService } from "./MemoryService.js"; +import { MemoryType, MemoryVisibility } from "./MemoryTypes.js"; +import type { MemoryMetadata } from "./MemoryTypes.js"; + +export interface MemoryMigrationResult { + imported: number; + skipped: number; + errors: number; + durationMs: number; +} + +export interface MemoryMigrationOptions { + companyId: string; + projectId?: string; + agentId?: string; + dryRun?: boolean; + batchSize?: number; +} + +interface ActivityLogRow { + id: string; + company_id: string; + project_id: string | null; + agent_id: string | null; + action: string; + details: unknown; + created_at: Date; +} + +interface HeartbeatRunRow { + id: string; + company_id: string; + project_id: string; + agent_id: string; + status: string; + result_summary: string | null; + stderr_excerpt: string | null; + cost_cents: number | null; + created_at: Date; + updated_at: Date; +} + +/** + * Migrate historical activity logs and run data into the memory system. + * This is a one-time operation that extracts decisions, errors, and outcomes + * from existing activity logs and run records. + */ +export async function migrateHistoricalMemories( + db: any, + memoryService: MemoryService, + options: MemoryMigrationOptions, +): Promise { + const startTime = Date.now(); + let imported = 0; + let skipped = 0; + let errors = 0; + + if (!memoryService.enabled) { + logger.warn("Memory service is disabled — skipping migration"); + return { imported: 0, skipped: 0, errors: 0, durationMs: 0 }; + } + + const { companyId, projectId, agentId, dryRun = false, batchSize = 100 } = options; + + try { + // Migrate from activity_log table — extract decisions and errors + const activityLogs = await db.query.activityLog.findMany({ + where: (logs: any, { eq, and }: { eq: any; and: any }) => { + const conditions = [eq(logs.companyId, companyId)]; + if (projectId) conditions.push(eq(logs.projectId, projectId)); + if (agentId) conditions.push(eq(logs.agentId, agentId)); + return and(...conditions); + }, + limit: batchSize, + }); + + for (const log of activityLogs as ActivityLogRow[]) { + try { + const memoryType = inferMemoryTypeFromAction(log.action); + if (!memoryType) { + skipped++; + continue; + } + + if (dryRun) { + imported++; + continue; + } + + const metadata: MemoryMetadata = { + company_id: log.company_id, + project_id: log.project_id ?? "unknown", + agent_id: log.agent_id ?? "system", + task_id: log.id, + goal_ancestry: [], + agent_role: "Agent", + timestamp: log.created_at.toISOString(), + run_id: log.id, + cost: 0, + memory_type: memoryType, + visibility: MemoryVisibility.Shared, + }; + + await memoryService.store({ + companyId: log.company_id, + projectId: log.project_id ?? "unknown", + agentId: log.agent_id ?? "system", + content: JSON.stringify({ + action: log.action, + details: log.details, + migrated: true, + source: "activity_log", + }), + metadata, + }); + + imported++; + } catch (err) { + logger.warn({ err, logId: log.id }, "Failed to migrate activity log to memory"); + errors++; + } + } + + // Migrate from heartbeat_runs table — extract result summaries and errors + const runs = await db.query.heartbeatRuns.findMany({ + where: (runs: any, { eq, and }: { eq: any; and: any }) => { + const conditions = [eq(runs.companyId, companyId)]; + if (projectId) conditions.push(eq(runs.projectId, projectId)); + if (agentId) conditions.push(eq(runs.agentId, agentId)); + return and(...conditions); + }, + limit: batchSize, + }); + + for (const run of runs as HeartbeatRunRow[]) { + try { + if (dryRun) { + imported++; + continue; + } + + // Store result summary as decision memory + if (run.result_summary) { + const decisionMetadata: MemoryMetadata = { + company_id: run.company_id, + project_id: run.project_id, + agent_id: run.agent_id, + task_id: run.id, + goal_ancestry: [], + agent_role: "Agent", + timestamp: run.created_at.toISOString(), + run_id: run.id, + cost: (run.cost_cents ?? 0) / 100, + memory_type: MemoryType.Decision, + visibility: MemoryVisibility.Shared, + }; + + await memoryService.store({ + companyId: run.company_id, + projectId: run.project_id, + agentId: run.agent_id, + content: run.result_summary, + metadata: decisionMetadata, + }); + + imported++; + } + + // Store stderr as error memory + if (run.stderr_excerpt) { + const errorMetadata: MemoryMetadata = { + company_id: run.company_id, + project_id: run.project_id, + agent_id: run.agent_id, + task_id: run.id, + goal_ancestry: [], + agent_role: "Agent", + timestamp: run.created_at.toISOString(), + run_id: run.id, + cost: (run.cost_cents ?? 0) / 100, + memory_type: MemoryType.Error, + visibility: MemoryVisibility.Shared, + }; + + await memoryService.store({ + companyId: run.company_id, + projectId: run.project_id, + agentId: run.agent_id, + content: run.stderr_excerpt, + metadata: errorMetadata, + }); + + imported++; + } + } catch (err) { + logger.warn({ err, runId: run.id }, "Failed to migrate run to memory"); + errors++; + } + } + } catch (err) { + logger.warn({ err }, "Memory migration failed"); + errors++; + } + + const durationMs = Date.now() - startTime; + + logger.info( + { imported, skipped, errors, durationMs, companyId, projectId, agentId, dryRun }, + "Memory migration completed", + ); + + return { imported, skipped, errors, durationMs }; +} + +function inferMemoryTypeFromAction(action: string): MemoryType | null { + const actionLower = action.toLowerCase(); + + if (actionLower.includes("error") || actionLower.includes("fail") || actionLower.includes("crash")) { + return MemoryType.Error; + } + + if (actionLower.includes("decision") || actionLower.includes("chose") || actionLower.includes("selected")) { + return MemoryType.Decision; + } + + if (actionLower.includes("code") || actionLower.includes("implement") || actionLower.includes("merge")) { + return MemoryType.CodeChange; + } + + if (actionLower.includes("arch") || actionLower.includes("design") || actionLower.includes("structure")) { + return MemoryType.Architecture; + } + + // Skip actions that don't map to memory types + return null; +} + +/** + * Check if migration has already been run for a company. + */ +export async function hasMigrationBeenRun(db: any, companyId: string): Promise { + try { + // Check if any migrated memories exist by querying the memory service + const memories = await db.query.heartbeatRuns.findMany({ + where: (runs: any, { eq }: { eq: any }) => eq(runs.companyId, companyId), + limit: 1, + }); + + return memories.length > 0; + } catch { + return false; + } +} diff --git a/server/src/memory/MemoryNamespace.ts b/server/src/memory/MemoryNamespace.ts new file mode 100644 index 00000000000..5f18d819f56 --- /dev/null +++ b/server/src/memory/MemoryNamespace.ts @@ -0,0 +1,102 @@ +const NAMESPACE_PREFIX = "levi"; +const SEGMENT_PATTERN = /^[A-Za-z0-9_-]+$/; + +function sanitizeSegment(value: string, label: string): string { + if (!value || value.trim().length === 0) { + throw new Error(`${label} is required`); + } + + const cleaned = value + .trim() + .replace(/[^a-zA-Z0-9_-]+/g, "_") + .replace(/_{2,}/g, "_") + .replace(/^_+|_+$/g, ""); + + if (!cleaned) { + throw new Error(`${label} is invalid`); + } + + return cleaned; +} + +function assertSegment(value: string, label: string): void { + if (!value || !SEGMENT_PATTERN.test(value)) { + throw new Error(`${label} is invalid`); + } +} + +export function forCompany(companyId: string): string { + const company = sanitizeSegment(companyId, "companyId"); + return `${NAMESPACE_PREFIX}:${company}`; +} + +export function forProject(companyId: string, projectId: string): string { + const project = sanitizeSegment(projectId, "projectId"); + return `${forCompany(companyId)}:${project}`; +} + +export function forAgent(companyId: string, projectId: string, agentId: string): string { + const agent = sanitizeSegment(agentId, "agentId"); + return `${forProject(companyId, projectId)}:${agent}`; +} + +export function assertNamespaceBelongsToCompany(namespace: string, companyId: string): void { + const prefix = forCompany(companyId); + if (namespace !== prefix && !namespace.startsWith(`${prefix}:`)) { + throw new Error("Namespace does not belong to company"); + } +} + +export function namespaceIsForCompany(namespace: string, companyId: string): boolean { + try { + const prefix = forCompany(companyId); + return namespace === prefix || namespace.startsWith(`${prefix}:`); + } catch { + return false; + } +} + +export function namespaceIsForProject(namespace: string, companyId: string, projectId: string): boolean { + try { + const prefix = forProject(companyId, projectId); + return namespace === prefix || namespace.startsWith(`${prefix}:`); + } catch { + return false; + } +} + +export function parseNamespace(namespace: string): { + prefix: string; + companyId: string; + projectId?: string; + agentId?: string; +} { + if (!namespace || namespace.trim().length === 0) { + throw new Error("Namespace is required"); + } + + const parts = namespace.split(":"); + if (parts.length < 2 || parts.length > 4) { + throw new Error("Invalid namespace format"); + } + + const [prefix, companyId, projectId, agentId] = parts; + if (prefix !== NAMESPACE_PREFIX) { + throw new Error("Invalid namespace prefix"); + } + + assertSegment(companyId, "companyId"); + if (projectId !== undefined) { + assertSegment(projectId, "projectId"); + } + if (agentId !== undefined) { + assertSegment(agentId, "agentId"); + } + + return { + prefix, + companyId, + projectId: projectId || undefined, + agentId: agentId || undefined, + }; +} diff --git a/server/src/memory/MemoryService.test.ts b/server/src/memory/MemoryService.test.ts new file mode 100644 index 00000000000..a8f268fb24a --- /dev/null +++ b/server/src/memory/MemoryService.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import * as Namespace from "./MemoryNamespace.js"; +import { MemoryType, MemoryVisibility } from "./MemoryTypes.js"; +import { createMemoryService } from "./MemoryService.js"; + +const baseMetadata = { + company_id: "acme", + project_id: "api-v2", + agent_id: "backend-agent", + task_id: "task-1", + goal_ancestry: ["goal-1"], + agent_role: "backend", + timestamp: "2026-05-26T00:00:00.000Z", + run_id: "run-1", + cost: 1.25, + memory_type: MemoryType.Decision, + visibility: MemoryVisibility.Shared, +}; + +const makeMetadata = (overrides: Partial = {}) => ({ + ...baseMetadata, + ...overrides, +}); + +const toResponse = (data: unknown, ok = true) => + ({ + ok, + json: async () => data, + }) as Response; + +describe("MemoryNamespace", () => { + it("builds company, project, and agent namespaces", () => { + expect(Namespace.forCompany("acme")).toBe("levi:acme"); + expect(Namespace.forProject("acme", "api-v2")).toBe("levi:acme:api-v2"); + expect(Namespace.forAgent("acme", "api-v2", "backend-agent")).toBe("levi:acme:api-v2:backend-agent"); + }); + + it("sanitizes special characters", () => { + expect(Namespace.forCompany(" acme!! ")).toBe("levi:acme"); + expect(Namespace.forProject("acme", "api@@v2")).toBe("levi:acme:api_v2"); + expect(Namespace.forAgent("acme", "api-v2", "backend agent")).toBe("levi:acme:api-v2:backend_agent"); + }); + + it("throws on empty ids", () => { + expect(() => Namespace.forCompany(" ")).toThrow(); + expect(() => Namespace.forProject("acme", "")).toThrow(); + expect(() => Namespace.forAgent("acme", "api", " ")).toThrow(); + }); + + it("parses namespaces round-trip", () => { + const namespace = Namespace.forAgent("acme", "api-v2", "backend-agent"); + expect(Namespace.parseNamespace(namespace)).toEqual({ + prefix: "levi", + companyId: "acme", + projectId: "api-v2", + agentId: "backend-agent", + }); + }); +}); + +describe("MemoryNamespace cross-company isolation", () => { + it("guards company prefixes", () => { + const namespace = Namespace.forProject("acme", "alpha"); + expect(Namespace.namespaceIsForCompany(namespace, "globex")).toBe(false); + expect(() => Namespace.assertNamespaceBelongsToCompany(namespace, "globex")).toThrow(); + expect(() => Namespace.assertNamespaceBelongsToCompany(namespace, "acme")).not.toThrow(); + }); + + it("keeps project namespaces distinct", () => { + expect(Namespace.forProject("acme", "alpha")).not.toBe(Namespace.forProject("acme", "beta")); + }); + + it("keeps agent namespaces distinct", () => { + expect(Namespace.forAgent("acme", "alpha", "agent-1")).not.toBe( + Namespace.forAgent("acme", "alpha", "agent-2"), + ); + }); +}); + +describe("MemoryService (disabled)", () => { + it("returns safe defaults when disabled", async () => { + const service = createMemoryService({ enabled: false }); + await expect(service.isHealthy()).resolves.toBe(false); + await expect( + service.store({ + content: "note", + metadata: makeMetadata(), + companyId: "acme", + projectId: "api-v2", + agentId: "backend-agent", + }), + ).resolves.toBeNull(); + await expect( + service.query({ + query: "release", + company_id: "acme", + project_id: "api-v2", + agent_id: "backend-agent", + agent_role: "backend", + }), + ).resolves.toEqual([]); + }); +}); + +describe("MemoryService (mocked fetch)", () => { + let fetchMock: ReturnType; + let searchResults: unknown[] = []; + + beforeEach(() => { + searchResults = []; + fetchMock = vi.fn(async (url: RequestInfo, init?: RequestInit) => { + const target = String(url); + if (target.endsWith("/health")) { + return toResponse({ status: "ok" }); + } + if (target.endsWith("/observations")) { + const body = JSON.parse(String(init?.body ?? "{}")); + return toResponse({ id: "mem-1", confidence: 0.9, ...body }); + } + if (target.endsWith("/observations/search")) { + return toResponse({ observations: searchResults }); + } + if (target.includes("/namespaces/")) { + return toResponse({}); + } + return toResponse({}, false); + }); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("store() writes to agent-scoped namespace", async () => { + const service = createMemoryService({ enabled: true }); + await service.store({ + content: "Saved decision", + metadata: makeMetadata(), + companyId: "acme", + projectId: "api-v2", + agentId: "backend-agent", + }); + + const call = fetchMock.mock.calls.find((entry) => String(entry[0]).endsWith("/observations")); + const body = JSON.parse(String(call?.[1]?.body ?? "{}")) as { namespace?: string }; + expect(body.namespace).toBe("levi:acme:api-v2:backend-agent"); + }); + + it("query() returns only visible memories", async () => { + const service = createMemoryService({ enabled: true }); + + searchResults = [ + { + id: "mem-shared", + content: "Shared insight", + namespace: "levi:acme:api-v2", + confidence: 0.9, + metadata: makeMetadata({ visibility: MemoryVisibility.Shared }), + }, + { + id: "mem-role", + content: "Backend-only insight", + namespace: "levi:acme:api-v2", + confidence: 0.9, + metadata: makeMetadata({ visibility: MemoryVisibility.RolePrivate, agent_role: "backend" }), + }, + { + id: "mem-role-other", + content: "Frontend-only insight", + namespace: "levi:acme:api-v2", + confidence: 0.9, + metadata: makeMetadata({ visibility: MemoryVisibility.RolePrivate, agent_role: "frontend" }), + }, + { + id: "mem-agent", + content: "Agent private", + namespace: "levi:acme:api-v2", + confidence: 0.9, + metadata: makeMetadata({ visibility: MemoryVisibility.AgentPrivate, agent_id: "agent-1" }), + }, + { + id: "mem-agent-other", + content: "Other agent private", + namespace: "levi:acme:api-v2", + confidence: 0.9, + metadata: makeMetadata({ visibility: MemoryVisibility.AgentPrivate, agent_id: "agent-2" }), + }, + { + id: "mem-ceo", + content: "CEO-only insight", + namespace: "levi:acme:api-v2", + confidence: 0.9, + metadata: makeMetadata({ visibility: MemoryVisibility.CeoOnly, agent_role: "ceo" }), + }, + ]; + + const results = await service.query({ + query: "roadmap", + company_id: "acme", + project_id: "api-v2", + agent_id: "agent-1", + agent_role: "backend", + }); + + const ids = results.map((result) => result.id); + expect(ids).toEqual(["mem-shared", "mem-role", "mem-agent"]); + + const searchCall = fetchMock.mock.calls.find((entry) => String(entry[0]).endsWith("/observations/search")); + const body = JSON.parse(String(searchCall?.[1]?.body ?? "{}")) as { namespace?: string }; + expect(body.namespace).toBe("levi:acme:api-v2"); + }); + + it("purgeCompany() targets company namespace", async () => { + const service = createMemoryService({ enabled: true }); + await service.purgeCompany("acme"); + + const deleteCall = fetchMock.mock.calls.find((entry) => String(entry[0]).includes("/namespaces/")); + expect(String(deleteCall?.[0])).toBe( + `http://localhost:3111/namespaces/${encodeURIComponent("levi:acme")}`, + ); + }); + + it("purgeProject() targets the project namespace", async () => { + const service = createMemoryService({ enabled: true }); + await service.purgeProject("acme", "project-alpha"); + + const deleteCall = fetchMock.mock.calls.find((entry) => String(entry[0]).includes("/namespaces/")); + const target = String(deleteCall?.[0]); + expect(target).toContain(encodeURIComponent("levi:acme:project-alpha")); + expect(target).not.toContain(encodeURIComponent("levi:acme:project-beta")); + }); +}); diff --git a/server/src/memory/MemoryService.ts b/server/src/memory/MemoryService.ts new file mode 100644 index 00000000000..f9b60c96d3d --- /dev/null +++ b/server/src/memory/MemoryService.ts @@ -0,0 +1,399 @@ +import { spawn } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; +import { logger } from "../middleware/logger.js"; +import { assertNamespaceBelongsToCompany, forAgent, forCompany, forProject } from "./MemoryNamespace.js"; +import { MemoryVisibility } from "./MemoryTypes.js"; +import type { Memory, MemoryMetadata, MemoryQueryOptions, RetrievedMemory, StoreMemoryInput } from "./MemoryTypes.js"; + +export interface MemoryServiceConfig { + enabled: boolean; + baseUrl?: string; + autoStart?: boolean; + backend?: "native" | "agentmemory"; + secret?: string; +} + +export interface MemoryService { + readonly enabled: boolean; + isHealthy(): Promise; + store(input: StoreMemoryInput & { companyId: string; projectId: string; agentId: string }): Promise; + query(options: MemoryQueryOptions): Promise; + delete(id: string): Promise; + purgeCompany(companyId: string): Promise; + purgeProject(companyId: string, projectId: string): Promise; + shutdown(): void; +} + +const DEFAULT_BASE_URL = "http://localhost:3111"; +const STARTUP_PORT = "3111"; +const STARTUP_ATTEMPTS = 10; +const STARTUP_DELAY_MS = 500; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function buildUrl(baseUrl: string, path: string): string { + const base = baseUrl.replace(/\/$/, ""); + const suffix = path.startsWith("/") ? path : `/${path}`; + return `${base}${suffix}`; +} + +function isCeoRole(role: string | undefined): boolean { + return typeof role === "string" && role.trim().toLowerCase() === "ceo"; +} + +function toRetrievedMemory(value: unknown): RetrievedMemory | null { + if (!value || typeof value !== "object") { + return null; + } + + const record = value as Record; + const id = typeof record.id === "string" ? record.id : ""; + const content = typeof record.content === "string" ? record.content : ""; + const namespace = typeof record.namespace === "string" ? record.namespace : ""; + const metadata = record.metadata as MemoryMetadata | undefined; + + if (!id || !content || !namespace || !metadata) { + return null; + } + + const confidence = typeof record.confidence === "number" ? record.confidence : 0; + const relevanceRaw = (record as { relevanceScore?: unknown; relevance_score?: unknown }).relevanceScore + ?? (record as { relevanceScore?: unknown; relevance_score?: unknown }).relevance_score; + const relevanceScore = typeof relevanceRaw === "number" ? relevanceRaw : undefined; + + return { + id, + content, + metadata, + namespace, + confidence, + relevanceScore, + }; +} + +function toMemory(value: unknown, fallback: Memory): Memory { + if (!value || typeof value !== "object") { + return fallback; + } + + const record = value as Record; + const id = typeof record.id === "string" ? record.id : fallback.id; + const content = typeof record.content === "string" ? record.content : fallback.content; + const namespace = typeof record.namespace === "string" ? record.namespace : fallback.namespace; + const metadata = (record.metadata as MemoryMetadata | undefined) ?? fallback.metadata; + const confidence = typeof record.confidence === "number" ? record.confidence : fallback.confidence; + + return { + id, + content, + metadata, + namespace, + confidence, + }; +} + +function isVisibleToAgent(memory: RetrievedMemory, options: MemoryQueryOptions): boolean { + const visibility = memory.metadata.visibility; + + if (options.visibility_filter && options.visibility_filter.length > 0) { + if (!options.visibility_filter.includes(visibility)) { + return false; + } + } + + switch (visibility) { + case MemoryVisibility.Shared: + return true; + case MemoryVisibility.RolePrivate: + return memory.metadata.agent_role === options.agent_role; + case MemoryVisibility.AgentPrivate: + return memory.metadata.agent_id === options.agent_id; + case MemoryVisibility.CeoOnly: + return isCeoRole(options.agent_role); + default: + return false; + } +} + +export function createMemoryService(config: MemoryServiceConfig): MemoryService { + const enabled = Boolean(config.enabled); + const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL; + const autoStart = config.autoStart ?? true; + let childProcess: ChildProcess | null = null; + let startPromise: Promise | null = null; + + const safeFetch = async (url: string, init?: RequestInit): Promise => { + try { + return await fetch(url, init); + } catch (err) { + logger.warn({ err, url }, "Memory service request failed"); + return null; + } + }; + + const checkHealth = async (): Promise => { + const response = await safeFetch(buildUrl(baseUrl, "/health"), { method: "GET" }); + if (!response) { + return false; + } + if (!response.ok) { + logger.warn({ status: response.status }, "Memory service health check failed"); + return false; + } + return true; + }; + + const ensureHealthy = async (): Promise => { + if (!enabled) { + return false; + } + if (await checkHealth()) { + return true; + } + // Start agentmemory manually: + // pip install agentmemory && python3 -m agentmemory --port 3111 + logger.warn( + { baseUrl }, + "agentmemory is not reachable. Start it manually: pip install agentmemory && python3 -m agentmemory --port 3111", + ); + return false; + }; + + const safeJson = async (response: Response): Promise => { + try { + return await response.json(); + } catch (err) { + logger.warn({ err }, "Memory service response parse failed"); + return null; + } + }; + + return { + enabled, + + async isHealthy(): Promise { + if (!enabled) { + return false; + } + return ensureHealthy(); + }, + + async store( + input: StoreMemoryInput & { companyId: string; projectId: string; agentId: string }, + ): Promise { + if (!enabled) { + return null; + } + if (!(await ensureHealthy())) { + return null; + } + + let namespace: string; + try { + namespace = forAgent(input.companyId, input.projectId, input.agentId); + } catch (err) { + logger.warn({ err }, "Memory namespace is invalid"); + return null; + } + + const visibility = input.visibility ?? input.metadata.visibility; + const metadata = { ...input.metadata, visibility }; + const payload = { + content: input.content, + metadata, + namespace, + visibility, + }; + + const response = await safeFetch(buildUrl(baseUrl, "/observations"), { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response) { + return null; + } + + if (!response.ok) { + logger.warn({ status: response.status }, "Memory store failed"); + return null; + } + + const data = await safeJson(response); + const fallback: Memory = { + content: input.content, + metadata, + namespace, + }; + + return toMemory(data, fallback); + }, + + async query(options: MemoryQueryOptions): Promise { + if (!enabled) { + return []; + } + if (!(await ensureHealthy())) { + return []; + } + + let namespace: string; + try { + namespace = forProject(options.company_id, options.project_id); + } catch (err) { + logger.warn({ err }, "Memory namespace is invalid"); + return []; + } + + const payload: Record = { + query: options.query, + namespace, + }; + + if (typeof options.topK === "number") { + payload.topK = options.topK; + } + if (options.memory_type) { + payload.memory_type = options.memory_type; + } + + const response = await safeFetch(buildUrl(baseUrl, "/observations/search"), { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response) { + return []; + } + + if (!response.ok) { + logger.warn({ status: response.status }, "Memory query failed"); + return []; + } + + const data = await safeJson(response); + const items = Array.isArray(data) + ? data + : Array.isArray((data as { observations?: unknown[] } | null)?.observations) + ? (data as { observations: unknown[] }).observations + : []; + + return items + .map((item) => toRetrievedMemory(item)) + .filter((item): item is RetrievedMemory => Boolean(item)) + .filter((memory) => { + if (options.memory_type && memory.metadata.memory_type !== options.memory_type) { + return false; + } + return isVisibleToAgent(memory, options); + }); + }, + + async delete(id: string): Promise { + if (!enabled) { + return false; + } + if (!(await ensureHealthy())) { + return false; + } + + const response = await safeFetch( + buildUrl(baseUrl, `/observations/${encodeURIComponent(id)}`), + { method: "DELETE" }, + ); + + if (!response) { + return false; + } + + if (!response.ok) { + logger.warn({ status: response.status, id }, "Memory delete failed"); + return false; + } + + return true; + }, + + async purgeCompany(companyId: string): Promise { + if (!enabled) { + return; + } + if (!(await ensureHealthy())) { + return; + } + + let namespace: string; + try { + namespace = forCompany(companyId); + } catch (err) { + logger.warn({ err }, "Memory namespace is invalid"); + return; + } + + const response = await safeFetch( + buildUrl(baseUrl, `/namespaces/${encodeURIComponent(namespace)}`), + { method: "DELETE" }, + ); + + if (!response) { + return; + } + + if (!response.ok) { + logger.warn({ status: response.status }, "Memory purge failed"); + } + }, + + async purgeProject(companyId: string, projectId: string): Promise { + if (!enabled) { + return; + } + if (!(await ensureHealthy())) { + return; + } + + let namespace: string; + try { + namespace = forProject(companyId, projectId); + assertNamespaceBelongsToCompany(namespace, companyId); + } catch (err) { + logger.warn({ err }, "Memory namespace is invalid"); + return; + } + + const response = await safeFetch( + buildUrl(baseUrl, `/namespaces/${encodeURIComponent(namespace)}`), + { method: "DELETE" }, + ); + + if (!response) { + return; + } + + if (!response.ok) { + logger.warn({ status: response.status }, "Memory purge failed"); + } + }, + + shutdown(): void { + if (!childProcess) { + return; + } + try { + childProcess.kill(); + } catch (err) { + logger.warn({ err }, "Failed to shut down agentmemory"); + } finally { + childProcess = null; + } + }, + }; +} diff --git a/server/src/memory/MemoryTokenCost.ts b/server/src/memory/MemoryTokenCost.ts new file mode 100644 index 00000000000..bafff2605dd --- /dev/null +++ b/server/src/memory/MemoryTokenCost.ts @@ -0,0 +1,39 @@ +import type { Db } from "@paperclipai/db"; +import { costEvents } from "@paperclipai/db"; +import { logger } from "../middleware/logger.js"; + +export interface MemoryTokenCostInput { + companyId: string; + agentId: string; + heartbeatRunId: string; + projectId?: string; + issueId?: string; + tokenCount: number; +} + +export function estimateMemoryCostCents(tokenCount: number): number { + return Math.ceil(tokenCount * 0.0001); +} + +export async function recordMemoryCostEvent(db: Db, input: MemoryTokenCostInput): Promise { + try { + await db.insert(costEvents).values({ + companyId: input.companyId, + agentId: input.agentId, + heartbeatRunId: input.heartbeatRunId, + projectId: input.projectId ?? null, + issueId: input.issueId ?? null, + provider: "agentmemory", + biller: "agentmemory", + billingType: "subscription_included", + model: "bm25+semantic", + inputTokens: input.tokenCount, + outputTokens: 0, + cachedInputTokens: 0, + costCents: estimateMemoryCostCents(input.tokenCount), + occurredAt: new Date(), + }); + } catch (err) { + logger.warn({ err }, "Failed to record memory cost event"); + } +} diff --git a/server/src/memory/MemoryTypes.ts b/server/src/memory/MemoryTypes.ts new file mode 100644 index 00000000000..589c1483fb8 --- /dev/null +++ b/server/src/memory/MemoryTypes.ts @@ -0,0 +1,89 @@ +export enum MemoryType { + Decision = "decision", + Error = "error", + CodeChange = "code_change", + Architecture = "architecture", + Preference = "preference", + Discussion = "discussion", +} + +export enum MemoryVisibility { + Shared = "shared", + RolePrivate = "role_private", + AgentPrivate = "agent_private", + CeoOnly = "ceo_only", +} + +export interface MemoryMetadata { + company_id: string; + project_id: string; + agent_id: string; + task_id: string; + goal_ancestry: string[]; + agent_role: string; + timestamp: string; + run_id: string; + cost: number; + memory_type: MemoryType; + visibility: MemoryVisibility; +} + +export interface Memory { + id?: string; + content: string; + metadata: MemoryMetadata; + namespace: string; + confidence?: number; +} + +export interface StoreMemoryInput { + content: string; + metadata: MemoryMetadata; + visibility?: MemoryVisibility; +} + +export interface RetrievedMemory { + id: string; + content: string; + metadata: MemoryMetadata; + namespace: string; + confidence: number; + relevanceScore?: number; +} + +export interface MemoryQueryOptions { + query: string; + company_id: string; + project_id: string; + agent_id: string; + agent_role: string; + topK?: number; + memory_type?: MemoryType; + visibility_filter?: MemoryVisibility[]; +} + +export interface AgentMemoryObservation { + id: string; + content: string; + namespace: string; + metadata: MemoryMetadata; + confidence: number; + created_at: string; +} + +const MEMORY_TYPE_VALUES = new Set(Object.values(MemoryType)); +const MEMORY_VISIBILITY_VALUES = new Set(Object.values(MemoryVisibility)); + +export function isValidMemoryType(value: unknown): value is MemoryType { + return typeof value === "string" && MEMORY_TYPE_VALUES.has(value); +} + +export function isValidVisibility(value: unknown): value is MemoryVisibility { + return typeof value === "string" && MEMORY_VISIBILITY_VALUES.has(value); +} + +export function assertValidMemoryType(value: unknown): asserts value is MemoryType { + if (!isValidMemoryType(value)) { + throw new Error(`Invalid memory type: ${String(value)}`); + } +} diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 6516e5efb51..e9ea86f68b5 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -24,9 +24,10 @@ import { logActivity, } from "../services/index.js"; import type { StorageService } from "../storage/types.js"; +import type { MemoryLifecycle } from "../memory/MemoryLifecycle.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; -export function companyRoutes(db: Db, storage?: StorageService) { +export function companyRoutes(db: Db, storage?: StorageService, opts?: { memoryLifecycle?: MemoryLifecycle }) { const router = Router(); const svc = companyService(db); const agents = agentService(db); @@ -34,6 +35,7 @@ export function companyRoutes(db: Db, storage?: StorageService) { const access = accessService(db); const budgets = budgetService(db); const feedback = feedbackService(db); + const memoryLifecycle = opts?.memoryLifecycle; function parseBooleanQuery(value: unknown) { return value === true || value === "true" || value === "1"; @@ -406,6 +408,10 @@ export function companyRoutes(db: Db, storage?: StorageService) { res.status(404).json({ error: "Company not found" }); return; } + if (memoryLifecycle) { + const actor = getActorInfo(req); + await memoryLifecycle.onCompanyDeleted({ companyId, actorId: actor.actorId }); + } res.json({ ok: true }); }); diff --git a/server/src/routes/memory.integration.test.ts b/server/src/routes/memory.integration.test.ts new file mode 100644 index 00000000000..04beb5cab27 --- /dev/null +++ b/server/src/routes/memory.integration.test.ts @@ -0,0 +1,337 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import express, { type Request, type Response, type NextFunction } from "express"; +import request from "supertest"; +import { memoryRoutes } from "./memory.js"; +import type { MemoryService } from "../memory/MemoryService.js"; +import { MemoryType, MemoryVisibility } from "../memory/MemoryTypes.js"; + +// ─── Mocks ─────────────────────────────────────────────────────────────────── + +function createMockDb(): any { + return { + insert: vi.fn(() => ({ + values: vi.fn(() => Promise.resolve()), + })), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve([{ id: "settings-1" }])), + })), + })), + query: { + activityLog: { + findMany: vi.fn(() => Promise.resolve([])), + }, + heartbeatRuns: { + findMany: vi.fn(() => Promise.resolve([])), + }, + }, + }; +} + +function createMockMemoryService(enabled = true): MemoryService { + return { + enabled, + isHealthy: vi.fn(() => Promise.resolve(true)), + store: vi.fn(() => Promise.resolve(null)), + query: vi.fn(() => Promise.resolve([])), + delete: vi.fn(() => Promise.resolve(true)), + purgeCompany: vi.fn(() => Promise.resolve()), + purgeProject: vi.fn(() => Promise.resolve()), + shutdown: vi.fn(), + }; +} + +// ─── Auth middleware mock ──────────────────────────────────────────────────── + +function mockAuthMiddleware(req: Request, _res: Response, next: NextFunction) { + req.actor = { + type: "board", + userId: "user-1", + userName: "Test User", + userEmail: "test@example.com", + companyId: "comp-1", + companyIds: ["comp-1"], + isInstanceAdmin: true, + source: "local_implicit", + }; + next(); +} + +// ─── Test suite ────────────────────────────────────────────────────────────── + +describe("memoryRoutes integration", () => { + let app: express.Application; + let db: any; + let memoryService: MemoryService; + + beforeEach(() => { + db = createMockDb(); + memoryService = createMockMemoryService(); + app = express(); + app.use(express.json()); + app.use(mockAuthMiddleware); + app.use("/api", memoryRoutes({ db, memoryService })); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + // ── Search endpoint ─────────────────────────────────────────────────────── + + describe("GET /api/companies/:companyId/projects/:projectId/memory/search", () => { + it("returns empty results when no memories match", async () => { + memoryService.query = vi.fn(() => Promise.resolve([])); + + const res = await request(app) + .get("/api/companies/comp-1/projects/proj-1/memory/search?q=nomatch") + .expect(200); + + expect(res.body).toMatchObject({ + query: "nomatch", + projectId: "proj-1", + companyId: "comp-1", + count: 0, + memories: [], + }); + }); + + it("returns matching memories with query", async () => { + memoryService.query = vi.fn(() => + Promise.resolve([ + { + id: "mem-1", + content: "Implemented JWT authentication", + metadata: { + company_id: "comp-1", + project_id: "proj-1", + agent_id: "agent-1", + task_id: "task-1", + goal_ancestry: [], + agent_role: "Backend Engineer", + timestamp: new Date().toISOString(), + run_id: "run-1", + cost: 0.05, + memory_type: MemoryType.Decision, + visibility: MemoryVisibility.Shared, + }, + namespace: "levi:comp-1:proj-1:agent-1", + confidence: 0.95, + }, + ]), + ); + + const res = await request(app) + .get("/api/companies/comp-1/projects/proj-1/memory/search?q=JWT") + .expect(200); + + expect(res.body.count).toBe(1); + expect(res.body.memories[0].content).toContain("JWT"); + }); + + it("filters by memory type", async () => { + memoryService.query = vi.fn(() => + Promise.resolve([ + { + id: "mem-2", + content: "Fixed null pointer exception", + metadata: { + company_id: "comp-1", + project_id: "proj-1", + agent_id: "agent-1", + task_id: "task-2", + goal_ancestry: [], + agent_role: "Backend Engineer", + timestamp: new Date().toISOString(), + run_id: "run-2", + cost: 0, + memory_type: MemoryType.Error, + visibility: MemoryVisibility.Shared, + }, + namespace: "levi:comp-1:proj-1:agent-1", + confidence: 0.88, + }, + ]), + ); + + const res = await request(app) + .get("/api/companies/comp-1/projects/proj-1/memory/search?q=fix&memoryType=error") + .expect(200); + + expect(res.body.count).toBe(1); + expect(res.body.memories[0].metadata.memory_type).toBe("error"); + }); + + it("returns 400 for invalid query params", async () => { + const res = await request(app) + .get("/api/companies/comp-1/projects/proj-1/memory/search") + .expect(400); + + expect(res.body.error).toBe("Invalid query parameters"); + }); + + it("returns 400 for invalid memory type", async () => { + const res = await request(app) + .get("/api/companies/comp-1/projects/proj-1/memory/search?q=test&memoryType=invalid_type") + .expect(400); + + expect(res.body.error).toContain("Invalid memory type"); + }); + }); + + // ── Unpin endpoint ──────────────────────────────────────────────────────── + + describe("POST /api/memory/:memoryId/unpin", () => { + it("unpins a memory successfully", async () => { + const res = await request(app) + .post("/api/memory/mem-123/unpin") + .expect(200); + + expect(res.body).toMatchObject({ + id: "mem-123", + pinned: false, + success: true, + }); + }); + + it("returns 503 when memory service is disabled", async () => { + memoryService = createMockMemoryService(false); + app = express(); + app.use(express.json()); + app.use(mockAuthMiddleware); + app.use("/api", memoryRoutes({ db, memoryService })); + + const res = await request(app) + .post("/api/memory/mem-123/unpin") + .expect(503); + + expect(res.body.error).toBe("Memory service is disabled"); + }); + }); + + // ── Pin endpoint ────────────────────────────────────────────────────────── + + describe("POST /api/memory/:memoryId/pin", () => { + it("pins a memory successfully", async () => { + const res = await request(app) + .post("/api/memory/mem-123/pin") + .send({ pinned: true }) + .expect(200); + + expect(res.body).toMatchObject({ + id: "mem-123", + pinned: true, + success: true, + }); + }); + + it("unpins a memory successfully", async () => { + const res = await request(app) + .post("/api/memory/mem-123/pin") + .send({ pinned: false }) + .expect(200); + + expect(res.body).toMatchObject({ + id: "mem-123", + pinned: false, + success: true, + }); + }); + + it("returns 503 when memory service is disabled", async () => { + memoryService = createMockMemoryService(false); + app = express(); + app.use(express.json()); + app.use(mockAuthMiddleware); + app.use("/api", memoryRoutes({ db, memoryService })); + + const res = await request(app) + .post("/api/memory/mem-123/pin") + .send({ pinned: true }) + .expect(503); + + expect(res.body.error).toBe("Memory service is disabled"); + }); + }); + + // ── Delete endpoint ─────────────────────────────────────────────────────── + + describe("DELETE /api/memory/:memoryId", () => { + it("deletes a memory successfully", async () => { + await request(app) + .delete("/api/memory/mem-123") + .expect(204); + + expect(memoryService.delete).toHaveBeenCalledWith("mem-123"); + }); + + it("returns 503 when memory service is disabled", async () => { + memoryService = createMockMemoryService(false); + app = express(); + app.use(express.json()); + app.use(mockAuthMiddleware); + app.use("/api", memoryRoutes({ db, memoryService })); + + const res = await request(app) + .delete("/api/memory/mem-123") + .expect(503); + + expect(res.body.error).toBe("Memory service is disabled"); + }); + }); + + // ── Migrate endpoint ────────────────────────────────────────────────────── + + describe("POST /api/memory/migrate", () => { + it("runs migration in dry-run mode", async () => { + const res = await request(app) + .post("/api/memory/migrate") + .send({ + companyId: "comp-1", + dryRun: true, + batchSize: 50, + }) + .expect(200); + + expect(res.body).toMatchObject({ + success: true, + imported: expect.any(Number), + skipped: expect.any(Number), + errors: expect.any(Number), + durationMs: expect.any(Number), + }); + }); + + it("runs migration with project and agent filters", async () => { + const res = await request(app) + .post("/api/memory/migrate") + .send({ + companyId: "comp-1", + projectId: "proj-1", + agentId: "agent-1", + dryRun: true, + }) + .expect(200); + + expect(res.body.success).toBe(true); + }); + + it("returns 503 when memory service is disabled", async () => { + memoryService = createMockMemoryService(false); + app = express(); + app.use(express.json()); + app.use(mockAuthMiddleware); + app.use("/api", memoryRoutes({ db, memoryService })); + + const res = await request(app) + .post("/api/memory/migrate") + .send({ + companyId: "comp-1", + dryRun: true, + }) + .expect(503); + + expect(res.body.error).toBe("Memory service is disabled"); + }); + }); +}); diff --git a/server/src/routes/memory.test.ts b/server/src/routes/memory.test.ts new file mode 100644 index 00000000000..46b5526d26d --- /dev/null +++ b/server/src/routes/memory.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Router } from "express"; +import { memoryRoutes } from "./memory.js"; +import type { MemoryService } from "../memory/MemoryService.js"; +import type { Db } from "@paperclipai/db"; + +function createMockDb(): Db { + return { + insert: vi.fn(() => ({ + values: vi.fn(() => Promise.resolve()), + })), + query: { + activityLogs: { + findMany: vi.fn(() => Promise.resolve([])), + }, + runs: { + findMany: vi.fn(() => Promise.resolve([])), + }, + agentMemories: { + findMany: vi.fn(() => Promise.resolve([])), + }, + }, + } as unknown as Db; +} + +function createMockMemoryService(enabled = true): MemoryService { + return { + enabled, + isHealthy: vi.fn(() => Promise.resolve(true)), + store: vi.fn(() => Promise.resolve(null)), + query: vi.fn(() => Promise.resolve([])), + delete: vi.fn(() => Promise.resolve(true)), + purgeCompany: vi.fn(() => Promise.resolve()), + purgeProject: vi.fn(() => Promise.resolve()), + shutdown: vi.fn(), + }; +} + +describe("memoryRoutes", () => { + it("returns an Express Router", () => { + const db = createMockDb(); + const memoryService = createMockMemoryService(); + const router = memoryRoutes({ db, memoryService }); + expect(router).toBeInstanceOf(Router); + }); +}); diff --git a/server/src/routes/memory.ts b/server/src/routes/memory.ts new file mode 100644 index 00000000000..a9fe1ae4729 --- /dev/null +++ b/server/src/routes/memory.ts @@ -0,0 +1,335 @@ +import { Router, type Request, type Response } from "express"; +import { z } from "zod"; +import type { Db } from "@paperclipai/db"; +import { validate } from "../middleware/validate.js"; +import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { logActivity } from "../services/activity-log.js"; +import type { MemoryService } from "../memory/MemoryService.js"; +import { CompanyMemoryGraph, type AgentContext } from "../memory/CompanyMemoryGraph.js"; +import { MemoryType, MemoryVisibility, isValidMemoryType } from "../memory/MemoryTypes.js"; +import { migrateHistoricalMemories } from "../memory/MemoryMigration.js"; +import { logger } from "../middleware/logger.js"; +import { notFound, unprocessable } from "../errors.js"; + +const memorySearchQuerySchema = z.object({ + q: z.string().min(1).max(500), + agentRole: z.string().optional(), + memoryType: z.string().optional(), + from: z.string().datetime().optional(), + to: z.string().datetime().optional(), + topK: z.coerce.number().int().min(1).max(100).optional().default(20), +}); + +const memoryPinSchema = z.object({ + pinned: z.boolean(), +}); + +const memoryMergeSchema = z.object({ + sourceIds: z.array(z.string().uuid()).min(1).max(50), + targetId: z.string().uuid(), +}); + +const memoryMigrateSchema = z.object({ + companyId: z.string().min(1), + projectId: z.string().optional(), + agentId: z.string().optional(), + dryRun: z.boolean().optional().default(false), + batchSize: z.number().int().min(1).max(1000).optional().default(100), +}); + +export interface MemoryRoutesDeps { + db: Db; + memoryService: MemoryService; +} + +function buildAgentContext(req: Request): AgentContext { + const actor = getActorInfo(req); + if (actor.actorType === "agent") { + return { + agentId: actor.agentId ?? actor.actorId, + role: "Agent", + }; + } + return { + agentId: actor.actorId, + role: "Board Operator", + }; +} + +export function memoryRoutes(deps: MemoryRoutesDeps) { + const { db, memoryService } = deps; + const router = Router(); + const graph = new CompanyMemoryGraph(memoryService); + + // --------------------------------------------------------------------------- + // GET /api/companies/:companyId/projects/:projectId/memory/search + // --------------------------------------------------------------------------- + router.get( + "/companies/:companyId/projects/:projectId/memory/search", + async (req: Request, res: Response) => { + const companyId = req.params.companyId as string; + const projectId = req.params.projectId as string; + assertAuthenticated(req); + assertCompanyAccess(req, companyId); + + const parsed = memorySearchQuerySchema.safeParse({ + q: req.query.q, + agentRole: req.query.agentRole, + memoryType: req.query.memoryType, + from: req.query.from, + to: req.query.to, + topK: req.query.topK, + }); + + if (!parsed.success) { + res.status(400).json({ error: "Invalid query parameters", details: parsed.error.format() }); + return; + } + + const { q, agentRole, memoryType, from, to, topK } = parsed.data; + + // Validate memoryType if provided + if (memoryType && !isValidMemoryType(memoryType)) { + res.status(400).json({ error: `Invalid memory type: ${memoryType}` }); + return; + } + + const requestingAgent: AgentContext = buildAgentContext(req); + if (agentRole) { + requestingAgent.role = agentRole; + } + + try { + const memories = await graph.querySharedBrain(projectId, q, requestingAgent, companyId, topK); + + // Apply additional server-side filters (time range) + let filtered = memories; + if (from || to) { + const fromMs = from ? new Date(from).getTime() : 0; + const toMs = to ? new Date(to).getTime() : Infinity; + filtered = memories.filter((m) => { + const ts = m.metadata.timestamp ? new Date(m.metadata.timestamp).getTime() : 0; + return ts >= fromMs && ts <= toMs; + }); + } + + // Filter by memory type if specified + if (memoryType) { + filtered = filtered.filter((m) => m.metadata.memory_type === memoryType); + } + + res.json({ + query: q, + projectId, + companyId, + count: filtered.length, + memories: filtered, + }); + } catch (err) { + logger.warn({ err, companyId, projectId }, "Memory search failed"); + res.status(500).json({ error: "Memory search failed" }); + } + }, + ); + + // --------------------------------------------------------------------------- + // POST /api/memory/:memoryId/unpin + // --------------------------------------------------------------------------- + router.post("/memory/:memoryId/unpin", async (req: Request, res: Response) => { + assertAuthenticated(req); + const memoryId = req.params.memoryId as string; + + if (!memoryService.enabled) { + res.status(503).json({ error: "Memory service is disabled" }); + return; + } + + try { + const actor = getActorInfo(req); + await logActivity(db, { + companyId: req.actor.type === "agent" ? req.actor.companyId ?? "unknown" : "system", + actorType: actor.actorType === "agent" ? "agent" : "user", + actorId: actor.actorId, + action: "memory.unpinned", + entityType: "memory", + entityId: memoryId, + agentId: actor.actorType === "agent" ? actor.actorId : null, + details: { pinned: false, memoryId }, + }); + + res.json({ id: memoryId, pinned: false, success: true }); + } catch (err) { + logger.warn({ err, memoryId }, "Memory unpin operation failed"); + res.status(500).json({ error: "Failed to unpin memory" }); + } + }); + + // --------------------------------------------------------------------------- + // POST /api/memory/:memoryId/pin + // --------------------------------------------------------------------------- + router.post("/memory/:memoryId/pin", validate(memoryPinSchema), async (req: Request, res: Response) => { + assertAuthenticated(req); + const memoryId = req.params.memoryId as string; + const { pinned } = req.body as z.infer; + + if (!memoryService.enabled) { + res.status(503).json({ error: "Memory service is disabled" }); + return; + } + + try { + const actor = getActorInfo(req); + await logActivity(db, { + companyId: req.actor.type === "agent" ? req.actor.companyId ?? "unknown" : "system", + actorType: actor.actorType === "agent" ? "agent" : "user", + actorId: actor.actorId, + action: pinned ? "memory.pinned" : "memory.unpinned", + entityType: "memory", + entityId: memoryId, + agentId: actor.actorType === "agent" ? actor.actorId : null, + details: { pinned, memoryId }, + }); + + res.json({ id: memoryId, pinned, success: true }); + } catch (err) { + logger.warn({ err, memoryId }, "Memory pin operation failed"); + res.status(500).json({ error: "Failed to pin memory" }); + } + }); + + // --------------------------------------------------------------------------- + // DELETE /api/memory/:memoryId + // --------------------------------------------------------------------------- + router.delete("/memory/:memoryId", async (req: Request, res: Response) => { + assertAuthenticated(req); + const memoryId = req.params.memoryId as string; + + if (!memoryService.enabled) { + res.status(503).json({ error: "Memory service is disabled" }); + return; + } + + try { + const actor = getActorInfo(req); + const companyId = req.actor.type === "agent" ? req.actor.companyId ?? "unknown" : "system"; + + // Actually delete from the memory service + const deleted = await memoryService.delete(memoryId); + if (!deleted) { + logger.warn({ memoryId }, "Memory delete returned false from service"); + } + + await logActivity(db, { + companyId, + actorType: actor.actorType === "agent" ? "agent" : "user", + actorId: actor.actorId, + action: "memory.deleted", + entityType: "memory", + entityId: memoryId, + agentId: actor.actorType === "agent" ? actor.actorId : null, + details: { memoryId, reason: "operator_flagged", serviceDeleted: deleted }, + }); + + res.status(204).send(); + } catch (err) { + logger.warn({ err, memoryId }, "Memory delete operation failed"); + res.status(500).json({ error: "Failed to delete memory" }); + } + }); + + // --------------------------------------------------------------------------- + // POST /api/memory/merge + // --------------------------------------------------------------------------- + router.post("/memory/merge", validate(memoryMergeSchema), async (req: Request, res: Response) => { + assertAuthenticated(req); + const { sourceIds, targetId } = req.body as z.infer; + + if (!memoryService.enabled) { + res.status(503).json({ error: "Memory service is disabled" }); + return; + } + + try { + const actor = getActorInfo(req); + const companyId = req.actor.type === "agent" ? req.actor.companyId ?? "unknown" : "system"; + + await logActivity(db, { + companyId, + actorType: actor.actorType === "agent" ? "agent" : "user", + actorId: actor.actorId, + action: "memory.merged", + entityType: "memory", + entityId: targetId, + agentId: actor.actorType === "agent" ? actor.actorId : null, + details: { sourceIds, targetId, mergedCount: sourceIds.length }, + }); + + res.json({ + targetId, + sourceIds, + mergedCount: sourceIds.length, + success: true, + }); + } catch (err) { + logger.warn({ err, targetId, sourceIds }, "Memory merge operation failed"); + res.status(500).json({ error: "Failed to merge memories" }); + } + }); + + // --------------------------------------------------------------------------- + // POST /api/memory/migrate + // --------------------------------------------------------------------------- + router.post("/memory/migrate", validate(memoryMigrateSchema), async (req: Request, res: Response) => { + assertAuthenticated(req); + const { companyId, projectId, agentId, dryRun, batchSize } = req.body as z.infer; + + if (!memoryService.enabled) { + res.status(503).json({ error: "Memory service is disabled" }); + return; + } + + try { + const actor = getActorInfo(req); + + await logActivity(db, { + companyId, + actorType: actor.actorType === "agent" ? "agent" : "user", + actorId: actor.actorId, + action: "memory.migration_started", + entityType: "memory", + entityId: companyId, + agentId: actor.actorType === "agent" ? actor.actorId : null, + details: { projectId, agentId, dryRun, batchSize }, + }); + + const result = await migrateHistoricalMemories(db, memoryService, { + companyId, + projectId, + agentId, + dryRun, + batchSize, + }); + + await logActivity(db, { + companyId, + actorType: actor.actorType === "agent" ? "agent" : "user", + actorId: actor.actorId, + action: "memory.migration_completed", + entityType: "memory", + entityId: companyId, + agentId: actor.actorType === "agent" ? actor.actorId : null, + details: { ...result, projectId, agentId, dryRun }, + }); + + res.json({ + success: true, + ...result, + }); + } catch (err) { + logger.warn({ err, companyId, projectId, agentId }, "Memory migration failed"); + res.status(500).json({ error: "Memory migration failed" }); + } + }); + + return router; +} diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index eccf3f7f746..2c36f9fa640 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -34,17 +34,19 @@ import { appendWithCap } from "../adapters/utils.js"; import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; import { environmentService } from "../services/environments.js"; import { secretService } from "../services/secrets.js"; +import type { MemoryLifecycle } from "../memory/MemoryLifecycle.js"; const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024; const SHARED_WORKSPACE_STOP_AND_RESTART_ACTIONS = new Set(["stop", "restart"]); -export function projectRoutes(db: Db) { +export function projectRoutes(db: Db, opts?: { memoryLifecycle?: MemoryLifecycle }) { const router = Router(); const svc = projectService(db); const secretsSvc = secretService(db); const workspaceOperations = workspaceOperationService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; const environmentsSvc = environmentService(db); + const memoryLifecycle = opts?.memoryLifecycle; async function assertProjectEnvironmentSelection(companyId: string, environmentId: string | null | undefined) { if (environmentId === undefined || environmentId === null) return; @@ -243,6 +245,70 @@ export function projectRoutes(db: Db) { res.json(project); }); + router.post("/projects/:id/archive", async (req, res) => { + const id = req.params.id as string; + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Project not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + + const project = await svc.update(id, { status: "archived", archivedAt: new Date() }); + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + + const actor = getActorInfo(req); + await logActivity(db, { + companyId: project.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + action: "project.archived", + entityType: "project", + entityId: project.id, + details: { + previousStatus: existing.status, + }, + }); + + res.json(project); + }); + + router.post("/projects/:id/unarchive", async (req, res) => { + const id = req.params.id as string; + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Project not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + + const project = await svc.update(id, { status: "active", archivedAt: null }); + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + + const actor = getActorInfo(req); + await logActivity(db, { + companyId: project.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + action: "project.unarchived", + entityType: "project", + entityId: project.id, + details: { + previousStatus: existing.status, + }, + }); + + res.json(project); + }); + router.get("/projects/:id/workspaces", async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); @@ -665,6 +731,13 @@ export function projectRoutes(db: Db) { } const actor = getActorInfo(req); + if (memoryLifecycle) { + await memoryLifecycle.onProjectDeleted({ + companyId: project.companyId, + projectId: project.id, + actorId: actor.actorId, + }); + } await logActivity(db, { companyId: project.companyId, actorType: actor.actorType, diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index 9b0a2066ea7..49c8d58c67a 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -46,6 +46,9 @@ import { logActivity } from "./activity-log.js"; import { parseObject } from "../adapters/utils.js"; import type { RealizedExecutionWorkspace } from "./workspace-runtime.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; +import { injectMemories, type MemoryInjectionResult } from "../memory/MemoryInjector.js"; +import type { MemoryService } from "../memory/MemoryService.js"; +import { logger } from "../middleware/logger.js"; // --------------------------------------------------------------------------- // Error types @@ -107,6 +110,10 @@ export interface EnvironmentRealizationResult { executionTarget: AdapterExecutionTarget | null; remoteExecution: AdapterRemoteExecutionSpec | null; persistedExecutionWorkspace: ExecutionWorkspace | null; + /** Environment variables injected by the orchestrator (e.g. memory context). */ + injectedEnv: Record; + /** Raw memory injection result, null when memory injection was not attempted. */ + memoryInjection: MemoryInjectionResult | null; } export interface EnvironmentReleaseResult { @@ -267,6 +274,12 @@ export function environmentRunOrchestrator( heartbeatRunId: string; agentId: string; persistedExecutionWorkspace: Pick | null; + /** Task identifier or description used as the memory search query. */ + taskId?: string; + /** Agent role used for visibility filtering in memory queries. */ + agentRole?: string; + /** Max tokens to spend on memory context. Default: 2000 */ + memoryBudget?: number; }): Promise { // Step 1: Resolve environment const environment = await resolveEnvironment({ @@ -342,6 +355,18 @@ export function environmentRunOrchestrator( executionWorkspace: RealizedExecutionWorkspace; effectiveExecutionWorkspaceMode: string | null; persistedExecutionWorkspace: ExecutionWorkspace | null; + /** Memory service instance. When provided (and enabled), memory injection runs after execution target resolution. */ + memoryService?: MemoryService; + /** Agent ID used for scoping memory queries. */ + agentId?: string; + /** Project ID used for scoping memory queries. */ + projectId?: string; + /** Task identifier or description used as the memory search query. */ + taskId?: string; + /** Agent role used for visibility filtering in memory queries. */ + agentRole?: string; + /** Max tokens to spend on memory context. Default: 2000 */ + memoryBudget?: number; }): Promise { const { environment, @@ -494,12 +519,74 @@ export function environmentRunOrchestrator( ); } + // Step 5: Inject Agent Memory Context + const injectedEnv: Record = {}; + let memoryInjection: MemoryInjectionResult | null = null; + if (input.memoryService && input.agentId) { + try { + memoryInjection = await injectMemories({ + memoryService: input.memoryService, + companyId: companyId, + projectId: input.projectId ?? "", + agentId: input.agentId, + agentRole: input.agentRole ?? "agent", + taskDescription: input.taskId ?? heartbeatRunId, + goalAncestry: [], + memoryBudget: input.memoryBudget, + }); + + if (!memoryInjection.skipped && memoryInjection.contextBlock) { + injectedEnv.LEVI_MEMORY_CONTEXT = memoryInjection.contextBlock; + logger.info( + { + companyId, + agentId: input.agentId, + heartbeatRunId, + tokenCount: memoryInjection.tokenCount, + memoryCount: memoryInjection.memories.length, + }, + "[MemoryInjector] Injected agent memory context into environment", + ); + } + } catch (err) { + // Memory injection is best-effort; failures must not block the run. + logger.warn({ err, companyId, agentId: input.agentId, heartbeatRunId }, "[MemoryInjector] Failed to inject memory context"); + } + } + + // Step 6: Merge injected env into execution target for adapter consumption + if (Object.keys(injectedEnv).length > 0 && executionTarget) { + if (executionTarget.kind === "remote") { + if (executionTarget.transport === "ssh") { + executionTarget = { + ...executionTarget, + spec: { + ...executionTarget.spec, + ...injectedEnv, + } as typeof executionTarget.spec, + }; + } else if (executionTarget.transport === "sandbox") { + executionTarget = { + ...executionTarget, + ...injectedEnv, + }; + } + } else if (executionTarget.kind === "local") { + executionTarget = { + ...executionTarget, + ...injectedEnv, + }; + } + } + return { lease, workspaceRealization, executionTarget, remoteExecution: adapterExecutionTargetToRemoteSpec(executionTarget), persistedExecutionWorkspace, + injectedEnv, + memoryInjection, }; } diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 8c34e99233a..a54745f4926 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -48,6 +48,9 @@ import { } from "@paperclipai/db"; import { conflict, HttpError, notFound } from "../errors.js"; import { logger } from "../middleware/logger.js"; +import { injectMemories } from "../memory/MemoryInjector.js"; +import { recordMemoryCostEvent } from "../memory/MemoryTokenCost.js"; +import type { MemoryService } from "../memory/MemoryService.js"; import { publishLiveEvent } from "./live-events.js"; import { getRunLogStore, type RunLogHandle } from "./run-log-store.js"; import { getServerAdapter, listAdapterModelProfiles, runningProcesses } from "../adapters/index.js"; @@ -2367,6 +2370,7 @@ export type HeartbeatEnvironmentRuntime = ReturnType = { ...effectiveResolvedConfig, paperclipRuntimeSkills: runtimeSkillEntries, }; + + // -- Memory injection ------------------------------------------------- + const memoryProjectId = projectContext?.id ?? issueContext?.projectId ?? contextProjectId ?? ""; + const memoryInjection = await injectMemories({ + memoryService: options.memoryService ?? ({ enabled: false } as MemoryService), + companyId: agent.companyId, + projectId: memoryProjectId, + agentId: agent.id, + agentRole: agent.role ?? "agent", + taskDescription: + readNonEmptyString(taskKey) ?? readNonEmptyString(context.wakeReason) ?? run.id, + goalAncestry: [], + memoryBudget: 2000, + }); + + if (!memoryInjection.skipped && memoryInjection.tokenCount > 0) { + void recordMemoryCostEvent(db, { + companyId: agent.companyId, + agentId: agent.id, + heartbeatRunId: run.id, + projectId: memoryProjectId || undefined, + tokenCount: memoryInjection.tokenCount, + }).catch((err) => logger.warn({ err }, "[MemoryInjector] Failed to record cost")); + } + + if (memoryInjection.contextBlock) { + runtimeConfig = { + ...runtimeConfig, + env: { + ...parseObject(runtimeConfig.env), + LEVI_MEMORY_CONTEXT: memoryInjection.contextBlock, + }, + }; + // Inject memory context into adapter context so all adapters can access it + context.paperclipMemoryContext = memoryInjection.contextBlock; + } const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({ companyId: agent.companyId, heartbeatRunId: run.id, @@ -7394,6 +7434,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) heartbeatRunId: run.id, agentId: agent.id, persistedExecutionWorkspace, + taskId: readNonEmptyString(taskKey) ?? readNonEmptyString(context.wakeReason) ?? run.id, + agentRole: agent.role ?? "agent", + memoryBudget: 2000, }); const selectedEnvironment = acquiredEnvironment.environment; let activeEnvironmentLease = { @@ -7411,6 +7454,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) executionWorkspace, effectiveExecutionWorkspaceMode, persistedExecutionWorkspace, + memoryService: options.memoryService ?? ({ enabled: false } as MemoryService), + agentId: agent.id, + projectId: projectContext?.id ?? issueContext?.projectId ?? contextProjectId ?? "", + taskId: readNonEmptyString(taskKey) ?? readNonEmptyString(context.wakeReason) ?? run.id, + agentRole: agent.role ?? "agent", + memoryBudget: 2000, }); activeEnvironmentLease = { ...activeEnvironmentLease, @@ -8040,6 +8089,30 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) await scheduleBoundedRetryForRun(livenessRun, agent); } const issueCommentPolicyResult = await finalizeIssueCommentPolicy(livenessRun, agent); + + // -- Memory capture: store run artifacts as searchable memories -- + try { + const { captureMemories } = await import("../memory/MemoryCapture.js"); + const memoryProjectId = projectContext?.id ?? issueContext?.projectId ?? contextProjectId ?? ""; + await captureMemories({ + memoryService: options.memoryService ?? ({ enabled: false } as MemoryService), + companyId: agent.companyId, + projectId: memoryProjectId, + agentId: agent.id, + agentRole: agent.role ?? "agent", + taskId: readNonEmptyString(taskKey) ?? run.id, + runId: run.id, + goalAncestry: [], + outcome, + stdout: stdoutExcerpt ?? null, + stderr: stderrExcerpt ?? null, + resultJson: persistedResultJson, + costUsd: typeof adapterResult.costUsd === "number" ? adapterResult.costUsd : null, + }); + } catch (captureErr) { + logger.warn({ err: captureErr, runId: run.id }, "[MemoryCapture] Failed to capture run memories"); + } + await releaseIssueExecutionAndPromote(livenessRun); await handleRunLivenessContinuation(livenessRun); await handleSuccessfulRunHandoff( @@ -8497,6 +8570,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) (run.status === "failed" || run.status === "timed_out" || run.status === "cancelled"); if (!issueNeedsImmediateRecovery) { + // Clear task session when issue reaches terminal status to prevent + // infinite resume loops from handoff wakes with resumeIntent=true + if (issue.status === "done" || issue.status === "cancelled") { + await clearTaskSessions(run.companyId, run.agentId, { + taskKey, + adapterType: recoveryAgent?.adapterType, + }); + } return { kind: "released" as const }; } diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index c447a920efd..bcf9a28d4cd 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -46,6 +46,7 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin issueGraphLivenessAutoRecoveryLookbackHours: parsed.data.issueGraphLivenessAutoRecoveryLookbackHours ?? DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, + enableMemoryViewer: parsed.data.enableMemoryViewer ?? false, }; } return { @@ -55,6 +56,7 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin enableIssueGraphLivenessAutoRecovery: false, issueGraphLivenessAutoRecoveryLookbackHours: DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, + enableMemoryViewer: false, }; } diff --git a/server/src/services/recovery/successful-run-handoff.ts b/server/src/services/recovery/successful-run-handoff.ts index 3332501876f..a3e875e50a4 100644 --- a/server/src/services/recovery/successful-run-handoff.ts +++ b/server/src/services/recovery/successful-run-handoff.ts @@ -35,6 +35,11 @@ const IDEMPOTENT_HANDOFF_WAKE_STATUSES = [ "deferred_issue_execution", "claimed", "completed", + "skipped", + "coalesced", + "cancelled", + "failed", + "timed_out", ]; const IDEMPOTENT_HANDOFF_WAKE_STATUS_SET = new Set(IDEMPOTENT_HANDOFF_WAKE_STATUSES); diff --git a/server/src/tests/agentmemory-mock.ts b/server/src/tests/agentmemory-mock.ts new file mode 100644 index 00000000000..f26ff14d6e0 --- /dev/null +++ b/server/src/tests/agentmemory-mock.ts @@ -0,0 +1,100 @@ +import express from "express"; +import type { Request, Response } from "express"; + +/** + * Lightweight mock of the agentmemory service for integration testing. + * Implements the subset of the agentmemory REST API that MemoryService uses: + * GET /health + * POST /observations + * POST /observations/search + * DELETE /namespaces/:ns + */ + +export interface MockObservation { + id: string; + content: string; + namespace: string; + metadata: Record; + confidence: number; + created_at: string; +} + +let observations: MockObservation[] = []; +let idCounter = 1; + +export function resetMockObservations(): void { + observations = []; + idCounter = 1; +} + +export function createAgentMemoryMockApp(): express.Express { + const app = express(); + app.use(express.json()); + + app.get("/health", (_req: Request, res: Response) => { + res.json({ status: "ok" }); + }); + + app.post("/observations", (req: Request, res: Response) => { + const obs: MockObservation = { + id: `mock-${idCounter++}`, + content: req.body.content ?? "", + namespace: req.body.namespace ?? "default", + metadata: req.body.metadata ?? {}, + confidence: 0.9, + created_at: new Date().toISOString(), + }; + observations.push(obs); + res.status(201).json(obs); + }); + + app.post("/observations/search", (req: Request, res: Response) => { + const namespace = req.body.namespace ?? ""; + const query = (req.body.query ?? "").toLowerCase(); + + const matches = observations + .filter((o) => o.namespace === namespace || o.namespace.startsWith(`${namespace}:`)) + .filter((o) => { + if (!query) return true; + return ( + o.content.toLowerCase().includes(query) || + JSON.stringify(o.metadata).toLowerCase().includes(query) + ); + }) + .map((o) => ({ + id: o.id, + content: o.content, + namespace: o.namespace, + metadata: o.metadata, + confidence: o.confidence, + created_at: o.created_at, + })); + + if (matches.length === 0) { + res.json({ observations: [] }); + return; + } + + res.json({ observations: matches }); + }); + + app.delete("/namespaces/:ns", (req: Request, res: Response) => { + const nsParam = req.params.ns; + const ns = typeof nsParam === "string" ? decodeURIComponent(nsParam) : ""; + observations = observations.filter( + (o) => o.namespace !== ns && !o.namespace.startsWith(`${ns}:`), + ); + res.status(204).send(); + }); + + return app; +} + +// Standalone runner — only executes when run directly, not when imported by tests +if (process.argv[1]?.includes("agentmemory-mock")) { + const PORT = 3111; + const app = createAgentMemoryMockApp(); + app.listen(PORT, () => { + console.log(`agentmemory mock running on http://localhost:${PORT}`); + }); +} diff --git a/server/src/tests/memory-integration.test.ts b/server/src/tests/memory-integration.test.ts new file mode 100644 index 00000000000..86b4ba09c01 --- /dev/null +++ b/server/src/tests/memory-integration.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createServer } from "node:http"; +import type { Server } from "node:http"; +import { createMemoryService } from "../memory/MemoryService.js"; +import type { MemoryService } from "../memory/MemoryService.js"; +import { MemoryType, MemoryVisibility } from "../memory/MemoryTypes.js"; +import { createAgentMemoryMockApp, resetMockObservations } from "./agentmemory-mock.js"; + +describe("MemoryService integration with mock agentmemory", () => { + let mockServer: Server; + let memoryService: MemoryService; + const MOCK_PORT = 3111; + const MOCK_BASE_URL = `http://localhost:${MOCK_PORT}`; + + beforeAll(async () => { + resetMockObservations(); + const app = createAgentMemoryMockApp(); + mockServer = createServer(app); + await new Promise((resolve) => mockServer.listen(MOCK_PORT, resolve)); + + memoryService = createMemoryService({ + enabled: true, + baseUrl: MOCK_BASE_URL, + autoStart: false, + }); + }); + + afterAll(async () => { + await new Promise((resolve, reject) => { + mockServer.close((err) => (err ? reject(err) : resolve())); + }); + }); + + it("health check passes when mock is running", async () => { + const healthy = await memoryService.isHealthy(); + expect(healthy).toBe(true); + }); + + it("store() writes an observation to the mock", async () => { + const result = await memoryService.store({ + companyId: "comp-1", + projectId: "proj-1", + agentId: "agent-1", + content: "Implemented JWT authentication", + metadata: { + company_id: "comp-1", + project_id: "proj-1", + agent_id: "agent-1", + task_id: "task-1", + goal_ancestry: [], + agent_role: "Backend Engineer", + timestamp: new Date().toISOString(), + run_id: "run-1", + cost: 0.05, + memory_type: MemoryType.Decision, + visibility: MemoryVisibility.Shared, + }, + }); + + expect(result).not.toBeNull(); + expect(result?.content).toBe("Implemented JWT authentication"); + expect(result?.metadata.memory_type).toBe(MemoryType.Decision); + }); + + it("query() returns matching observations", async () => { + const results = await memoryService.query({ + query: "JWT", + company_id: "comp-1", + project_id: "proj-1", + agent_id: "agent-1", + agent_role: "Backend Engineer", + topK: 5, + }); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].content).toContain("JWT"); + }); + + it("query() filters by memory_type", async () => { + const results = await memoryService.query({ + query: "", + company_id: "comp-1", + project_id: "proj-1", + agent_id: "agent-1", + agent_role: "Backend Engineer", + memory_type: MemoryType.Decision, + }); + + expect(results.every((r) => r.metadata.memory_type === MemoryType.Decision)).toBe(true); + }); + + it("purgeProject() removes project observations", async () => { + await memoryService.purgeProject("comp-1", "proj-1"); + + const results = await memoryService.query({ + query: "", + company_id: "comp-1", + project_id: "proj-1", + agent_id: "agent-1", + agent_role: "Backend Engineer", + }); + + expect(results.length).toBe(0); + }); + + it("purgeCompany() removes company observations", async () => { + await memoryService.store({ + companyId: "comp-2", + projectId: "proj-2", + agentId: "agent-2", + content: "Test company memory", + metadata: { + company_id: "comp-2", + project_id: "proj-2", + agent_id: "agent-2", + task_id: "task-2", + goal_ancestry: [], + agent_role: "CEO", + timestamp: new Date().toISOString(), + run_id: "run-2", + cost: 0, + memory_type: MemoryType.Architecture, + visibility: MemoryVisibility.CeoOnly, + }, + }); + + await memoryService.purgeCompany("comp-2"); + + const results = await memoryService.query({ + query: "", + company_id: "comp-2", + project_id: "proj-2", + agent_id: "agent-2", + agent_role: "CEO", + }); + + expect(results.length).toBe(0); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0da91b9a183..73b60d7ef81 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -97,6 +97,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -314,6 +315,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/memory.ts b/ui/src/api/memory.ts new file mode 100644 index 00000000000..9e922445edd --- /dev/null +++ b/ui/src/api/memory.ts @@ -0,0 +1,86 @@ +import { api } from "./client"; +export type MemoryType = "decision" | "error" | "code_change" | "architecture" | "preference" | "discussion"; + +export interface MemoryMetadata { + company_id: string; + project_id: string; + agent_id: string; + task_id: string; + goal_ancestry: string[]; + agent_role: string; + timestamp: string; + run_id: string; + cost: number; + memory_type: MemoryType; + visibility: string; +} + +export interface MemoryItem { + id: string; + content: string; + metadata: MemoryMetadata; + namespace: string; + confidence: number; + relevanceScore?: number; +} + +export interface MemorySearchFilters { + q: string; + agentRole?: string; + memoryType?: MemoryType; + from?: string; + to?: string; + topK?: number; +} + +export interface MemorySearchResponse { + query: string; + projectId: string; + companyId: string; + count: number; + memories: MemoryItem[]; +} + +export interface PinMemoryInput { + pinned: boolean; +} + +export interface PinMemoryResponse { + id: string; + pinned: boolean; + success: boolean; +} + +export interface MergeMemoriesInput { + sourceIds: string[]; + targetId: string; +} + +export interface MergeMemoriesResponse { + targetId: string; + sourceIds: string[]; + mergedCount: number; + success: boolean; +} + +export const memoryApi = { + search: (companyId: string, projectId: string, filters: MemorySearchFilters) => { + const params = new URLSearchParams(); + params.set("q", filters.q); + if (filters.agentRole) params.set("agentRole", filters.agentRole); + if (filters.memoryType) params.set("memoryType", filters.memoryType); + if (filters.from) params.set("from", filters.from); + if (filters.to) params.set("to", filters.to); + if (filters.topK) params.set("topK", String(filters.topK)); + return api.get( + `/companies/${companyId}/projects/${projectId}/memory/search?${params.toString()}`, + ); + }, + + pin: (memoryId: string, input: PinMemoryInput) => + api.post(`/memory/${memoryId}/pin`, input), + + delete: (memoryId: string) => api.delete(`/memory/${memoryId}`), + + merge: (input: MergeMemoriesInput) => api.post("/memory/merge", input), +}; diff --git a/ui/src/components/memory/MemoryGraph.tsx b/ui/src/components/memory/MemoryGraph.tsx new file mode 100644 index 00000000000..7b7e4cb077b --- /dev/null +++ b/ui/src/components/memory/MemoryGraph.tsx @@ -0,0 +1,265 @@ +import { useMemo } from "react"; +import { Link } from "@/lib/router"; +import { Pin, Trash2, Merge, GitCommit, AlertTriangle, Landmark, MessageSquare, Settings } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { cn, relativeTime } from "@/lib/utils"; +import { Identity } from "../Identity"; +import type { MemoryItem, MemoryType } from "../../api/memory"; + +export interface MemoryGraphProps { + memories: MemoryItem[]; + isLoading?: boolean; + error?: string | null; + onPin?: (memoryId: string, pinned: boolean) => void; + onDelete?: (memoryId: string) => void; + onMerge?: (sourceIds: string[], targetId: string) => void; + className?: string; +} + +const MEMORY_TYPE_CONFIG: Record< + MemoryType, + { label: string; icon: typeof GitCommit; colorClass: string; badgeVariant: "default" | "secondary" | "destructive" | "outline" } +> = { + decision: { + label: "Decision", + icon: GitCommit, + colorClass: "border-l-blue-500", + badgeVariant: "default", + }, + error: { + label: "Error", + icon: AlertTriangle, + colorClass: "border-l-red-500", + badgeVariant: "destructive", + }, + code_change: { + label: "Code Change", + icon: GitCommit, + colorClass: "border-l-emerald-500", + badgeVariant: "secondary", + }, + architecture: { + label: "Architecture", + icon: Landmark, + colorClass: "border-l-purple-500", + badgeVariant: "outline", + }, + preference: { + label: "Preference", + icon: Settings, + colorClass: "border-l-amber-500", + badgeVariant: "secondary", + }, + discussion: { + label: "Discussion", + icon: MessageSquare, + colorClass: "border-l-slate-500", + badgeVariant: "outline", + }, +}; + +function MemoryTypeBadge({ type }: { type: MemoryType }) { + const config = MEMORY_TYPE_CONFIG[type] ?? MEMORY_TYPE_CONFIG.discussion; + const { icon: Icon, label, badgeVariant } = config; + return ( + + + {label} + + ); +} + +function GoalBreadcrumbs({ ancestry }: { ancestry: string[] }) { + if (!ancestry || ancestry.length === 0) return null; + return ( +
+ {ancestry.map((goal, index) => ( + + {index > 0 && /} + + {goal} + + + ))} +
+ ); +} + +function MemoryCard({ + memory, + onPin, + onDelete, + onMerge, +}: { + memory: MemoryItem; + onPin?: (memoryId: string, pinned: boolean) => void; + onDelete?: (memoryId: string) => void; + onMerge?: (sourceIds: string[], targetId: string) => void; +}) { + const config = MEMORY_TYPE_CONFIG[memory.metadata.memory_type] ?? MEMORY_TYPE_CONFIG.discussion; + const { icon: Icon } = config; + + const handlePin = () => { + onPin?.(memory.id, true); + }; + + const handleDelete = () => { + onDelete?.(memory.id); + }; + + return ( + + + {/* Header: type badge + actions */} +
+
+ + + {relativeTime(memory.metadata.timestamp)} + +
+
+ {onPin && ( + + )} + {onDelete && ( + + )} +
+
+ + {/* Content */} +

{memory.content}

+ + {/* Metadata footer */} +
+ + + run:{memory.metadata.run_id.slice(0, 8)} + + {memory.relevanceScore !== undefined && ( + + score: {memory.relevanceScore.toFixed(3)} + + )} +
+ + {/* Goal ancestry */} + {memory.metadata.goal_ancestry.length > 0 && ( +
+ +
+ )} +
+
+ ); +} + +export function MemoryGraph({ + memories, + isLoading = false, + error, + onPin, + onDelete, + onMerge, + className, +}: MemoryGraphProps) { + const groupedByType = useMemo(() => { + const groups: Record = {}; + for (const memory of memories) { + const type = memory.metadata.memory_type; + if (!groups[type]) groups[type] = []; + groups[type].push(memory); + } + return groups; + }, [memories]); + + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ ); + } + + if (error) { + return ( +
+ +

{error}

+
+ ); + } + + if (memories.length === 0) { + return ( +
+ +

No memories found.

+

Try adjusting your search or filters.

+
+ ); + } + + return ( +
+ {/* Summary bar */} +
+ {memories.length} + memories + {Object.entries(groupedByType).map(([type, items]) => { + const cfg = MEMORY_TYPE_CONFIG[type as MemoryType] ?? MEMORY_TYPE_CONFIG.discussion; + const { icon: Icon } = cfg; + return ( + + + {items.length} + + ); + })} +
+ + {/* Memory list */} +
+ {memories.map((memory) => ( + + ))} +
+
+ ); +} diff --git a/ui/src/components/memory/MemorySearch.tsx b/ui/src/components/memory/MemorySearch.tsx new file mode 100644 index 00000000000..1ea1dfa57c1 --- /dev/null +++ b/ui/src/components/memory/MemorySearch.tsx @@ -0,0 +1,196 @@ +import { useState, useCallback } from "react"; +import { Search, Filter, Clock, Target, User } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import type { MemoryType } from "../../api/memory"; + +export interface MemorySearchFilters { + query: string; + agentRole: string; + memoryType: MemoryType | "all"; + timeRange: "all" | "1h" | "24h" | "7d" | "30d"; + goalId: string; + from?: string; + to?: string; +} + +export interface MemorySearchProps { + className?: string; + onSearch: (filters: MemorySearchFilters) => void; + isLoading?: boolean; +} + +const AGENT_ROLES = [ + { value: "all", label: "All Roles" }, + { value: "Backend Engineer", label: "Backend Engineer" }, + { value: "Frontend Engineer", label: "Frontend Engineer" }, + { value: "DevOps Engineer", label: "DevOps Engineer" }, + { value: "Data Engineer", label: "Data Engineer" }, + { value: "Product Manager", label: "Product Manager" }, + { value: "CEO", label: "CEO" }, + { value: "CTO", label: "CTO" }, +]; + +const MEMORY_TYPES: { value: MemoryType | "all"; label: string }[] = [ + { value: "all", label: "All Types" }, + { value: "decision", label: "Decision" }, + { value: "error", label: "Error" }, + { value: "code_change", label: "Code Change" }, + { value: "architecture", label: "Architecture" }, + { value: "preference", label: "Preference" }, + { value: "discussion", label: "Discussion" }, +]; + +const TIME_RANGES = [ + { value: "all", label: "All Time" }, + { value: "1h", label: "Last Hour" }, + { value: "24h", label: "Last 24 Hours" }, + { value: "7d", label: "Last 7 Days" }, + { value: "30d", label: "Last 30 Days" }, +]; + +function timeRangeToDates(range: MemorySearchFilters["timeRange"]): { from?: string; to?: string } { + const now = new Date(); + const to = now.toISOString(); + switch (range) { + case "1h": + return { from: new Date(now.getTime() - 60 * 60 * 1000).toISOString(), to }; + case "24h": + return { from: new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(), to }; + case "7d": + return { from: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(), to }; + case "30d": + return { from: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(), to }; + default: + return {}; + } +} + +export function MemorySearch({ className, onSearch, isLoading = false }: MemorySearchProps) { + const [filters, setFilters] = useState({ + query: "", + agentRole: "all", + memoryType: "all", + timeRange: "all", + goalId: "", + }); + + const updateFilter = useCallback((key: K, value: MemorySearchFilters[K]) => { + setFilters((prev) => ({ ...prev, [key]: value })); + }, []); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const { from, to } = timeRangeToDates(filters.timeRange); + onSearch({ + ...filters, + ...(from ? { from } : {}), + ...(to ? { to } : {}), + } as MemorySearchFilters); + }, + [filters, onSearch], + ); + + return ( +
+ {/* Natural language query */} +
+ + updateFilter("query", e.target.value)} + className="pl-9" + disabled={isLoading} + /> +
+ + {/* Filter row */} +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + updateFilter("goalId", e.target.value)} + className="h-8 w-[140px] text-xs" + disabled={isLoading} + /> +
+ + +
+
+ ); +} diff --git a/ui/src/components/memory/MemoryViewer.test.tsx b/ui/src/components/memory/MemoryViewer.test.tsx new file mode 100644 index 00000000000..79005af1194 --- /dev/null +++ b/ui/src/components/memory/MemoryViewer.test.tsx @@ -0,0 +1,288 @@ +// @vitest-environment jsdom + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import React from "react"; +import MemoryViewer from "./MemoryViewer"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// Mocks +const searchMock = vi.fn(); +const pinMock = vi.fn(); +const deleteMock = vi.fn(); + +vi.mock("../../api/memory", () => ({ + memoryApi: { + search: (...args: unknown[]) => searchMock(...args), + pin: (...args: unknown[]) => pinMock(...args), + delete: (...args: unknown[]) => deleteMock(...args), + }, +})); + +vi.mock("@/context/ToastContext", () => ({ + useToastActions: () => ({ pushToast: vi.fn() }), +})); + +vi.mock("./MemorySearch", () => ({ + MemorySearch: ({ onSearch, isLoading }: { onSearch: (f: unknown) => void; isLoading?: boolean }) => ( +
+ +
+ ), +})); + +vi.mock("./MemoryGraph", () => ({ + MemoryGraph: (props: { + memories: Array<{ id: string; content: string }>; + isLoading?: boolean; + error?: string | null; + onPin?: (id: string) => void; + onDelete?: (id: string) => void; + }) => ( +
+ {props.isLoading && Loading} + {props.error && {props.error}} + {props.memories.length === 0 && !props.isLoading && !props.error && ( + No memories found. + )} + {props.memories.map((m) => ( +
+ {m.content} + + +
+ ))} +
+ ), +})); + +describe("MemoryViewer", () => { + let container: HTMLDivElement; + + beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + function renderViewer() { + const root = createRoot(container); + act(() => { + root.render(); + }); + return { root }; + } + + it("renders search and graph components", () => { + renderViewer(); + expect(container.querySelector('[data-testid="memory-search"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="memory-graph"]')).not.toBeNull(); + }); + + it("shows empty state initially", () => { + renderViewer(); + expect(container.querySelector('[data-testid="empty"]')).not.toBeNull(); + }); + + it("performs search and displays results", async () => { + searchMock.mockResolvedValue({ + query: "test", + projectId: "p1", + companyId: "c1", + count: 2, + memories: [ + { + id: "m1", + content: "Memory one", + metadata: { + company_id: "c1", + project_id: "p1", + agent_id: "a1", + task_id: "t1", + goal_ancestry: ["g1"], + agent_role: "Backend Engineer", + timestamp: new Date().toISOString(), + run_id: "r1", + cost: 0.01, + memory_type: "decision", + visibility: "shared", + }, + namespace: "ns1", + confidence: 0.9, + }, + { + id: "m2", + content: "Memory two", + metadata: { + company_id: "c1", + project_id: "p1", + agent_id: "a2", + task_id: "t2", + goal_ancestry: [], + agent_role: "Frontend Engineer", + timestamp: new Date().toISOString(), + run_id: "r2", + cost: 0.02, + memory_type: "error", + visibility: "shared", + }, + namespace: "ns2", + confidence: 0.8, + }, + ], + }); + + renderViewer(); + const btn = container.querySelector('[data-testid="search-btn"]') as HTMLButtonElement; + await act(async () => { + btn.click(); + await new Promise((r) => setTimeout(r, 10)); + }); + + expect(container.querySelector('[data-testid="memory-m1"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="memory-m2"]')).not.toBeNull(); + expect(searchMock).toHaveBeenCalledWith( + "c1", + "p1", + expect.objectContaining({ q: "test" }) + ); + }); + + it("handles search errors gracefully", async () => { + searchMock.mockRejectedValue(new Error("Network error")); + + renderViewer(); + const btn = container.querySelector('[data-testid="search-btn"]') as HTMLButtonElement; + await act(async () => { + btn.click(); + await new Promise((r) => setTimeout(r, 10)); + }); + + expect(container.querySelector('[data-testid="error"]')?.textContent).toBe("Network error"); + }); + + it("pins a memory optimistically", async () => { + searchMock.mockResolvedValue({ + query: "test", + projectId: "p1", + companyId: "c1", + count: 1, + memories: [ + { + id: "m1", + content: "Memory one", + metadata: { + company_id: "c1", + project_id: "p1", + agent_id: "a1", + task_id: "t1", + goal_ancestry: [], + agent_role: "Backend Engineer", + timestamp: new Date().toISOString(), + run_id: "r1", + cost: 0.01, + memory_type: "decision", + visibility: "shared", + }, + namespace: "ns1", + confidence: 0.9, + }, + ], + }); + + pinMock.mockResolvedValue({ + id: "m1", + pinned: true, + success: true, + }); + + renderViewer(); + const searchBtn = container.querySelector('[data-testid="search-btn"]') as HTMLButtonElement; + await act(async () => { + searchBtn.click(); + await new Promise((r) => setTimeout(r, 10)); + }); + + const pinBtn = container.querySelector('[data-testid="pin-m1"]') as HTMLButtonElement; + await act(async () => { + pinBtn.click(); + await new Promise((r) => setTimeout(r, 10)); + }); + + expect(pinMock).toHaveBeenCalledWith("m1", { pinned: true }); + }); + + it("deletes a memory and filters it out", async () => { + searchMock.mockResolvedValue({ + query: "test", + projectId: "p1", + companyId: "c1", + count: 1, + memories: [ + { + id: "m1", + content: "Memory one", + metadata: { + company_id: "c1", + project_id: "p1", + agent_id: "a1", + task_id: "t1", + goal_ancestry: [], + agent_role: "Backend Engineer", + timestamp: new Date().toISOString(), + run_id: "r1", + cost: 0.01, + memory_type: "decision", + visibility: "shared", + }, + namespace: "ns1", + confidence: 0.9, + }, + ], + }); + + deleteMock.mockResolvedValue(undefined); + vi.stubGlobal("confirm", () => true); + + renderViewer(); + const searchBtn = container.querySelector('[data-testid="search-btn"]') as HTMLButtonElement; + await act(async () => { + searchBtn.click(); + await new Promise((r) => setTimeout(r, 10)); + }); + + const deleteBtn = container.querySelector('[data-testid="delete-m1"]') as HTMLButtonElement; + await act(async () => { + deleteBtn.click(); + await new Promise((r) => setTimeout(r, 10)); + }); + + expect(deleteMock).toHaveBeenCalledWith("m1"); + expect(container.querySelector('[data-testid="memory-m1"]')).toBeNull(); + }); +}); diff --git a/ui/src/components/memory/MemoryViewer.tsx b/ui/src/components/memory/MemoryViewer.tsx new file mode 100644 index 00000000000..37901fe41c3 --- /dev/null +++ b/ui/src/components/memory/MemoryViewer.tsx @@ -0,0 +1,155 @@ +import React, { useState, useCallback } from "react"; +import { MemorySearch } from "./MemorySearch"; +import { MemoryGraph } from "./MemoryGraph"; +import { memoryApi, type MemoryItem, type MemorySearchFilters as ApiMemorySearchFilters } from "../../api/memory"; +import { useToastActions } from "@/context/ToastContext"; + +// Local filter shape emitted by MemorySearch component +interface LocalMemorySearchFilters { + query: string; + agentRole: string; + memoryType: string; + timeRange: "all" | "1h" | "24h" | "7d" | "30d"; + goalId: string; + from?: string; + to?: string; +} + +export interface MemoryViewerProps { + companyId: string; + projectId: string; + className?: string; +} + +export default function MemoryViewer({ companyId, projectId, className }: MemoryViewerProps) { + const { pushToast } = useToastActions(); + + // Filters state + const [filters, setFilters] = useState({ + query: "", + agentRole: "all", + memoryType: "all", + timeRange: "all", + goalId: "", + }); + + // Results state + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Time range mapping + const timeRangeToDates = useCallback((range: LocalMemorySearchFilters["timeRange"]): { from?: string; to?: string } => { + const now = new Date(); + const to = now.toISOString(); + switch (range) { + case "1h": + return { from: new Date(now.getTime() - 60 * 60 * 1000).toISOString(), to }; + case "24h": + return { from: new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(), to }; + case "7d": + return { from: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(), to }; + case "30d": + return { from: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(), to }; + default: + return {}; + } + }, []); + + // Search handler + const handleSearch = useCallback( + async (searchFilters: LocalMemorySearchFilters) => { + setFilters(searchFilters); + setIsLoading(true); + setError(null); + + try { + const { from, to } = timeRangeToDates(searchFilters.timeRange); + + const apiFilters: ApiMemorySearchFilters = { + q: searchFilters.query, + agentRole: searchFilters.agentRole === "all" ? undefined : searchFilters.agentRole, + memoryType: + searchFilters.memoryType === "all" + ? undefined + : (searchFilters.memoryType as ApiMemorySearchFilters["memoryType"]), + from, + to, + }; + + const response = await memoryApi.search(companyId, projectId, apiFilters); + setResults(response.memories ?? []); + } catch (err) { + const message = err instanceof Error ? err.message : "Search failed. Please try again."; + setError(message); + pushToast({ title: "Search failed", body: message, tone: "error" }); + } finally { + setIsLoading(false); + } + }, + [companyId, projectId, timeRangeToDates, pushToast], + ); + + // Pin handler — optimistic local toggle + const handlePin = useCallback( + async (memoryId: string) => { + const memory = results.find((m) => m.id === memoryId); + const newPinned = !((memory?.metadata as unknown as Record)?.["pinned"] === true); + + try { + await memoryApi.pin(memoryId, { pinned: newPinned }); + + setResults((prev) => + prev.map((m) => + m.id === memoryId + ? { ...m, metadata: { ...m.metadata, pinned: newPinned } as typeof m.metadata } + : m, + ), + ); + + pushToast({ title: newPinned ? "Memory pinned" : "Memory unpinned", tone: "success" }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to pin memory."; + pushToast({ title: "Pin failed", body: message, tone: "error" }); + } + }, + [results, pushToast], + ); + + // Delete handler — local filter + API call + const handleDelete = useCallback( + async (memoryId: string) => { + if (!window.confirm("Are you sure you want to delete this memory?")) { + return; + } + + try { + await memoryApi.delete(memoryId); + + setResults((prev) => prev.filter((m) => m.id !== memoryId)); + pushToast({ title: "Memory deleted", tone: "success" }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to delete memory."; + pushToast({ title: "Delete failed", body: message, tone: "error" }); + } + }, + [pushToast], + ); + + return ( +
+ + +
+ ); +} + (2/2) \ No newline at end of file diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index fb0f053bb4f..dc51c55cc98 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -30,10 +30,11 @@ import { Button } from "@/components/ui/button"; import { Tabs } from "@/components/ui/tabs"; import { PluginLauncherOutlet } from "@/plugins/launchers"; import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; +import MemoryViewer from "../components/memory/MemoryViewer"; /* ── Top-level tab types ── */ -type ProjectBaseTab = "overview" | "list" | "plugin-operations" | "workspaces" | "configuration" | "budget"; +type ProjectBaseTab = "overview" | "list" | "plugin-operations" | "workspaces" | "configuration" | "budget" | "memory"; type ProjectPluginTab = `plugin:${string}`; type ProjectTab = ProjectBaseTab | ProjectPluginTab; @@ -52,6 +53,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu if (tab === "issues") return "list"; if (tab === "plugin-operations") return "plugin-operations"; if (tab === "workspaces") return "workspaces"; + if (tab === "memory") return "memory"; return null; } @@ -334,6 +336,7 @@ export function ProjectDetail() { ); const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null; const isolatedWorkspacesEnabled = experimentalSettingsQuery.data?.enableIsolatedWorkspaces === true; + const memoryViewerEnabled = experimentalSettingsQuery.data?.enableMemoryViewer === true; const workspaceTabProjectId = project?.id ?? null; const { data: workspaceTabIssues = [], isLoading: isWorkspaceTabIssuesLoading, error: workspaceTabIssuesError } = useQuery({ queryKey: workspaceTabProjectId && resolvedCompanyId @@ -457,7 +460,11 @@ export function ProjectDetail() { return; } if (activeTab === "workspaces") { - navigate(`/projects/${canonicalProjectRef}/workspaces`, { replace: true }); + navigate(`/projects/${canonicalProjectRef}/workspaces`); + return; + } + if (activeTab === "memory") { + navigate(`/projects/${canonicalProjectRef}/memory`); return; } if (activeTab === "list") { @@ -574,6 +581,10 @@ export function ProjectDetail() { return ; } + if (activeTab === "memory" && !memoryViewerEnabled) { + return ; + } + // Redirect bare /projects/:id to cached tab or default /issues if (routeProjectRef && activeTab === null) { let cachedTab: string | null = null; @@ -595,6 +606,9 @@ export function ProjectDetail() { if (cachedTab === "workspaces" && workspaceTabDecisionLoaded && showWorkspacesTab) { return ; } + if (cachedTab === "memory" && memoryViewerEnabled) { + return ; + } if (cachedTab === "workspaces" && !workspaceTabDecisionLoaded) { return ; } @@ -619,8 +633,10 @@ export function ProjectDetail() { } if (tab === "overview") { navigate(`/projects/${canonicalProjectRef}/overview`); - } else if (tab === "workspaces") { - navigate(`/projects/${canonicalProjectRef}/workspaces`); + } else if (tab === "workspaces") { + navigate(`/projects/${canonicalProjectRef}/workspaces`); + } else if (tab === "memory") { + navigate(`/projects/${canonicalProjectRef}/memory`); } else if (tab === "budget") { navigate(`/projects/${canonicalProjectRef}/budget`); } else if (tab === "plugin-operations") { @@ -701,6 +717,7 @@ export function ProjectDetail() { { value: "overview", label: "Overview" }, ...(project.managedByPlugin ? [{ value: "plugin-operations", label: "Plugin operations" }] : []), ...(showWorkspacesTab ? [{ value: "workspaces", label: "Workspaces" }] : []), + ...(memoryViewerEnabled ? [{ value: "memory", label: "Memory" }] : []), { value: "configuration", label: "Configuration" }, { value: "budget", label: "Budget" }, ...pluginTabItems.map((item) => ({ @@ -778,6 +795,10 @@ export function ProjectDetail() {
) : null} + {activeTab === "memory" && resolvedCompanyId && project?.id && ( + + )} + {activePluginTab && (