diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..dfff83e82 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +node_modules +**/node_modules +**/dist +release +**/release +.env +.env.* +!.env.example +*.log +*.sqlite +*.sqlite-* +.memmy +.codex +.pi diff --git a/.env.example b/.env.example index 79c90efae..93d864348 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,24 @@ MEMMY_GA4_MEASUREMENT_ID= # Legal agreement page base URLs MEMMY_LEGAL_CN_BASE_URL=https://memmy.cn MEMMY_LEGAL_INTL_BASE_URL=https://memmy.bot + +# Docker Memory service. Generate a unique value with at least 32 random bytes. +MEMMY_MEMORY_TOKEN= +# Host-side port; keep the default for a same-machine Windows desktop client. +MEMMY_MEMORY_HOST_PORT=18960 + +# OpenAI-compatible project memory models. Keep the key only in local .env. +MEMMY_SUMMARY_PROVIDER=openai_compatible +MEMMY_SUMMARY_ENDPOINT=http://192.168.8.191:20128/v1 +MEMMY_SUMMARY_MODEL=auto/best-fast +MEMMY_SUMMARY_API_KEY= +MEMMY_SUMMARY_MAX_TOKENS=768 +MEMMY_SUMMARY_TIMEOUT_MS=60000 + +MEMMY_EVOLUTION_PROVIDER=openai_compatible +MEMMY_EVOLUTION_ENDPOINT=http://192.168.8.191:20128/v1 +MEMMY_EVOLUTION_MODEL=auto/best-reasoning +MEMMY_EVOLUTION_API_KEY= +MEMMY_EVOLUTION_ENABLE_THINKING=true +MEMMY_EVOLUTION_MAX_TOKENS=4096 +MEMMY_EVOLUTION_TIMEOUT_MS=180000 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 000000000..4bc83edec --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,139 @@ +name: Publish Memory Docker image + +on: + push: + tags: + - "v*.*.*" + workflow_call: + inputs: + tag: + description: Image tag to publish + required: true + type: string + ref: + description: Trusted Git ref or commit to build + required: true + type: string + workflow_dispatch: + inputs: + tag: + description: Image tag to publish + required: true + default: edge + type: string + ref: + description: Git ref or commit to build + required: true + default: main + type: string + +permissions: + contents: read + packages: write + +concurrency: + group: memory-docker-${{ github.ref }}-${{ inputs.tag || 'tag' }} + cancel-in-progress: false + +jobs: + smoke: + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build smoke image + uses: docker/build-push-action@v6 + with: + context: . + file: Memory/Dockerfile + platforms: linux/amd64 + load: true + tags: memmy-memory:smoke + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run container smoke test + shell: bash + run: | + docker run -d --name memmy-memory-smoke \ + -e HOME=/home/node \ + -e MEMMY_MEMORY_HOST=0.0.0.0 \ + -e MEMMY_MEMORY_PORT=18960 \ + -e MEMMY_MEMORY_DB=/data/memory.sqlite \ + -e MEMMY_MEMORY_TOKEN=release-smoke-token \ + -p 127.0.0.1:28960:18960 \ + --read-only \ + --tmpfs /tmp:rw,noexec,nosuid,size=128m \ + --tmpfs /data:rw,noexec,nosuid,size=128m,uid=1000,gid=1000 \ + --cap-drop ALL \ + --security-opt no-new-privileges:true \ + memmy-memory:smoke + trap 'docker logs memmy-memory-smoke; docker rm -f memmy-memory-smoke' EXIT + node scripts/smoke-memory-docker.mjs + + publish: + needs: smoke + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Resolve image name + id: image + shell: bash + run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/memmy-memory" >> "$GITHUB_OUTPUT" + + - name: Validate release tag + shell: bash + env: + IMAGE_TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} + run: | + version="$(node -p "require('./package.json').version")" + if [[ "$IMAGE_TAG" == v* ]]; then + test "$IMAGE_TAG" = "v$version" + fi + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate image metadata + id: metadata + uses: docker/metadata-action@v5 + with: + images: ${{ steps.image.outputs.name }} + tags: | + type=ref,event=tag + type=raw,value=${{ inputs.tag }},enable=${{ github.event_name != 'push' }} + type=sha,prefix=sha- + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: Memory/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: mode=max + sbom: true diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index 1982507d2..099ef7513 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -13,8 +13,7 @@ on: required: true type: string -permissions: - contents: write +permissions: {} concurrency: group: release-${{ github.event_name == 'workflow_dispatch' && format('release/v{0}', inputs.version) || github.event.pull_request.head.ref }} @@ -22,12 +21,17 @@ concurrency: jobs: release: + permissions: + contents: write if: >- github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/v')) runs-on: ubuntu-latest environment: release + outputs: + tag: ${{ steps.release.outputs.tag }} + target_sha: ${{ steps.release.outputs.target_sha }} env: GH_TOKEN: ${{ github.token }} steps: @@ -201,3 +205,13 @@ jobs: run: | set -euo pipefail gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest + + docker: + needs: release + permissions: + contents: read + packages: write + uses: ./.github/workflows/docker-publish.yml + with: + tag: ${{ needs.release.outputs.tag }} + ref: ${{ needs.release.outputs.target_sha }} diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml new file mode 100644 index 000000000..83d1a4ab5 --- /dev/null +++ b/.github/workflows/windows-package.yml @@ -0,0 +1,58 @@ +name: Windows Package + +on: + workflow_dispatch: {} + push: + branches: + - actions/windows-package/** + +permissions: + contents: read + +concurrency: + group: windows-package-${{ github.ref }} + cancel-in-progress: true + +jobs: + package: + runs-on: windows-latest + timeout-minutes: 120 + defaults: + run: + shell: bash + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.15.0 + cache: npm + cache-dependency-path: | + package-lock.json + App/memmy-agent/package-lock.json + + - name: Prepare packaging environment + run: cp .env.example .env + + - name: Build unsigned Windows x64 China installer + run: npm run package:win:x64:cn:unsigned + + - name: Create SHA-256 checksum + run: | + installer="$(find App/shell/desktop/release -maxdepth 1 -type f -name 'Memmy-*-win32-x64-cn-unsigned.exe' -print -quit)" + test -n "$installer" + sha256sum "$installer" > "$installer.sha256" + ls -lh "$installer" "$installer.sha256" + + - name: Upload installer artifact + uses: actions/upload-artifact@v4 + with: + name: memmy-windows-x64-cn-unsigned-${{ github.sha }} + path: | + App/shell/desktop/release/Memmy-*-win32-x64-cn-unsigned.exe + App/shell/desktop/release/Memmy-*-win32-x64-cn-unsigned.exe.sha256 + if-no-files-found: error + compression-level: 0 + retention-days: 7 diff --git a/.gitignore b/.gitignore index 1475e2048..fc6b422be 100644 --- a/.gitignore +++ b/.gitignore @@ -12,12 +12,14 @@ memmy-memory-*.tgz .DS_Store App/memmy-agent/package-lock.json sessions/ +output/ .* !.github/ !.github/workflows/ !.github/workflows/*.yml !.github/release-notes/ !.github/release-notes/*.md +!.dockerignore .env .env.* !.env.example diff --git a/App/backend/README.md b/App/backend/README.md index f4d9fe2e7..39ae84bf1 100644 --- a/App/backend/README.md +++ b/App/backend/README.md @@ -26,7 +26,7 @@ npm run db:migrate - `adapters/inbound/local-api`: Fastify routes, runtime-token authentication, CORS, SSE, and the Composio MCP bridge. - `adapters/outbound/agent-source`: built-in history readers for Cursor, Claude - Code, Codex, OpenCode, OpenClaw, Hermes, and WorkBuddy. + Code, Codex, Pi, OpenCode, OpenClaw, Hermes, and WorkBuddy. - `adapters/outbound/skill-writer`: Memory skill, hook, command, and plugin installation for the supported agents. - `adapters/outbound/agent-adapter`: manifest, loader, and registry contracts @@ -114,6 +114,7 @@ Every route in this table requires the local runtime token. | Cursor | Windows: `%APPDATA%\Cursor\User`; macOS: `~/Library/Application Support/Cursor/User`; Linux: `${XDG_CONFIG_HOME:-~/.config}/Cursor/User` (`workspaceStorage/*/state.vscdb` and `globalStorage/state.vscdb`) | `~/.cursor/skills/memmy-memory/` and `~/.cursor/hooks.json` | | Claude Code | `~/.claude/projects/**/*.jsonl` | `~/.claude/CLAUDE.md`, `skills/memmy-memory/`, hooks, and the resume command | | Codex | `~/.codex/sessions/**/rollout-*.jsonl` | `~/.codex/AGENTS.md`, `skills/memmy-memory/`, and hooks | +| Pi | `${PI_CODING_AGENT_SESSION_DIR:-~/.pi/agent/sessions}/**/*.jsonl` | `~/.pi/agent/AGENTS.md`, `skills/memmy-memory/`, and native extension | | OpenCode | `${XDG_DATA_HOME:-~/.local/share}/opencode/opencode.db` | `${XDG_CONFIG_HOME:-~/.config}/opencode/AGENTS.md`, `skills/memmy-memory/`, plugin, and resume command | | OpenClaw | SQLite databases under `~/.openclaw/` | Workspace `AGENTS.md`, `~/.openclaw/skills/memmy-memory/`, and the Memory extension | | Hermes | `~/.hermes/sessions/**/*.jsonl` and `~/.hermes/state.db` | `~/.hermes/SOUL.md`, `skills/memmy-memory/`, and Memory/resume plugins | diff --git a/App/backend/local-api-contracts/src/index.ts b/App/backend/local-api-contracts/src/index.ts index 3008ff29c..36694c8d3 100644 --- a/App/backend/local-api-contracts/src/index.ts +++ b/App/backend/local-api-contracts/src/index.ts @@ -169,6 +169,43 @@ export const ByokTokenUsageSummarySchema = z.object({ }); export type ByokTokenUsageSummary = z.infer; +// --- Agent Token Usage Stats (Pi / Codex / Claude Code) --- + +export const AgentKindSchema = z.enum(["pi", "codex", "claude_code"]); +export type AgentKind = z.infer; + +export const AgentTokenStatsDtoSchema = z.object({ + agent: AgentKindSchema, + sessions: z.number().int().nonnegative(), + apiCalls: z.number().int().nonnegative(), + inputTokens: z.number().int().nonnegative(), + outputTokens: z.number().int().nonnegative(), + cacheReadTokens: z.number().int().nonnegative(), + cacheWriteTokens: z.number().int().nonnegative(), + reasoningTokens: z.number().int().nonnegative().optional(), + totalTokens: z.number().int().nonnegative(), + cost: z.number().nonnegative().optional(), + available: z.boolean() +}); +export type AgentTokenStatsDto = z.infer; + +export const ProjectTokenStatsDtoSchema = z.object({ + project: z.string(), + agents: z.array(AgentTokenStatsDtoSchema), + combinedInputTokens: z.number().int().nonnegative(), + combinedOutputTokens: z.number().int().nonnegative(), + combinedCacheReadTokens: z.number().int().nonnegative(), + combinedTotalTokens: z.number().int().nonnegative(), + estimatedCost: z.number().nonnegative().optional() +}); +export type ProjectTokenStatsDto = z.infer; + +export const AgentTokenStatsResponseSchema = z.object({ + projects: z.array(ProjectTokenStatsDtoSchema), + scannedAt: z.string().datetime() +}); +export type AgentTokenStatsResponse = z.infer; + export const AgentGatewayRuntimeConfigSchema = z.object({ baseUrl: z.string().url(), bootstrapSecret: z.string().min(1).optional() @@ -176,7 +213,8 @@ export const AgentGatewayRuntimeConfigSchema = z.object({ export type AgentGatewayRuntimeConfig = z.infer; export const MemoryServiceRuntimeConfigSchema = z.object({ - baseUrl: z.string().url() + baseUrl: z.string().url(), + ownership: z.enum(["managed", "remote"]).default("managed") }); export type MemoryServiceRuntimeConfig = z.infer; diff --git a/App/backend/local-api-contracts/src/memory-runtime.ts b/App/backend/local-api-contracts/src/memory-runtime.ts index 2cc81ba62..2ab20d36d 100644 --- a/App/backend/local-api-contracts/src/memory-runtime.ts +++ b/App/backend/local-api-contracts/src/memory-runtime.ts @@ -134,6 +134,27 @@ export const MemoryDetailItemSchema = MemoryListItemSchema.extend({ body: z.string(), createdAt: IsoTimeSchema, sourceMemoryIds: z.array(NonEmptyStringSchema), + provenance: z.object({ + sourceAgent: NonEmptyStringSchema, + profileId: z.string().optional(), + projectId: z.string().optional(), + workspaceId: z.string().optional(), + workspacePath: z.string().optional(), + sessionId: z.string().optional(), + turnId: z.string().optional(), + adapterId: z.string().optional(), + requestId: z.string().optional(), + sourceMemoryIds: z.array(NonEmptyStringSchema), + repository: z.string().optional(), + branch: z.string().optional(), + commit: z.string().optional(), + capturedAt: IsoTimeSchema + }).optional(), + supersession: z.object({ + supersedesMemoryIds: z.array(NonEmptyStringSchema), + supersededByMemoryId: NonEmptyStringSchema.optional(), + reason: z.string().optional() + }).optional(), metadata: UnknownRecordSchema }); export type MemoryDetailItem = z.infer; @@ -311,7 +332,7 @@ export type StartTurnOutput = z.infer; /** Definition for complete turn input. */ export const CompleteTurnInputSchema = RuntimeRequestFieldsSchema.extend({ - sessionId: NonEmptyStringSchema, + contextPacketId: NonEmptyStringSchema.optional(), episodeId: NonEmptyStringSchema.optional(), query: NonEmptyStringSchema, answer: NonEmptyStringSchema, @@ -386,7 +407,16 @@ export const AddMemoryInputSchema = RuntimeRequestFieldsSchema.extend({ sessionId: z.string().optional(), turnId: z.string().optional(), createdAt: IsoTimeSchema.optional(), - deferProcessing: z.boolean().optional() + deferProcessing: z.boolean().optional(), + sourceMemoryIds: z.array(NonEmptyStringSchema).optional(), + provenance: z.object({ + repository: z.string().optional(), + branch: z.string().optional(), + commit: z.string().optional(), + workspacePath: z.string().optional() + }).optional(), + supersedesMemoryId: NonEmptyStringSchema.optional(), + supersessionReason: z.string().optional() }); export type AddMemoryInput = z.infer; @@ -473,6 +503,43 @@ export const GetMemoryOutputSchema = z.object({ }); export type GetMemoryOutput = z.infer; +export const MemoryHistoryItemSchema = z.object({ + seq: z.number().int().nonnegative(), + version: z.number().int().positive().optional(), + changeType: NonEmptyStringSchema, + source: NonEmptyStringSchema, + createdAt: IsoTimeSchema, + before: z.unknown().optional(), + after: z.unknown().optional() +}); +export type MemoryHistoryItem = z.infer; + +export const MemoryHistoryOutputSchema = z.object({ + id: NonEmptyStringSchema, + currentVersion: z.number().int().positive(), + items: z.array(MemoryHistoryItemSchema), + serverTime: IsoTimeSchema +}); +export type MemoryHistoryOutput = z.infer; + +export const RestoreMemoryInputSchema = z.object({ + version: z.number().int().positive(), + reason: z.string().optional() +}); +export type RestoreMemoryInput = z.infer; + +export const RestoreMemoryOutputSchema = z.object({ + ok: z.literal(true), + id: NonEmptyStringSchema, + version: z.number().int().positive(), + restoredVersion: z.number().int().positive(), + changeSeq: z.number().int().nonnegative(), + auditId: z.union([z.string(), z.number()]), + embeddingJobId: NonEmptyStringSchema.optional(), + serverTime: IsoTimeSchema +}); +export type RestoreMemoryOutput = z.infer; + /** Definition for delete memory input. */ export const DeleteMemoryInputSchema = RuntimeRequestFieldsSchema; export type DeleteMemoryInput = z.infer; @@ -656,6 +723,205 @@ export const PanelAnalysisOutputSchema = z.object({ }); export type PanelAnalysisOutput = z.infer; +/** Schema for a project-scoped context pack generated from active memory. */ +export const ProjectContextPackOutputSchema = z.object({ + namespace: z.object({ + userId: z.string().optional(), + tenantId: z.string().optional(), + projectId: z.string().optional(), + workspaceId: z.string().optional(), + workspacePath: z.string().optional(), + source: z.string().optional(), + profileId: z.string().optional(), + profileLabel: z.string().optional(), + sessionKey: z.string().optional() + }), + conventions: z.array(MemoryListItemSchema), + commands: z.array(MemoryListItemSchema), + architectureFacts: z.array(MemoryListItemSchema), + recentTasks: z.array(z.object({ + id: NonEmptyStringSchema, + title: z.string(), + updatedAt: IsoTimeSchema + })), + userPreferences: z.array(MemoryListItemSchema), + graph: z.object({ + nodes: z.array(MemoryListItemSchema.extend({ external: z.boolean().optional() })), + edges: z.array(z.object({ + sourceId: NonEmptyStringSchema, + targetId: NonEmptyStringSchema, + relation: z.enum(["source", "supersedes"]), + reason: z.string().optional() + })) + }), + markdown: z.string(), + authoritative: z.object({ + state: z.lazy(() => ProjectContextStateSchema), + stable: z.lazy(() => ProjectContextStableStateSchema) + }).optional(), + generatedAt: IsoTimeSchema +}); +export type ProjectContextPackOutput = z.infer; +/** Schema for a runtime namespace used by project context operations. */ +export const RuntimeNamespaceSchema = z.object({ + source: NonEmptyStringSchema, + profileId: NonEmptyStringSchema, + profileLabel: NonEmptyStringSchema.optional(), + projectId: NonEmptyStringSchema.optional(), + workspaceId: NonEmptyStringSchema.optional(), + workspacePath: NonEmptyStringSchema.optional(), + sessionKey: NonEmptyStringSchema.optional(), + userId: NonEmptyStringSchema.optional(), + tenantId: NonEmptyStringSchema.optional() +}).strict(); +export type RuntimeNamespace = z.infer; + +/** Schema for project-context mutation provenance. */ +export const ProjectContextProvenanceSchema = z.object({ + sourceAgent: NonEmptyStringSchema, + sourceMemoryIds: z.array(NonEmptyStringSchema), + capturedAt: IsoTimeSchema, + tenantId: NonEmptyStringSchema.optional(), + profileId: NonEmptyStringSchema.optional(), + projectId: NonEmptyStringSchema.optional(), + workspaceId: NonEmptyStringSchema.optional(), + workspacePath: NonEmptyStringSchema.optional(), + sessionId: NonEmptyStringSchema.optional(), + turnId: NonEmptyStringSchema.optional(), + adapterId: NonEmptyStringSchema.optional(), + requestId: NonEmptyStringSchema.optional(), + repository: NonEmptyStringSchema.optional(), + branch: NonEmptyStringSchema.optional(), + commit: NonEmptyStringSchema.optional() +}).strict(); +export type ProjectContextProvenance = z.infer; + +const ProjectContextMutationFieldsSchema = z.object({ + namespace: RuntimeNamespaceSchema, + source: NonEmptyStringSchema, + adapterId: NonEmptyStringSchema, + requestId: NonEmptyStringSchema, + provenance: ProjectContextProvenanceSchema +}).strict(); + +const ProjectGoalSchema = z.object({ + id: NonEmptyStringSchema, + namespaceId: NonEmptyStringSchema, + userId: NonEmptyStringSchema, + projectId: NonEmptyStringSchema.optional(), + workspaceId: NonEmptyStringSchema.optional(), + workspacePath: NonEmptyStringSchema.optional(), + title: NonEmptyStringSchema, + summary: z.string(), + detail: z.string(), + acceptanceCriteria: z.array(z.string()), + constraints: z.array(z.string()), + status: z.enum(["candidate", "active", "completed", "archived"]), + version: z.number().int().nonnegative(), + supersedesId: NonEmptyStringSchema.optional(), + sourceMemoryIds: z.array(NonEmptyStringSchema), + provenance: z.record(z.string(), z.unknown()), + createdAt: IsoTimeSchema, + updatedAt: IsoTimeSchema +}).strict(); +export const ProjectGoalRecordSchema = ProjectGoalSchema; +export type ProjectGoalRecord = z.infer; + +const ProjectWorkItemSchema = z.object({ + id: NonEmptyStringSchema, + namespaceId: NonEmptyStringSchema, + userId: NonEmptyStringSchema, + projectId: NonEmptyStringSchema.optional(), + workspaceId: NonEmptyStringSchema.optional(), + workspacePath: NonEmptyStringSchema.optional(), + goalId: NonEmptyStringSchema.optional(), + title: NonEmptyStringSchema, + summary: z.string(), + nextStep: z.string(), + acceptanceCriteria: z.array(z.string()), + constraints: z.array(z.string()), + status: z.enum(["pending", "active", "blocked", "completed", "archived"]), + focused: z.boolean(), + sourceMemoryIds: z.array(NonEmptyStringSchema), + provenance: z.record(z.string(), z.unknown()), + createdAt: IsoTimeSchema, + updatedAt: IsoTimeSchema +}).strict(); +export const ProjectWorkItemRecordSchema = ProjectWorkItemSchema; +export type ProjectWorkItemRecord = z.infer; + +export const ProjectFactRecordSchema = z.object({ + id: NonEmptyStringSchema, + namespaceId: NonEmptyStringSchema, + userId: NonEmptyStringSchema, + projectId: NonEmptyStringSchema.optional(), + workspaceId: NonEmptyStringSchema.optional(), + workspacePath: NonEmptyStringSchema.optional(), + kind: z.enum(["decision", "constraint"]), + content: NonEmptyStringSchema, + status: z.enum(["candidate", "active", "superseded", "archived"]), + supersedesId: NonEmptyStringSchema.optional(), + sourceMemoryIds: z.array(NonEmptyStringSchema), + provenance: z.record(z.string(), z.unknown()), + createdAt: IsoTimeSchema, + updatedAt: IsoTimeSchema +}).strict(); +export type ProjectFactRecord = z.infer; + +export const ProjectContextStateSchema = z.object({ + namespaceId: NonEmptyStringSchema, + activeGoal: ProjectGoalRecordSchema.nullable(), + goals: z.array(ProjectGoalRecordSchema), + workItems: z.array(ProjectWorkItemRecordSchema), + focusedWorkItem: ProjectWorkItemRecordSchema.nullable(), + facts: z.array(ProjectFactRecordSchema) +}).strict(); +export type ProjectContextState = z.infer; + +export const ProjectContextStableStateSchema = z.object({ + namespaceId: NonEmptyStringSchema, + status: z.enum(["ready", "no_confirmed_goal", "conflict"]), + version: z.number().int().nonnegative(), + goal: ProjectGoalRecordSchema.nullable(), + focusedWorkItem: ProjectWorkItemRecordSchema.nullable(), + facts: z.array(ProjectFactRecordSchema), + markdown: z.string(), + sourceMemoryIds: z.array(NonEmptyStringSchema), + generatedAt: IsoTimeSchema +}).strict(); +export type ProjectContextStableState = z.infer; + +export const ProjectContextReadStateSchema = ProjectContextStateSchema; +export type ProjectContextReadState = z.infer; +export const ProjectContextProposeGoalInputSchema = ProjectContextMutationFieldsSchema.extend({ + title: NonEmptyStringSchema, + summary: z.string(), + detail: z.string(), + acceptanceCriteria: z.array(z.string()).optional(), + constraints: z.array(z.string()).optional(), + sourceMemoryIds: z.array(NonEmptyStringSchema).optional() +}).strict(); +export type ProjectContextProposeGoalInput = z.infer; +export const ProjectContextGoalDecisionInputSchema = ProjectContextMutationFieldsSchema.strict(); +export type ProjectContextGoalDecisionInput = z.infer; +export const ProjectContextWorkItemCreateInputSchema = ProjectContextMutationFieldsSchema.extend({ + goalId: NonEmptyStringSchema.optional(), + title: NonEmptyStringSchema, + summary: z.string(), + nextStep: z.string(), + acceptanceCriteria: z.array(z.string()).optional(), + constraints: z.array(z.string()).optional(), + status: z.enum(["pending", "active", "blocked", "completed", "archived"]).optional(), + sourceMemoryIds: z.array(NonEmptyStringSchema).optional() +}).strict(); +export type ProjectContextWorkItemCreateInput = z.infer; +export const ProjectContextWorkItemUpdateInputSchema = ProjectContextMutationFieldsSchema.extend({ + goalId: NonEmptyStringSchema.nullable().optional(), title: NonEmptyStringSchema.nullable().optional(), summary: z.string().nullable().optional(), nextStep: z.string().nullable().optional(), acceptanceCriteria: z.array(z.string()).nullable().optional(), constraints: z.array(z.string()).nullable().optional(), status: z.enum(["pending", "active", "blocked", "completed", "archived"]).nullable().optional(), sourceMemoryIds: z.array(NonEmptyStringSchema).nullable().optional() +}).strict(); +export type ProjectContextWorkItemUpdateInput = z.infer; +export const ProjectContextFocusInputSchema = ProjectContextMutationFieldsSchema.extend({ workItemId: NonEmptyStringSchema.nullable() }).strict(); +export type ProjectContextFocusInput = z.infer; + /** Schema for panel items output. */ export const PanelItemsOutputSchema = z.object({ items: z.array(MemoryListItemSchema), diff --git a/App/backend/package.json b/App/backend/package.json index 448975071..507aace55 100644 --- a/App/backend/package.json +++ b/App/backend/package.json @@ -12,15 +12,16 @@ } }, "scripts": { - "build": "npm run build -w @memmy/local-api-contracts && node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node -e \"const fs=require('node:fs'); fs.cpSync('src/infrastructure/app-state-store/migrations', 'dist/src/infrastructure/app-state-store/migrations', { recursive: true }); fs.cpSync('src/infrastructure/agent-source-store/migrations', 'dist/src/infrastructure/agent-source-store/migrations', { recursive: true }); fs.cpSync('src/infrastructure/idempotency-store/migrations', 'dist/src/infrastructure/idempotency-store/migrations', { recursive: true }); if (fs.existsSync('src/adapters/outbound/agent-adapter/plugins')) fs.cpSync('src/adapters/outbound/agent-adapter/plugins', 'dist/src/adapters/outbound/agent-adapter/plugins', { recursive: true });\"", + "build": "npm run build -w @memmy/memory && npm run build -w @memmy/local-api-contracts && node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node -e \"const fs=require('node:fs'); fs.cpSync('src/infrastructure/app-state-store/migrations', 'dist/src/infrastructure/app-state-store/migrations', { recursive: true }); fs.cpSync('src/infrastructure/agent-source-store/migrations', 'dist/src/infrastructure/agent-source-store/migrations', { recursive: true }); fs.cpSync('src/infrastructure/idempotency-store/migrations', 'dist/src/infrastructure/idempotency-store/migrations', { recursive: true }); if (fs.existsSync('src/adapters/outbound/agent-adapter/plugins')) fs.cpSync('src/adapters/outbound/agent-adapter/plugins', 'dist/src/adapters/outbound/agent-adapter/plugins', { recursive: true });\"", "lint": "eslint \"src/**/*.ts\" \"vitest.config.ts\"", - "typecheck": "npm run build -w @memmy/local-api-contracts && tsc -p tsconfig.json --noEmit", - "test": "npm run build -w @memmy/local-api-contracts && vitest run", - "test:agent-adapter:coverage": "npm run build -w @memmy/local-api-contracts && vitest run src/adapters/outbound/agent-adapter/tests --coverage", + "typecheck": "npm run build -w @memmy/memory && npm run build -w @memmy/local-api-contracts && tsc -p tsconfig.json --noEmit", + "test": "npm run build -w @memmy/memory && npm run build -w @memmy/local-api-contracts && vitest run", + "test:agent-adapter:coverage": "npm run build -w @memmy/memory && npm run build -w @memmy/local-api-contracts && vitest run src/adapters/outbound/agent-adapter/tests --coverage", "db:migrate": "tsx src/infrastructure/app-state-store/cli/migrate.ts" }, "dependencies": { "@memmy/local-api-contracts": "0.0.0", + "@memmy/memory": "1.0.5", "@modelcontextprotocol/sdk": "^1.29.0", "dotenv": "^16.6.1", "fastify": "^5.8.5", diff --git a/App/backend/src/adapters/inbound/local-api/routes/agent-runtime/memory.ts b/App/backend/src/adapters/inbound/local-api/routes/agent-runtime/memory.ts index 25299ec97..5097f5508 100644 --- a/App/backend/src/adapters/inbound/local-api/routes/agent-runtime/memory.ts +++ b/App/backend/src/adapters/inbound/local-api/routes/agent-runtime/memory.ts @@ -3,7 +3,8 @@ import { AddMemoryInputSchema, DeleteMemoryInputSchema, MemoryApiLogsInputSchema, - MemoryProcessingStatusInputSchema + MemoryProcessingStatusInputSchema, + RestoreMemoryInputSchema } from "@memmy/local-api-contracts"; import { z } from "zod"; import type { FastifyInstance } from "fastify"; @@ -15,6 +16,10 @@ const MemoryParamsSchema = z.object({ id: z.string().min(1) }); +const MemoryVersionParamsSchema = MemoryParamsSchema.extend({ + version: z.coerce.number().int().positive() +}); + export function registerMemoryRoutes(app: FastifyInstance, deps: AgentRuntimeRouteDeps): void { app.post( "/api/v1/memory/add", @@ -74,6 +79,25 @@ export function registerMemoryRoutes(app: FastifyInstance, deps: AgentRuntimeRou }) ); + app.get( + "/api/v1/memory/:id/history", + { preHandler: deps.authenticateRuntimeToken }, + withErrorEnvelope(async (request, reply) => { + const params = MemoryParamsSchema.parse(request.params); + return reply.send(await deps.services.memoryDetail.history(params.id, runtimeContext())); + }) + ); + + app.post( + "/api/v1/memory/:id/history/:version/restore", + { preHandler: deps.authenticateRuntimeToken }, + withErrorEnvelope(async (request, reply) => { + const params = MemoryVersionParamsSchema.parse(request.params); + const input = RestoreMemoryInputSchema.parse(request.body); + return reply.send(await deps.services.memoryDetail.restore(params.id, params.version, input, runtimeContext())); + }) + ); + app.delete( "/api/v1/memory/:id", { preHandler: deps.authenticateRuntimeToken }, diff --git a/App/backend/src/adapters/inbound/local-api/routes/agent-runtime/panel.ts b/App/backend/src/adapters/inbound/local-api/routes/agent-runtime/panel.ts index f50a2441f..9b26e9ed9 100644 --- a/App/backend/src/adapters/inbound/local-api/routes/agent-runtime/panel.ts +++ b/App/backend/src/adapters/inbound/local-api/routes/agent-runtime/panel.ts @@ -1,5 +1,5 @@ /** Memory Panel runtime routes. */ -import { PanelItemsInputSchema, PanelTasksInputSchema } from "@memmy/local-api-contracts"; +import { PanelItemsInputSchema, PanelTasksInputSchema, ProjectContextFocusInputSchema, ProjectContextGoalDecisionInputSchema, ProjectContextProposeGoalInputSchema, ProjectContextWorkItemCreateInputSchema, ProjectContextWorkItemUpdateInputSchema, RuntimeNamespaceSchema } from "@memmy/local-api-contracts"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { withErrorEnvelope } from "../../../../../services/error-envelope.js"; @@ -23,6 +23,48 @@ export function registerPanelRoutes(app: FastifyInstance, deps: AgentRuntimeRout }) ); + app.get( + "/api/v1/panel/context-pack", + { preHandler: deps.authenticateRuntimeToken }, + withErrorEnvelope(async (request, reply) => { + const { projectId } = z.object({ projectId: z.string().min(1) }).parse(request.query); + return reply.send(await deps.services.panel.contextPack(projectId, runtimeContext())); + }) + ); + + app.get("/api/v1/project-context/state", { preHandler: deps.authenticateRuntimeToken }, withErrorEnvelope(async (request, reply) => { + const query = request.query as Record; + const namespace = typeof query.namespace === "string" + ? z.string().transform((value, ctx) => { + try { + return JSON.parse(value) as unknown; + } catch { + ctx.addIssue({ code: "custom", message: "namespace must be valid JSON" }); + return z.NEVER; + } + }).pipe(RuntimeNamespaceSchema).parse(query.namespace) + : RuntimeNamespaceSchema.parse(query); + return reply.send(await deps.services.panel.projectContextState(namespace, runtimeContext(request))); + })); + app.post("/api/v1/project-context/goals/propose", { preHandler: deps.authenticateRuntimeToken }, withErrorEnvelope(async (request, reply) => { + const input = ProjectContextProposeGoalInputSchema.parse(request.body); + return reply.send(await deps.services.panel.proposeProjectGoal(input, runtimeContext(request))); + })); + app.post("/api/v1/project-context/goals/:id/approve", { preHandler: deps.authenticateRuntimeToken }, withErrorEnvelope(async (request, reply) => { + const { id } = z.object({ id: z.string().min(1) }).parse(request.params); + return reply.send(await deps.services.panel.approveProjectGoal(id, ProjectContextGoalDecisionInputSchema.parse(request.body), runtimeContext(request))); + })); + app.post("/api/v1/project-context/goals/:id/reject", { preHandler: deps.authenticateRuntimeToken }, withErrorEnvelope(async (request, reply) => { + const { id } = z.object({ id: z.string().min(1) }).parse(request.params); + return reply.send(await deps.services.panel.rejectProjectGoal(id, ProjectContextGoalDecisionInputSchema.parse(request.body), runtimeContext(request))); + })); + app.post("/api/v1/project-context/work-items", { preHandler: deps.authenticateRuntimeToken }, withErrorEnvelope(async (request, reply) => reply.send(await deps.services.panel.createProjectWorkItem(ProjectContextWorkItemCreateInputSchema.parse(request.body), runtimeContext(request))))); + app.patch("/api/v1/project-context/work-items/:id", { preHandler: deps.authenticateRuntimeToken }, withErrorEnvelope(async (request, reply) => { + const { id } = z.object({ id: z.string().min(1) }).parse(request.params); + return reply.send(await deps.services.panel.updateProjectWorkItem(id, ProjectContextWorkItemUpdateInputSchema.parse(request.body), runtimeContext(request))); + })); + app.put("/api/v1/project-context/focus", { preHandler: deps.authenticateRuntimeToken }, withErrorEnvelope(async (request, reply) => reply.send(await deps.services.panel.setProjectFocus(ProjectContextFocusInputSchema.parse(request.body), runtimeContext(request))))); + app.get( "/api/v1/panel/items", { preHandler: deps.authenticateRuntimeToken }, @@ -57,8 +99,11 @@ export function registerPanelRoutes(app: FastifyInstance, deps: AgentRuntimeRout } -function runtimeContext(): RuntimeContext { - return { adapterId: "runtime" }; +function runtimeContext(request?: { headers?: Record; body?: unknown }): RuntimeContext { + const body = request?.body as { requestId?: unknown } | undefined; + const header = request?.headers?.["x-request-id"]; + const requestId = typeof body?.requestId === "string" ? body.requestId : typeof header === "string" ? header : undefined; + return { adapterId: "runtime", requestId }; } function queryValues(rawUrl: string | undefined, name: string): string[] | undefined { diff --git a/App/backend/src/adapters/inbound/local-api/routes/agent-token-stats.ts b/App/backend/src/adapters/inbound/local-api/routes/agent-token-stats.ts new file mode 100644 index 000000000..5366d898c --- /dev/null +++ b/App/backend/src/adapters/inbound/local-api/routes/agent-token-stats.ts @@ -0,0 +1,25 @@ +import { AgentTokenStatsResponseSchema } from "@memmy/local-api-contracts"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import type { AgentTokenStatsService } from "../../../../services/agent-token-stats-service.js"; +import { withErrorEnvelope } from "../../../../services/error-envelope.js"; + +export interface RegisterAgentTokenStatsRoutesOptions { + agentTokenStats: AgentTokenStatsService; + authenticateRuntimeToken: (request: FastifyRequest, reply: FastifyReply) => Promise; +} + +export function registerAgentTokenStatsRoutes( + app: FastifyInstance, + options: RegisterAgentTokenStatsRoutesOptions +): void { + app.get( + "/api/app/agent-token-stats", + { preHandler: options.authenticateRuntimeToken }, + withErrorEnvelope(async (_request, reply) => { + const response = AgentTokenStatsResponseSchema.parse( + await options.agentTokenStats.getStats() + ); + return reply.send(response); + }) + ); +} diff --git a/App/backend/src/adapters/inbound/local-api/server.ts b/App/backend/src/adapters/inbound/local-api/server.ts index ed5d346e8..14f189e74 100644 --- a/App/backend/src/adapters/inbound/local-api/server.ts +++ b/App/backend/src/adapters/inbound/local-api/server.ts @@ -11,6 +11,7 @@ import { registerAgentSourceRoutes } from "./routes/agent-sources.js"; import { registerAgentRuntimeRoutes } from "./routes/agent-runtime/index.js"; import { registerAsrRoutes } from "./routes/asr.js"; import { registerByokTokenUsageRoutes } from "./routes/byok-token-usage.js"; +import { registerAgentTokenStatsRoutes } from "./routes/agent-token-stats.js"; import { registerChannelRoutes } from "./routes/channels.js"; import { registerComposioMcpRoutes } from "./routes/composio-mcp.js"; import { registerIntegrationRoutes } from "./routes/integrations.js"; @@ -122,6 +123,10 @@ export function createLocalApiServer(options: CreateLocalApiServerOptions): Fast byokTokenUsage: options.services.byokTokenUsage, authenticateRuntimeToken }); + registerAgentTokenStatsRoutes(app, { + agentTokenStats: options.services.agentTokenStats, + authenticateRuntimeToken + }); registerAsrRoutes(app, { asr: options.services.asr, authenticateRuntimeToken diff --git a/App/backend/src/adapters/inbound/local-api/tests/agent-runtime-routes.test.ts b/App/backend/src/adapters/inbound/local-api/tests/agent-runtime-routes.test.ts index f1f051fe0..2c440f6f4 100644 --- a/App/backend/src/adapters/inbound/local-api/tests/agent-runtime-routes.test.ts +++ b/App/backend/src/adapters/inbound/local-api/tests/agent-runtime-routes.test.ts @@ -29,10 +29,20 @@ describe("agent runtime local api routes", () => { { method: "POST", url: "/api/v1/memory/processing/status", payload: { memoryIds: ["memory-1"] } }, { method: "POST", url: "/api/v1/memory/memory-1/processing/retry", payload: {} }, { method: "GET", url: "/api/v1/memory/memory-1" }, + { method: "GET", url: "/api/v1/memory/memory-1/history" }, + { method: "POST", url: "/api/v1/memory/memory-1/history/1/restore", payload: { version: 1, reason: "desktop restore" } }, { method: "DELETE", url: "/api/v1/memory/memory-1" }, { method: "GET", url: "/api/v1/memory/logs?tools=memory_add,memory_search&limit=20&offset=0" }, { method: "GET", url: "/api/v1/panel/overview" }, { method: "GET", url: "/api/v1/panel/analysis" }, + { method: "GET", url: "/api/v1/panel/context-pack?projectId=project-1" }, + { method: "GET", url: `/api/v1/project-context/state?namespace=${encodeURIComponent(JSON.stringify(projectNamespace()))}` }, + { method: "POST", url: "/api/v1/project-context/goals/propose", payload: { ...projectMutation(), title: "Ship context", summary: "", detail: "" } }, + { method: "POST", url: "/api/v1/project-context/goals/goal-1/approve", payload: projectMutation() }, + { method: "POST", url: "/api/v1/project-context/goals/goal-1/reject", payload: projectMutation() }, + { method: "POST", url: "/api/v1/project-context/work-items", payload: { ...projectMutation(), title: "Verify context", summary: "", nextStep: "Run smoke" } }, + { method: "PATCH", url: "/api/v1/project-context/work-items/work-1", payload: { ...projectMutation(), status: "active" } }, + { method: "PUT", url: "/api/v1/project-context/focus", payload: { ...projectMutation(), workItemId: "work-1" } }, { method: "GET", url: "/api/v1/panel/items?layer=L1&status=activated&page=1" }, { method: "GET", url: "/api/v1/panel/tasks?page=1" }, { method: "DELETE", url: "/api/v1/panel/tasks/episode-1" } @@ -46,7 +56,7 @@ describe("agent runtime local api routes", () => { payload: request.payload }); - expect(response.statusCode, `${request.method} ${request.url}`).toBe(200); + expect(response.statusCode, `${request.method} ${request.url}: ${response.body}`).toBe(200); } }); @@ -212,6 +222,24 @@ describe("agent runtime local api routes", () => { }); }); + it("returns invalid_argument for malformed project namespace JSON", async () => { + app = createServer(); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/project-context/state?namespace=%7Bbroken", + headers: { "x-memmy-local-token": "test-token", "x-request-id": "req-namespace" } + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + error: { + code: "invalid_argument", + requestId: "req-namespace" + } + }); + }); + it("unwraps duplicate service responses", async () => { app = createServer({ turn: { @@ -261,6 +289,64 @@ describe("agent runtime local api routes", () => { } }); }); + + it("forwards project context governance through the authenticated local API", async () => { + const calls: Array<{ operation: string; id?: string; input: unknown; context?: unknown }> = []; + app = createServer({ + panel: { + async projectContextState(input: unknown, context: unknown) { + calls.push({ operation: "state", input, context }); + return projectContextStateOutput(); + }, + async proposeProjectGoal(input: unknown, context: unknown) { + calls.push({ operation: "propose", input, context }); + return projectGoalOutput("candidate"); + }, + async approveProjectGoal(id: string, input: unknown, context: unknown) { + calls.push({ operation: "approve", id, input, context }); + return projectGoalOutput("active"); + }, + async rejectProjectGoal(id: string, input: unknown, context: unknown) { + calls.push({ operation: "reject", id, input, context }); + return projectGoalOutput("archived"); + }, + async createProjectWorkItem(input: unknown, context: unknown) { + calls.push({ operation: "create-work", input, context }); + return projectWorkItemOutput(); + }, + async updateProjectWorkItem(id: string, input: unknown, context: unknown) { + calls.push({ operation: "update-work", id, input, context }); + return projectWorkItemOutput(); + }, + async setProjectFocus(input: unknown, context: unknown) { + calls.push({ operation: "focus", input, context }); + return projectWorkItemOutput(); + } + } + }); + const headers = { "x-memmy-local-token": "test-token", "x-request-id": "route-request" }; + const requests = [ + { method: "GET", url: `/api/v1/project-context/state?namespace=${encodeURIComponent(JSON.stringify(projectNamespace()))}` }, + { method: "POST", url: "/api/v1/project-context/goals/propose", payload: { ...projectMutation(), title: "Ship context", summary: "", detail: "" } }, + { method: "POST", url: "/api/v1/project-context/goals/goal-1/approve", payload: projectMutation() }, + { method: "POST", url: "/api/v1/project-context/goals/goal-1/reject", payload: projectMutation() }, + { method: "POST", url: "/api/v1/project-context/work-items", payload: { ...projectMutation(), title: "Verify context", summary: "", nextStep: "Run smoke" } }, + { method: "PATCH", url: "/api/v1/project-context/work-items/work-1", payload: { ...projectMutation(), status: "active" } }, + { method: "PUT", url: "/api/v1/project-context/focus", payload: { ...projectMutation(), workItemId: "work-1" } } + ]; + + for (const request of requests) { + const response = await app.inject({ ...request, headers }); + expect(response.statusCode, `${request.method} ${request.url}: ${response.body}`).toBe(200); + } + + expect(calls.map(({ operation, id }) => id ? `${operation}:${id}` : operation)).toEqual([ + "state", "propose", "approve:goal-1", "reject:goal-1", "create-work", "update-work:work-1", "focus" + ]); + expect(calls[0]?.input).toEqual(projectNamespace()); + expect(hasRuntimeProvenance(calls[0]?.context, "runtime", "route-request")).toBe(true); + expect(calls.slice(1).every(({ context }) => hasRuntimeProvenance(context, "runtime", "client-request"))).toBe(true); + }); }); function createServer(overrides: Record = {}): FastifyInstance { @@ -360,15 +446,25 @@ function createServer(overrides: Record = {}): FastifyInstance memoryDetail: { async add() { return addMemoryOutput(); }, async getById() { return getMemoryOutput(); }, + async history(id: string) { return memoryHistoryOutput(id); }, + async restore(id: string, targetVersion: number) { return restoreMemoryOutput(id, targetVersion); }, async delete() { return deleteMemoryOutput(); } }, panel: { async overview() { return panelOverviewOutput(); }, async analysis() { return panelAnalysisOutput(); }, + async contextPack(projectId: string) { return projectContextPackOutput(projectId); }, async items() { return panelItemsOutput(); }, async tasks() { return panelTasksOutput(); }, async deleteTask(id: string) { return { ok: true as const, id, deletedMemoryIds: [], serverTime: now() }; }, - async memoryApiLogs() { return { logs: [], total: 0, limit: 20, offset: 0, serverTime: now() }; } + async memoryApiLogs() { return { logs: [], total: 0, limit: 20, offset: 0, serverTime: now() }; }, + async projectContextState() { return projectContextStateOutput(); }, + async proposeProjectGoal() { return projectGoalOutput("candidate"); }, + async approveProjectGoal() { return projectGoalOutput("active"); }, + async rejectProjectGoal() { return projectGoalOutput("archived"); }, + async createProjectWorkItem() { return projectWorkItemOutput(); }, + async updateProjectWorkItem() { return projectWorkItemOutput(); }, + async setProjectFocus() { return projectWorkItemOutput(); }, }, ...overrides } as unknown as BackendServices; @@ -380,6 +476,48 @@ function createServer(overrides: Record = {}): FastifyInstance }); } +function projectNamespace() { + return { source: "codex", profileId: "default", userId: "user-1", projectId: "project-1" }; +} + +function projectMutation() { + return { + namespace: projectNamespace(), + source: "desktop", + adapterId: "desktop-client", + requestId: "client-request", + provenance: { sourceAgent: "desktop", sourceMemoryIds: [], capturedAt: now() } + }; +} + +function hasRuntimeProvenance(input: unknown, adapterId: string, requestId: string): boolean { + if (!input || typeof input !== "object") return false; + if (!("adapterId" in input) || !("requestId" in input)) return false; + return input.adapterId === adapterId && input.requestId === requestId; +} + +function projectGoalOutput(status: "candidate" | "active" | "archived") { + return { + id: "goal-1", namespaceId: "local:project-1", userId: "user-1", projectId: "project-1", + title: "Ship context", summary: "", detail: "", acceptanceCriteria: [], constraints: [], status, + version: 1, sourceMemoryIds: [], provenance: {}, createdAt: now(), updatedAt: now() + }; +} + +function projectWorkItemOutput() { + return { + id: "work-1", namespaceId: "local:project-1", userId: "user-1", projectId: "project-1", goalId: "goal-1", + title: "Verify context", summary: "", nextStep: "Run smoke", acceptanceCriteria: [], constraints: [], + status: "active" as const, focused: true, sourceMemoryIds: [], provenance: {}, createdAt: now(), updatedAt: now() + }; +} + +function projectContextStateOutput() { + const goal = projectGoalOutput("active"); + const workItem = projectWorkItemOutput(); + return { namespaceId: "local:project-1", activeGoal: goal, goals: [goal], workItems: [workItem], focusedWorkItem: workItem, facts: [] }; +} + function memoryModels() { return { summary: { provider: "openai_compatible", model: "memory_summary", configured: true, remote: true }, @@ -426,6 +564,20 @@ function addMemoryInput() { return { content: "remember this", source: "codex" }; } +function projectContextPackOutput(projectId: string) { + return { + namespace: { projectId }, + conventions: [], + commands: [], + architectureFacts: [], + recentTasks: [], + userPreferences: [], + graph: { nodes: [], edges: [] }, + markdown: `# Project Memory Pack: ${projectId}`, + generatedAt: now() + }; +} + function openSessionOutput() { return { sessionId: "session-1", status: "open", resumed: false, serverTime: now() }; } @@ -513,6 +665,27 @@ function getMemoryOutput() { }; } +function memoryHistoryOutput(id: string) { + return { + id, + currentVersion: 1, + items: [{ seq: 1, version: 1, changeType: "created", source: "turn_complete", createdAt: now(), after: {} }], + serverTime: now() + }; +} + +function restoreMemoryOutput(id: string, targetVersion: number) { + return { + ok: true as const, + id, + version: 2, + restoredVersion: targetVersion, + changeSeq: 2, + auditId: "audit-restore-1", + serverTime: now() + }; +} + function deleteMemoryOutput() { return { ok: true, diff --git a/App/backend/src/adapters/inbound/local-api/tests/agent-sources-route.test.ts b/App/backend/src/adapters/inbound/local-api/tests/agent-sources-route.test.ts index b01b9f0b8..e6bae39ed 100644 --- a/App/backend/src/adapters/inbound/local-api/tests/agent-sources-route.test.ts +++ b/App/backend/src/adapters/inbound/local-api/tests/agent-sources-route.test.ts @@ -415,6 +415,7 @@ describe("agent sources local api routes", () => { "cursor", "claude_code", "codex", + "pi", "opencode", "openclaw", "hermes", diff --git a/App/backend/src/adapters/outbound/agent-paths.ts b/App/backend/src/adapters/outbound/agent-paths.ts index 6fd411752..6fe96cca0 100644 --- a/App/backend/src/adapters/outbound/agent-paths.ts +++ b/App/backend/src/adapters/outbound/agent-paths.ts @@ -52,6 +52,24 @@ export function resolveCodexSessionsDirectory(options: ResolveAgentPathOptions = return createAgentPathRuntime(options).pathApi.join(resolveCodexHomeDirectory(options), "sessions"); } +export function resolvePiHomeDirectory(options: ResolveAgentPathOptions = {}): string { + const runtime = createAgentPathRuntime(options); + return resolveConfiguredDirectory( + runtime.environment.PI_CODING_AGENT_DIR, + runtime.pathApi.join(runtime.homeDirectory, ".pi", "agent"), + runtime + ); +} + +export function resolvePiSessionsDirectory(options: ResolveAgentPathOptions = {}): string { + const runtime = createAgentPathRuntime(options); + return resolveConfiguredDirectory( + runtime.environment.PI_CODING_AGENT_SESSION_DIR, + runtime.pathApi.join(resolvePiHomeDirectory(options), "sessions"), + runtime + ); +} + export function resolveOpencodeConfigDirectory(options: ResolveAgentPathOptions = {}): string { const runtime = createAgentPathRuntime(options); const xdgConfigRoot = resolveConfiguredDirectory( diff --git a/App/backend/src/adapters/outbound/agent-source/pi/adapter.ts b/App/backend/src/adapters/outbound/agent-source/pi/adapter.ts new file mode 100644 index 000000000..d84bc43b4 --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/pi/adapter.ts @@ -0,0 +1,106 @@ +/** Pi source adapter module. */ +import { access } from "node:fs/promises"; +import { resolvePiSessionsDirectory } from "../../agent-paths.js"; +import { collectConversationWindow, remainingMessageCapacity } from "../conversation-window.js"; +import { redactSecrets } from "../secret-redactor.js"; +import type { ConversationMessage, ScanOptions, SourceAdapter, SourceDescriptor } from "../types.js"; +import { discoverPiSessions } from "./session-discovery.js"; +import { readPiSession, type RawPiMessage } from "./session-reader.js"; + +const PI_SOURCE_ID = "pi"; + +export interface CreatePiSourceAdapterDeps { + sessionsRoot?: string; + descriptor?: SourceDescriptor; +} + +export function createPiSourceAdapter(deps: CreatePiSourceAdapterDeps = {}): SourceAdapter { + const sessionsRoot = deps.sessionsRoot ?? resolvePiSessionsDirectory(); + const descriptor = deps.descriptor ?? Object.freeze({ + sourceId: PI_SOURCE_ID, + displayName: "Pi", + builtin: true, + dataPath: sessionsRoot + }); + + return { + descriptor, + async detect() { + try { + await access(sessionsRoot); + return true; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return false; + } + throw error; + } + }, + async *scan(options: ScanOptions) { + throwIfAborted(options.signal); + options.onProgress?.({ sourceId: descriptor.sourceId, phase: "discover", current: 0, total: 1 }); + const sessions = await discoverPiSessions({ + root: sessionsRoot, + order: options.order === "recent_first" ? "recent_first" : "path_asc", + maxSessions: options.maxScanTargets + }); + options.onProgress?.({ sourceId: descriptor.sourceId, phase: "discover", current: sessions.length, total: sessions.length }); + + let emittedMessages = 0; + for (const [sessionIndex, session] of sessions.entries()) { + throwIfAborted(options.signal); + if (options.maxMessages !== undefined && emittedMessages >= options.maxMessages) { + break; + } + options.onProgress?.({ + sourceId: descriptor.sourceId, + phase: "read", + current: sessionIndex, + total: sessions.length, + message: session.sessionFilePath + }); + const messages = await collectConversationWindow( + readPiSession(session.sessionFilePath, options.signal), + options.since, + options.signal, + remainingMessageCapacity(options.maxMessages, emittedMessages) + ); + for (const rawMessage of messages) { + throwIfAborted(options.signal); + if (options.maxMessages !== undefined && emittedMessages >= options.maxMessages) { + break; + } + emittedMessages += 1; + yield toConversationMessage(descriptor.sourceId, rawMessage, session.workspacePath, session.gitRoot); + } + } + options.onProgress?.({ sourceId: descriptor.sourceId, phase: "done", current: emittedMessages, total: emittedMessages }); + } + }; +} + +function toConversationMessage( + sourceId: string, + rawMessage: RawPiMessage, + workspacePath: string | null, + gitRoot: string | null +): ConversationMessage { + return { + ...rawMessage, + sourceId, + content: redactSecrets(rawMessage.content), + workspacePath, + gitRoot, + rawMeta: Object.freeze({}) + }; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new DOMException("Pi source scan aborted", "AbortError"); + } +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/App/backend/src/adapters/outbound/agent-source/pi/index.ts b/App/backend/src/adapters/outbound/agent-source/pi/index.ts new file mode 100644 index 000000000..bba100fa2 --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/pi/index.ts @@ -0,0 +1,2 @@ +/** Pi module. */ +export { createPiSourceAdapter } from "./adapter.js"; diff --git a/App/backend/src/adapters/outbound/agent-source/pi/session-discovery.ts b/App/backend/src/adapters/outbound/agent-source/pi/session-discovery.ts new file mode 100644 index 000000000..add146dfd --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/pi/session-discovery.ts @@ -0,0 +1,87 @@ +/** Pi session discovery module. */ +import { existsSync } from "node:fs"; +import { stat } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { readJsonlObjects } from "../jsonl-lines.js"; +import { readDirectoryIfExists } from "../read-directory.js"; + +export interface PiSessionFile { + sessionFilePath: string; + workspacePath: string | null; + gitRoot: string | null; +} + +export interface DiscoverPiSessionsOptions { + root: string; + order?: "path_asc" | "recent_first"; + maxSessions?: number; +} + +export async function discoverPiSessions(options: DiscoverPiSessionsOptions): Promise { + const files = await listSessionFiles(options.root, options.order ?? "path_asc", options.maxSessions); + const sessions: PiSessionFile[] = []; + + for (const sessionFilePath of files) { + const workspacePath = await readSessionCwd(sessionFilePath); + sessions.push({ + sessionFilePath, + workspacePath, + gitRoot: workspacePath ? findGitRoot(workspacePath) : null + }); + } + + return sessions; +} + +async function listSessionFiles( + root: string, + order: "path_asc" | "recent_first", + maxSessions: number | undefined +): Promise { + const files: Array<{ path: string; mtimeMs: number }> = []; + const directories = [root]; + + for (let directoryIndex = 0; directoryIndex < directories.length; directoryIndex += 1) { + const currentDirectory = directories[directoryIndex]!; + for (const entry of await readDirectoryIfExists(currentDirectory)) { + const path = join(currentDirectory, entry.name); + if (entry.isDirectory()) { + directories.push(path); + } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { + const fileStat = await stat(path); + files.push({ path, mtimeMs: fileStat.mtimeMs }); + } + } + } + + return files + .sort((left, right) => order === "recent_first" + ? right.mtimeMs - left.mtimeMs || right.path.localeCompare(left.path) + : left.path.localeCompare(right.path)) + .slice(0, maxSessions ?? files.length) + .map((file) => file.path); +} + +async function readSessionCwd(filePath: string): Promise { + try { + for await (const record of readJsonlObjects(filePath)) { + if (record.type === "session") { + return typeof record.cwd === "string" ? record.cwd : null; + } + } + } catch { + return null; + } + return null; +} + +function findGitRoot(workspacePath: string): string | null { + let current = workspacePath; + while (current !== dirname(current)) { + if (existsSync(join(current, ".git"))) { + return current; + } + current = dirname(current); + } + return existsSync(join(current, ".git")) ? current : null; +} diff --git a/App/backend/src/adapters/outbound/agent-source/pi/session-reader.ts b/App/backend/src/adapters/outbound/agent-source/pi/session-reader.ts new file mode 100644 index 000000000..2bfdddafd --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/pi/session-reader.ts @@ -0,0 +1,172 @@ +/** Pi session reader module. */ +import { basename } from "node:path"; +import { readJsonlObjects, type JsonObject } from "../jsonl-lines.js"; + +export interface RawPiMessage { + messageId: string; + conversationId: string; + role: "user" | "assistant" | "tool" | "system"; + content: string; + createdAt: string; +} + +interface PiEntry { + id: string; + parentId: string | null; + record: JsonObject; +} + +export async function* readPiSession(filePath: string, signal?: AbortSignal): AsyncIterable { + const entries: PiEntry[] = []; + const handledEntryIds = new Set(); + let sessionId = basename(filePath, ".jsonl"); + + for await (const record of readJsonlObjects(filePath, signal)) { + if (record.type === "session" && typeof record.id === "string") { + sessionId = record.id; + } + if (typeof record.id === "string") { + entries.push({ + id: record.id, + parentId: typeof record.parentId === "string" ? record.parentId : null, + record + }); + } + collectHandledEntryIds(record, handledEntryIds); + } + + const activeEntryIds = collectActiveBranchIds(entries); + for (const entry of entries) { + if (!activeEntryIds.has(entry.id) || handledEntryIds.has(entry.id)) { + continue; + } + const message = toRawPiMessage(entry.record, sessionId, entry.id); + if (message) { + yield message; + } + } +} + +function collectHandledEntryIds(record: JsonObject, handledEntryIds: Set): void { + if (record.type !== "custom" || record.customType !== "memmy-memory-capture" || !isRecord(record.data)) { + return; + } + if (!Array.isArray(record.data.entryIds)) { + return; + } + for (const entryId of record.data.entryIds) { + if (typeof entryId === "string") handledEntryIds.add(entryId); + } +} + +function collectActiveBranchIds(entries: readonly PiEntry[]): Set { + const byId = new Map(entries.map((entry) => [entry.id, entry])); + const activeIds = new Set(); + let current = entries.at(-1); + while (current && !activeIds.has(current.id)) { + activeIds.add(current.id); + current = current.parentId ? byId.get(current.parentId) : undefined; + } + return activeIds; +} + +function toRawPiMessage(record: JsonObject, sessionId: string, entryId: string): RawPiMessage | null { + if (record.type !== "message" || !isRecord(record.message)) { + return null; + } + const message = record.message; + const role = message.role; + if (role !== "user" && role !== "assistant" && role !== "toolResult" && role !== "system") { + return null; + } + const content = renderContent(message.content, role, message); + if (!content) { + return null; + } + return { + messageId: `${sessionId}:${entryId}`, + conversationId: sessionId, + role: role === "toolResult" ? "tool" : role, + content, + createdAt: normalizeTimestamp(record.timestamp ?? message.timestamp) + }; +} + +function renderContent(content: unknown, role: string, message: Record): string | null { + if (typeof content === "string") { + return content.trim() || null; + } + if (!Array.isArray(content)) { + return null; + } + + const parts: string[] = []; + for (const item of content) { + if (!isRecord(item) || item.type === "thinking") { + continue; + } + if (item.type === "text" && typeof item.text === "string" && item.text.trim()) { + parts.push(item.text.trim()); + continue; + } + if (item.type === "toolCall") { + parts.push(renderToolCall(item)); + continue; + } + if (role === "toolResult") { + const text = typeof item.text === "string" ? item.text : formatValue(item); + if (text.trim()) { + parts.push(text.trim()); + } + } + } + const rendered = parts.filter(Boolean).join("\n\n"); + if (role !== "toolResult" || !rendered) { + return rendered || null; + } + return [ + `Tool: ${normalizeString(message.toolName) || "tool"}`, + normalizeString(message.toolCallId) ? `Call ID: ${normalizeString(message.toolCallId)}` : "", + message.isError === true ? "Status: error" : "", + `Output:\n${rendered}` + ].filter(Boolean).join("\n\n"); +} + +function renderToolCall(item: Record): string { + return [ + `Tool: ${normalizeString(item.name) || "tool"}`, + normalizeString(item.id) ? `Call ID: ${normalizeString(item.id)}` : "", + item.arguments !== undefined ? `Input:\n${formatValue(item.arguments)}` : "" + ].filter(Boolean).join("\n\n"); +} + +function formatValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function normalizeTimestamp(value: unknown): string { + if (typeof value === "number") { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? new Date(0).toISOString() : date.toISOString(); + } + if (typeof value === "string") { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? new Date(0).toISOString() : date.toISOString(); + } + return new Date(0).toISOString(); +} + +function normalizeString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/App/backend/src/adapters/outbound/agent-source/pi/tests/adapter.test.ts b/App/backend/src/adapters/outbound/agent-source/pi/tests/adapter.test.ts new file mode 100644 index 000000000..866041cb3 --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/pi/tests/adapter.test.ts @@ -0,0 +1,109 @@ +/** Pi source adapter tests. */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createPiSourceAdapter } from "../index.js"; +import { discoverPiSessions } from "../session-discovery.js"; +import { readPiSession } from "../session-reader.js"; + +let tempDirectory: string | undefined; + +afterEach(() => { + if (tempDirectory) { + rmSync(tempDirectory, { recursive: true, force: true }); + tempDirectory = undefined; + } +}); + +describe("Pi source adapter", () => { + it("reads the active branch with text and tool traces but excludes thinking", async () => { + const fixture = createFixture(); + const messages = await collect(readPiSession(fixture.sessionPath)); + + expect(messages).toEqual([ + expect.objectContaining({ messageId: "pi-session-1:user-1", role: "user", content: expect.stringContaining("sk-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN") }), + expect.objectContaining({ role: "assistant", content: expect.stringContaining("Tool: bash") }), + expect.objectContaining({ role: "tool", content: expect.stringContaining("Tool: bash") }), + expect.objectContaining({ role: "assistant", content: "Done" }) + ]); + expect(messages.map((message) => message.content).join("\n")).not.toContain("private reasoning"); + expect(messages.map((message) => message.content).join("\n")).not.toContain("abandoned answer"); + }); + + it("discovers nested sessions and streams redacted messages", async () => { + const fixture = createFixture(); + const adapter = createPiSourceAdapter({ sessionsRoot: fixture.sessionsRoot }); + + await expect(discoverPiSessions({ root: fixture.sessionsRoot })).resolves.toEqual([ + expect.objectContaining({ sessionFilePath: fixture.sessionPath, workspacePath: fixture.workspacePath }) + ]); + const messages = await collect(adapter.scan({})); + expect(messages[0]).toEqual(expect.objectContaining({ + sourceId: "pi", + conversationId: "pi-session-1", + content: "Use OPENAI_API_KEY=[REDACTED:openai_api_key]", + workspacePath: fixture.workspacePath + })); + }); + + it("treats a missing sessions directory as empty history", async () => { + const sessionsRoot = join(tmpdir(), `memmy-missing-pi-${crypto.randomUUID()}`); + await expect(discoverPiSessions({ root: sessionsRoot })).resolves.toEqual([]); + await expect(collect(createPiSourceAdapter({ sessionsRoot }).scan({}))).resolves.toEqual([]); + }); + + it("honors scan limits and aborts", async () => { + const fixture = createFixture(); + const adapter = createPiSourceAdapter({ sessionsRoot: fixture.sessionsRoot }); + await expect(collect(adapter.scan({ maxMessages: 2 }))).resolves.toHaveLength(2); + + const controller = new AbortController(); + controller.abort(); + await expect(collect(adapter.scan({ signal: controller.signal }))).rejects.toThrow("Pi source scan aborted"); + }); + + it("skips entries already handled by the live extension", async () => { + const fixture = createFixture([ + { + type: "custom", + id: "capture-1", + parentId: "assistant-2", + timestamp: "2026-08-01T00:00:06.000Z", + customType: "memmy-memory-capture", + data: { entryIds: ["user-1", "assistant-1", "tool-1", "assistant-2"], status: "succeeded" } + } + ]); + + await expect(collect(readPiSession(fixture.sessionPath))).resolves.toEqual([]); + }); +}); + +function createFixture(extraRows: Array> = []): { sessionsRoot: string; sessionPath: string; workspacePath: string } { + tempDirectory = mkdtempSync(join(tmpdir(), "memmy-pi-source-")); + const sessionsRoot = join(tempDirectory, "sessions"); + const workspacePath = join(tempDirectory, "workspace"); + const sessionDirectory = join(sessionsRoot, "--workspace--"); + const sessionPath = join(sessionDirectory, "2026-08-01T00-00-00-000Z_pi-session-1.jsonl"); + mkdirSync(sessionDirectory, { recursive: true }); + mkdirSync(workspacePath, { recursive: true }); + const rows = [ + { type: "session", version: 3, id: "pi-session-1", timestamp: "2026-08-01T00:00:00.000Z", cwd: workspacePath }, + { type: "message", id: "user-1", parentId: null, timestamp: "2026-08-01T00:00:01.000Z", message: { role: "user", content: [{ type: "text", text: "Use OPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN" }] } }, + { type: "message", id: "abandoned", parentId: "user-1", timestamp: "2026-08-01T00:00:02.000Z", message: { role: "assistant", content: [{ type: "text", text: "abandoned answer" }] } }, + { type: "message", id: "assistant-1", parentId: "user-1", timestamp: "2026-08-01T00:00:03.000Z", message: { role: "assistant", content: [{ type: "thinking", thinking: "private reasoning" }, { type: "toolCall", id: "call-1", name: "bash", arguments: { command: "pwd" } }] } }, + { type: "message", id: "tool-1", parentId: "assistant-1", timestamp: "2026-08-01T00:00:04.000Z", message: { role: "toolResult", toolCallId: "call-1", toolName: "bash", content: [{ type: "text", text: "command output" }] } }, + { type: "message", id: "assistant-2", parentId: "tool-1", timestamp: "2026-08-01T00:00:05.000Z", message: { role: "assistant", content: [{ type: "text", text: "Done" }] } }, + ...extraRows + ]; + writeFileSync(sessionPath, `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`, "utf8"); + return { sessionsRoot, sessionPath, workspacePath }; +} + +async function collect(iterable: AsyncIterable): Promise { + const items: T[] = []; + for await (const item of iterable) { + items.push(item); + } + return items; +} diff --git a/App/backend/src/adapters/outbound/agent-source/tests/agent-paths.test.ts b/App/backend/src/adapters/outbound/agent-source/tests/agent-paths.test.ts index 09abf57b5..dc5bb1990 100644 --- a/App/backend/src/adapters/outbound/agent-source/tests/agent-paths.test.ts +++ b/App/backend/src/adapters/outbound/agent-source/tests/agent-paths.test.ts @@ -12,6 +12,8 @@ import { resolveOpencodeDatabasePath, resolveOpenclawConfigPath, resolveOpenclawStateDirectory, + resolvePiHomeDirectory, + resolvePiSessionsDirectory, resolveWorkbuddyHomeDirectory, resolveWorkbuddyProjectsDirectory } from "../../agent-paths.js"; @@ -25,6 +27,8 @@ const ENVIRONMENT_VARIABLES = [ "OPENCODE_CONFIG_DIR", "OPENCLAW_CONFIG_PATH", "OPENCLAW_STATE_DIR", + "PI_CODING_AGENT_DIR", + "PI_CODING_AGENT_SESSION_DIR", "WORKBUDDY_CONFIG_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME" @@ -48,6 +52,8 @@ describe("agent paths", () => { process.env.HERMES_HOME = "/tmp/hermes-home"; process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-state"; process.env.OPENCLAW_CONFIG_PATH = "/tmp/openclaw-config.json"; + process.env.PI_CODING_AGENT_DIR = "/tmp/pi-home"; + process.env.PI_CODING_AGENT_SESSION_DIR = "/tmp/pi-sessions"; process.env.WORKBUDDY_CONFIG_DIR = "/tmp/workbuddy-home"; expect(resolveClaudeCodeHomeDirectory()).toBe("/tmp/claude-home"); @@ -55,6 +61,8 @@ describe("agent paths", () => { expect(resolveHermesHomeDirectory()).toBe("/tmp/hermes-home"); expect(resolveOpenclawStateDirectory()).toBe("/tmp/openclaw-state"); expect(resolveOpenclawConfigPath()).toBe("/tmp/openclaw-config.json"); + expect(resolvePiHomeDirectory()).toBe("/tmp/pi-home"); + expect(resolvePiSessionsDirectory()).toBe("/tmp/pi-sessions"); expect(resolveWorkbuddyHomeDirectory()).toBe("/tmp/workbuddy-home"); }); @@ -80,7 +88,7 @@ describe("agent paths", () => { expect(resolveOpencodeConfigDirectory()).toBe("/tmp/custom-opencode"); }); - it("resolves all seven Agent source paths on macOS", () => { + it("resolves all eight Agent source paths on macOS", () => { const options = { platform: "darwin" as const, homeDirectory: "/Users/alice", @@ -93,6 +101,7 @@ describe("agent paths", () => { codex: resolveCodexSessionsDirectory(options), opencode: resolveOpencodeDatabasePath(options), openclaw: resolveOpenclawStateDirectory(options), + pi: resolvePiSessionsDirectory(options), hermes: resolveHermesHomeDirectory(options), workbuddy: resolveWorkbuddyProjectsDirectory(options) }).toEqual({ @@ -101,12 +110,13 @@ describe("agent paths", () => { codex: "/Users/alice/.codex/sessions", opencode: "/Users/alice/.local/share/opencode/opencode.db", openclaw: "/Users/alice/.openclaw", + pi: "/Users/alice/.pi/agent/sessions", hermes: "/Users/alice/.hermes", workbuddy: "/Users/alice/.workbuddy/projects" }); }); - it("resolves all seven Agent source paths on Windows", () => { + it("resolves all eight Agent source paths on Windows", () => { const options = { platform: "win32", homeDirectory: "C:\\Users\\alice", @@ -121,6 +131,7 @@ describe("agent paths", () => { codex: resolveCodexSessionsDirectory(options), opencode: resolveOpencodeDatabasePath(options), openclaw: resolveOpenclawStateDirectory(options), + pi: resolvePiSessionsDirectory(options), hermes: resolveHermesHomeDirectory(options), workbuddy: resolveWorkbuddyProjectsDirectory(options) }).toEqual({ @@ -129,6 +140,7 @@ describe("agent paths", () => { codex: "C:\\Users\\alice\\.codex\\sessions", opencode: "C:\\Users\\alice\\.local\\share\\opencode\\opencode.db", openclaw: "C:\\Users\\alice\\.openclaw", + pi: "C:\\Users\\alice\\.pi\\agent\\sessions", hermes: "C:\\Users\\alice\\.hermes", workbuddy: "C:\\Users\\alice\\.workbuddy\\projects" }); diff --git a/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts b/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts index ae07eebbd..a3b6d59f8 100644 --- a/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts +++ b/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts @@ -10,16 +10,22 @@ import { GetMemoryOutputSchema, MemoryApiLogsOutputSchema, MemoryHealthSnapshotSchema, + MemoryHistoryOutputSchema, MemoryProcessingStatusOutputSchema, MemoryReloadConfigOutputSchema, PanelAnalysisOutputSchema, PanelItemsOutputSchema, PanelOverviewOutputSchema, + ProjectContextPackOutputSchema, + ProjectContextReadStateSchema, + ProjectGoalRecordSchema, + ProjectWorkItemRecordSchema, PanelTasksOutputSchema, OpenSessionOutputSchema, SearchOutputSchema, StartTurnOutputSchema, RetryMemoryProcessingOutputSchema, + RestoreMemoryOutputSchema, WorkerRunOutputSchema } from "@memmy/local-api-contracts"; import type { ZodType } from "zod"; @@ -54,7 +60,7 @@ export function createHttpMemoryClient( const fetchImpl = options.fetchImpl ?? globalThis.fetch; async function request( - method: "GET" | "POST" | "DELETE", + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", pathKey: PathKey, responseSchema: ZodType, requestOptions: { @@ -63,6 +69,7 @@ export function createHttpMemoryClient( query?: Readonly>; signal?: AbortSignal; timeoutMs?: number; + headers?: Readonly>; } = {} ): Promise { const url = appendQuery(buildMemoryLayerUrl(config.baseUrl, pathKey, requestOptions.params), requestOptions.query); @@ -75,7 +82,8 @@ export function createHttpMemoryClient( method, headers: { ...(hasBody ? { "content-type": "application/json" } : {}), - authorization: `Bearer ${config.token}` + authorization: `Bearer ${config.token}`, + ...requestOptions.headers }, body: hasBody ? JSON.stringify(requestOptions.body) : undefined, signal: combineAbortSignals(timeoutSignal, requestOptions.signal) @@ -160,6 +168,21 @@ export function createHttpMemoryClient( }); }, + async memoryHistory(memoryId) { + return request("GET", "memoryHistory", MemoryHistoryOutputSchema, { + params: { id: memoryId }, + query: { limit: 100 } + }); + }, + + async restoreMemory(input) { + const { memoryId, targetVersion, ...body } = input; + return request("POST", "restoreMemory", RestoreMemoryOutputSchema, { + params: { id: memoryId, version: String(targetVersion) }, + body + }); + }, + async deleteMemory(input) { const { memoryId, ...body } = input; return request("DELETE", "deleteMemory", DeleteMemoryOutputSchema, { @@ -207,6 +230,41 @@ export function createHttpMemoryClient( return request("GET", "panelAnalysis", PanelAnalysisOutputSchema); }, + async projectContextPack(projectId) { + return request("GET", "projectContextPack", ProjectContextPackOutputSchema, { + headers: { "x-memmy-project-id": projectId } + }); + }, + async projectContextState(namespace) { + return request("GET", "projectContextState", ProjectContextReadStateSchema, { + query: { namespace: JSON.stringify(namespace) } + }); + }, + + async proposeProjectGoal(input) { + return request("POST", "proposeProjectGoal", ProjectGoalRecordSchema, { body: input }); + }, + + async approveProjectGoal(goalId, input) { + return request("POST", "approveProjectGoal", ProjectGoalRecordSchema, { params: { id: goalId }, body: input }); + }, + + async rejectProjectGoal(goalId, input) { + return request("POST", "rejectProjectGoal", ProjectGoalRecordSchema, { params: { id: goalId }, body: input }); + }, + + async createProjectWorkItem(input) { + return request("POST", "createProjectWorkItem", ProjectWorkItemRecordSchema, { body: input }); + }, + + async updateProjectWorkItem(workItemId, input) { + return request("PATCH", "updateProjectWorkItem", ProjectWorkItemRecordSchema, { params: { id: workItemId }, body: input }); + }, + + async setProjectFocus(input) { + return request("PUT", "setProjectFocus", ProjectWorkItemRecordSchema.nullable(), { body: input }); + }, + async panelItems(input) { return request("GET", "panelItems", PanelItemsOutputSchema, { query: input }); }, diff --git a/App/backend/src/adapters/outbound/memory-client/index.ts b/App/backend/src/adapters/outbound/memory-client/index.ts index b0e999c73..c99ab027d 100644 --- a/App/backend/src/adapters/outbound/memory-client/index.ts +++ b/App/backend/src/adapters/outbound/memory-client/index.ts @@ -4,6 +4,7 @@ export { createMemosSqliteMemoryClient, discoverMemosSqliteSources, type CreateMemosSqliteMemoryClientOptions, + type EmbeddedProjectContextService, type MemosSqliteSource } from "./memos-sqlite-memory-client.js"; export { MemoryLayerError, MemoryLayerNetworkError } from "./errors.js"; diff --git a/App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts b/App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts index 5bcb246a2..2ba329d4d 100644 --- a/App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts +++ b/App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts @@ -10,6 +10,8 @@ export const MEMORY_LAYER_PATHS = Object.freeze({ search: "/api/v1/memory/search", addMemory: "/api/v1/memory/add", getMemory: "/api/v1/memory/:id", + memoryHistory: "/api/v1/memory/:id/history", + restoreMemory: "/api/v1/memory/:id/history/:version/restore", deleteMemory: "/api/v1/memory/:id", runWorker: "/api/v1/worker/run", enqueueImportSummaries: "/api/v1/worker/import-summaries/enqueue", @@ -18,6 +20,14 @@ export const MEMORY_LAYER_PATHS = Object.freeze({ memoryApiLogs: "/api/v1/memory/logs", panelOverview: "/api/v1/panel/overview", panelAnalysis: "/api/v1/panel/analysis", + projectContextPack: "/api/v1/panel/context-pack", + projectContextState: "/api/v1/project-context/state", + proposeProjectGoal: "/api/v1/project-context/goals/propose", + approveProjectGoal: "/api/v1/project-context/goals/:id/approve", + rejectProjectGoal: "/api/v1/project-context/goals/:id/reject", + createProjectWorkItem: "/api/v1/project-context/work-items", + updateProjectWorkItem: "/api/v1/project-context/work-items/:id", + setProjectFocus: "/api/v1/project-context/focus", panelItems: "/api/v1/panel/items", panelTasks: "/api/v1/panel/tasks", deletePanelTask: "/api/v1/panel/tasks/:id" diff --git a/App/backend/src/adapters/outbound/memory-client/memos-sqlite-memory-client.ts b/App/backend/src/adapters/outbound/memory-client/memos-sqlite-memory-client.ts index b7df133da..55df71a95 100644 --- a/App/backend/src/adapters/outbound/memory-client/memos-sqlite-memory-client.ts +++ b/App/backend/src/adapters/outbound/memory-client/memos-sqlite-memory-client.ts @@ -4,6 +4,7 @@ import { homedir } from "node:os"; import { basename, join, resolve } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { getLoadablePath as getSqliteVecLoadablePath } from "sqlite-vec"; +import { MemoryDb, ProjectContextService, Repositories } from "@memmy/memory"; import type { AddMemoryInput, AddMemoryOutput, @@ -40,6 +41,16 @@ import type { MemoryClient } from "./types.js"; const SOURCE_ID_SEPARATOR = "::"; const DEFAULT_MEMORY_HOME = join(homedir(), ".memmy"); const PANEL_DAILY_ACTIVITY_DAYS = 371; +export interface EmbeddedProjectContextService { + readProjectContext(namespace: Parameters[0]): Awaited>; + proposeProjectGoal(input: Parameters[0]): Awaited>; + approveProjectGoal(input: { namespace: Parameters[0]; candidateId: string }): Awaited>; + rejectProjectGoal(input: { namespace: Parameters[0]; candidateId: string }): Awaited>; + createProjectWorkItem(input: Parameters[0]): Awaited>; + updateProjectWorkItem(input: Parameters[1] & { workItemId: string }): Awaited>; + selectProjectWorkItem(input: Parameters[0]): Awaited> | undefined; +} + export interface MemosSqliteSource { id: string; @@ -50,6 +61,7 @@ export interface MemosSqliteSource { export interface CreateMemosSqliteMemoryClientOptions { sources: readonly MemosSqliteSource[]; now?: () => string; + memoryService?: EmbeddedProjectContextService; } interface LocalMemoryRow { @@ -160,6 +172,7 @@ export function discoverMemosSqliteSources(env: NodeJS.ProcessEnv = process.env) export function createMemosSqliteMemoryClient(options: CreateMemosSqliteMemoryClientOptions): MemoryClient { const now = options.now ?? (() => new Date().toISOString()); const sources = options.sources.filter((source) => existsSync(source.dbPath)); + const memoryService = options.memoryService ?? createEmbeddedProjectContextService(sources); return { async health() { @@ -278,6 +291,14 @@ export function createMemosSqliteMemoryClient(options: CreateMemosSqliteMemoryCl return { item: detail.item, version: detail.version, etag: detail.etag }; }, + async memoryHistory() { + return readOnlyOperationUnavailable(); + }, + + async restoreMemory() { + return readOnlyOperationUnavailable(); + }, + async addMemory(_input: AddMemoryInput): Promise { return readOnlyOperationUnavailable(); }, @@ -337,7 +358,7 @@ export function createMemosSqliteMemoryClient(options: CreateMemosSqliteMemoryCl async panelAnalysis(): Promise { const rows = listMemoryRows(sources); const dates = lastSevenDateKeys(now()); - const logs = listApiLogRows(sources, {}, 10_000) + const logs = readApiLogRows(sources, {}, 10_000).rows .filter((row) => dates.includes(dateKey(row.called_at))); const skillRows = rows.filter((item) => item.row.memory_layer === "Skill"); const recallScores = logs @@ -361,6 +382,39 @@ export function createMemosSqliteMemoryClient(options: CreateMemosSqliteMemoryCl }; }, + async projectContextPack() { + return readOnlyOperationUnavailable(); + }, + + async projectContextState(namespace) { + const state = requireProjectContextService(memoryService).readProjectContext(namespace); + return { ...state, activeGoal: state.activeGoal ?? null, focusedWorkItem: state.focusedWorkItem ?? null }; + }, + + async proposeProjectGoal(input) { + return requireProjectContextService(memoryService).proposeProjectGoal(input); + }, + + async approveProjectGoal(goalId, input) { + return requireProjectContextService(memoryService).approveProjectGoal({ namespace: input.namespace, candidateId: goalId }); + }, + + async rejectProjectGoal(goalId, input) { + return requireProjectContextService(memoryService).rejectProjectGoal({ namespace: input.namespace, candidateId: goalId }); + }, + + async createProjectWorkItem(input) { + return requireProjectContextService(memoryService).createProjectWorkItem(input); + }, + + async updateProjectWorkItem(workItemId, input) { + return requireProjectContextService(memoryService).updateProjectWorkItem({ ...input, workItemId }); + }, + + async setProjectFocus(input) { + return requireProjectContextService(memoryService).selectProjectWorkItem(input) ?? null; + }, + async panelItems(input: PanelItemsInput): Promise { const pageSize = 20; const filtered = listMemoryRows(sources) @@ -434,7 +488,8 @@ export function createMemosSqliteMemoryClient(options: CreateMemosSqliteMemoryCl async memoryApiLogs(input: MemoryApiLogsInput): Promise { const limit = normalizeLimit(input.limit); const offset = normalizeOffset(input.offset); - const rows = listApiLogRows(sources, input, limit + offset); + const result = readApiLogRows(sources, input, limit + offset); + const rows = result.rows; return { logs: rows.slice(offset, offset + limit).map((row) => ({ @@ -442,12 +497,12 @@ export function createMemosSqliteMemoryClient(options: CreateMemosSqliteMemoryCl toolName: row.tool_name, ...(row.source_agent ? { sourceAgent: row.source_agent } : {}), inputJson: row.input_json, - outputJson: apiLogOutputWithCurrentTraceSummary(row), + outputJson: row.output_json, durationMs: nonNegativeInt(row.duration_ms, 0), success: row.success !== 0, calledAt: normalizeIsoTime(row.called_at) })), - total: countApiLogRows(sources, input), + total: result.total, limit, offset, nextOffset: rows.length > offset + limit ? offset + limit : undefined, @@ -457,6 +512,33 @@ export function createMemosSqliteMemoryClient(options: CreateMemosSqliteMemoryCl }; } +function createEmbeddedProjectContextService(sources: readonly MemosSqliteSource[]): EmbeddedProjectContextService | undefined { + const source = sources.find((candidate) => basename(candidate.dbPath) === "memory.sqlite") ?? sources[0]; + if (!source) return undefined; + const invoke = (operation: (service: ProjectContextService) => Result): Result => { + const db = new MemoryDb({ path: source.dbPath }); + try { + return operation(new ProjectContextService({ repositories: new Repositories(db.db) })); + } finally { + db.close(); + } + }; + return { + readProjectContext: (namespace) => invoke((service) => { const state = service.read(namespace); return { ...state, activeGoal: state.activeGoal ?? null, focusedWorkItem: state.focusedWorkItem ?? null }; }), + proposeProjectGoal: (input) => invoke((service) => service.proposeGoal(input)), + approveProjectGoal: (input) => invoke((service) => service.approveGoal(input)), + rejectProjectGoal: (input) => invoke((service) => service.rejectGoal(input)), + createProjectWorkItem: (input) => invoke((service) => service.createWorkItem(input)), + updateProjectWorkItem: (input) => invoke((service) => service.updateWorkItem(input)), + selectProjectWorkItem: (input) => invoke((service) => service.selectWorkItem(input)) + }; +} + +function requireProjectContextService(service: EmbeddedProjectContextService | undefined): EmbeddedProjectContextService { + if (!service) throw new MemoryLayerError("memory_layer_unavailable", 503, "project context requires a Memory service SQLite source"); + return service; +} + /** * Throws the unified error for write operations not supported by the local SQLite data source. */ @@ -485,21 +567,20 @@ function listMemoryRows(sources: readonly MemosSqliteSource[]): MemoryRow[] { * @param maxRows the maximum number of rows to prefetch for cross-source merge sorting. * @returns log rows sorted by call time in descending order. */ -function listApiLogRows( +function readApiLogRows( sources: readonly MemosSqliteSource[], input: MemoryApiLogsInput, maxRows: number -): LocalApiLogRow[] { +): { rows: LocalApiLogRow[]; total: number } { const tools = normalizeApiLogTools(input.tools); const placeholders = tools.map(() => "?").join(", "); const agentFilter = apiLogSourceAgentFilter(input); - return sources - .flatMap((source) => withDb(source, (db) => { + const sourceResults = sources.map((source) => withDb(source, (db) => { if (!tableExists(db, "api_logs")) { - return []; + return { rows: [] as LocalApiLogRow[], total: 0 }; } - return db + const rows = db .prepare( `SELECT id, tool_name, source_agent, input_json, output_json, duration_ms, success, called_at FROM api_logs @@ -509,13 +590,28 @@ function listApiLogRows( LIMIT ?` ) .all(...tools, ...agentFilter.parameters, maxRows) - .map((row) => ({ ...row as unknown as Omit, source })); - })) + .map((row) => { + const logRow = { ...row as unknown as Omit, source }; + return { + ...logRow, + output_json: apiLogOutputWithCurrentTraceSummary(logRow, db) + }; + }); + const countRow = db + .prepare(`SELECT COUNT(*) AS count FROM api_logs WHERE tool_name IN (${placeholders}) ${agentFilter.sql}`) + .get(...tools, ...agentFilter.parameters) as { count: number }; + return { rows, total: nonNegativeInt(countRow.count, 0) }; + })); + return { + rows: sourceResults + .flatMap((result) => result.rows) .sort((a, b) => b.called_at.localeCompare(a.called_at) || b.id - a.id) - .slice(0, maxRows); + .slice(0, maxRows), + total: sourceResults.reduce((total, result) => total + result.total, 0) + }; } -function apiLogOutputWithCurrentTraceSummary(row: LocalApiLogRow): string { +function apiLogOutputWithCurrentTraceSummary(row: LocalApiLogRow, db: DatabaseSync): string { if (row.tool_name !== "memory_add") return row.output_json; try { @@ -531,10 +627,9 @@ function apiLogOutputWithCurrentTraceSummary(row: LocalApiLogRow): string { const memoryId = stringValue(role === "span" ? record.spanId : record.traceId) ?? stringValue(record.traceId); if (!memoryId) return detail; - const memory = withDb(row.source, (db) => { - if (!tableExists(db, "memories")) return undefined; - return db.prepare("SELECT * FROM memories WHERE id = ?").get(memoryId) as LocalMemoryRow | undefined; - }); + const memory = tableExists(db, "memories") + ? db.prepare("SELECT * FROM memories WHERE id = ?").get(memoryId) as LocalMemoryRow | undefined + : undefined; const value = memory ? role === "span" ? spanGoalFromParsed(parsedRow(memory)) @@ -552,29 +647,6 @@ function apiLogOutputWithCurrentTraceSummary(row: LocalApiLogRow): string { } } -/** - * Counts the Memory API logs in local SQLite data sources. - * - * @param sources the list of SQLite data sources. - * @param input the log filter conditions. - * @returns the total number of logs matching the filter conditions. - */ -function countApiLogRows(sources: readonly MemosSqliteSource[], input: MemoryApiLogsInput): number { - const tools = normalizeApiLogTools(input.tools); - const placeholders = tools.map(() => "?").join(", "); - const agentFilter = apiLogSourceAgentFilter(input); - return sources.reduce((total, source) => total + withDb(source, (db) => { - if (!tableExists(db, "api_logs")) { - return 0; - } - - const row = db - .prepare(`SELECT COUNT(*) AS count FROM api_logs WHERE tool_name IN (${placeholders}) ${agentFilter.sql}`) - .get(...tools, ...agentFilter.parameters) as { count: number }; - return nonNegativeInt(row.count, 0); - }), 0); -} - function apiLogSourceAgentFilter(input: MemoryApiLogsInput): { sql: string; parameters: string[] } { const sourceAgent = input.sourceAgent?.trim(); const excludedSourceAgents = uniqueStrings( @@ -1149,21 +1221,21 @@ function sourceLabelFromParsed(parsed: ParsedRow): string | undefined { } function sourceLabelFromSessionId(value: string | null): string | undefined { - const normalized = value?.trim().toLowerCase(); + const normalized = value?.trim().toLowerCase().replace(/[\s_:/\\]+/gu, "-"); if (!normalized) return undefined; if (normalized === "claude" || normalized.startsWith("claude-")) return "claude-code"; if (normalized === "open-code" || normalized.startsWith("open-code-")) return "opencode"; - for (const source of ["hermes", "openclaw", "codex", "cursor", "claude-code", "opencode", "workbuddy"]) { + for (const source of ["hermes", "openclaw", "codex", "cursor", "claude-code", "opencode", "workbuddy", "omp"]) { if (normalized === source || normalized.startsWith(`${source}-`)) return source; } return undefined; } function normalizedAgentSource(value: string | undefined): string | undefined { - const normalized = value?.trim().toLowerCase(); + const normalized = value?.trim().toLowerCase().replace(/[\s_:/\\]+/gu, "-"); if (normalized === "claude") return "claude-code"; if (normalized === "open-code") return "opencode"; - return ["hermes", "openclaw", "codex", "cursor", "claude-code", "opencode", "workbuddy"].includes(normalized ?? "") + return ["hermes", "openclaw", "codex", "cursor", "claude-code", "opencode", "workbuddy", "omp"].includes(normalized ?? "") ? normalized : undefined; } diff --git a/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts b/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts index aba0cf7f8..ab54f3b1c 100644 --- a/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts +++ b/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts @@ -23,6 +23,8 @@ describe("HttpMemoryClient", () => { "/api/v1/memory/search", "/api/v1/memory/add", "/api/v1/memory/:id", + "/api/v1/memory/:id/history", + "/api/v1/memory/:id/history/:version/restore", "/api/v1/memory/:id", "/api/v1/worker/run", "/api/v1/worker/import-summaries/enqueue", @@ -31,6 +33,14 @@ describe("HttpMemoryClient", () => { "/api/v1/memory/logs", "/api/v1/panel/overview", "/api/v1/panel/analysis", + "/api/v1/panel/context-pack", + "/api/v1/project-context/state", + "/api/v1/project-context/goals/propose", + "/api/v1/project-context/goals/:id/approve", + "/api/v1/project-context/goals/:id/reject", + "/api/v1/project-context/work-items", + "/api/v1/project-context/work-items/:id", + "/api/v1/project-context/focus", "/api/v1/panel/items", "/api/v1/panel/tasks", "/api/v1/panel/tasks/:id" @@ -48,6 +58,7 @@ describe("HttpMemoryClient", () => { method: string; path: string; authorization: string | undefined; + projectId: string | undefined; body: unknown; }> = []; const baseUrl = await startServer(async (request, response) => { @@ -56,6 +67,7 @@ describe("HttpMemoryClient", () => { method: request.method ?? "", path: new URL(request.url ?? "/", "http://localhost").pathname, authorization: request.headers.authorization, + projectId: request.headers["x-memmy-project-id"] as string | undefined, body }); sendJson(response, fixtureFor(request.method ?? "", new URL(request.url ?? "/", "http://localhost").pathname, body)); @@ -80,12 +92,15 @@ describe("HttpMemoryClient", () => { await expect(client.search({ ...searchInput(), verbose: true })).resolves.toMatchObject({ debug: { hits: [] } }); await expect(client.addMemory(addMemoryInput())).resolves.toMatchObject({ id: "memory-1" }); await expect(client.getMemory({ memoryId: "memory-1" })).resolves.toMatchObject({ item: { id: "memory-1" } }); + await expect(client.memoryHistory("memory-1")).resolves.toMatchObject({ id: "memory-1", currentVersion: 1 }); + await expect(client.restoreMemory({ memoryId: "memory-1", targetVersion: 1, version: 1, reason: "desktop restore" })).resolves.toMatchObject({ restoredVersion: 1 }); await expect(client.deleteMemory({ memoryId: "memory-1", source: "codex" })).resolves.toMatchObject({ status: "deleted" }); await expect( client.memoryApiLogs({ tools: ["memory_add", "memory_search"], limit: 20, offset: 0 }) ).resolves.toMatchObject({ logs: [] }); await expect(client.panelOverview()).resolves.toMatchObject({ counts: { memories: 0 } }); await expect(client.panelAnalysis()).resolves.toMatchObject({ metrics: { avgRecallScore: 0 } }); + await expect(client.projectContextPack("project-1")).resolves.toMatchObject({ namespace: { projectId: "project-1" } }); await expect(client.panelItems(panelItemsInput())).resolves.toMatchObject({ items: [] }); await expect(client.panelTasks({ page: 1 })).resolves.toMatchObject({ tasks: [] }); await expect(client.deletePanelTask("episode-1")).resolves.toMatchObject({ ok: true, id: "episode-1" }); @@ -101,15 +116,19 @@ describe("HttpMemoryClient", () => { "POST /api/v1/memory/search", "POST /api/v1/memory/add", "GET /api/v1/memory/memory-1", + "GET /api/v1/memory/memory-1/history", + "POST /api/v1/memory/memory-1/history/1/restore", "DELETE /api/v1/memory/memory-1", "GET /api/v1/memory/logs", "GET /api/v1/panel/overview", "GET /api/v1/panel/analysis", + "GET /api/v1/panel/context-pack", "GET /api/v1/panel/items", "GET /api/v1/panel/tasks", "DELETE /api/v1/panel/tasks/episode-1" ]); expect(requests.every((request) => request.authorization === "Bearer memory-token")).toBe(true); + expect(requests.find((request) => request.path === "/api/v1/panel/context-pack")?.projectId).toBe("project-1"); expect( requests .filter((request) => requestBodySource(request.body) !== undefined) @@ -132,6 +151,42 @@ describe("HttpMemoryClient", () => { source: "codex" }); }); + it("uses the exact methods, paths, bodies, query, and response schemas for project context", async () => { + const requests: Array<{ method: string; url: URL; body: unknown }> = []; + const baseUrl = await startServer(async (request, response) => { + const body = await readJson(request); + const url = new URL(request.url ?? "/", "http://localhost"); + requests.push({ method: request.method ?? "", url, body }); + if (url.pathname.endsWith("/state")) return sendJson(response, projectContextStateOutput()); + if (url.pathname.endsWith("/focus")) return sendJson(response, body && (body as { workItemId?: unknown }).workItemId === null ? null : projectWorkItemOutput()); + if (url.pathname.includes("work-items")) return sendJson(response, projectWorkItemOutput()); + return sendJson(response, projectGoalOutput(url.pathname.endsWith("/reject") ? "archived" : url.pathname.endsWith("/approve") ? "active" : "candidate")); + }); + const client = createHttpMemoryClient({ baseUrl, token: "memory-token", timeoutMs: 500, maxRetries: 0 }); + const namespace = projectNamespace(); + const mutation = projectMutation(); + await client.projectContextState(namespace); + await client.proposeProjectGoal({ ...mutation, title: "Task 4", summary: "", detail: "" }); + await client.approveProjectGoal("goal 1", mutation); + await client.rejectProjectGoal("goal 1", { ...mutation, requestId: "req-reject" }); + await client.createProjectWorkItem({ ...mutation, title: "Tests", summary: "", nextStep: "Fix" }); + await client.updateProjectWorkItem("work 1", { ...mutation, status: "active" }); + await expect(client.setProjectFocus({ ...mutation, workItemId: null })).resolves.toBeNull(); + expect(requests.map(({ method, url }) => `${method} ${url.pathname}`)).toEqual([ + "GET /api/v1/project-context/state", "POST /api/v1/project-context/goals/propose", "POST /api/v1/project-context/goals/goal%201/approve", + "POST /api/v1/project-context/goals/goal%201/reject", "POST /api/v1/project-context/work-items", "PATCH /api/v1/project-context/work-items/work%201", "PUT /api/v1/project-context/focus" + ]); + expect(JSON.parse(requests[0]!.url.searchParams.get("namespace")!)).toEqual(namespace); + expect(requests.slice(1).map(({ body }) => body)).toEqual([ + { ...mutation, title: "Task 4", summary: "", detail: "" }, mutation, { ...mutation, requestId: "req-reject" }, + { ...mutation, title: "Tests", summary: "", nextStep: "Fix" }, { ...mutation, status: "active" }, { ...mutation, workItemId: null } + ]); + }); + it("rejects schema-invalid project-context responses", async () => { + const baseUrl = await startServer(async (_request, response) => sendJson(response, { id: "missing-fields" })); + const client = createHttpMemoryClient({ baseUrl, token: "", timeoutMs: 500, maxRetries: 0 }); + await expect(client.proposeProjectGoal({ ...projectMutation(), title: "Task 4", summary: "", detail: "" })).rejects.toThrow(); + }); it("forwards memory log Agent filters to the Memory service", async () => { const requestUrls: URL[] = []; @@ -381,10 +436,13 @@ function fixtureFor(method: string, path: string, body: unknown): unknown { if (method === "POST" && path === "/api/v1/memory/search") return searchOutput(body); if (method === "POST" && path === "/api/v1/memory/add") return addMemoryOutput(body); if (method === "GET" && path === "/api/v1/memory/memory-1") return getMemoryOutput(); + if (method === "GET" && path === "/api/v1/memory/memory-1/history") return memoryHistoryOutput(); + if (method === "POST" && path === "/api/v1/memory/memory-1/history/1/restore") return restoreMemoryOutput(); if (method === "DELETE" && path === "/api/v1/memory/memory-1") return deleteMemoryOutput(); if (method === "GET" && path === "/api/v1/memory/logs") return memoryApiLogsOutput(); if (method === "GET" && path === "/api/v1/panel/overview") return panelOverviewOutput(); if (method === "GET" && path === "/api/v1/panel/analysis") return panelAnalysisOutput(); + if (method === "GET" && path === "/api/v1/panel/context-pack") return projectContextPackOutput("project-1"); if (method === "GET" && path === "/api/v1/panel/items") return panelItemsOutput(); if (method === "GET" && path === "/api/v1/panel/tasks") return panelTasksOutput(); if (method === "DELETE" && path === "/api/v1/panel/tasks/episode-1") { @@ -519,6 +577,27 @@ function getMemoryOutput() { }; } +function memoryHistoryOutput() { + return { + id: "memory-1", + currentVersion: 1, + items: [{ seq: 1, version: 1, changeType: "created", source: "turn_complete", createdAt: now(), after: {} }], + serverTime: now() + }; +} + +function restoreMemoryOutput() { + return { + ok: true, + id: "memory-1", + version: 2, + restoredVersion: 1, + changeSeq: 2, + auditId: "audit-restore-1", + serverTime: now() + }; +} + function deleteMemoryOutput() { return { ok: true, id: "memory-1", kind: "trace", status: "deleted", changeSeq: 2, syncCursor: "cursor-2", auditId: "audit-1", serverTime: now() }; } @@ -565,15 +644,27 @@ function now() { } function panelDays() { - return [ - "2026-05-23", - "2026-05-24", - "2026-05-25", - "2026-05-26", - "2026-05-27", - "2026-05-28", - "2026-05-29" - ].map((date) => ({ date, count: 0 })); + return ["2026-05-23", "2026-05-24", "2026-05-25", "2026-05-26", "2026-05-27", "2026-05-28", "2026-05-29"].map((date) => ({ date, count: 0 })); +} + +function projectNamespace() { + return { source: "codex", profileId: "default", userId: "user-4", projectId: "project-4" }; +} + +function projectMutation() { + return { namespace: projectNamespace(), source: "codex", adapterId: "adapter-4", requestId: "req-4", provenance: { sourceAgent: "codex", sourceMemoryIds: [], capturedAt: now(), adapterId: "adapter-4", requestId: "req-4" } }; +} + +function projectGoalOutput(status: "candidate" | "active" | "archived") { + return { id: "goal-1", namespaceId: "ns-1", userId: "user-4", projectId: "project-4", title: "Task 4", summary: "", detail: "", acceptanceCriteria: [], constraints: [], status, version: status === "active" ? 1 : 0, sourceMemoryIds: [], provenance: {}, createdAt: now(), updatedAt: now() }; +} + +function projectWorkItemOutput() { + return { id: "work-1", namespaceId: "ns-1", userId: "user-4", projectId: "project-4", title: "Tests", summary: "", nextStep: "Fix", acceptanceCriteria: [], constraints: [], status: "active", focused: true, sourceMemoryIds: [], provenance: {}, createdAt: now(), updatedAt: now() }; +} + +function projectContextStateOutput() { + return { namespaceId: "ns-1", activeGoal: null, goals: [], workItems: [], focusedWorkItem: null, facts: [] }; } function openSessionInput() { @@ -603,3 +694,10 @@ function addMemoryInput() { function panelItemsInput() { return { layer: "L1" as const, status: "activated" as const, page: 1 }; } + +function projectContextPackOutput(projectId: string) { + return { + namespace: { projectId }, conventions: [], commands: [], architectureFacts: [], recentTasks: [], userPreferences: [], + graph: { nodes: [], edges: [] }, markdown: `# Project Memory Pack: ${projectId}`, generatedAt: now() + }; +} diff --git a/App/backend/src/adapters/outbound/memory-client/tests/memos-sqlite-memory-client.test.ts b/App/backend/src/adapters/outbound/memory-client/tests/memos-sqlite-memory-client.test.ts index 51e3e964e..4998cda58 100644 --- a/App/backend/src/adapters/outbound/memory-client/tests/memos-sqlite-memory-client.test.ts +++ b/App/backend/src/adapters/outbound/memory-client/tests/memos-sqlite-memory-client.test.ts @@ -6,6 +6,7 @@ import { DatabaseSync } from "node:sqlite"; import { getLoadablePath as getSqliteVecLoadablePath } from "sqlite-vec"; import { afterEach, describe, expect, it } from "vitest"; import { createMemosSqliteMemoryClient } from "../memos-sqlite-memory-client.js"; +import { MemoryDb } from "@memmy/memory"; const NOW = "2026-06-08T10:00:00.000Z"; @@ -18,7 +19,45 @@ afterEach(() => { } }); -describe("createMemosSqliteMemoryClient", () => { +describe("createMemosSqliteMemoryClient", { timeout: 10_000 }, () => { + it("delegates all project-context operations to the embedded Memory service", async () => { + const calls: Array<{ operation: string; input: unknown }> = []; + const namespace = { source: "codex", profileId: "default", userId: "user-4", projectId: "project-4" }; + const goal = projectGoalRecord(); + const work = projectWorkItemRecord(); + const memoryService = { + readProjectContext(input: unknown) { calls.push({ operation: "read", input }); return { namespaceId: "ns-1", activeGoal: null, goals: [], workItems: [], focusedWorkItem: null, facts: [] }; }, + proposeProjectGoal(input: unknown) { calls.push({ operation: "propose", input }); return goal; }, + approveProjectGoal(input: unknown) { calls.push({ operation: "approve", input }); return { ...goal, status: "active" as const, version: 1 }; }, + rejectProjectGoal(input: unknown) { calls.push({ operation: "reject", input }); return { ...goal, status: "archived" as const }; }, + createProjectWorkItem(input: unknown) { calls.push({ operation: "create", input }); return work; }, + updateProjectWorkItem(input: unknown) { calls.push({ operation: "update", input }); return { ...work, status: "active" as const }; }, + selectProjectWorkItem(input: unknown) { calls.push({ operation: "focus", input }); return (input as { workItemId: string | null }).workItemId ? { ...work, focused: true } : undefined; } + }; + const client = createMemosSqliteMemoryClient({ sources: [], memoryService }); + const mutation = projectMutation(namespace); + await client.projectContextState(namespace); + await client.proposeProjectGoal({ ...mutation, title: "Task 4", summary: "", detail: "" }); + await client.approveProjectGoal("goal-1", mutation); + await client.rejectProjectGoal("goal-1", mutation); + await client.createProjectWorkItem({ ...mutation, title: "Tests", summary: "", nextStep: "Fix" }); + await client.updateProjectWorkItem("work-1", { ...mutation, status: "active" }); + await expect(client.setProjectFocus({ ...mutation, workItemId: null })).resolves.toBeNull(); + expect(calls.map(({ operation }) => operation)).toEqual(["read", "propose", "approve", "reject", "create", "update", "focus"]); + }); + it("uses the packaged Memory service when no delegate is supplied", async () => { + tempDir = mkdtempSync(join(tmpdir(), "memmy-project-context-")); + const dbPath = join(tempDir, "memory.sqlite"); + const db = new MemoryDb({ path: dbPath }); + db.close(); + const namespace = { source: "codex", profileId: "default", userId: "user-4", projectId: "project-4" }; + const client = createMemosSqliteMemoryClient({ sources: [{ id: "memmy-memory", label: "memmy", dbPath }] }); + const input = projectMutation(namespace); + const goal = await client.proposeProjectGoal({ ...input, title: "Task 4", summary: "API", detail: "Authoritative" }); + const state = await client.projectContextState(namespace); + expect(state.goals.map((item) => item.id)).toEqual([goal.id]); + expect(state.goals[0]?.title).toBe("Task 4"); + }); it("preserves Span memory kinds in panel responses", async () => { const dbPath = createMemoryDatabase({ id: "span_sqlite_1", @@ -118,6 +157,29 @@ describe("createMemosSqliteMemoryClient", () => { expect(detail.item.metrics).toEqual({ value: 0.42, alpha: 0.8, reflectionDone: true }); }); + it("derives OMP source from the session id when the row agent is the default", async () => { + const dbPath = createMemoryDatabase({ + id: "trace_omp_1", + sessionId: "omp::session-20260608", + agentId: "codex", + tagsJson: JSON.stringify(["trace"]), + infoJson: "{}", + propertiesJson: JSON.stringify({ internal_info: { source: "turn.complete" } }) + }); + const client = createMemosSqliteMemoryClient({ + sources: [{ id: "memmy-memory", label: "memmy", dbPath }], + now: () => NOW + }); + + const list = await client.panelItems({ layer: "L1", page: 1 }); + expect(list.items[0]?.tags).toEqual(["omp", "trace"]); + expect(list.items[0]?.metadata?.source).toBe("omp"); + await expect(client.panelItems({ layer: "L1", sourceAgent: "omp", page: 1 })) + .resolves.toMatchObject({ total: 1, items: [{ id: expect.stringContaining("trace_omp_1") }] }); + await expect(client.panelItems({ layer: "L1", sourceAgent: "codex", page: 1 })) + .resolves.toMatchObject({ total: 0, items: [] }); + }); + it("filters custom L1 panel item sources as other", async () => { const dbPath = createMemoryDatabase({ id: "trace_other_1", @@ -461,7 +523,8 @@ describe("createMemosSqliteMemoryClient", () => { memoryValue: "Delete this exact SQLite memory.", tagsJson: JSON.stringify(["trace", "codex", "delete-me"]), infoJson: JSON.stringify({ source: "codex" }), - propertiesJson: JSON.stringify({ internal_info: { source: "codex", memory_kind: "trace" } }) + propertiesJson: JSON.stringify({ internal_info: { source: "codex", memory_kind: "trace" } }), + withVector: true }); const client = createMemosSqliteMemoryClient({ sources: [{ id: "memmy-memory", label: "memmy", dbPath }], @@ -519,6 +582,18 @@ describe("createMemosSqliteMemoryClient", () => { }); }); +function projectMutation(namespace: { source: string; profileId: string; userId: string; projectId: string }) { + return { namespace, source: "codex", adapterId: "adapter-4", requestId: "req-4", provenance: { sourceAgent: "codex", sourceMemoryIds: [], capturedAt: NOW } }; +} + +function projectGoalRecord() { + return { id: "goal-1", namespaceId: "ns-1", userId: "user-4", projectId: "project-4", title: "Task 4", summary: "", detail: "", acceptanceCriteria: [], constraints: [], status: "candidate" as const, version: 0, sourceMemoryIds: [], provenance: {}, createdAt: NOW, updatedAt: NOW }; +} + +function projectWorkItemRecord() { + return { id: "work-1", namespaceId: "ns-1", userId: "user-4", projectId: "project-4", title: "Tests", summary: "", nextStep: "Fix", acceptanceCriteria: [], constraints: [], status: "pending" as const, focused: false, sourceMemoryIds: [], provenance: {}, createdAt: NOW, updatedAt: NOW }; +} + function createMemoryDatabase(row: { id: string; sessionId: string | null; @@ -536,11 +611,14 @@ function createMemoryDatabase(row: { id: string; toolCalls: Array>; }; + withVector?: boolean; }): string { tempDir = mkdtempSync(join(tmpdir(), "memmy-sqlite-client-")); const dbPath = join(tempDir, "memory.sqlite"); - const db = new DatabaseSync(dbPath, { allowExtension: true }); - db.loadExtension(getSqliteVecLoadablePath()); + const db = new DatabaseSync(dbPath, { allowExtension: row.withVector === true }); + if (row.withVector) { + db.loadExtension(getSqliteVecLoadablePath()); + } db.exec(` CREATE TABLE memories ( id TEXT PRIMARY KEY, @@ -596,26 +674,28 @@ function createMemoryDatabase(row: { NOW, null ); - db.exec(` - CREATE TABLE memory_vector_entries ( - id INTEGER PRIMARY KEY, - memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE, - vector_field TEXT NOT NULL, - embedding_model TEXT, - embedding_provider TEXT, - embedding_dim INTEGER NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE (memory_id, vector_field) - ); - CREATE VIRTUAL TABLE memory_vec_3 USING vec0(embedding float[3] distance_metric=cosine); - `); - db.prepare(` - INSERT INTO memory_vector_entries ( - id, memory_id, vector_field, embedding_model, embedding_provider, embedding_dim, updated_at - ) VALUES (1, ?, 'vec_summary', 'test', 'openai_compatible', 3, ?) - `).run(row.id, NOW); - db.prepare(`INSERT INTO memory_vec_3 (rowid, embedding) VALUES (?, ?)`) - .run(1n, Buffer.from(new Float32Array([1, 0, 0]).buffer)); + if (row.withVector) { + db.exec(` + CREATE TABLE memory_vector_entries ( + id INTEGER PRIMARY KEY, + memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE, + vector_field TEXT NOT NULL, + embedding_model TEXT, + embedding_provider TEXT, + embedding_dim INTEGER NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (memory_id, vector_field) + ); + CREATE VIRTUAL TABLE memory_vec_3 USING vec0(embedding float[3] distance_metric=cosine); + `); + db.prepare(` + INSERT INTO memory_vector_entries ( + id, memory_id, vector_field, embedding_model, embedding_provider, embedding_dim, updated_at + ) VALUES (1, ?, 'vec_summary', 'test', 'openai_compatible', 3, ?) + `).run(row.id, NOW); + db.prepare(`INSERT INTO memory_vec_3 (rowid, embedding) VALUES (?, ?)`) + .run(1n, Buffer.from(new Float32Array([1, 0, 0]).buffer)); + } if (row.rawTurn) { db.exec(` CREATE TABLE raw_turns ( diff --git a/App/backend/src/adapters/outbound/memory-client/tests/mock-memory-client.test.ts b/App/backend/src/adapters/outbound/memory-client/tests/mock-memory-client.test.ts index 84475112f..e1a42179b 100644 --- a/App/backend/src/adapters/outbound/memory-client/tests/mock-memory-client.test.ts +++ b/App/backend/src/adapters/outbound/memory-client/tests/mock-memory-client.test.ts @@ -6,6 +6,7 @@ import { CompleteTurnOutputSchema, DeleteMemoryOutputSchema, GetMemoryOutputSchema, + MemoryHistoryOutputSchema, MemoryApiLogsOutputSchema, MemoryHealthSnapshotSchema, MemoryReloadConfigOutputSchema, @@ -13,6 +14,7 @@ import { PanelAnalysisOutputSchema, PanelItemsOutputSchema, PanelOverviewOutputSchema, + RestoreMemoryOutputSchema, SearchOutputSchema, StartTurnOutputSchema } from "@memmy/local-api-contracts"; @@ -34,6 +36,8 @@ describe("createMockMemoryClient", () => { expect(search.debug.hits).toEqual([]); expect(AddMemoryOutputSchema.parse(await client.addMemory(addMemoryInput())).id).toBeTruthy(); expect(GetMemoryOutputSchema.parse(await client.getMemory({ memoryId: "memory-1" })).item.id).toBe("memory-1"); + expect(MemoryHistoryOutputSchema.parse(await client.memoryHistory("memory-1")).currentVersion).toBe(1); + expect(RestoreMemoryOutputSchema.parse(await client.restoreMemory({ memoryId: "memory-1", targetVersion: 1, version: 1 })).restoredVersion).toBe(1); expect(DeleteMemoryOutputSchema.parse(await client.deleteMemory({ memoryId: "memory-1" })).status).toBe("deleted"); expect(MemoryApiLogsOutputSchema.parse(await client.memoryApiLogs({ limit: 20, offset: 0 })).logs).toEqual([]); expect(PanelOverviewOutputSchema.parse(await client.panelOverview()).counts.memories).toBe(0); @@ -53,6 +57,8 @@ describe("createMockMemoryClient", () => { () => client.search(searchInput()), () => client.addMemory(addMemoryInput()), () => client.getMemory({ memoryId: "memory-1" }), + () => client.memoryHistory("memory-1"), + () => client.restoreMemory({ memoryId: "memory-1", targetVersion: 1, version: 1 }), () => client.deleteMemory({ memoryId: "memory-1" }), () => client.memoryApiLogs({ limit: 20, offset: 0 }), () => client.panelOverview(), @@ -79,15 +85,18 @@ describe("createMockMemoryClient", () => { "deleteMemory", "enqueueImportSummaries", "getMemory", + "memoryHistory", "getMemoryProcessingStatus", "health", "memoryApiLogs", "openSession", "panelAnalysis", + "projectContextPack", "panelItems", "panelOverview", "reloadConfig", "retryMemoryProcessing", + "restoreMemory", "runWorker", "search", "startTurn" diff --git a/App/backend/src/adapters/outbound/memory-client/types.ts b/App/backend/src/adapters/outbound/memory-client/types.ts index ba728dd91..f9fab61ef 100644 --- a/App/backend/src/adapters/outbound/memory-client/types.ts +++ b/App/backend/src/adapters/outbound/memory-client/types.ts @@ -14,6 +14,7 @@ import type { MemoryApiLogsInput, MemoryApiLogsOutput, MemoryHealthSnapshot, + MemoryHistoryOutput, MemoryProcessingStatusOutput, MemoryReloadConfigInput, MemoryReloadConfigOutput, @@ -21,6 +22,16 @@ import type { PanelItemsInput, PanelItemsOutput, PanelOverviewOutput, + ProjectContextPackOutput, + ProjectContextFocusInput, + ProjectContextGoalDecisionInput, + ProjectContextProposeGoalInput, + ProjectContextReadState, + ProjectContextWorkItemCreateInput, + ProjectContextWorkItemUpdateInput, + ProjectGoalRecord, + ProjectWorkItemRecord, + RuntimeNamespace, PanelTasksInput, PanelTasksOutput, OpenSessionInput, @@ -30,6 +41,8 @@ import type { StartTurnInput, StartTurnOutput, RetryMemoryProcessingOutput, + RestoreMemoryInput, + RestoreMemoryOutput, WorkerRunOutput } from "@memmy/local-api-contracts"; @@ -47,6 +60,8 @@ export interface MemoryClient { search(input: SearchInput): Promise; addMemory(input: AddMemoryInput): Promise; getMemory(input: { memoryId: string }): Promise; + memoryHistory(memoryId: string): Promise; + restoreMemory(input: RestoreMemoryInput & { memoryId: string; targetVersion: number }): Promise; deleteMemory(input: DeleteMemoryInput & { memoryId: string }): Promise; enqueueImportSummaries(memoryIds?: string[]): Promise; @@ -62,6 +77,14 @@ export interface MemoryClient { panelOverview(): Promise; panelAnalysis(): Promise; + projectContextPack(projectId: string): Promise; + projectContextState(namespace: RuntimeNamespace): Promise; + proposeProjectGoal(input: ProjectContextProposeGoalInput): Promise; + approveProjectGoal(goalId: string, input: ProjectContextGoalDecisionInput): Promise; + rejectProjectGoal(goalId: string, input: ProjectContextGoalDecisionInput): Promise; + createProjectWorkItem(input: ProjectContextWorkItemCreateInput): Promise; + updateProjectWorkItem(workItemId: string, input: ProjectContextWorkItemUpdateInput): Promise; + setProjectFocus(input: ProjectContextFocusInput): Promise; panelItems(input: PanelItemsInput): Promise; panelTasks(input: PanelTasksInput): Promise; deletePanelTask(taskId: string): Promise; diff --git a/App/backend/src/adapters/outbound/skill-writer/pi/index.ts b/App/backend/src/adapters/outbound/skill-writer/pi/index.ts new file mode 100644 index 000000000..7887ec512 --- /dev/null +++ b/App/backend/src/adapters/outbound/skill-writer/pi/index.ts @@ -0,0 +1,2 @@ +/** Pi module. */ +export { createPiSkillTarget } from "./target.js"; diff --git a/App/backend/src/adapters/outbound/skill-writer/pi/target.ts b/App/backend/src/adapters/outbound/skill-writer/pi/target.ts new file mode 100644 index 000000000..425f781f4 --- /dev/null +++ b/App/backend/src/adapters/outbound/skill-writer/pi/target.ts @@ -0,0 +1,141 @@ +/** Pi skill target module. */ +import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { resolvePiHomeDirectory } from "../../agent-paths.js"; +import { readMemmyMemoryServiceConfig } from "../memmy-runtime-config.js"; +import { removeMemmySkillDirectory, replaceMemmySkillDirectory } from "../skill-directory.js"; +import { renderMemmyPiExtension } from "../templates/memmy-pi-extension.js"; +import { renderMemmyPluginSkillManifest } from "../templates/memmy-plugin.js"; +import { renderMemmySkillBootstrapManifest } from "../templates/memmy-skill-directory.js"; +import type { SkillManifest, SkillTarget } from "../types.js"; + +const PI_TARGET_ID = "pi"; +const START_MARKER = ""; +const END_MARKER = ""; +const TARGET_FILE_NAME = "AGENTS.md"; +const EXTENSION_DIRECTORY_NAME = "extensions"; +const EXTENSION_FILE_NAME = "memmy-memory.ts"; +const CONFIG_FILE_NAME = "memmy-memory-config.json"; + +export interface CreatePiSkillTargetDeps { + rootDirectory?: string; + memmyConfigPath?: string; +} + +export function createPiSkillTarget(deps: CreatePiSkillTargetDeps = {}): SkillTarget { + const rootDirectory = deps.rootDirectory ?? resolvePiHomeDirectory(); + const memmyConfigPath = deps.memmyConfigPath ?? join(homedir(), ".memmy", "config.yaml"); + + return { + targetId: PI_TARGET_ID, + displayName: "Pi", + async resolveRootDirectory() { + return resolveExistingDirectory(rootDirectory); + }, + async install(manifest) { + const root = await requirePiRoot(rootDirectory); + await installSkill(root, manifest); + }, + async uninstall() { + const root = await resolveExistingDirectory(rootDirectory); + if (!root) return; + await removeBootstrap(root); + await removeMemmySkillDirectory(root); + }, + async isInstalled() { + const root = await resolveExistingDirectory(rootDirectory); + if (!root) return false; + return (await readTextFile(join(root, TARGET_FILE_NAME))).includes(START_MARKER); + }, + async installPlugin() { + const root = await requirePiRoot(rootDirectory); + const extensionDirectory = join(root, EXTENSION_DIRECTORY_NAME); + await mkdir(extensionDirectory, { recursive: true }); + await writeFileAtomically(join(extensionDirectory, EXTENSION_FILE_NAME), renderMemmyPiExtension()); + await writeFileAtomically( + join(extensionDirectory, CONFIG_FILE_NAME), + `${JSON.stringify({ + memmy_config_path: memmyConfigPath, + ...(await readMemmyMemoryServiceConfig(memmyConfigPath)) + }, null, 2)}\n` + ); + await installSkill(root, renderMemmyPluginSkillManifest(PI_TARGET_ID)); + }, + async uninstallPlugin() { + const root = await resolveExistingDirectory(rootDirectory); + if (!root) return; + await rm(join(root, EXTENSION_DIRECTORY_NAME, EXTENSION_FILE_NAME), { force: true }); + await rm(join(root, EXTENSION_DIRECTORY_NAME, CONFIG_FILE_NAME), { force: true }); + await removeBootstrap(root); + await removeMemmySkillDirectory(root); + } + }; +} + +async function installSkill(root: string, manifest: SkillManifest): Promise { + const filePath = join(root, TARGET_FILE_NAME); + const existing = await readTextFile(filePath); + await writeFileAtomically(filePath, upsertMarkerBlock(existing, renderMemmySkillBootstrapManifest(manifest))); + await replaceMemmySkillDirectory(root, manifest); +} + +async function removeBootstrap(root: string): Promise { + const filePath = join(root, TARGET_FILE_NAME); + const existing = await readTextFile(filePath); + if (existing.includes(START_MARKER)) { + await writeFileAtomically(filePath, existing.replace(markerPattern(), "")); + } +} + +function upsertMarkerBlock(existing: string, manifest: SkillManifest): string { + const block = `${manifest.marker}\n${manifest.content.trimEnd()}\n${END_MARKER}\n`; + if (markerPattern().test(existing)) { + return existing.replace(markerPattern(), block); + } + const separator = existing && !existing.endsWith("\n") ? "\n" : ""; + return `${existing}${separator}${block}`; +} + +function markerPattern(): RegExp { + return new RegExp(`${escapeRegExp(START_MARKER)}\\n[\\s\\S]*?${escapeRegExp(END_MARKER)}\\n?`, "m"); +} + +async function requirePiRoot(directory: string): Promise { + const root = await resolveExistingDirectory(directory); + if (!root) throw new Error("Pi is not installed or its directory is unavailable"); + return root; +} + +async function resolveExistingDirectory(directory: string): Promise { + try { + return (await stat(directory)).isDirectory() ? directory : null; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return null; + throw error; + } +} + +async function readTextFile(filePath: string): Promise { + try { + return await readFile(filePath, "utf8"); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return ""; + throw error; + } +} + +async function writeFileAtomically(filePath: string, content: string): Promise { + await mkdir(dirname(filePath), { recursive: true }); + const tempPath = join(dirname(filePath), `.${basename(filePath)}.${process.pid}.${Date.now()}.tmp`); + await writeFile(tempPath, content, "utf8"); + await rename(tempPath, filePath); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/App/backend/src/adapters/outbound/skill-writer/pi/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/pi/tests/target.test.ts new file mode 100644 index 000000000..d47c0a7ed --- /dev/null +++ b/App/backend/src/adapters/outbound/skill-writer/pi/tests/target.test.ts @@ -0,0 +1,304 @@ +/** Pi skill target tests. */ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { createPiSkillTarget } from "../index.js"; +import { renderMemmyDefaultSkillManifest } from "../../templates/memmy-default.js"; + +let tempDirectory: string | undefined; + +afterEach(() => { + if (tempDirectory) { + rmSync(tempDirectory, { recursive: true, force: true }); + tempDirectory = undefined; + } +}); + +describe("Pi skill target", () => { + it("installs the Pi extension, config, bootstrap, and skill idempotently", async () => { + const fixture = createFixture(); + const target = createPiSkillTarget(fixture); + writeFileSync(join(fixture.rootDirectory, "AGENTS.md"), "manual instructions\n", "utf8"); + + await target.installPlugin?.("pi"); + await target.installPlugin?.("pi"); + + const extension = readFileSync(join(fixture.rootDirectory, "extensions", "memmy-memory.ts"), "utf8"); + expect(extension).toContain('pi.on("before_agent_start"'); + expect(extension).toContain('pi.on("agent_settled"'); + expect(extension).toContain('pi.on("input"'); + expect(extension).toContain('pi.registerCommand("memmy-resume"'); + expect(extension).not.toContain('pi.on("agent_end"'); + const config = JSON.parse(readFileSync(join(fixture.rootDirectory, "extensions", "memmy-memory-config.json"), "utf8")); + expect(config).toEqual({ + memmy_config_path: fixture.memmyConfigPath, + endpoint: "http://127.0.0.1:18960", + token: "test-token" + }); + const agents = readFileSync(join(fixture.rootDirectory, "AGENTS.md"), "utf8"); + expect(agents.match(//gu)).toHaveLength(1); + expect(agents).toContain("manual instructions"); + expect(readFileSync(join(fixture.rootDirectory, "skills", "memmy-memory", "SKILL.md"), "utf8")) + .toContain("A Memmy Memory Hook or plugin is installed for this agent."); + }); + + it("reads the Memory storage endpoint instead of an earlier model endpoint", async () => { + const fixture = createFixture(); + writeFileSync(fixture.memmyConfigPath, [ + "memmyMemory:", + " profiles:", + " byok:", + " summary:", + ' endpoint: "http://model-provider.example/v1"', + " storage:", + ' endpoint: "http://127.0.0.1:18960"', + ' token: "test-token"', + "" + ].join("\n"), "utf8"); + + const requestedOrigins: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + requestedOrigins.push(url.origin); + if (url.pathname === "/api/v1/sessions/open") return jsonResponse({ sessionId: "pi-memory-session" }); + if (url.pathname === "/api/v1/turns/start") return jsonResponse({ turnId: "pi-live-turn" }); + return jsonResponse({}, 404); + }; + + const target = createPiSkillTarget(fixture); + await target.installPlugin?.("pi"); + + try { + const extensionPath = join(fixture.rootDirectory, "extensions", "memmy-memory.ts"); + const extensionModule = await import(`${pathToFileURL(extensionPath).href}?test=${crypto.randomUUID()}`) as { + default: (pi: unknown) => void; + }; + const handlers = new Map unknown>(); + extensionModule.default({ + on(event: string, handler: (...args: never[]) => unknown) { + handlers.set(event, handler); + }, + registerCommand() {}, + appendEntry() {} + }); + + await handlers.get("before_agent_start")?.( + { prompt: "Use the configured Memory service" }, + extensionContext([], "root") as never + ); + + expect(requestedOrigins).toEqual([ + "http://127.0.0.1:18960", + "http://127.0.0.1:18960" + ]); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("uninstalls only Memmy-owned files", async () => { + const fixture = createFixture(); + const target = createPiSkillTarget(fixture); + const unrelatedExtension = join(fixture.rootDirectory, "extensions", "unrelated.ts"); + writeFileSync(unrelatedExtension, "export default () => {};\n", "utf8"); + await target.installPlugin?.("pi"); + + await target.uninstallPlugin?.("pi"); + + expect(existsSync(unrelatedExtension)).toBe(true); + expect(existsSync(join(fixture.rootDirectory, "extensions", "memmy-memory.ts"))).toBe(false); + expect(existsSync(join(fixture.rootDirectory, "skills", "memmy-memory"))).toBe(false); + expect(readFileSync(join(fixture.rootDirectory, "AGENTS.md"), "utf8")).toBe(""); + }); + + it("does not create the Pi directory when Pi is unavailable", async () => { + tempDirectory = mkdtempSync(join(tmpdir(), "memmy-pi-missing-")); + const rootDirectory = join(tempDirectory, ".pi", "agent"); + const target = createPiSkillTarget({ rootDirectory }); + await expect(target.install(renderMemmyDefaultSkillManifest("pi"))).rejects.toThrow("Pi is not installed"); + expect(existsSync(rootDirectory)).toBe(false); + }); + + it("awaits settled capture, preserves status, redacts secrets, and marks handled entries", async () => { + const fixture = createFixture(); + const requests: Array<{ path: string; body: Record }> = []; + let releaseComplete: (() => void) | undefined; + const completeGate = new Promise((resolve) => { + releaseComplete = resolve; + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const path = new URL(input instanceof Request ? input.url : String(input)).pathname; + const body = init?.body ? JSON.parse(String(init.body)) as Record : {}; + requests.push({ path, body }); + if (path === "/api/v1/sessions/open") return jsonResponse({ sessionId: "pi-memory-session" }); + if (path === "/api/v1/turns/start") return jsonResponse({ + turnId: "pi-live-turn", + episodeId: "pi-episode", + sourceMemoryIds: ["memory-1"], + injectedContext: { markdown: "prior context" } + }); + if (path === "/api/v1/turns/pi-live-turn/complete") { + await completeGate; + return jsonResponse({ turnId: "pi-live-turn" }); + } + return jsonResponse({}, 404); + }; + const target = createPiSkillTarget(fixture); + await target.installPlugin?.("pi"); + + try { + const extensionPath = join(fixture.rootDirectory, "extensions", "memmy-memory.ts"); + const extensionModule = await import(`${pathToFileURL(extensionPath).href}?test=${crypto.randomUUID()}`) as { + default: (pi: unknown) => void; + }; + const handlers = new Map unknown>(); + const markers: Array<{ customType: string; data: unknown }> = []; + extensionModule.default({ + on(event: string, handler: (...args: never[]) => unknown) { + handlers.set(event, handler); + }, + registerCommand() {}, + appendEntry(customType: string, data: unknown) { + markers.push({ customType, data }); + } + }); + const secret = "sk-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN"; + const branch = [ + sessionEntry("parent", null, "system", "setup"), + sessionEntry("user-1", "parent", "user", `First ${secret}`), + sessionEntry("assistant-1", "user-1", "assistant", "Partial answer"), + sessionEntry("user-2", "assistant-1", "user", "Follow-up password=hunter2"), + sessionEntry("assistant-2", "user-2", "assistant", "", "error", `Failed with ${secret}`) + ]; + const context = extensionContext(branch, "parent"); + await handlers.get("before_agent_start")?.({ prompt: `First ${secret}` }, context as never); + + let settled = false; + const settledPromise = Promise.resolve(handlers.get("agent_settled")?.({}, context as never)).then(() => { + settled = true; + }); + await waitFor(() => requests.some((item) => item.path.endsWith("/complete"))); + expect(settled).toBe(false); + releaseComplete?.(); + await settledPromise; + + expect(requests.find((item) => item.path.endsWith("/start"))?.body.query).toBe("First [REDACTED:openai_api_key]"); + expect(requests.find((item) => item.path.endsWith("/complete"))?.body).toMatchObject({ + query: "First [REDACTED:openai_api_key]\n\nFollow-up password=[REDACTED:password]", + answer: "Partial answer", + status: "failed" + }); + expect(markers).toEqual([expect.objectContaining({ + customType: "memmy-memory-capture", + data: expect.objectContaining({ entryIds: ["user-1", "assistant-1", "user-2", "assistant-2"], status: "failed" }) + })]); + } finally { + releaseComplete?.(); + globalThis.fetch = originalFetch; + } + }); + + it("does not complete aborted runs but marks their entries handled", async () => { + const fixture = createFixture(); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const path = new URL(input instanceof Request ? input.url : String(input)).pathname; + if (path === "/api/v1/sessions/open") return jsonResponse({ sessionId: "pi-memory-session" }); + if (path === "/api/v1/turns/start") return jsonResponse({ + turnId: "pi-aborted-turn", + episodeId: "pi-episode", + injectedContext: { markdown: "" } + }); + throw new Error(`Unexpected request: ${path}`); + }; + const target = createPiSkillTarget(fixture); + await target.installPlugin?.("pi"); + const extensionPath = join(fixture.rootDirectory, "extensions", "memmy-memory.ts"); + const extensionModule = await import(`${pathToFileURL(extensionPath).href}?test=${crypto.randomUUID()}`) as { + default: (pi: unknown) => void; + }; + const handlers = new Map unknown>(); + const markers: Array<{ customType: string; data: unknown }> = []; + extensionModule.default({ + on(event: string, handler: (...args: never[]) => unknown) { + handlers.set(event, handler); + }, + registerCommand() {}, + appendEntry(customType: string, data: unknown) { + markers.push({ customType, data }); + } + }); + try { + const branch = [ + sessionEntry("parent", null, "system", "setup"), + sessionEntry("user-1", "parent", "user", "cancel me"), + sessionEntry("assistant-1", "user-1", "assistant", "partial", "aborted") + ]; + const context = extensionContext(branch, "parent"); + await handlers.get("before_agent_start")?.({ prompt: "cancel me" }, context as never); + await handlers.get("agent_settled")?.({}, context as never); + expect(markers).toEqual([expect.objectContaining({ data: expect.objectContaining({ status: "aborted" }) })]); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +function createFixture(): { rootDirectory: string; memmyConfigPath: string } { + tempDirectory = mkdtempSync(join(tmpdir(), "memmy-pi-target-")); + const rootDirectory = join(tempDirectory, ".pi", "agent"); + const memmyConfigPath = join(tempDirectory, ".memmy", "config.yaml"); + mkdirSync(join(rootDirectory, "extensions"), { recursive: true }); + mkdirSync(join(tempDirectory, ".memmy"), { recursive: true }); + writeFileSync(memmyConfigPath, 'storage:\n endpoint: "http://127.0.0.1:18960"\n token: "test-token"\n', "utf8"); + return { rootDirectory, memmyConfigPath }; +} + +function sessionEntry( + id: string, + parentId: string | null, + role: string, + text: string, + stopReason = "stop", + errorMessage?: string +): Record { + return { + type: "message", + id, + parentId, + timestamp: new Date().toISOString(), + message: { + role, + content: [{ type: "text", text }], + ...(role === "assistant" ? { stopReason, errorMessage } : {}) + } + }; +} + +function extensionContext(branch: Array>, leafId: string): Record { + return { + cwd: "/tmp/pi-project", + ui: { notify() {} }, + sessionManager: { + getSessionId: () => "pi-session-1", + getLeafId: () => leafId, + getBranch: () => branch + } + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +async function waitFor(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("Timed out waiting for condition"); +} diff --git a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-agent-protocol.test.ts b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-agent-protocol.test.ts new file mode 100644 index 000000000..750536444 --- /dev/null +++ b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-agent-protocol.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + MEMMY_AGENT_PROTOCOL_FIELDS, + MEMMY_AGENT_PROTOCOL_VERSION +} from "./memmy-agent-protocol.js"; +import { renderMemmyPiExtension } from "./memmy-pi-extension.js"; +import { renderMemmyResumeHookScript } from "./memmy-resume-hook.js"; + +describe("Memmy agent protocol templates", () => { + it.each([ + ["pi", "memmy-pi-extension", renderMemmyPiExtension()], + ["codex", "memmy-codex-hook", renderMemmyResumeHookScript({ source: "codex", mode: "codex" })], + ["claude_code", "memmy-claude_code-hook", renderMemmyResumeHookScript({ source: "claude_code", mode: "claude-code" })] + ])("renders the shared lifecycle contract for %s", (source, adapterId, script) => { + expect(script).toContain(`const MEMMY_PROTOCOL_VERSION = "${MEMMY_AGENT_PROTOCOL_VERSION}"`); + expect(script).toContain(`const SOURCE = "${source}"`); + expect(script).toContain(source === "pi" ? `const ADAPTER_ID = "${adapterId}"` : 'const ADAPTER_ID = "memmy-" + SOURCE + "-hook"'); + expect(script).toContain("/api/v1/sessions/open"); + expect(script).toContain("/api/v1/turns/start"); + expect(script).toContain("/complete"); + expect(script).toContain("namespace: protocolNamespace"); + expect(script).toContain("provenance: buildProtocolProvenance"); + expect(script).toContain("sourceMemoryIds"); + expect(script).toContain("readGitProvenance"); + expect(script).toContain("capturedAt: new Date().toISOString()"); + }); + + it("publishes the stable v1 field inventory", () => { + expect(MEMMY_AGENT_PROTOCOL_FIELDS).toEqual([ + "protocolVersion", + "source", + "adapterId", + "requestId", + "sessionId", + "turnId", + "episodeId", + "workspacePath", + "projectId", + "sourceMemoryIds", + "provenance" + ]); + }); +}); diff --git a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-agent-protocol.ts b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-agent-protocol.ts new file mode 100644 index 000000000..15304b6c3 --- /dev/null +++ b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-agent-protocol.ts @@ -0,0 +1,18 @@ +/** Shared contract rendered into standalone Memmy agent adapters. */ + +export const MEMMY_AGENT_PROTOCOL_VERSION = "memmy.agent.v1"; + +export const MEMMY_AGENT_PROTOCOL_FIELDS = [ + "protocolVersion", + "source", + "adapterId", + "requestId", + "sessionId", + "turnId", + "episodeId", + "workspacePath", + "projectId", + "sourceMemoryIds", + "provenance" +] as const; + diff --git a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-pi-extension.ts b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-pi-extension.ts new file mode 100644 index 000000000..f1caa81f9 --- /dev/null +++ b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-pi-extension.ts @@ -0,0 +1,596 @@ +/** Pi Memmy extension template. */ + +import { MEMMY_AGENT_PROTOCOL_VERSION } from "./memmy-agent-protocol.js"; + +export function renderMemmyPiExtension(): string { + return String.raw`import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { execFileSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const SOURCE = "pi"; +const ADAPTER_ID = "memmy-pi-extension"; +const MEMMY_PROTOCOL_VERSION = ${JSON.stringify(MEMMY_AGENT_PROTOCOL_VERSION)}; +const CONFIG_URL = new URL("./memmy-memory-config.json", import.meta.url); +const DEFAULT_MEMMY_CONFIG_PATH = join(homedir(), ".memmy", "config.yaml"); +const FETCH_TIMEOUT_MS = 45000; +const SEARCH_LIMIT = 20; +const DISPLAY_LIMIT = 5; +const RESUME_STATE_TTL_MS = 10 * 60 * 1000; +const RESUME_CONTEXT_MAX_CHARS = 24000; + +export default function memmyPiExtension(pi: ExtensionAPI): void { + const pendingTurns = new Map(); + let pendingResume: PendingResume | null = null; + let selectedResumeContext = ""; + let captureQueue = Promise.resolve(); + let turnSequence = 0; + + pi.on("before_agent_start", async (event, ctx) => { + const query = sanitizeText(event.prompt); + if (!query || isResumeCommand(query)) { + return; + } + let injectedContext = selectedResumeContext; + selectedResumeContext = ""; + const startParentId = ctx.sessionManager.getLeafId(); + turnSequence += 1; + const requestedTurnId = "pi-turn-" + hashText([ + ctx.sessionManager.getSessionId(), + startParentId || "root", + query, + String(turnSequence) + ].join("\u0000")); + try { + const memmy = await createMemmyClient(); + const externalSessionId = "pi-memory-" + ctx.sessionManager.getSessionId(); + const workspacePath = ctx.cwd || undefined; + const openRequestId = "pi-open:" + externalSessionId; + const openProvenance = buildProtocolProvenance({ + requestId: openRequestId, + sessionId: externalSessionId, + workspacePath + }); + const opened = await memmy.post("/api/v1/sessions/open", { + protocolVersion: MEMMY_PROTOCOL_VERSION, + adapterId: ADAPTER_ID, + requestId: openRequestId, + sessionId: externalSessionId, + source: SOURCE, + workspacePath, + provenance: openProvenance, + meta: { + memmyProtocolVersion: MEMMY_PROTOCOL_VERSION, + provenance: openProvenance + } + }); + const sessionId = normalizeText(opened.sessionId) || externalSessionId; + const projectId = normalizeText(opened.projectId) || undefined; + const startRequestId = "pi-start:" + requestedTurnId; + const provenance = buildProtocolProvenance({ + requestId: startRequestId, + sessionId, + turnId: requestedTurnId, + workspacePath, + projectId + }); + const turn = await memmy.post("/api/v1/turns/start", { + protocolVersion: MEMMY_PROTOCOL_VERSION, + adapterId: ADAPTER_ID, + requestId: startRequestId, + namespace: protocolNamespace(workspacePath, projectId), + sessionId, + turnId: requestedTurnId, + query: redactSecrets(query), + source: SOURCE, + provenance + }); + pendingTurns.set(requestedTurnId, { + sessionId, + turnId: normalizeText(turn.turnId) || requestedTurnId, + episodeId: normalizeText(turn.episodeId) || undefined, + sourceMemoryIds: Array.isArray(turn.sourceMemoryIds) ? turn.sourceMemoryIds : undefined, + workspacePath, + projectId, + provenance, + initialQuery: query, + startParentId + }); + const recalled = normalizeText(turn.injectedContext && turn.injectedContext.markdown); + injectedContext = [injectedContext, recalled].filter(Boolean).join("\n\n"); + } catch { + pendingTurns.delete(requestedTurnId); + } + + if (injectedContext) { + return { + message: { + customType: "memmy-memory-context", + content: renderMemoryContext(injectedContext, query), + display: true + } + }; + } + }); + + pi.on("agent_settled", async (_event, ctx) => { + const captures = settledCaptures(ctx, pendingTurns); + if (!captures.length) { + return; + } + for (const capture of captures) { + pendingTurns.delete(capture.pendingKey); + if (capture.stopReason === "aborted") { + markSessionEntriesHandled(pi, capture.entryIds, "aborted"); + continue; + } + if (!capture.answer) { + continue; + } + const job = captureQueue.then(async () => { + await completeTurn(capture.turn, capture.query, capture.answer, capture.status); + markSessionEntriesHandled(pi, capture.entryIds, capture.status); + }); + captureQueue = job.catch(() => undefined); + await job.catch(() => undefined); + } + }); + + pi.on("session_shutdown", async () => { + pendingTurns.clear(); + pendingResume = null; + selectedResumeContext = ""; + await captureQueue; + }); + + pi.on("input", async (event, ctx) => { + if (event.source === "extension" || !/^[1-5]$/u.test(event.text.trim())) { + return { action: "continue" }; + } + const selection = Number(event.text.trim()); + const state = pendingResume; + if (!state || Date.now() - state.createdAt > RESUME_STATE_TTL_MS) { + pendingResume = null; + return { action: "continue" }; + } + const candidate = state.candidates.find((item) => item.index === selection); + if (!candidate) { + return { action: "continue" }; + } + try { + const memmy = await createMemmyClient(); + const detail = await memmy.get("/api/v1/memory/" + encodeURIComponent(candidate.episodeId)); + pendingResume = null; + selectedResumeContext = buildResumeContext(candidate, detail); + ctx.ui.notify("Resuming Memmy episode " + candidate.episodeId, "info"); + return { + action: "transform", + text: "Continue Memmy episode " + candidate.episodeId + ": " + (candidate.title || candidate.episodeId), + images: event.images + }; + } catch (error) { + ctx.ui.notify("Memmy resume failed: " + formatError(error), "warning"); + return { action: "handled" }; + } + }); + + pi.registerCommand("memmy-resume", { + description: "Find and resume a prior Memmy episode", + handler: async (args, ctx) => { + const query = normalizeText(args); + if (!query) { + ctx.ui.notify("Usage: /memmy-resume ", "warning"); + return; + } + if (query === "cancel") { + pendingResume = null; + ctx.ui.notify("Memmy resume selection cancelled.", "info"); + return; + } + try { + const memmy = await createMemmyClient(); + const result = await memmy.post("/api/v1/memory/search", { + query, + layers: ["L1"], + limit: SEARCH_LIMIT, + verbose: true, + source: SOURCE + }); + const candidates = await buildEpisodeCandidates(memmy, result); + pendingResume = { createdAt: Date.now(), candidates }; + ctx.ui.notify(formatResumeCandidates(query, candidates), "info"); + } catch (error) { + ctx.ui.notify("Memmy resume search failed: " + formatError(error), "warning"); + } + } + }); +} + +interface PendingTurn { + sessionId: string; + turnId: string; + episodeId?: string; + sourceMemoryIds?: unknown[]; + workspacePath?: string; + projectId?: string; + provenance?: Record; + initialQuery: string; + startParentId: string | null; +} + +interface SettledCapture { + pendingKey: string; + turn: PendingTurn; + query: string; + answer: string; + status: "succeeded" | "failed"; + stopReason: string; + entryIds: string[]; +} + +interface ResumeCandidate { + index: number; + episodeId: string; + title: string; + summary: string; +} + +interface PendingResume { + createdAt: number; + candidates: ResumeCandidate[]; +} + +async function completeTurn( + turn: PendingTurn, + query: string, + answer: string, + status: "succeeded" | "failed" +): Promise { + const memmy = await createMemmyClient(); + const requestId = "pi-complete:" + turn.turnId + ":" + hashText(answer); + await memmy.post("/api/v1/turns/" + encodeURIComponent(turn.turnId) + "/complete", { + protocolVersion: MEMMY_PROTOCOL_VERSION, + adapterId: ADAPTER_ID, + requestId, + namespace: protocolNamespace(turn.workspacePath, turn.projectId), + sessionId: turn.sessionId, + episodeId: turn.episodeId, + query: redactSecrets(query), + answer: redactSecrets(answer), + status, + source: SOURCE, + sourceMemoryIds: turn.sourceMemoryIds, + provenance: buildProtocolProvenance({ + ...turn.provenance, + requestId, + sessionId: turn.sessionId, + turnId: turn.turnId, + workspacePath: turn.workspacePath, + projectId: turn.projectId, + sourceMemoryIds: turn.sourceMemoryIds + }) + }); +} + +function settledCaptures(ctx: ExtensionContext, pendingTurns: Map): SettledCapture[] { + const branch = ctx.sessionManager.getBranch(); + const claimedUserEntryIds = new Set(); + const located = [...pendingTurns].flatMap(([pendingKey, turn]) => { + const parentIndex = turn.startParentId ? branch.findIndex((entry) => entry.id === turn.startParentId) : -1; + const firstUserIndex = branch.findIndex((entry, index) => + index > parentIndex && + entry.type === "message" && + entry.message.role === "user" && + !claimedUserEntryIds.has(entry.id) && + messageText(entry.message) === turn.initialQuery + ); + if (firstUserIndex < 0) return []; + claimedUserEntryIds.add(branch[firstUserIndex]!.id); + return [{ pendingKey, turn, firstUserIndex }]; + }).sort((left, right) => left.firstUserIndex - right.firstUserIndex); + const captures: SettledCapture[] = []; + for (const [index, current] of located.entries()) { + const nextStartIndex = located[index + 1]?.firstUserIndex ?? branch.length; + const runEntries = branch.slice(current.firstUserIndex, nextStartIndex).filter((entry) => entry.type === "message"); + const userTexts = runEntries + .filter((entry) => entry.type === "message" && entry.message.role === "user") + .map((entry) => entry.type === "message" ? messageText(entry.message) : "") + .filter(Boolean); + const assistantEntries = runEntries.filter((entry) => + entry.type === "message" && entry.message.role === "assistant" + ); + const lastAssistant = assistantEntries.at(-1); + if (!lastAssistant || lastAssistant.type !== "message" || lastAssistant.message.role !== "assistant") { + continue; + } + const stopReason = normalizeText(lastAssistant.message.stopReason); + const assistantTexts = assistantEntries + .map((entry) => entry.type === "message" ? messageText(entry.message) : "") + .filter(Boolean); + const errorMessage = sanitizeText(lastAssistant.message.errorMessage); + captures.push({ + pendingKey: current.pendingKey, + turn: current.turn, + query: userTexts.join("\n\n"), + answer: assistantTexts.join("\n\n") || (stopReason === "error" ? errorMessage : ""), + status: stopReason === "error" ? "failed" : "succeeded", + stopReason, + entryIds: runEntries.map((entry) => entry.id) + }); + } + return captures; +} + +function messageText(message: { content: unknown }): string { + if (typeof message.content === "string") return sanitizeText(message.content); + if (!Array.isArray(message.content)) return ""; + return sanitizeText(message.content + .filter((part): part is { type: "text"; text: string } => isRecord(part) && part.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("\n")); +} + +function markSessionEntriesHandled( + pi: ExtensionAPI, + entryIds: string[], + status: "succeeded" | "failed" | "aborted" +): void { + if (!entryIds.length) return; + pi.appendEntry("memmy-memory-capture", { entryIds, status }); +} + +async function buildEpisodeCandidates(memmy: MemmyClient, result: Record): Promise { + const hits = extractHits(result).slice(0, SEARCH_LIMIT); + const candidates = new Map>(); + for (const hit of hits) { + const memoryId = normalizeText(hit.id || hit.memoryId || hit.refId); + if (!memoryId) continue; + const detail = await memmy.get("/api/v1/memory/" + encodeURIComponent(memoryId)).catch(() => ({})); + const refs = isRecord(detail.refs) ? detail.refs : {}; + const episode = isRecord(refs.episode) ? refs.episode : {}; + const episodeId = normalizeText(episode.id || detail.episodeId || hit.episodeId) || + (memoryId.startsWith("episode_") ? memoryId : ""); + if (!episodeId || candidates.has(episodeId)) continue; + candidates.set(episodeId, { + episodeId, + title: normalizeText(episode.title || detail.title || hit.title) || episodeId, + summary: normalizeText(episode.summary || detail.summary || hit.summary || hit.body) + }); + if (candidates.size >= DISPLAY_LIMIT) break; + } + return [...candidates.values()].map((candidate, index) => ({ ...candidate, index: index + 1 })); +} + +function extractHits(result: Record): Record[] { + const debug = isRecord(result.debug) ? result.debug : {}; + for (const value of [result.hits, debug.hits, result.results, debug.results, result.memories, debug.memories]) { + if (Array.isArray(value)) return value.filter(isRecord); + } + return []; +} + +function formatResumeCandidates(query: string, candidates: ResumeCandidate[]): string { + if (!candidates.length) return "No L1 Memmy memories found for: \"" + query + "\""; + return [ + "Memmy resume candidates for \"" + query + "\":", + "", + ...candidates.map((candidate) => candidate.index + ". " + candidate.episodeId + + "\n " + truncate(candidate.title, 160) + + (candidate.summary ? "\n " + truncate(candidate.summary, 260) : "")), + "", + "Enter 1-5 to resume, or /memmy-resume cancel." + ].join("\n"); +} + +function buildResumeContext(candidate: ResumeCandidate, detail: Record): string { + return truncate([ + "The user selected this prior Memmy episode and wants to continue it.", + "Episode id: " + candidate.episodeId, + "Episode title: " + candidate.title, + normalizeText(detail.body) ? "Episode detail:\n" + normalizeText(detail.body) : "", + JSON.stringify(detail, null, 2) + ].filter(Boolean).join("\n\n"), RESUME_CONTEXT_MAX_CHARS); +} + +function renderMemoryContext(markdown: string, query: string): string { + return [ + "", + markdown, + "", + "", + "", + query, + "" + ].join("\n"); +} + +interface ProtocolProvenanceInput { + requestId?: string; + sessionId?: string; + turnId?: string; + workspacePath?: string; + projectId?: string; + sourceMemoryIds?: unknown[]; + [key: string]: unknown; +} + +function protocolNamespace(workspacePath?: string, projectId?: string): Record { + return { + source: SOURCE, + profileId: "default", + workspacePath, + projectId + }; +} + +function buildProtocolProvenance(input: ProtocolProvenanceInput): Record { + const workspacePath = normalizeText(input.workspacePath) || undefined; + return { + ...input, + ...readGitProvenance(workspacePath), + sourceAgent: SOURCE, + adapterId: ADAPTER_ID, + workspacePath, + sourceMemoryIds: Array.isArray(input.sourceMemoryIds) + ? input.sourceMemoryIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : [], + capturedAt: new Date().toISOString() + }; +} + +function readGitProvenance(workspacePath?: string): Record { + if (!workspacePath) return {}; + try { + const git = (...args: string[]) => execFileSync("git", ["-C", workspacePath, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }).trim(); + const repository = git("rev-parse", "--show-toplevel"); + const branch = git("rev-parse", "--abbrev-ref", "HEAD"); + const commit = git("rev-parse", "HEAD"); + return { + ...(repository ? { repository } : {}), + ...(branch && branch !== "HEAD" ? { branch } : {}), + ...(commit ? { commit } : {}) + }; + } catch { + return {}; + } +} + +interface MemmyClient { + get(path: string): Promise>; + post(path: string, body: Record): Promise>; +} + +async function createMemmyClient(): Promise { + const localConfig = await readJsonConfig(); + const configPath = normalizeText(process.env.MEMMY_CONFIG) || normalizeText(localConfig.memmy_config_path) || DEFAULT_MEMMY_CONFIG_PATH; + const runtimeConfig = await readYamlConfig(configPath).catch(() => ({})); + const baseUrl = normalizeText(runtimeConfig.endpoint || localConfig.endpoint || "http://127.0.0.1:18960").replace(/\/+$/u, ""); + const token = normalizeText(runtimeConfig.token || localConfig.token); + return { + async get(path) { + return request(new URL(path, baseUrl), { method: "GET", headers: token ? { authorization: "Bearer " + token } : {} }); + }, + async post(path, body) { + const headers: Record = { "content-type": "application/json" }; + if (token) headers.authorization = "Bearer " + token; + return request(new URL(path, baseUrl), { method: "POST", headers, body: JSON.stringify({ ...body, source: SOURCE }) }); + } + }; +} + +async function request(url: URL, init: RequestInit): Promise> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; + if (!response.ok) throw new Error(normalizeText(data?.error?.message) || response.statusText || "Memmy HTTP " + response.status); + return isRecord(data) ? data : {}; + } finally { + clearTimeout(timeout); + } +} + +async function readJsonConfig(): Promise> { + try { + const value = JSON.parse(await readFile(CONFIG_URL, "utf8")); + return isRecord(value) ? value : {}; + } catch { + return {}; + } +} + +async function readYamlConfig(path: string): Promise> { + const content = await readFile(path, "utf8"); + return parseStorageBlock(content); +} + +function parseStorageBlock(content: string): Record { + const storages: Array> = []; + let activeStorage: Record | null = null; + let storageIndent = 0; + for (const rawLine of content.split(/\r?\n/u)) { + const line = rawLine.replace(/#.*$/u, "").replace(/\s+$/u, ""); + if (!line.trim()) continue; + const indent = line.match(/^\s*/u)?.[0].length ?? 0; + if (/^\s*storage:\s*$/u.test(line)) { + activeStorage = {}; + storageIndent = indent; + storages.push(activeStorage); + continue; + } + if (activeStorage && indent <= storageIndent) activeStorage = null; + if (!activeStorage) continue; + const match = line.match(/^\s+(endpoint|token):\s*(.*?)\s*$/u); + if (match) activeStorage[match[1]] = parseYamlScalar(match[2]); + } + return storages.find((storage) => storage.endpoint) ?? storages[0] ?? {}; +} + +function parseYamlScalar(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + try { + return JSON.parse(trimmed) as string; + } catch { + return trimmed.slice(1, -1); + } + } + return trimmed; +} + +function isResumeCommand(value: string): boolean { + return /^\/?memmy-resume(?:\s|$)/u.test(value.trim()); +} + +function sanitizeText(value: unknown): string { + return normalizeText(value).replace(/\u0000/gu, "").trim(); +} + +function redactSecrets(input: string): string { + return redactBase64Runs(input + .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gu, "[REDACTED:ssh_private_key]") + .replace(/\b(Authorization\s*:\s*Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1[REDACTED:authorization_bearer]") + .replace(/\bsk-ant-api\d{2}-[A-Za-z0-9_-]{40,}\b/gu, "[REDACTED:anthropic_api_key]") + .replace(/\bsk-(?:proj-)?[A-Za-z0-9_-]{40,}\b/gu, "[REDACTED:openai_api_key]") + .replace(/\bAIza[A-Za-z0-9_-]{32,}\b/gu, "[REDACTED:google_api_key]") + .replace(/\b([A-Za-z0-9_]*password[A-Za-z0-9_]*\s*[:=]\s*)(?:"[^"\n]+"|'[^'\n]+'|[^\s#&]+)/giu, "$1[REDACTED:password]")); +} + +function redactBase64Runs(input: string): string { + return input.replace(/(^|[^A-Za-z0-9_])([A-Za-z0-9+/]{32,}={0,2})(?=$|[^A-Za-z0-9_])/gu, "$1[REDACTED:base64_secret]"); +} + +function normalizeText(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function truncate(value: string, limit: number): string { + return value.length <= limit ? value : value.slice(0, limit - 1) + "…"; +} + +function hashText(value: string): string { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +`; +} diff --git a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-resume-hook.ts b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-resume-hook.ts index e775f64c8..05d468339 100644 --- a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-resume-hook.ts +++ b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-resume-hook.ts @@ -1,5 +1,7 @@ /** Memmy resume hook template. */ +import { MEMMY_AGENT_PROTOCOL_VERSION } from "./memmy-agent-protocol.js"; + export type MemmyResumeHookMode = "claude-code" | "codex" | "cursor"; export interface RenderMemmyResumeHookScriptOptions { @@ -10,12 +12,15 @@ export interface RenderMemmyResumeHookScriptOptions { /** Renders the Node hook script used by prompt-submit hooks. */ export function renderMemmyResumeHookScript(options: RenderMemmyResumeHookScriptOptions): string { return String.raw`#!/usr/bin/env node +import { execFileSync } from "node:child_process"; import { readFile, unlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; const SOURCE = ${JSON.stringify(options.source)}; const MODE = ${JSON.stringify(options.mode)}; +const ADAPTER_ID = "memmy-" + SOURCE + "-hook"; +const MEMMY_PROTOCOL_VERSION = ${JSON.stringify(MEMMY_AGENT_PROTOCOL_VERSION)}; const CONFIG_URL = new URL("./memmy-memory-config.json", import.meta.url); const STATE_URL = new URL("./memmy-resume-state.json", import.meta.url); const DEFAULT_MEMMY_CONFIG_PATH = join(homedir(), ".memmy", "config.yaml"); @@ -172,25 +177,53 @@ async function captureCompletedTurn(payload) { const client = await createMemmyClient(); const externalSessionId = memoryExternalSessionId(payload); + const currentWorkspacePath = workspacePath(payload) || undefined; + const openRequestId = SOURCE + "-open:" + externalSessionId; + const openProvenance = buildProtocolProvenance({ + requestId: openRequestId, + sessionId: externalSessionId, + workspacePath: currentWorkspacePath + }); const opened = await client.post("/api/v1/sessions/open", { + protocolVersion: MEMMY_PROTOCOL_VERSION, + adapterId: ADAPTER_ID, + requestId: openRequestId, sessionId: externalSessionId, source: SOURCE, - workspacePath: workspacePath(payload) || undefined + workspacePath: currentWorkspacePath, + provenance: openProvenance, + meta: { + memmyProtocolVersion: MEMMY_PROTOCOL_VERSION, + provenance: openProvenance + } }); const sessionId = normalizeText(opened.sessionId) || externalSessionId; const turnId = normalizeText(pending && pending.turnId) || platformTurnId(payload) || SOURCE + "-fallback-" + hashText([sessionId, query, answer].join("\\u0000")); + const completeRequestId = SOURCE + "-complete:" + turnId + ":" + hashText([status, query, answer].join("\\u0000")); + const projectId = normalizeText(opened.projectId) || normalizeText(pending && pending.projectId) || undefined; await client.post("/api/v1/turns/" + encodeURIComponent(turnId) + "/complete", { - adapterId: "memmy-" + SOURCE + "-hook", - requestId: SOURCE + "-complete:" + turnId + ":" + hashText([status, query, answer].join("\\u0000")), + protocolVersion: MEMMY_PROTOCOL_VERSION, + adapterId: ADAPTER_ID, + requestId: completeRequestId, + namespace: protocolNamespace(currentWorkspacePath, projectId), sessionId, episodeId: normalizeText(pending && pending.episodeId) || undefined, query, answer, status, source: SOURCE, - sourceMemoryIds: Array.isArray(pending && pending.sourceMemoryIds) ? pending.sourceMemoryIds : undefined + sourceMemoryIds: Array.isArray(pending && pending.sourceMemoryIds) ? pending.sourceMemoryIds : undefined, + provenance: buildProtocolProvenance({ + ...(pending && pending.provenance && typeof pending.provenance === "object" ? pending.provenance : {}), + requestId: completeRequestId, + sessionId, + turnId, + workspacePath: currentWorkspacePath, + projectId, + sourceMemoryIds: Array.isArray(pending && pending.sourceMemoryIds) ? pending.sourceMemoryIds : [] + }) }); await clearTurnState(payload); } @@ -202,20 +235,47 @@ async function startCapturedTurn(payload, prompt) { } const client = await createMemmyClient(); const externalSessionId = memoryExternalSessionId(payload); + const currentWorkspacePath = workspacePath(payload) || undefined; + const openRequestId = SOURCE + "-open:" + externalSessionId; + const openProvenance = buildProtocolProvenance({ + requestId: openRequestId, + sessionId: externalSessionId, + workspacePath: currentWorkspacePath + }); const opened = await client.post("/api/v1/sessions/open", { + protocolVersion: MEMMY_PROTOCOL_VERSION, + adapterId: ADAPTER_ID, + requestId: openRequestId, sessionId: externalSessionId, source: SOURCE, - workspacePath: workspacePath(payload) || undefined + workspacePath: currentWorkspacePath, + provenance: openProvenance, + meta: { + memmyProtocolVersion: MEMMY_PROTOCOL_VERSION, + provenance: openProvenance + } }); const sessionId = normalizeText(opened.sessionId) || externalSessionId; + const projectId = normalizeText(opened.projectId) || undefined; const requestedTurnId = platformTurnId(payload) || SOURCE + "-turn-" + hashText([sessionId, query, String(Date.now())].join("\\u0000")); + const startRequestId = SOURCE + "-start:" + requestedTurnId; + const provenance = buildProtocolProvenance({ + requestId: startRequestId, + sessionId, + turnId: requestedTurnId, + workspacePath: currentWorkspacePath, + projectId + }); const turn = await client.post("/api/v1/turns/start", { - adapterId: "memmy-" + SOURCE + "-hook", - requestId: SOURCE + "-start:" + requestedTurnId, + protocolVersion: MEMMY_PROTOCOL_VERSION, + adapterId: ADAPTER_ID, + requestId: startRequestId, + namespace: protocolNamespace(currentWorkspacePath, projectId), sessionId, turnId: requestedTurnId, - query + query, + provenance }); const state = { createdAt: new Date().toISOString(), @@ -224,6 +284,9 @@ async function startCapturedTurn(payload, prompt) { episodeId: normalizeText(turn && turn.episodeId) || undefined, query, sourceMemoryIds: Array.isArray(turn && turn.sourceMemoryIds) ? turn.sourceMemoryIds : undefined, + workspacePath: currentWorkspacePath, + projectId, + provenance, answer: "" }; await writeTurnState(payload, state); @@ -437,6 +500,51 @@ function workspacePath(payload) { return roots.map(item => normalizeText(item)).find(Boolean) || ""; } +function protocolNamespace(workspacePathValue, projectId) { + return { + source: SOURCE, + profileId: "default", + workspacePath: workspacePathValue, + projectId + }; +} + +function buildProtocolProvenance(input) { + const workspacePathValue = normalizeText(input && input.workspacePath) || undefined; + const sourceMemoryIds = Array.isArray(input && input.sourceMemoryIds) + ? input.sourceMemoryIds.filter(value => typeof value === "string" && value.trim()) + : []; + return { + ...(input && typeof input === "object" ? input : {}), + ...readGitProvenance(workspacePathValue), + sourceAgent: SOURCE, + adapterId: ADAPTER_ID, + workspacePath: workspacePathValue, + sourceMemoryIds, + capturedAt: new Date().toISOString() + }; +} + +function readGitProvenance(workspacePathValue) { + if (!workspacePathValue) return {}; + try { + const git = (...args) => execFileSync("git", ["-C", workspacePathValue, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }).trim(); + const repository = git("rev-parse", "--show-toplevel"); + const branch = git("rev-parse", "--abbrev-ref", "HEAD"); + const commit = git("rev-parse", "HEAD"); + return { + ...(repository ? { repository } : {}), + ...(branch && branch !== "HEAD" ? { branch } : {}), + ...(commit ? { commit } : {}) + }; + } catch { + return {}; + } +} + function completedTurnStatus(payload) { const status = normalizeText( payload.status || diff --git a/App/backend/src/analytics/agent-source-analytics.ts b/App/backend/src/analytics/agent-source-analytics.ts index d8b89b893..955c2856e 100644 --- a/App/backend/src/analytics/agent-source-analytics.ts +++ b/App/backend/src/analytics/agent-source-analytics.ts @@ -29,7 +29,7 @@ export type AgentSourceInstallType = export type AgentSourceKind = "hook" | "native_plugin" | "skill" | "managed_skill"; const HOOK_AGENT_SOURCE_IDS = new Set(["cursor", "claude_code", "codex"]); -const NATIVE_PLUGIN_AGENT_SOURCE_IDS = new Set(["opencode", "openclaw", "hermes"]); +const NATIVE_PLUGIN_AGENT_SOURCE_IDS = new Set(["pi", "opencode", "openclaw", "hermes"]); const AGENT_SOURCE_ANALYTICS_SOURCE = "memmy-backend"; export type AgentSourceLifecycleAnalytics = { diff --git a/App/backend/src/infrastructure/app-state-store/tests/index.test.ts b/App/backend/src/infrastructure/app-state-store/tests/index.test.ts index e81a676fc..e15077081 100644 --- a/App/backend/src/infrastructure/app-state-store/tests/index.test.ts +++ b/App/backend/src/infrastructure/app-state-store/tests/index.test.ts @@ -18,7 +18,7 @@ afterEach(() => { } }); -describe("app state store migrations", () => { +describe("app state store migrations", { timeout: 60_000 }, () => { it("creates initial tables and seed rows idempotently", () => { tempDir = mkdtempSync(join(tmpdir(), "memmy-app-state-")); const databasePath = join(tempDir, "app.sqlite"); diff --git a/App/backend/src/infrastructure/memmy-config/index.ts b/App/backend/src/infrastructure/memmy-config/index.ts index b6d959fd1..2e97fa93e 100644 --- a/App/backend/src/infrastructure/memmy-config/index.ts +++ b/App/backend/src/infrastructure/memmy-config/index.ts @@ -1258,16 +1258,29 @@ async function writeMemmyConfig(config: Record, configPath: str const configDirectory = dirname(configPath); const tempPath = join(configDirectory, `.${basename(configPath)}.${process.pid}.${Date.now()}.tmp`); const body = YAML.stringify(config); + const content = body.endsWith("\n") ? body : `${body}\n`; + const writeOptions = { + encoding: "utf8", + mode: 0o600 + } as const; await mkdir(configDirectory, { recursive: true, mode: 0o700 }); await chmod(configDirectory, 0o700); try { - await writeFile(tempPath, body.endsWith("\n") ? body : `${body}\n`, { - encoding: "utf8", - mode: 0o600 - }); - await rename(tempPath, configPath); + await writeFile(tempPath, content, writeOptions); + try { + await rename(tempPath, configPath); + } catch (error) { + if (!isFileReplacementConflict(error)) { + throw error; + } + + // Windows can deny rename-over-existing when another reader omits delete sharing. + await chmod(configPath, 0o600).catch(() => undefined); + await writeFile(configPath, content, writeOptions); + await rm(tempPath, { force: true }).catch(() => undefined); + } await chmod(configPath, 0o600); } catch (error) { await rm(tempPath, { force: true }); @@ -1335,3 +1348,7 @@ function omitUndefined(value: Record): Record function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; } + +function isFileReplacementConflict(error: unknown): boolean { + return isNodeError(error) && (error.code === "EPERM" || error.code === "EACCES" || error.code === "EBUSY"); +} diff --git a/App/backend/src/services/agent-source-auto-inject-service.ts b/App/backend/src/services/agent-source-auto-inject-service.ts index 1ae9ac98c..053097043 100644 --- a/App/backend/src/services/agent-source-auto-inject-service.ts +++ b/App/backend/src/services/agent-source-auto-inject-service.ts @@ -3,8 +3,8 @@ import type { AgentSourceAutoInjectResult, ScanPreferences } from "@memmy/local- import type { PermissionManager } from "../permission/index.js"; import type { AgentSourceService } from "./agent-source-service.js"; -const AUTO_INJECT_AGENT_SOURCE_IDS = new Set(["cursor", "claude_code", "codex", "opencode", "openclaw", "hermes", "workbuddy"]); -const HOOK_OR_PLUGIN_AGENT_SOURCE_IDS = new Set(["cursor", "claude_code", "codex", "opencode", "openclaw", "hermes"]); +const AUTO_INJECT_AGENT_SOURCE_IDS = new Set(["cursor", "claude_code", "codex", "pi", "opencode", "openclaw", "hermes", "workbuddy"]); +const HOOK_OR_PLUGIN_AGENT_SOURCE_IDS = new Set(["cursor", "claude_code", "codex", "pi", "opencode", "openclaw", "hermes"]); export interface AgentSourceAutoInjectService { runOnce(): Promise; diff --git a/App/backend/src/services/agent-token-stats-service.ts b/App/backend/src/services/agent-token-stats-service.ts new file mode 100644 index 000000000..f808a69c8 --- /dev/null +++ b/App/backend/src/services/agent-token-stats-service.ts @@ -0,0 +1,457 @@ +import { + AgentKindSchema, + AgentTokenStatsDtoSchema, + ProjectTokenStatsDtoSchema, + AgentTokenStatsResponseSchema, + type AgentKind, + type AgentTokenStatsDto, + type ProjectTokenStatsDto, + type AgentTokenStatsResponse +} from "@memmy/local-api-contracts"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { readJsonlObjects, type JsonObject } from "../adapters/outbound/agent-source/jsonl-lines.js"; + +export interface AgentTokenStatsService { + getStats(): Promise; +} + +export interface CreateAgentTokenStatsServiceOptions { + /** Override home directory for testing. */ + homeDir?: string; +} + +interface PerAgentAccumulator { + sessions: number; + apiCalls: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + reasoningTokens: number; + totalTokens: number; + cost: number; +} + +interface ProjectAccumulator { + pi: PerAgentAccumulator; + codex: PerAgentAccumulator; + claude_code: PerAgentAccumulator; +} + +const CACHE_TTL_MS = 5 * 60 * 1000; + +interface CacheEntry { + value: AgentTokenStatsResponse; + expiresAt: number; +} + +export function createAgentTokenStatsService( + options: CreateAgentTokenStatsServiceOptions = {} +): AgentTokenStatsService { + const homeDir = options.homeDir ?? os.homedir(); + let cache: CacheEntry | null = null; + + return { + async getStats() { + const now = Date.now(); + if (cache && cache.expiresAt > now) { + return cache.value; + } + + const projects = new Map(); + + // Scan Pi sessions + await scanPiSessions(homeDir, projects); + + // Scan Codex sessions + await scanCodexSessions(homeDir, projects); + + // Scan Claude Code transcripts (session count only, no token data) + await scanClaudeCodeTranscripts(homeDir, projects); + + // Build response + const response = buildResponse(projects); + const validated = AgentTokenStatsResponseSchema.parse(response); + + cache = { + value: validated, + expiresAt: now + CACHE_TTL_MS + }; + + return validated; + } + }; +} + +function getOrCreateProject( + projects: Map, + projectPath: string +): ProjectAccumulator { + let acc = projects.get(projectPath); + if (!acc) { + acc = { + pi: createEmptyAccumulator(), + codex: createEmptyAccumulator(), + claude_code: createEmptyAccumulator() + }; + projects.set(projectPath, acc); + } + return acc; +} + +function createEmptyAccumulator(): PerAgentAccumulator { + return { + sessions: 0, + apiCalls: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + cost: 0 + }; +} + +async function scanPiSessions( + homeDir: string, + projects: Map +): Promise { + const piSessionsDir = path.join(homeDir, ".pi", "agent", "sessions"); + if (!fs.existsSync(piSessionsDir)) { + return; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(piSessionsDir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.startsWith("--") || !entry.name.endsWith("--")) continue; + + // Decode path: --mnt-d-Project-Miller-memmy-agent-- → /mnt/d/Project/Miller/memmy-agent + const projectPath = decodePiPath(entry.name); + const sessionDir = path.join(piSessionsDir, entry.name); + const acc = getOrCreateProject(projects, projectPath); + + // Find JSONL files in this session directory + let files: string[]; + try { + files = fs.readdirSync(sessionDir).filter((f) => f.endsWith(".jsonl")); + } catch { + continue; + } + + for (const file of files) { + const filePath = path.join(sessionDir, file); + await processPiSessionFile(filePath, acc.pi); + } + } +} + +function decodePiPath(encoded: string): string { + // Remove leading -- and trailing -- + const inner = encoded.slice(2, -2); + // Replace - with / + return "/" + inner.replace(/-/g, "/"); +} + +async function processPiSessionFile( + filePath: string, + acc: PerAgentAccumulator +): Promise { + let hasUsage = false; + + for await (const obj of readJsonlObjects(filePath)) { + // Look for assistant messages with usage data + if (obj.type === "assistant" && obj.usage && typeof obj.usage === "object") { + const usage = obj.usage as JsonObject; + const input = toNumber(usage.input); + const output = toNumber(usage.output); + const cacheRead = toNumber(usage.cacheRead); + const cacheWrite = toNumber(usage.cacheWrite); + const total = toNumber(usage.totalTokens); + const cost = obj.cost && typeof obj.cost === "object" + ? toNumber((obj.cost as JsonObject).total) + : 0; + + acc.inputTokens += input; + acc.outputTokens += output; + acc.cacheReadTokens += cacheRead; + acc.cacheWriteTokens += cacheWrite; + acc.totalTokens += total; + acc.cost += cost; + acc.apiCalls += 1; + hasUsage = true; + } + + // Also check for compaction/branch_summary entries with usage + if ((obj.type === "compaction" || obj.type === "branch_summary") && obj.usage && typeof obj.usage === "object") { + const usage = obj.usage as JsonObject; + const input = toNumber(usage.input); + const output = toNumber(usage.output); + const cacheRead = toNumber(usage.cacheRead); + const cacheWrite = toNumber(usage.cacheWrite); + const total = toNumber(usage.totalTokens); + const cost = obj.cost && typeof obj.cost === "object" + ? toNumber((obj.cost as JsonObject).total) + : 0; + + acc.inputTokens += input; + acc.outputTokens += output; + acc.cacheReadTokens += cacheRead; + acc.cacheWriteTokens += cacheWrite; + acc.totalTokens += total; + acc.cost += cost; + acc.apiCalls += 1; + hasUsage = true; + } + } + + if (hasUsage) { + acc.sessions += 1; + } +} + +async function scanCodexSessions( + homeDir: string, + projects: Map +): Promise { + const codexSessionsDir = path.join(homeDir, ".codex", "sessions"); + if (!fs.existsSync(codexSessionsDir)) { + return; + } + + // Structure: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl + const dateDirs = walkDirectoryTree(codexSessionsDir, 3); // YYYY/MM/DD + + for (const dateDir of dateDirs) { + let files: string[]; + try { + files = fs.readdirSync(dateDir).filter((f) => f.startsWith("rollout-") && f.endsWith(".jsonl")); + } catch { + continue; + } + + for (const file of files) { + const filePath = path.join(dateDir, file); + await processCodexSessionFile(filePath, projects); + } + } +} + +function walkDirectoryTree(rootDir: string, depth: number): string[] { + const results: string[] = []; + + function walk(dir: string, currentDepth: number) { + if (currentDepth > depth) return; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const fullPath = path.join(dir, entry.name); + if (currentDepth === depth) { + results.push(fullPath); + } else { + walk(fullPath, currentDepth + 1); + } + } + } + + walk(rootDir, 1); + return results; +} + +async function processCodexSessionFile( + filePath: string, + projects: Map +): Promise { + let cwd: string | null = null; + let lastTokenCount: { + input_tokens: number; + output_tokens: number; + cached_input_tokens: number; + cache_write_input_tokens: number; + reasoning_output_tokens: number; + total_tokens: number; + } | null = null; + + for await (const obj of readJsonlObjects(filePath)) { + // Extract cwd from session_meta + if (obj.type === "session_meta" && obj.payload && typeof obj.payload === "object") { + const payload = obj.payload as JsonObject; + if (typeof payload.cwd === "string") { + cwd = payload.cwd; + } + } + + // Take the LAST token_count event (values are cumulative) + if (obj.type === "event_msg" && obj.payload && typeof obj.payload === "object") { + const payload = obj.payload as JsonObject; + if (payload.type === "token_count" && payload.info && typeof payload.info === "object") { + const info = payload.info as JsonObject; + // Structure: info.total_token_usage.{input_tokens, output_tokens, ...} + const totalUsage = info.total_token_usage; + if (totalUsage && typeof totalUsage === "object") { + const usage = totalUsage as JsonObject; + lastTokenCount = { + input_tokens: toNumber(usage.input_tokens), + output_tokens: toNumber(usage.output_tokens), + cached_input_tokens: toNumber(usage.cached_input_tokens), + cache_write_input_tokens: toNumber(usage.cache_write_input_tokens), + reasoning_output_tokens: toNumber(usage.reasoning_output_tokens), + total_tokens: toNumber(usage.total_tokens) + }; + } + } + } + } + + if (!cwd || !lastTokenCount) { + return; + } + + const acc = getOrCreateProject(projects, cwd); + acc.codex.sessions += 1; + acc.codex.apiCalls += 1; + acc.codex.inputTokens += lastTokenCount.input_tokens; + acc.codex.outputTokens += lastTokenCount.output_tokens; + acc.codex.cacheReadTokens += lastTokenCount.cached_input_tokens; + acc.codex.cacheWriteTokens += lastTokenCount.cache_write_input_tokens; + acc.codex.reasoningTokens += lastTokenCount.reasoning_output_tokens; + acc.codex.totalTokens += lastTokenCount.total_tokens; +} + +async function scanClaudeCodeTranscripts( + homeDir: string, + projects: Map +): Promise { + const claudeTranscriptsDir = path.join(homeDir, ".claude", "transcripts"); + if (!fs.existsSync(claudeTranscriptsDir)) { + return; + } + + let files: string[]; + try { + files = fs.readdirSync(claudeTranscriptsDir).filter((f) => f.endsWith(".jsonl")); + } catch { + return; + } + + // Claude Code transcripts don't have token data, but we can count sessions + // We'll attribute them to a generic "claude" project or try to extract from content + for (const file of files) { + const filePath = path.join(claudeTranscriptsDir, file); + await processClaudeCodeTranscript(filePath, projects); + } +} + +async function processClaudeCodeTranscript( + filePath: string, + projects: Map +): Promise { + let cwd: string | null = null; + + // Try to find cwd or project identifier from the transcript + for await (const obj of readJsonlObjects(filePath)) { + if (obj.cwd && typeof obj.cwd === "string") { + cwd = obj.cwd; + break; + } + } + + if (!cwd) { + // Use a generic path if we can't determine the project + cwd = "claude-code-unknown"; + } + + const acc = getOrCreateProject(projects, cwd); + acc.claude_code.sessions += 1; + // No token data available for Claude Code +} + +function buildResponse( + projects: Map +): AgentTokenStatsResponse { + const projectStats: ProjectTokenStatsDto[] = []; + + for (const [projectPath, acc] of projects) { + const agents: AgentTokenStatsDto[] = []; + + // Pi + if (acc.pi.sessions > 0 || acc.codex.sessions > 0 || acc.claude_code.sessions > 0) { + agents.push(buildAgentDto("pi", acc.pi, true)); + agents.push(buildAgentDto("codex", acc.codex, true)); + agents.push(buildAgentDto("claude_code", acc.claude_code, false)); + } + + if (agents.length === 0) continue; + + const combinedInputTokens = agents.reduce((sum, a) => sum + a.inputTokens, 0); + const combinedOutputTokens = agents.reduce((sum, a) => sum + a.outputTokens, 0); + const combinedCacheReadTokens = agents.reduce((sum, a) => sum + (a.cacheReadTokens ?? 0), 0); + const combinedTotalTokens = agents.reduce((sum, a) => sum + a.totalTokens, 0); + const estimatedCost = agents.reduce((sum, a) => sum + (a.cost ?? 0), 0); + + projectStats.push( + ProjectTokenStatsDtoSchema.parse({ + project: projectPath, + agents, + combinedInputTokens, + combinedOutputTokens, + combinedCacheReadTokens, + combinedTotalTokens, + estimatedCost: estimatedCost > 0 ? estimatedCost : undefined + }) + ); + } + + // Sort by total tokens descending + projectStats.sort((a, b) => b.combinedTotalTokens - a.combinedTotalTokens); + + return { + projects: projectStats, + scannedAt: new Date().toISOString() + }; +} + +function buildAgentDto( + agent: AgentKind, + acc: PerAgentAccumulator, + available: boolean +): AgentTokenStatsDto { + return AgentTokenStatsDtoSchema.parse({ + agent, + sessions: acc.sessions, + apiCalls: acc.apiCalls, + inputTokens: acc.inputTokens, + outputTokens: acc.outputTokens, + cacheReadTokens: acc.cacheReadTokens, + cacheWriteTokens: acc.cacheWriteTokens, + reasoningTokens: acc.reasoningTokens > 0 ? acc.reasoningTokens : undefined, + totalTokens: acc.totalTokens, + cost: acc.cost > 0 ? acc.cost : undefined, + available + }); +} + +function toNumber(value: unknown): number { + if (typeof value === "number") return value; + if (typeof value === "string") { + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} diff --git a/App/backend/src/services/builtin-agent-source-registry.ts b/App/backend/src/services/builtin-agent-source-registry.ts index d88cc177b..f9c84c326 100644 --- a/App/backend/src/services/builtin-agent-source-registry.ts +++ b/App/backend/src/services/builtin-agent-source-registry.ts @@ -4,6 +4,7 @@ import { createCursorSourceAdapter } from "../adapters/outbound/agent-source/cur import { createHermesSourceAdapter } from "../adapters/outbound/agent-source/hermes/index.js"; import { createOpenclawSourceAdapter } from "../adapters/outbound/agent-source/openclaw/index.js"; import { createOpencodeSourceAdapter } from "../adapters/outbound/agent-source/opencode/index.js"; +import { createPiSourceAdapter } from "../adapters/outbound/agent-source/pi/index.js"; import { createSourceRegistry, type SourceRegistry } from "../adapters/outbound/agent-source/source-registry.js"; import { createWorkbuddySourceAdapter } from "../adapters/outbound/agent-source/workbuddy/index.js"; @@ -12,6 +13,7 @@ export function createBuiltinAgentSourceRegistry(): SourceRegistry { createCursorSourceAdapter(), createClaudeCodeSourceAdapter(), createCodexSourceAdapter(), + createPiSourceAdapter(), createOpencodeSourceAdapter(), createOpenclawSourceAdapter(), createHermesSourceAdapter(), diff --git a/App/backend/src/services/index.ts b/App/backend/src/services/index.ts index 602dbeabf..5de0e9c53 100644 --- a/App/backend/src/services/index.ts +++ b/App/backend/src/services/index.ts @@ -18,6 +18,7 @@ import { createCursorSkillTarget } from "../adapters/outbound/skill-writer/curso import { createHermesSkillTarget } from "../adapters/outbound/skill-writer/hermes/index.js"; import { createOpenclawSkillTarget } from "../adapters/outbound/skill-writer/openclaw/index.js"; import { createOpencodeSkillTarget } from "../adapters/outbound/skill-writer/opencode/index.js"; +import { createPiSkillTarget } from "../adapters/outbound/skill-writer/pi/index.js"; import { createWorkbuddySkillTarget } from "../adapters/outbound/skill-writer/workbuddy/index.js"; import { createSkillTargetRegistry, type SkillTargetRegistry } from "../adapters/outbound/skill-writer/target-registry.js"; import type { CloudClient } from "../adapters/outbound/cloud-client/index.js"; @@ -39,6 +40,10 @@ import { createByokTokenUsageService, type ByokTokenUsageService } from "./byok-token-usage-service.js"; +import { + createAgentTokenStatsService, + type AgentTokenStatsService +} from "./agent-token-stats-service.js"; import { createBootstrapService, type BootstrapScenario, @@ -90,6 +95,8 @@ export interface BackendServices { asr: AsrService; /** Token quota. */ tokenQuota: TokenQuotaService; + /** Agent token stats. */ + agentTokenStats: AgentTokenStatsService; } export interface CreateBackendServicesOptions { @@ -125,6 +132,7 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba createCursorSkillTarget({ memmyConfigPath: options.memmyConfigPath }), createClaudeCodeSkillTarget({ memmyConfigPath: options.memmyConfigPath }), createCodexSkillTarget({ memmyConfigPath: options.memmyConfigPath }), + createPiSkillTarget({ memmyConfigPath: options.memmyConfigPath }), createOpencodeSkillTarget(), createOpenclawSkillTarget({ memmyConfigPath: options.memmyConfigPath }), createHermesSkillTarget({ memmyConfigPath: options.memmyConfigPath }), @@ -246,7 +254,8 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba tokenQuota: createTokenQuotaService({ cloudClient: options.cloudClient, accountSessionRepository: options.appStateStore.repositories.accountSession - }) + }), + agentTokenStats: createAgentTokenStatsService() }; } diff --git a/App/backend/src/services/memory-detail-service.ts b/App/backend/src/services/memory-detail-service.ts index d4ef7e455..b2294816c 100644 --- a/App/backend/src/services/memory-detail-service.ts +++ b/App/backend/src/services/memory-detail-service.ts @@ -3,6 +3,9 @@ import type { AddMemoryInput, AddMemoryOutput, GetMemoryOutput, + MemoryHistoryOutput, + RestoreMemoryInput, + RestoreMemoryOutput, DeleteMemoryInput, DeleteMemoryOutput } from "@memmy/local-api-contracts"; @@ -12,6 +15,8 @@ import type { RuntimeContext } from "./runtime-context.js"; export interface MemoryDetailService { add(input: AddMemoryInput, ctx: RuntimeContext): Promise; getById(id: string, ctx: RuntimeContext): Promise; + history(id: string, ctx: RuntimeContext): Promise; + restore(id: string, targetVersion: number, input: RestoreMemoryInput, ctx: RuntimeContext): Promise; delete(id: string, input: DeleteMemoryInput, ctx: RuntimeContext): Promise; } @@ -27,6 +32,14 @@ export function createMemoryDetailService(deps: { return deps.memoryClient.getMemory({ memoryId: id }); }, + async history(id, _ctx) { + return deps.memoryClient.memoryHistory(id); + }, + + async restore(id, targetVersion, input, _ctx) { + return deps.memoryClient.restoreMemory({ ...input, memoryId: id, targetVersion }); + }, + async delete(id, input, _ctx) { return deps.memoryClient.deleteMemory({ ...input, memoryId: id }); } diff --git a/App/backend/src/services/model-config-tester.ts b/App/backend/src/services/model-config-tester.ts index 5f864fcaf..1192f9552 100644 --- a/App/backend/src/services/model-config-tester.ts +++ b/App/backend/src/services/model-config-tester.ts @@ -355,6 +355,7 @@ function sendOpenAiCompatibleChatProbe( body: JSON.stringify({ model: input.modelId, messages: [{ role: "user", content: "ping" }], + stream: false, // Reasoning tokens consume the output budget first, so the fallback needs enough budget to emit content. [tokenLimitParam]: tokenLimitParam === "max_completion_tokens" ? 128 : input.provider === "baidu" ? 64 : 1 }), diff --git a/App/backend/src/services/panel-service.ts b/App/backend/src/services/panel-service.ts index b7920bbfd..6b065a6b6 100644 --- a/App/backend/src/services/panel-service.ts +++ b/App/backend/src/services/panel-service.ts @@ -8,7 +8,17 @@ import type { DeletePanelTaskOutput, MemoryApiLogsInput, MemoryApiLogsOutput, - PanelOverviewOutput + PanelOverviewOutput, + ProjectContextPackOutput, + ProjectContextFocusInput, + ProjectContextGoalDecisionInput, + ProjectContextProposeGoalInput, + ProjectContextReadState, + ProjectContextWorkItemCreateInput, + ProjectContextWorkItemUpdateInput, + ProjectGoalRecord, + ProjectWorkItemRecord, + RuntimeNamespace, } from "@memmy/local-api-contracts"; import { MemoryLayerError } from "../adapters/outbound/memory-client/index.js"; import type { MemoryClient } from "../adapters/outbound/memory-client/index.js"; @@ -18,6 +28,14 @@ import type { RuntimeContext } from "./runtime-context.js"; export interface PanelService { overview(ctx: RuntimeContext): Promise; analysis(ctx: RuntimeContext): Promise; + contextPack(projectId: string, ctx: RuntimeContext): Promise; + projectContextState(namespace: RuntimeNamespace, ctx: RuntimeContext): Promise; + proposeProjectGoal(input: ProjectContextProposeGoalInput, ctx: RuntimeContext): Promise; + approveProjectGoal(id: string, input: ProjectContextGoalDecisionInput, ctx: RuntimeContext): Promise; + rejectProjectGoal(id: string, input: ProjectContextGoalDecisionInput, ctx: RuntimeContext): Promise; + createProjectWorkItem(input: ProjectContextWorkItemCreateInput, ctx: RuntimeContext): Promise; + updateProjectWorkItem(id: string, input: ProjectContextWorkItemUpdateInput, ctx: RuntimeContext): Promise; + setProjectFocus(input: ProjectContextFocusInput, ctx: RuntimeContext): Promise; items(input: PanelItemsInput, ctx: RuntimeContext): Promise; tasks(input: PanelTasksInput, ctx: RuntimeContext): Promise; deleteTask(id: string, ctx: RuntimeContext): Promise; @@ -35,6 +53,38 @@ export function createPanelService(deps: { memoryClient: MemoryClient }): PanelS return deps.memoryClient.panelAnalysis(); }, + async contextPack(projectId, _ctx) { + return deps.memoryClient.projectContextPack(projectId); + }, + + async projectContextState(namespace, _ctx) { + return deps.memoryClient.projectContextState(namespace); + }, + + async proposeProjectGoal(input, ctx) { + return deps.memoryClient.proposeProjectGoal(withRuntimeProvenance(input, ctx)); + }, + + async approveProjectGoal(id, input, ctx) { + return deps.memoryClient.approveProjectGoal(id, withRuntimeProvenance(input, ctx)); + }, + + async rejectProjectGoal(id, input, ctx) { + return deps.memoryClient.rejectProjectGoal(id, withRuntimeProvenance(input, ctx)); + }, + + async createProjectWorkItem(input, ctx) { + return deps.memoryClient.createProjectWorkItem(withRuntimeProvenance(input, ctx)); + }, + + async updateProjectWorkItem(id, input, ctx) { + return deps.memoryClient.updateProjectWorkItem(id, withRuntimeProvenance(input, ctx)); + }, + + async setProjectFocus(input, ctx) { + return deps.memoryClient.setProjectFocus(withRuntimeProvenance(input, ctx)); + }, + async items(input, _ctx) { return deps.memoryClient.panelItems(input); }, @@ -75,3 +125,12 @@ function isMissingMemoryLogsRoute(error: unknown): boolean { error.message.toLowerCase().includes("logs") ); } + +function withRuntimeProvenance(input: T, ctx: RuntimeContext): T { + return { + ...input, + adapterId: ctx.adapterId, + requestId: ctx.requestId ?? input.requestId, + provenance: { ...input.provenance, adapterId: ctx.adapterId, requestId: ctx.requestId ?? input.requestId } + } as T; +} diff --git a/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts b/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts index 74892a835..a229a0113 100644 --- a/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts +++ b/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts @@ -39,11 +39,12 @@ describe("agent source auto inject service", () => { await expect(service.runOnce()).resolves.toEqual({ ok: true, skipped: false, - installed: ["cursor", "opencode", "openclaw", "workbuddy"], + installed: ["cursor", "pi", "opencode", "openclaw", "workbuddy"], failed: [] }); expect(calls).toEqual([ "plugin:cursor:auto_inject", + "plugin:pi:auto_inject", "plugin:opencode:auto_inject", "plugin:openclaw:auto_inject", "skill:workbuddy", @@ -113,6 +114,7 @@ function createAgentSources(calls: string[]) { return [ source("cursor", "not_connected", true), source("codex", "skill_installed", true), + source("pi", "not_connected", true), source("opencode", "not_connected", true), source("openclaw", "not_connected", true), source("workbuddy", "not_connected", true), diff --git a/App/backend/src/services/tests/builtin-agent-source-registry.test.ts b/App/backend/src/services/tests/builtin-agent-source-registry.test.ts index 6177ef323..7dcccec7f 100644 --- a/App/backend/src/services/tests/builtin-agent-source-registry.test.ts +++ b/App/backend/src/services/tests/builtin-agent-source-registry.test.ts @@ -9,6 +9,7 @@ describe("built-in agent source registry", () => { "cursor", "claude_code", "codex", + "pi", "opencode", "openclaw", "hermes", diff --git a/App/backend/src/services/tests/model-config-tester.test.ts b/App/backend/src/services/tests/model-config-tester.test.ts index e72f197cd..578bcbd57 100644 --- a/App/backend/src/services/tests/model-config-tester.test.ts +++ b/App/backend/src/services/tests/model-config-tester.test.ts @@ -42,6 +42,7 @@ describe("model config tester", () => { expect(JSON.parse(String(calls[0]?.init.body))).toMatchObject({ model: "gpt-5.5", max_tokens: 1, + stream: false, messages: [{ role: "user", content: "ping" }] }); expect(JSON.stringify(result)).not.toContain("sk-test-secret"); diff --git a/App/backend/src/tests/index.test.ts b/App/backend/src/tests/index.test.ts index e24885d3a..800187412 100644 --- a/App/backend/src/tests/index.test.ts +++ b/App/backend/src/tests/index.test.ts @@ -84,7 +84,10 @@ describe("local api", () => { }); expect(reloadReasons).toEqual([{ reason: "desktop_startup" }]); - expect(backend.runtimeConfig.memory).toEqual({ baseUrl: "http://127.0.0.1:18960" }); + expect(backend.runtimeConfig.memory).toEqual({ + baseUrl: "http://127.0.0.1:18960", + ownership: "managed" + }); }); it("uses the built-in default Cloud client when MEMMY_CLOUD_URL is missing", async () => { @@ -1007,7 +1010,7 @@ describe("local api", () => { } }); - it("exposes the seven built-in agent sources in registry order", async () => { + it("exposes the eight built-in agent sources in registry order", async () => { backend = await createTempBackend(); const response = await fetch(`${backend.runtimeConfig.baseUrl}/api/agent-sources`, { @@ -1022,6 +1025,7 @@ describe("local api", () => { expect.objectContaining({ sourceId: "cursor", displayName: "Cursor" }), expect.objectContaining({ sourceId: "claude_code", displayName: "Claude Code" }), expect.objectContaining({ sourceId: "codex", displayName: "Codex" }), + expect.objectContaining({ sourceId: "pi", displayName: "Pi" }), expect.objectContaining({ sourceId: "opencode", displayName: "Opencode" }), expect.objectContaining({ sourceId: "openclaw", displayName: "OpenClaw" }), expect.objectContaining({ sourceId: "hermes", displayName: "Hermes" }), diff --git a/App/backend/src/tests/memmy-config-windows-write.test.ts b/App/backend/src/tests/memmy-config-windows-write.test.ts new file mode 100644 index 000000000..95affc55c --- /dev/null +++ b/App/backend/src/tests/memmy-config-windows-write.test.ts @@ -0,0 +1,59 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import YAML from "yaml"; + +const { renameMock } = vi.hoisted(() => ({ + renameMock: vi.fn() +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + rename: renameMock + }; +}); + +import { patchMcpServerConfigInMemmyConfig } from "../infrastructure/memmy-config/index.js"; + +const temporaryDirectories: string[] = []; +let actualRename: typeof import("node:fs/promises").rename; + +beforeEach(async () => { + const actual = await vi.importActual("node:fs/promises"); + actualRename = actual.rename; + renameMock.mockImplementation(actualRename); +}); + +afterEach(async () => { + renameMock.mockReset(); + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("Memmy config Windows replacement", () => { + it("falls back without losing the config when replacing an existing file returns EPERM", async () => { + const directory = await mkdtemp(join(tmpdir(), "memmy-config-windows-")); + temporaryDirectories.push(directory); + const configPath = join(directory, "config.yaml"); + await writeFile(configPath, "memmyMemory:\n storage:\n runtime: remote\n", "utf8"); + + const permissionError = Object.assign(new Error("operation not permitted"), { code: "EPERM" }); + renameMock.mockRejectedValueOnce(permissionError); + + await patchMcpServerConfigInMemmyConfig( + "composio", + { type: "streamableHttp", url: "http://127.0.0.1:12345/mcp/composio" }, + configPath + ); + + const config = YAML.parse(await readFile(configPath, "utf8")) as { + memmyMemory: { storage: { runtime: string } }; + tools: { mcpServers: { composio: { url: string } } }; + }; + expect(config.memmyMemory.storage.runtime).toBe("remote"); + expect(config.tools.mcpServers.composio.url).toBe("http://127.0.0.1:12345/mcp/composio"); + expect(renameMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/App/backend/src/tests/support/mock-memory-client.ts b/App/backend/src/tests/support/mock-memory-client.ts index acaa73e7b..18ce1a7f3 100644 --- a/App/backend/src/tests/support/mock-memory-client.ts +++ b/App/backend/src/tests/support/mock-memory-client.ts @@ -194,6 +194,30 @@ export function createMockMemoryClient(options: CreateMockMemoryClientOptions = }; }, + async memoryHistory(memoryId) { + failIfNeeded(); + const serverTime = now(); + return { + id: memoryId, + currentVersion: 1, + items: [{ seq: 1, version: 1, changeType: "created", source: "mock", createdAt: serverTime, after: {} }], + serverTime + }; + }, + + async restoreMemory(input) { + failIfNeeded(); + return { + ok: true, + id: input.memoryId, + version: input.version + 1, + restoredVersion: input.targetVersion, + changeSeq: nextChange().changeSeq, + auditId: randomUUID(), + serverTime: now() + }; + }, + async deleteMemory(input) { failIfNeeded(); return { @@ -281,6 +305,21 @@ export function createMockMemoryClient(options: CreateMockMemoryClientOptions = }; }, + async projectContextPack(projectId) { + failIfNeeded(); + return { + namespace: { projectId }, + conventions: [], + commands: [], + architectureFacts: [], + recentTasks: [], + userPreferences: [], + graph: { nodes: [], edges: [] }, + markdown: `# Project Memory Pack: ${projectId}`, + generatedAt: now() + }; + }, + async panelItems() { failIfNeeded(); return { diff --git a/App/backend/vitest.config.ts b/App/backend/vitest.config.ts index 5bc0dc538..77a10ab07 100644 --- a/App/backend/vitest.config.ts +++ b/App/backend/vitest.config.ts @@ -26,6 +26,11 @@ function findRepoEnvFile(startDir: string): string | null { const envPath = findRepoEnvFile(moduleDir); const parsed = envPath ? (loadDotenv({ path: envPath, processEnv: {} }).parsed ?? {}) : {}; +const configuredMaxWorkers = Number.parseInt(process.env.MEMMY_TEST_MAX_WORKERS ?? "", 10); +const isWindowsMountedWorkspace = process.platform === "linux" && /^\/mnt\/[a-z]\//iu.test(moduleDir); +const maxWorkers = Number.isInteger(configuredMaxWorkers) && configuredMaxWorkers > 0 + ? configuredMaxWorkers + : isWindowsMountedWorkspace ? 1 : 4; const testEnv = { ...parsed, MEMMY_CLOUD_SERVICE: "https://cloud.test.invalid" @@ -33,6 +38,8 @@ const testEnv = { export default defineConfig({ test: { + maxWorkers, + testTimeout: 10_000, env: testEnv, coverage: { provider: "v8", diff --git a/App/frontend/desktop/src/analytics/gtag-config.ts b/App/frontend/desktop/src/analytics/gtag-config.ts index c44a68662..d00490229 100644 --- a/App/frontend/desktop/src/analytics/gtag-config.ts +++ b/App/frontend/desktop/src/analytics/gtag-config.ts @@ -7,7 +7,7 @@ export function resolveAnalyticsAppEnv(isProd = import.meta.env.PROD): Analytics /** Matches legal-links: MEMMY_APP_EDITION=intl → intl, otherwise cn. */ export function resolveAnalyticsAppEdition( - rawEdition = import.meta.env.MEMMY_APP_EDITION as string | undefined + rawEdition?: string ): AnalyticsAppEdition { return rawEdition?.trim().toLowerCase() === "intl" ? "intl" : "cn"; } @@ -40,7 +40,7 @@ export function resolveGtagConfigOptions(input?: { return { send_page_view: false, app_env: resolveAnalyticsAppEnv(isProd), - app_edition: input?.appEdition ?? resolveAnalyticsAppEdition(), + app_edition: input?.appEdition ?? resolveAnalyticsAppEdition(import.meta.env.MEMMY_APP_EDITION as string | undefined), ...(debugMode ? { debug_mode: 1 } : {}) }; } diff --git a/App/frontend/desktop/src/analytics/page-view.ts b/App/frontend/desktop/src/analytics/page-view.ts index 9bebe1750..74752ca56 100644 --- a/App/frontend/desktop/src/analytics/page-view.ts +++ b/App/frontend/desktop/src/analytics/page-view.ts @@ -27,6 +27,7 @@ const MEMORY_SUB_PAGE_TITLES: Record = { "world-model": "World Model", skills: "Skills", analytics: "Analytics", + "token-stats": "Token Stats", logs: "Logs", sources: "Sources" }; diff --git a/App/frontend/desktop/src/api/agent-token-stats-client.ts b/App/frontend/desktop/src/api/agent-token-stats-client.ts new file mode 100644 index 000000000..20eaf3b1b --- /dev/null +++ b/App/frontend/desktop/src/api/agent-token-stats-client.ts @@ -0,0 +1,22 @@ +import { + AgentTokenStatsResponseSchema, + type AgentTokenStatsResponse, + type RuntimeConfig +} from "@memmy/local-api-contracts"; +import { requestJson } from "./http.js"; + +export interface AgentTokenStatsClient { + getStats(): Promise; +} + +export function createHttpAgentTokenStatsClient(config: RuntimeConfig): AgentTokenStatsClient { + return { + async getStats() { + return requestJson({ + config, + path: "/api/app/agent-token-stats", + schema: AgentTokenStatsResponseSchema + }); + } + }; +} diff --git a/App/frontend/desktop/src/api/client-types.ts b/App/frontend/desktop/src/api/client-types.ts index 14c3325ac..5e1158807 100644 --- a/App/frontend/desktop/src/api/client-types.ts +++ b/App/frontend/desktop/src/api/client-types.ts @@ -7,6 +7,10 @@ import { createHttpByokTokenUsageClient, type ByokTokenUsageClient } from "./byok-token-usage-client.js"; +import { + createHttpAgentTokenStatsClient, + type AgentTokenStatsClient +} from "./agent-token-stats-client.js"; import { createHttpChannelsClient, type ChannelsClient } from "./channels-client.js"; import { createHttpConfigClient, type ConfigClient } from "./config-client.js"; import { @@ -32,6 +36,7 @@ export interface AppClients { asr: AsrClient; memmyAgent: MemmyAgentClient; tokenQuota: TokenQuotaClient; + agentTokenStats: AgentTokenStatsClient; } export interface CreateAppClientsInput { @@ -56,6 +61,7 @@ export function createAppClients(input: CreateAppClientsInput): AppClients { byokTokenUsage: createHttpByokTokenUsageClient(input.runtimeConfig), asr: createHttpAsrClient(input.runtimeConfig), memmyAgent: createMemmyAgentClient(input.runtimeConfig.agentGateway), - tokenQuota: createHttpTokenQuotaClient(input.runtimeConfig) + tokenQuota: createHttpTokenQuotaClient(input.runtimeConfig), + agentTokenStats: createHttpAgentTokenStatsClient(input.runtimeConfig) }; } diff --git a/App/frontend/desktop/src/api/memory-runtime-client.ts b/App/frontend/desktop/src/api/memory-runtime-client.ts index 085382845..62b6dba4d 100644 --- a/App/frontend/desktop/src/api/memory-runtime-client.ts +++ b/App/frontend/desktop/src/api/memory-runtime-client.ts @@ -11,6 +11,7 @@ import { MemoryApiLogsInputSchema, MemoryApiLogsOutputSchema, MemoryHealthSnapshotSchema, + MemoryHistoryOutputSchema, MemoryProcessingStatusInputSchema, MemoryProcessingStatusOutputSchema, MemoryReloadConfigInputSchema, @@ -21,6 +22,16 @@ import { PanelItemsInputSchema, PanelItemsOutputSchema, PanelOverviewOutputSchema, + ProjectContextPackOutputSchema, + ProjectContextFocusInputSchema, + ProjectContextGoalDecisionInputSchema, + ProjectContextProposeGoalInputSchema, + ProjectContextReadStateSchema, + ProjectContextWorkItemCreateInputSchema, + ProjectContextWorkItemUpdateInputSchema, + ProjectGoalRecordSchema, + ProjectWorkItemRecordSchema, + RuntimeNamespaceSchema, PanelTasksInputSchema, PanelTasksOutputSchema, SearchInputSchema, @@ -28,6 +39,8 @@ import { StartTurnInputSchema, StartTurnOutputSchema, RetryMemoryProcessingOutputSchema, + RestoreMemoryInputSchema, + RestoreMemoryOutputSchema, type CloseSessionInput, type CloseSessionOutput, type CompleteTurnInput, @@ -40,6 +53,7 @@ import { type MemoryApiLogsInput, type MemoryApiLogsOutput, type MemoryHealthSnapshot, + type MemoryHistoryOutput, type MemoryProcessingStatusOutput, type MemoryReloadConfigInput, type MemoryReloadConfigOutput, @@ -49,6 +63,16 @@ import { type PanelItemsInput, type PanelItemsOutput, type PanelOverviewOutput, + type ProjectContextPackOutput, + type ProjectContextFocusInput, + type ProjectContextGoalDecisionInput, + type ProjectContextProposeGoalInput, + type ProjectContextReadState, + type ProjectContextWorkItemCreateInput, + type ProjectContextWorkItemUpdateInput, + type ProjectGoalRecord, + type ProjectWorkItemRecord, + type RuntimeNamespace, type PanelTasksInput, type PanelTasksOutput, type SearchInput, @@ -56,6 +80,8 @@ import { type StartTurnInput, type StartTurnOutput, type RetryMemoryProcessingOutput, + type RestoreMemoryInput, + type RestoreMemoryOutput, type RuntimeConfig } from "@memmy/local-api-contracts"; import { ApiRequestError, requestJson } from "./http.js"; @@ -72,10 +98,20 @@ export const MEMORY_RUNTIME_ENDPOINTS = [ "POST /api/v1/memory/processing/status", "POST /api/v1/memory/:id/processing/retry", "GET /api/v1/memory/:id", + "GET /api/v1/memory/:id/history", + "POST /api/v1/memory/:id/history/:version/restore", "DELETE /api/v1/memory/:id", "GET /api/v1/memory/logs", "GET /api/v1/panel/overview", "GET /api/v1/panel/analysis", + "GET /api/v1/panel/context-pack", + "GET /api/v1/project-context/state", + "POST /api/v1/project-context/goals/propose", + "POST /api/v1/project-context/goals/:id/approve", + "POST /api/v1/project-context/goals/:id/reject", + "POST /api/v1/project-context/work-items", + "PATCH /api/v1/project-context/work-items/:id", + "PUT /api/v1/project-context/focus", "GET /api/v1/panel/items", "GET /api/v1/panel/tasks", "DELETE /api/v1/panel/tasks/:id" @@ -90,13 +126,23 @@ export interface MemoryRuntimeClient { completeTurn(turnId: string, input: CompleteTurnInput): Promise; search(input: SearchInput): Promise; addMemory(input: AddMemoryInput): Promise; - getMemory(id: string): Promise; + getMemory(id: string, options?: { signal?: AbortSignal }): Promise; + getMemoryHistory(id: string, options?: { signal?: AbortSignal }): Promise; + restoreMemory(id: string, targetVersion: number, input: RestoreMemoryInput): Promise; deleteMemory(id: string): Promise; getMemoryProcessingStatus(memoryIds: string[]): Promise; retryMemoryProcessing(id: string): Promise; listMemoryLogs(input: MemoryApiLogsInput): Promise; getPanelOverview(): Promise; getPanelAnalysis(): Promise; + getProjectContextPack(projectId: string): Promise; + getProjectContextState(namespace: RuntimeNamespace): Promise; + proposeProjectGoal(input: ProjectContextProposeGoalInput): Promise; + approveProjectGoal(id: string, input: ProjectContextGoalDecisionInput): Promise; + rejectProjectGoal(id: string, input: ProjectContextGoalDecisionInput): Promise; + createProjectWorkItem(input: ProjectContextWorkItemCreateInput): Promise; + updateProjectWorkItem(id: string, input: ProjectContextWorkItemUpdateInput): Promise; + setProjectFocus(input: ProjectContextFocusInput): Promise; listPanelItems(input: PanelItemsInput): Promise; listPanelTasks(input: PanelTasksInput): Promise; deletePanelTask(id: string): Promise; @@ -156,8 +202,31 @@ export function createHttpMemoryRuntimeClient(config: RuntimeConfig): MemoryRunt return requestJson({ config, path: "/api/v1/memory/add", schema: AddMemoryOutputSchema, body: AddMemoryInputSchema.parse(input) }); }, - async getMemory(id) { - return requestJson({ config, path: `/api/v1/memory/${encodeURIComponent(id)}`, schema: GetMemoryOutputSchema }); + async getMemory(id, options) { + return requestJson({ + config, + path: `/api/v1/memory/${encodeURIComponent(id)}`, + schema: GetMemoryOutputSchema, + init: { signal: options?.signal } + }); + }, + + async getMemoryHistory(id, options) { + return requestJson({ + config, + path: `/api/v1/memory/${encodeURIComponent(id)}/history`, + schema: MemoryHistoryOutputSchema, + init: { signal: options?.signal } + }); + }, + + async restoreMemory(id, targetVersion, input) { + return requestJson({ + config, + path: `/api/v1/memory/${encodeURIComponent(id)}/history/${targetVersion}/restore`, + schema: RestoreMemoryOutputSchema, + body: RestoreMemoryInputSchema.parse(input) + }); }, async deleteMemory(id) { @@ -203,6 +272,42 @@ export function createHttpMemoryRuntimeClient(config: RuntimeConfig): MemoryRunt return requestJson({ config, path: "/api/v1/panel/analysis", schema: PanelAnalysisOutputSchema }); }, + async getProjectContextPack(projectId) { + return requestJson({ + config, + path: withQuery("/api/v1/panel/context-pack", { projectId }), + schema: ProjectContextPackOutputSchema + }); + }, + async getProjectContextState(namespace) { + const parsed = RuntimeNamespaceSchema.parse(namespace); + return requestJson({ config, path: withQuery("/api/v1/project-context/state", { namespace: JSON.stringify(parsed) }), schema: ProjectContextReadStateSchema }); + }, + + async proposeProjectGoal(input) { + return requestJson({ config, path: "/api/v1/project-context/goals/propose", schema: ProjectGoalRecordSchema, body: ProjectContextProposeGoalInputSchema.parse(input) }); + }, + + async approveProjectGoal(id, input) { + return requestJson({ config, path: `/api/v1/project-context/goals/${encodeURIComponent(id)}/approve`, schema: ProjectGoalRecordSchema, body: ProjectContextGoalDecisionInputSchema.parse(input) }); + }, + + async rejectProjectGoal(id, input) { + return requestJson({ config, path: `/api/v1/project-context/goals/${encodeURIComponent(id)}/reject`, schema: ProjectGoalRecordSchema, body: ProjectContextGoalDecisionInputSchema.parse(input) }); + }, + + async createProjectWorkItem(input) { + return requestJson({ config, path: "/api/v1/project-context/work-items", schema: ProjectWorkItemRecordSchema, body: ProjectContextWorkItemCreateInputSchema.parse(input) }); + }, + + async updateProjectWorkItem(id, input) { + return requestJson({ config, path: `/api/v1/project-context/work-items/${encodeURIComponent(id)}`, schema: ProjectWorkItemRecordSchema, body: ProjectContextWorkItemUpdateInputSchema.parse(input), init: { method: "PATCH" } }); + }, + + async setProjectFocus(input) { + return requestJson({ config, path: "/api/v1/project-context/focus", schema: ProjectWorkItemRecordSchema.nullable(), body: ProjectContextFocusInputSchema.parse(input), init: { method: "PUT" } }); + }, + async listPanelItems(input) { return requestJson({ config, path: withQuery("/api/v1/panel/items", PanelItemsInputSchema.parse(input)), schema: PanelItemsOutputSchema }); }, @@ -276,6 +381,12 @@ export function createUnavailableMemoryRuntimeClient(): MemoryRuntimeClient { async getMemory() { throw unavailable(); }, + async getMemoryHistory() { + throw unavailable(); + }, + async restoreMemory() { + throw unavailable(); + }, async deleteMemory() { throw unavailable(); }, @@ -294,6 +405,16 @@ export function createUnavailableMemoryRuntimeClient(): MemoryRuntimeClient { async getPanelAnalysis() { throw unavailable(); }, + async getProjectContextPack() { + throw unavailable(); + }, + async getProjectContextState() { throw unavailable(); }, + async proposeProjectGoal() { throw unavailable(); }, + async approveProjectGoal() { throw unavailable(); }, + async rejectProjectGoal() { throw unavailable(); }, + async createProjectWorkItem() { throw unavailable(); }, + async updateProjectWorkItem() { throw unavailable(); }, + async setProjectFocus() { throw unavailable(); }, async listPanelItems() { throw unavailable(); }, diff --git a/App/frontend/desktop/src/api/tests/client-selection.test.ts b/App/frontend/desktop/src/api/tests/client-selection.test.ts index 84044efe9..24b33f50e 100644 --- a/App/frontend/desktop/src/api/tests/client-selection.test.ts +++ b/App/frontend/desktop/src/api/tests/client-selection.test.ts @@ -5,7 +5,7 @@ import { createAppClients } from "../client-types.js"; const runtimeConfig: RuntimeConfig = { baseUrl: "http://127.0.0.1:18100", localToken: "token", - memory: { baseUrl: "http://127.0.0.1:18960" } + memory: { baseUrl: "http://127.0.0.1:18960", ownership: "managed" } }; afterEach(() => { diff --git a/App/frontend/desktop/src/api/tests/memory-runtime-client.test.ts b/App/frontend/desktop/src/api/tests/memory-runtime-client.test.ts index fcf56e898..13a493d20 100644 --- a/App/frontend/desktop/src/api/tests/memory-runtime-client.test.ts +++ b/App/frontend/desktop/src/api/tests/memory-runtime-client.test.ts @@ -12,7 +12,7 @@ describe("memory runtime client", () => { }); it("declares the memory runtime endpoints exposed under /api/v1", () => { - expect(MEMORY_RUNTIME_ENDPOINTS).toHaveLength(18); + expect(MEMORY_RUNTIME_ENDPOINTS).toHaveLength(28); expect(MEMORY_RUNTIME_ENDPOINTS).toEqual([ "GET /api/v1/health", "POST /api/v1/admin/reload-config", @@ -25,10 +25,20 @@ describe("memory runtime client", () => { "POST /api/v1/memory/processing/status", "POST /api/v1/memory/:id/processing/retry", "GET /api/v1/memory/:id", + "GET /api/v1/memory/:id/history", + "POST /api/v1/memory/:id/history/:version/restore", "DELETE /api/v1/memory/:id", "GET /api/v1/memory/logs", "GET /api/v1/panel/overview", "GET /api/v1/panel/analysis", + "GET /api/v1/panel/context-pack", + "GET /api/v1/project-context/state", + "POST /api/v1/project-context/goals/propose", + "POST /api/v1/project-context/goals/:id/approve", + "POST /api/v1/project-context/goals/:id/reject", + "POST /api/v1/project-context/work-items", + "PATCH /api/v1/project-context/work-items/:id", + "PUT /api/v1/project-context/focus", "GET /api/v1/panel/items", "GET /api/v1/panel/tasks", "DELETE /api/v1/panel/tasks/:id" @@ -184,4 +194,114 @@ describe("memory runtime client", () => { const otherUrl = fetchMock.mock.calls[1]?.[0] as URL; expect(otherUrl.searchParams.getAll("excludedSourceAgents")).toEqual(["memmy-agent", "cursor"]); }); + + it("loads a project context pack through the scoped local API route", async () => { + const fetchMock = vi.fn(async (_input: URL | RequestInfo) => new Response(JSON.stringify({ + namespace: { projectId: "project-1" }, + conventions: [], + commands: [], + architectureFacts: [], + recentTasks: [], + userPreferences: [], + graph: { nodes: [], edges: [] }, + markdown: "# Project Memory Pack: project-1", + generatedAt: "2026-08-08T12:00:00.000Z" + }), { status: 200, headers: { "content-type": "application/json" } })); + vi.stubGlobal("fetch", fetchMock); + + const client = createHttpMemoryRuntimeClient(runtimeConfig); + await expect(client.getProjectContextPack("project-1")).resolves.toMatchObject({ namespace: { projectId: "project-1" } }); + + const url = fetchMock.mock.calls[0]?.[0] as URL; + expect(url.pathname).toBe("/api/v1/panel/context-pack"); + expect(url.searchParams.get("projectId")).toBe("project-1"); + }); + + it("reads and mutates authoritative project context through typed routes", async () => { + const fetchMock = vi.fn(async (input: URL | RequestInfo, _init?: RequestInit) => { + const path = (input as URL).pathname; + const body = path.endsWith("/state") + ? { namespaceId: "namespace-1", activeGoal: null, goals: [], workItems: [], focusedWorkItem: null, facts: [] } + : path.endsWith("/focus") + ? null + : { id: "goal-1", namespaceId: "namespace-1", userId: "user-1", projectId: "project-1", title: "Goal", summary: "", detail: "", acceptanceCriteria: [], constraints: [], status: "active", version: 1, sourceMemoryIds: [], provenance: {}, createdAt: "2026-08-08T12:00:00.000Z", updatedAt: "2026-08-08T12:00:00.000Z" }; + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + }); + vi.stubGlobal("fetch", fetchMock); + const client = createHttpMemoryRuntimeClient(runtimeConfig); + const namespace = { source: "desktop", profileId: "default", projectId: "project-1" }; + const mutation = { namespace, source: "desktop", adapterId: "memmy-desktop", requestId: "request-1", provenance: { sourceAgent: "memmy-desktop", sourceMemoryIds: [], capturedAt: "2026-08-08T12:00:00.000Z", projectId: "project-1" } }; + + await client.getProjectContextState(namespace); + await client.approveProjectGoal("goal/1", mutation); + await client.setProjectFocus({ ...mutation, workItemId: null }); + + expect((fetchMock.mock.calls[0]?.[0] as URL).pathname).toBe("/api/v1/project-context/state"); + expect((fetchMock.mock.calls[0]?.[0] as URL).searchParams.get("namespace")).toBe(JSON.stringify(namespace)); + expect((fetchMock.mock.calls[1]?.[0] as URL).pathname).toBe("/api/v1/project-context/goals/goal%2F1/approve"); + expect(fetchMock.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ body: JSON.stringify(mutation) })); + expect((fetchMock.mock.calls[2]?.[0] as URL).pathname).toBe("/api/v1/project-context/focus"); + }); + + it("passes a memory detail abort signal through to fetch", async () => { + const fetchMock = vi.fn(async (_input: URL | RequestInfo, _init?: RequestInit) => new Response(JSON.stringify({ + item: { + id: "memory-1", + kind: "trace", + memoryLayer: "L1", + status: "activated", + title: "Memory one", + summary: "", + tags: [], + createdAt: "2026-08-08T10:00:00.000Z", + updatedAt: "2026-08-08T12:00:00.000Z", + version: 1, + body: "Body", + sourceMemoryIds: [], + metadata: {} + }, + version: 1 + }), { status: 200, headers: { "content-type": "application/json" } })); + vi.stubGlobal("fetch", fetchMock); + const controller = new AbortController(); + + await createHttpMemoryRuntimeClient(runtimeConfig).getMemory("memory-1", { signal: controller.signal }); + + expect(fetchMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ signal: controller.signal })); + }); + + it("loads history and restores a selected source version", async () => { + const fetchMock = vi.fn(async (input: URL | RequestInfo, _init?: RequestInit) => { + const url = input as URL; + const restoring = url.pathname.endsWith("/restore"); + return new Response(JSON.stringify(restoring ? { + ok: true, + id: "memory-1", + version: 4, + restoredVersion: 1, + changeSeq: 4, + auditId: "audit-restore-1", + serverTime: "2026-08-08T13:00:00.000Z" + } : { + id: "memory-1", + currentVersion: 3, + items: [{ seq: 1, version: 1, changeType: "created", source: "turn_complete", createdAt: "2026-08-07T10:00:00.000Z", after: {} }], + serverTime: "2026-08-08T12:00:00.000Z" + }), { status: 200, headers: { "content-type": "application/json" } }); + }); + vi.stubGlobal("fetch", fetchMock); + const client = createHttpMemoryRuntimeClient(runtimeConfig); + + await expect(client.getMemoryHistory("memory-1")).resolves.toMatchObject({ currentVersion: 3 }); + await expect(client.restoreMemory("memory-1", 1, { version: 3, reason: "restored from desktop context pack" })) + .resolves.toMatchObject({ version: 4, restoredVersion: 1 }); + + const restoreUrl = fetchMock.mock.calls[1]?.[0] as URL; + const restoreInit = fetchMock.mock.calls[1]?.[1]; + expect(restoreUrl.pathname).toBe("/api/v1/memory/memory-1/history/1/restore"); + expect(restoreInit).toEqual(expect.objectContaining({ + method: "POST", + body: JSON.stringify({ version: 3, reason: "restored from desktop context pack" }) + })); + }); }); diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index 092842441..5a83a87b0 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -28,6 +28,7 @@ export const zhCNMessages = { "common.confirm": "确认", "common.save": "保存", "common.close": "关闭", + "common.retry": "重试", "common.dismiss": "知道了", "common.continue": "继续", "common.loading": "加载中", @@ -310,10 +311,10 @@ export const zhCNMessages = { "onboarding.permission.title": "Memmy 需要你的授权", "onboarding.permission.subtitle": "首次导入历史,之后让每个 AI 自动接上上下文", "onboarding.permission.scanTitle": "扫描已有 Agent 对话", - "onboarding.permission.scanBody": "首次读取 Cursor / Codex / WorkBuddy 等本地对话历史,转成可复用记忆", - "onboarding.permission.writeTitle": "让常用 Agent 自动接入 Memmy 记忆", - "onboarding.permission.writeBody": "继续使用原来的 Agent,切换工具或新开对话时,自动同步与注入相关背景", - "onboarding.permission.notice": "你可以随时在「记忆管理 -> 跨 Agent 接入」中调整授权", + "onboarding.permission.scanBody": "读取 Cursor / Codex / Pi / WorkBuddy 等本地历史对话,生成你的记忆", + "onboarding.permission.writeTitle": "允许其他Agent使用Memmy的记忆", + "onboarding.permission.writeBody": "将会通过插件 / CLI / 修改 AGENTS.md 等方式引导消费记忆", + "onboarding.permission.notice": "你可以随时在「记忆管理 -> 接入源管理」中调整授权", "onboarding.permission.none": "不允许", "onboarding.permission.scan": "仅允许扫描", "onboarding.permission.all": "全部允许", @@ -500,6 +501,71 @@ export const zhCNMessages = { "home.project.standalone": "不使用项目", "home.project.clear": "清除项目", "home.project.desktopRequired": "请在 Memmy 桌面端打开本地文件夹", + "home.contextPack.trigger": "上下文包", + "home.contextPack.open": "查看当前项目上下文包", + "home.contextPack.title": "项目上下文包", + "home.contextPack.loading": "正在生成项目上下文包...", + "home.contextPack.error": "上下文包暂时无法读取", + "home.contextPack.empty": "当前项目还没有可用的上下文记忆", + "home.contextPack.generated": "由当前项目的有效记忆实时生成", + "home.contextPack.copy": "复制 Markdown", + "home.contextPack.copied": "已复制", + "home.contextPack.conventions": "项目约定", + "home.contextPack.commands": "常用命令", + "home.contextPack.architectureFacts": "架构事实", + "home.contextPack.recentTasks": "最近任务", + "home.contextPack.userPreferences": "用户偏好", + "home.contextPack.governance.title": "项目治理", + "home.contextPack.governance.loading": "正在读取确认状态...", + "home.contextPack.governance.error": "项目治理状态暂时无法读取", + "home.contextPack.governance.mutationError": "操作未完成,当前状态已保留,请重试。", + "home.contextPack.governance.currentGoal": "当前目标", + "home.contextPack.governance.currentFocus": "当前焦点", + "home.contextPack.governance.none": "未设置", + "home.contextPack.governance.candidate": "候选目标", + "home.contextPack.governance.workItem": "工作项", + "home.contextPack.governance.approve": "批准目标", + "home.contextPack.governance.reject": "拒绝", + "home.contextPack.governance.setFocus": "设为焦点", + "home.contextPack.governance.clearFocus": "取消焦点", + "home.contextPack.detail.title": "记忆详情", + "home.contextPack.detail.back": "返回上下文包", + "home.contextPack.detail.loading": "正在加载记忆详情...", + "home.contextPack.detail.error": "这条记忆暂时无法读取", + "home.contextPack.detail.metadata": "基本信息", + "home.contextPack.detail.content": "完整内容", + "home.contextPack.detail.evidence": "证据记忆", + "home.contextPack.detail.versionRelations": "版本关系", + "home.contextPack.detail.provenance": "来源记录", + "home.contextPack.detail.agent": "来源 Agent", + "home.contextPack.detail.repository": "仓库", + "home.contextPack.detail.branch": "分支", + "home.contextPack.detail.commit": "提交", + "home.contextPack.detail.capturedAt": "采集时间", + "home.contextPack.detail.supersedes": "替代的记忆", + "home.contextPack.detail.supersededBy": "被此记忆替代", + "home.contextPack.detail.reason": "变更原因", + "home.contextPack.detail.none": "无", + "home.contextPack.detail.empty": "无正文内容", + "home.contextPack.detail.unknown": "未知", + "home.contextPack.detail.openGraph": "查看关系图", + "home.contextPack.graph.title": "记忆关系图", + "home.contextPack.graph.back": "返回记忆详情", + "home.contextPack.graph.locate": "定位所选记忆", + "home.contextPack.graph.openMemory": "打开记忆详情", + "home.contextPack.graph.empty": "当前记忆没有可用的关系图", + "home.contextPack.history.title": "版本历史", + "home.contextPack.history.loading": "正在加载版本历史...", + "home.contextPack.history.error": "版本历史暂时无法读取", + "home.contextPack.history.empty": "暂无版本记录", + "home.contextPack.history.current": "当前版本", + "home.contextPack.history.restore": "恢复", + "home.contextPack.history.confirm": "确认恢复此版本?当前内容会保留在版本历史中。", + "home.contextPack.history.cancel": "取消", + "home.contextPack.history.confirmAction": "确认恢复", + "home.contextPack.history.restoring": "正在恢复...", + "home.contextPack.history.success": "已恢复来源版本", + "home.contextPack.history.restoreError": "恢复失败,记忆可能已被其他进程修改,请重新加载后再试。", "home.agent.restart": "重启 Agent", "home.agent.restarting": "正在重启 Agent", "home.agent.restartCompleted": "Agent 重启完成", @@ -709,11 +775,13 @@ export const zhCNMessages = { "memory.cliNotInstalled": "未安装", "memory.daemonRunning": "运行中", "memory.daemonStopped": "已停止", - "memory.preferences": "自动同步", - "memory.autoScan": "自动同步会话", - "memory.autoScanDescription": "自动从已接入的 Agent 采集新对话,无需手动点「同步新增」", - "memory.autoInject": "发现新 Agent 时自动接入", - "memory.autoInjectDescription": "自动安装接入组件;关闭后只出现在下方列表,由你手动接入", + "memory.preferences": "扫描行为", + "memory.autoScan": "自动扫描已知 Agent", + "memory.autoScanDescription": "启动时扫描 Cursor / Codex / Pi / Claude Code / WorkBuddy 等已安装 Agent 的新对话", + "memory.watchFiles": "自动增量同步", + "memory.watchFilesDescription": "自动跟进 Agent 会话文件的新增内容", + "memory.autoInject": "新发现 Agent 自动安装 Hook/插件", + "memory.autoInjectDescription": "自动为新发现的 Agent 安装对应的 Hook 或插件,Skill 会随接入一并安装;关闭后,新发现的 Agent 仅出现在下方列表中等待你手动安装", "memory.scan": "同步新增", "memory.syncNew": "同步新增", "memory.syncCompleted": "同步完成", @@ -762,6 +830,7 @@ export const zhCNMessages = { "memory.nav.skills": "技能", "memory.nav.analytics": "分析", "memory.nav.logs": "日志", + "memory.nav.tokenStats": "Token 用量", "memory.sourcesNav": "跨Agent接入", "memory.overview.title": "概览", "memory.overview.loading": "正在加载记忆概览", @@ -979,6 +1048,26 @@ export const zhCNMessages = { "memory.analytics.trendTitle": "最近30天 - 记忆写入 vs 调用", "memory.analytics.writes": "写入", "memory.analytics.calls": "调用", + "memory.tokenStats.title": "Token 用量统计", + "memory.tokenStats.description": "Pi、Codex、Claude Code 各 Agent 的 Token 消耗", + "memory.tokenStats.loading": "正在扫描会话文件...", + "memory.tokenStats.empty": "未找到 Token 用量数据", + "memory.tokenStats.project": "项目", + "memory.tokenStats.allProjects": "所有项目", + "memory.tokenStats.pi": "Pi", + "memory.tokenStats.codex": "Codex", + "memory.tokenStats.claudeCode": "Claude Code", + "memory.tokenStats.sessions": "会话数", + "memory.tokenStats.apiCalls": "API 调用数", + "memory.tokenStats.inputTokens": "输入 Token", + "memory.tokenStats.outputTokens": "输出 Token", + "memory.tokenStats.cacheRead": "缓存读取", + "memory.tokenStats.cacheWrite": "缓存写入", + "memory.tokenStats.reasoning": "推理", + "memory.tokenStats.total": "总 Token", + "memory.tokenStats.cost": "预估费用", + "memory.tokenStats.unavailable": "Token 数据不可用", + "memory.tokenStats.combined": "合计", "memory.worldModel.title": "场域认知", "memory.worldModel.description": "助手对你工作场景的整体认知——常用工具、文件布局、反复出现的约束。", "memory.worldModel.loading": "正在加载场域认知", @@ -1108,6 +1197,12 @@ export const zhCNMessages = { "memory.restartServiceStillUnavailable": "记忆服务已重启,但健康检查仍未通过", "memory.restartServiceStillUnavailableWithReason": "记忆服务已重启,但健康检查仍未通过:{reason}", "memory.restartServiceUnavailable": "当前运行环境不支持重启记忆服务", + "memory.reconnectService": "重新连接", + "memory.reconnectServiceBusy": "连接中...", + "memory.reconnectServiceDone": "已重新连接记忆服务", + "memory.reconnectServiceFailed": "重新连接记忆服务失败:{reason}", + "memory.reconnectServiceStillUnavailable": "记忆服务仍不可用", + "memory.reconnectServiceStillUnavailableWithReason": "记忆服务仍不可用:{reason}", "memory.installSkill": "安装 Skill", "memory.installHook": "安装 Hook", "memory.installPlugin": "安装插件", @@ -1421,6 +1516,7 @@ export const enUSMessages: Record = { "common.confirm": "Confirm", "common.save": "Save", "common.close": "Close", + "common.retry": "Retry", "common.dismiss": "Dismiss", "common.continue": "Continue", "common.loading": "Loading", @@ -1703,10 +1799,10 @@ export const enUSMessages: Record = { "onboarding.permission.title": "Memmy needs your authorization", "onboarding.permission.subtitle": "Import history once, then let every AI pick up the context", "onboarding.permission.scanTitle": "Scan existing Agent conversations", - "onboarding.permission.scanBody": "First read of local Cursor / Codex / WorkBuddy chat history, turned into reusable memory", - "onboarding.permission.writeTitle": "Let your usual Agents connect to Memmy memory", - "onboarding.permission.writeBody": "Keep using your Agents; when you switch tools or start a new chat, relevant context is synced and injected automatically", - "onboarding.permission.notice": "You can change this later in Memory -> Cross-Agent access", + "onboarding.permission.scanBody": "Read local Cursor / Codex / Pi / WorkBuddy histories and generate your memory", + "onboarding.permission.writeTitle": "Allow other Agents to use Memmy memory", + "onboarding.permission.writeBody": "Guide memory consumption through plugins / CLI / modifying AGENTS.md, etc.", + "onboarding.permission.notice": "You can change this later in Memory -> Sources", "onboarding.permission.none": "Deny", "onboarding.permission.scan": "Scan only", "onboarding.permission.all": "Allow all", @@ -1893,6 +1989,71 @@ export const enUSMessages: Record = { "home.project.standalone": "No project", "home.project.clear": "Clear project", "home.project.desktopRequired": "Open a local folder in the Memmy desktop app", + "home.contextPack.trigger": "Context pack", + "home.contextPack.open": "View the current project context pack", + "home.contextPack.title": "Project context pack", + "home.contextPack.loading": "Generating the project context pack...", + "home.contextPack.error": "The context pack is temporarily unavailable", + "home.contextPack.empty": "This project has no context memory yet", + "home.contextPack.generated": "Generated live from active memory for this project", + "home.contextPack.copy": "Copy Markdown", + "home.contextPack.copied": "Copied", + "home.contextPack.conventions": "Project conventions", + "home.contextPack.commands": "Common commands", + "home.contextPack.architectureFacts": "Architecture facts", + "home.contextPack.recentTasks": "Recent tasks", + "home.contextPack.userPreferences": "User preferences", + "home.contextPack.governance.title": "Project governance", + "home.contextPack.governance.loading": "Loading confirmed project state...", + "home.contextPack.governance.error": "Project governance is temporarily unavailable", + "home.contextPack.governance.mutationError": "The action did not complete. Existing state was preserved; try again.", + "home.contextPack.governance.currentGoal": "Current goal", + "home.contextPack.governance.currentFocus": "Current focus", + "home.contextPack.governance.none": "Not set", + "home.contextPack.governance.candidate": "Candidate goal", + "home.contextPack.governance.workItem": "Work item", + "home.contextPack.governance.approve": "Approve goal", + "home.contextPack.governance.reject": "Reject", + "home.contextPack.governance.setFocus": "Set focus", + "home.contextPack.governance.clearFocus": "Clear focus", + "home.contextPack.detail.title": "Memory detail", + "home.contextPack.detail.back": "Back to context pack", + "home.contextPack.detail.loading": "Loading memory detail...", + "home.contextPack.detail.error": "This memory is temporarily unavailable", + "home.contextPack.detail.metadata": "Details", + "home.contextPack.detail.content": "Full content", + "home.contextPack.detail.evidence": "Evidence memories", + "home.contextPack.detail.versionRelations": "Version relations", + "home.contextPack.detail.provenance": "Provenance", + "home.contextPack.detail.agent": "Source agent", + "home.contextPack.detail.repository": "Repository", + "home.contextPack.detail.branch": "Branch", + "home.contextPack.detail.commit": "Commit", + "home.contextPack.detail.capturedAt": "Captured", + "home.contextPack.detail.supersedes": "Supersedes", + "home.contextPack.detail.supersededBy": "Superseded by", + "home.contextPack.detail.reason": "Reason", + "home.contextPack.detail.none": "None", + "home.contextPack.detail.empty": "No body content", + "home.contextPack.detail.unknown": "Unknown", + "home.contextPack.detail.openGraph": "View relation graph", + "home.contextPack.graph.title": "Memory relation graph", + "home.contextPack.graph.back": "Back to memory detail", + "home.contextPack.graph.locate": "Locate selected memory", + "home.contextPack.graph.openMemory": "Open memory detail", + "home.contextPack.graph.empty": "No relation graph is available for this memory", + "home.contextPack.history.title": "Version history", + "home.contextPack.history.loading": "Loading version history...", + "home.contextPack.history.error": "Version history is temporarily unavailable", + "home.contextPack.history.empty": "No version history", + "home.contextPack.history.current": "Current version", + "home.contextPack.history.restore": "Restore", + "home.contextPack.history.confirm": "Restore this version? The current content will remain in version history.", + "home.contextPack.history.cancel": "Cancel", + "home.contextPack.history.confirmAction": "Confirm restore", + "home.contextPack.history.restoring": "Restoring...", + "home.contextPack.history.success": "Restored from source version", + "home.contextPack.history.restoreError": "Restore failed. The memory may have changed elsewhere; reload and try again.", "home.agent.restart": "Restart Agent", "home.agent.restarting": "Restarting Agent", "home.agent.restartCompleted": "Agent restarted", @@ -2102,11 +2263,13 @@ export const enUSMessages: Record = { "memory.cliNotInstalled": "Not installed", "memory.daemonRunning": "Running", "memory.daemonStopped": "Stopped", - "memory.preferences": "Auto sync", - "memory.autoScan": "Auto-sync conversations", - "memory.autoScanDescription": "Automatically collect new conversations from connected Agents—no need to click Sync new", - "memory.autoInject": "Auto-connect newly found Agents", - "memory.autoInjectDescription": "Install the integration automatically; when off, new Agents only appear in the list below for you to connect manually", + "memory.preferences": "Scan behavior", + "memory.autoScan": "Auto-scan known Agents", + "memory.autoScanDescription": "Scan new conversations from installed Agents such as Cursor / Codex / Pi / Claude Code / WorkBuddy on startup", + "memory.watchFiles": "Auto incremental sync", + "memory.watchFilesDescription": "Automatically follow newly written Agent conversation files", + "memory.autoInject": "Auto-install Hooks/plugins for new Agents", + "memory.autoInjectDescription": "Automatically install the matching Hook or plugin for each new Agent; the Skill is installed with the integration. When disabled, newly found Agents stay in the list until you install them manually", "memory.scan": "Sync new", "memory.syncNew": "Sync new", "memory.syncCompleted": "Synced", @@ -2154,6 +2317,7 @@ export const enUSMessages: Record = { "memory.nav.skills": "Skills", "memory.nav.analytics": "Analytics", "memory.nav.logs": "Logs", + "memory.nav.tokenStats": "Token Usage", "memory.sourcesNav": "Cross-Agent access", "memory.overview.title": "Overview", "memory.overview.loading": "Loading memory overview", @@ -2371,6 +2535,26 @@ export const enUSMessages: Record = { "memory.analytics.trendTitle": "Last 30 days - memory writes vs calls", "memory.analytics.writes": "Writes", "memory.analytics.calls": "Calls", + "memory.tokenStats.title": "Token Usage by Agent", + "memory.tokenStats.description": "Token consumption across Pi, Codex, and Claude Code sessions", + "memory.tokenStats.loading": "Scanning session files...", + "memory.tokenStats.empty": "No token usage data found", + "memory.tokenStats.project": "Project", + "memory.tokenStats.allProjects": "All Projects", + "memory.tokenStats.pi": "Pi", + "memory.tokenStats.codex": "Codex", + "memory.tokenStats.claudeCode": "Claude Code", + "memory.tokenStats.sessions": "Sessions", + "memory.tokenStats.apiCalls": "API Calls", + "memory.tokenStats.inputTokens": "Input Tokens", + "memory.tokenStats.outputTokens": "Output Tokens", + "memory.tokenStats.cacheRead": "Cache Read", + "memory.tokenStats.cacheWrite": "Cache Write", + "memory.tokenStats.reasoning": "Reasoning", + "memory.tokenStats.total": "Total Tokens", + "memory.tokenStats.cost": "Estimated Cost", + "memory.tokenStats.unavailable": "Token data unavailable", + "memory.tokenStats.combined": "Combined Totals", "memory.worldModel.title": "World models", "memory.worldModel.description": "The assistant's models of your working context: common tools, file layout, and recurring constraints.", "memory.worldModel.loading": "Loading world models", @@ -2500,6 +2684,12 @@ export const enUSMessages: Record = { "memory.restartServiceStillUnavailable": "The memory service restarted, but its health check still fails", "memory.restartServiceStillUnavailableWithReason": "The memory service restarted, but its health check still fails: {reason}", "memory.restartServiceUnavailable": "Restarting the memory service is unavailable in this runtime", + "memory.reconnectService": "Reconnect", + "memory.reconnectServiceBusy": "Connecting...", + "memory.reconnectServiceDone": "Reconnected to the memory service", + "memory.reconnectServiceFailed": "Failed to reconnect to the memory service: {reason}", + "memory.reconnectServiceStillUnavailable": "The memory service is still unavailable", + "memory.reconnectServiceStillUnavailableWithReason": "The memory service is still unavailable: {reason}", "memory.installSkill": "Install Skill", "memory.installHook": "Install Hook", "memory.installPlugin": "Install plugin", diff --git a/App/frontend/desktop/src/pages/agent-source-logos.ts b/App/frontend/desktop/src/pages/agent-source-logos.ts index 527cde235..fff93c67d 100644 --- a/App/frontend/desktop/src/pages/agent-source-logos.ts +++ b/App/frontend/desktop/src/pages/agent-source-logos.ts @@ -12,6 +12,7 @@ export const MEMORY_AGENT_SOURCE_VALUES = [ "cursor", "claude_code", "codex", + "pi", "opencode", "openclaw", "hermes", @@ -24,6 +25,7 @@ const AGENT_SOURCE_DISPLAY_NAMES: Record = { cursor: "Cursor", claude_code: "Claude Code", codex: "Codex", + pi: "Pi", opencode: "OpenCode", openclaw: "OpenClaw", hermes: "Hermes", diff --git a/App/frontend/desktop/src/pages/context-pack-relation-graph-layout.ts b/App/frontend/desktop/src/pages/context-pack-relation-graph-layout.ts new file mode 100644 index 000000000..518d0de85 --- /dev/null +++ b/App/frontend/desktop/src/pages/context-pack-relation-graph-layout.ts @@ -0,0 +1,73 @@ +import { MarkerType, Position, type Edge, type Node } from "@xyflow/react"; +import type { MemoryListItem, ProjectContextPackOutput } from "@memmy/local-api-contracts"; + +export type ContextPackGraph = ProjectContextPackOutput["graph"]; + +export type MemoryRelationNodeData = MemoryListItem & { + external?: boolean; + anchor: boolean; +} & Record; + +export type MemoryRelationFlowNode = Node; + +export type MemoryRelationLayout = { + nodes: MemoryRelationFlowNode[]; + edges: Edge[]; + width: number; + height: number; +}; + +const LAYERS = ["L1", "L2", "L3", "Skill"] as const; +const NODE_WIDTH = 190; +const NODE_HEIGHT = 72; +const X_GAP = 76; +const Y_GAP = 34; +const LEFT = 28; +const TOP = 28; + +export function layoutMemoryRelationGraph(graph: ContextPackGraph, anchorId: string): MemoryRelationLayout { + const columnCounts = new Map(); + const nodeIds = new Set(graph.nodes.map((node) => node.id)); + const nodes: MemoryRelationFlowNode[] = graph.nodes.map((node) => { + const column = Math.max(0, LAYERS.indexOf(node.memoryLayer)); + const row = columnCounts.get(node.memoryLayer) ?? 0; + columnCounts.set(node.memoryLayer, row + 1); + return { + id: node.id, + type: "memoryRelation", + position: { + x: LEFT + column * (NODE_WIDTH + X_GAP), + y: TOP + row * (NODE_HEIGHT + Y_GAP) + }, + data: { ...node, anchor: node.id === anchorId }, + draggable: false, + sourcePosition: Position.Right, + targetPosition: Position.Left, + style: { width: NODE_WIDTH, height: NODE_HEIGHT } + }; + }); + const edges: Edge[] = graph.edges + .filter((edge) => nodeIds.has(edge.sourceId) && nodeIds.has(edge.targetId)) + .map((edge, index) => ({ + id: `${edge.relation}:${edge.sourceId}:${edge.targetId}:${index}`, + source: edge.sourceId, + target: edge.targetId, + type: "default", + label: edge.relation, + markerEnd: { type: MarkerType.ArrowClosed }, + className: `context-pack-relation-edge context-pack-relation-edge--${edge.relation}`, + style: { + strokeWidth: edge.relation === "supersedes" ? 1.6 : 1.8, + strokeDasharray: edge.relation === "supersedes" ? "5 4" : undefined + }, + data: { reason: edge.reason } + })); + const maxRows = Math.max(1, ...LAYERS.map((layer) => columnCounts.get(layer) ?? 0)); + + return { + nodes, + edges, + width: LEFT * 2 + LAYERS.length * NODE_WIDTH + (LAYERS.length - 1) * X_GAP, + height: TOP * 2 + maxRows * NODE_HEIGHT + Math.max(0, maxRows - 1) * Y_GAP + }; +} diff --git a/App/frontend/desktop/src/pages/context-pack-relation-graph.tsx b/App/frontend/desktop/src/pages/context-pack-relation-graph.tsx new file mode 100644 index 000000000..215d94e27 --- /dev/null +++ b/App/frontend/desktop/src/pages/context-pack-relation-graph.tsx @@ -0,0 +1,145 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Background, + Controls, + Handle, + Position, + ReactFlow, + type NodeProps, + type NodeTypes, + type ReactFlowInstance +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { ArrowLeft, Crosshair, ExternalLink } from "lucide-react"; +import type { MessageKey } from "../i18n/messages.js"; +import { + layoutMemoryRelationGraph, + type ContextPackGraph, + type MemoryRelationFlowNode +} from "./context-pack-relation-graph-layout.js"; + +type Translate = (key: MessageKey) => string; + +const nodeTypes: NodeTypes = { memoryRelation: MemoryRelationNodeView }; + +export function ContextPackRelationGraph(props: { + graph: ContextPackGraph; + anchorId: string; + t: Translate; + onBack: () => void; + onOpenMemory: (memoryId: string) => void; +}) { + const [selectedId, setSelectedId] = useState(props.anchorId); + const [instance, setInstance] = useState | null>(null); + const layout = useMemo( + () => layoutMemoryRelationGraph(props.graph, props.anchorId), + [props.anchorId, props.graph] + ); + const nodes = useMemo( + () => layout.nodes.map((node) => ({ ...node, selected: node.id === selectedId })), + [layout.nodes, selectedId] + ); + const selectedNode = props.graph.nodes.find((node) => node.id === selectedId) ?? null; + + useEffect(() => { + setSelectedId(props.anchorId); + }, [props.anchorId, props.graph]); + + function locate(memoryId: string) { + setSelectedId(memoryId); + window.requestAnimationFrame(() => { + void instance?.fitView({ nodes: [{ id: memoryId }], duration: 180, maxZoom: 1.2, padding: 0.45 }); + }); + } + + if (layout.nodes.length === 0) { + return ( +
+ +
{props.t("home.contextPack.graph.empty")}
+
+ ); + } + + return ( +
+
+ + +
+
+ + nodes={nodes} + edges={layout.edges} + nodeTypes={nodeTypes} + fitView + minZoom={0.35} + maxZoom={1.4} + nodesDraggable={false} + nodesConnectable={false} + elementsSelectable + onInit={(flow) => { + setInstance(flow); + window.requestAnimationFrame(() => { + void flow.fitView({ nodes: [{ id: props.anchorId }], maxZoom: 1.1, padding: 0.45 }); + }); + }} + onNodeClick={(_, node) => locate(String(node.id))} + onPaneClick={() => setSelectedId(props.anchorId)} + > + + + +
+ {selectedNode ? ( +
+
+ {selectedNode.memoryLayer} · {selectedNode.kind} + {selectedNode.title} + {selectedNode.id} +
+ +
+ ) : null} +
+ ); +} + +function GraphBackButton(props: { t: Translate; onBack: () => void }) { + return ( + + ); +} + +function MemoryRelationNodeView(props: NodeProps) { + return ( +
+ +
+ {props.data.memoryLayer} + {props.data.kind} +
+ {props.data.title} + +
+ ); +} diff --git a/App/frontend/desktop/src/pages/first-encounter-relay-challenge.tsx b/App/frontend/desktop/src/pages/first-encounter-relay-challenge.tsx index d55038b12..649178193 100644 --- a/App/frontend/desktop/src/pages/first-encounter-relay-challenge.tsx +++ b/App/frontend/desktop/src/pages/first-encounter-relay-challenge.tsx @@ -28,7 +28,7 @@ export interface FirstEncounterRelayOptInProps { onOpenConnections?: () => void; } -const RELAY_AGENT_IDS = new Set(["cursor", "claude_code", "codex", "opencode", "openclaw", "hermes", "workbuddy"]); +const RELAY_AGENT_IDS = new Set(["cursor", "claude_code", "codex", "pi", "opencode", "openclaw", "hermes", "workbuddy"]); type RelayFeedback = | { kind: "copied" } | { kind: "copy_failed" } diff --git a/App/frontend/desktop/src/pages/home-page.tsx b/App/frontend/desktop/src/pages/home-page.tsx index 70b9f9ef2..ce766a879 100644 --- a/App/frontend/desktop/src/pages/home-page.tsx +++ b/App/frontend/desktop/src/pages/home-page.tsx @@ -74,8 +74,9 @@ import { writePendingFirstEncounterTaskLaunch } from "./first-encounter-task-launch.js"; import { HistoryDagPanel, type HistoryDagPanelState } from "./history-dag-panel.js"; +import { ProjectContextPackDialog } from "./project-context-pack-dialog.js"; import { Mic, Pause, Plus, Send } from "./memory/memory-prototype-icons.js"; -import { ArrowDown, Check, ChevronDown, Folder, Plus as LucidePlus, RotateCw, X } from "lucide-react"; +import { ArrowDown, BookOpen, Check, ChevronDown, Folder, Plus as LucidePlus, RotateCw, X } from "lucide-react"; export { agentChatScopeKey, updateComposerDraftForScope }; export { hydrateAgentThreadInBackground }; @@ -257,6 +258,29 @@ export function hasActiveAgentConversation(currentChatId: string | null, message return Boolean(currentChatId) && messageCount > 0; } +export function resolveContextPackProject(input: { + currentChatId: string | null; + currentSessionKey: string | null; + sessions: readonly MemmyAgentSessionSummary[]; + tasks: readonly Pick[]; + projects: readonly MemmyAgentProject[]; + draftProject: MemmyAgentProject | null; +}): MemmyAgentProject | null { + if (!input.currentChatId) return input.draftProject; + + const sessionProjectId = input.currentSessionKey + ? input.sessions.find((session) => session.key === input.currentSessionKey)?.projectId + : null; + const taskProjectId = input.tasks.find((task) => ( + task.chatId === input.currentChatId + || (input.currentSessionKey !== null && task.sessionKey === input.currentSessionKey) + ))?.projectId; + const projectId = sessionProjectId ?? taskProjectId ?? null; + return projectId + ? input.projects.find((project) => project.id === projectId) ?? null + : null; +} + export function shouldAcceptAgentStatusResult(input: { pendingStatusChatId: string | null; subscribedChatId: string | null; @@ -678,6 +702,7 @@ export function HomePage() { const [historyDagPanel, setHistoryDagPanel] = useState({ open: false }); const [isCreatingChat, setIsCreatingChat] = useState(false); const [projectPickerOpen, setProjectPickerOpen] = useState(false); + const [openContextPackProjectId, setOpenContextPackProjectId] = useState(null); const [projectPickerOperationId, setProjectPickerOperationId] = useState(null); const [firstEncounterRelayChatId, setFirstEncounterRelayChatId] = useState(() => ( readFirstEncounterRelayChat(typeof window === "undefined" ? undefined : window.sessionStorage) @@ -716,6 +741,22 @@ export function HomePage() { const selectedDraftProject = draftTarget.kind === "project" ? state.agent.projects.find((project) => project.id === draftTarget.projectId) ?? null : null; + const contextPackProject = resolveContextPackProject({ + currentChatId: state.agent.currentChatId, + currentSessionKey: state.agent.currentSessionKey, + sessions: state.agent.sessions, + tasks: state.agent.tasks, + projects: state.agent.projects, + draftProject: selectedDraftProject + }); + const contextPackOpen = contextPackProject?.id === openContextPackProjectId; + const previousContextPackProjectIdRef = useRef(contextPackProject?.id ?? null); + useEffect(() => { + const projectId = contextPackProject?.id ?? null; + if (previousContextPackProjectIdRef.current === projectId) return; + previousContextPackProjectIdRef.current = projectId; + setOpenContextPackProjectId(null); + }, [contextPackProject?.id]); const currentSessionProjectBlocked = state.agent.projectRegistryState === "corrupt" && Boolean( state.agent.currentSessionKey @@ -2199,6 +2240,18 @@ export function HomePage() { onSelect={selectDraftTarget} onChooseOther={() => void selectOtherProjectFolder()} /> + {contextPackProject ? ( + + ) : null}
@@ -2352,12 +2405,36 @@ export function HomePage() { />
+
+ {contextPackProject ? ( + + ) : null} +

{t("home.notice")}

void selectMedia(event)} /> )} + {contextPackProject ? ( + setOpenContextPackProjectId(null)} + /> + ) : null} ); } diff --git a/App/frontend/desktop/src/pages/memory-page.tsx b/App/frontend/desktop/src/pages/memory-page.tsx index 3db6d58da..ae1953c06 100644 --- a/App/frontend/desktop/src/pages/memory-page.tsx +++ b/App/frontend/desktop/src/pages/memory-page.tsx @@ -27,11 +27,13 @@ import { PoliciesSubPage } from "./memory/policies-sub-page.js"; import { SkillsSubPage } from "./memory/skills-sub-page.js"; import { SourcesSubPage } from "./memory/sources-sub-page.js"; import { TasksSubPage } from "./memory/tasks-sub-page.js"; +import { TokenStatsSubPage } from "./memory/token-stats-sub-page.js"; import { WorldModelSubPage } from "./memory/world-model-sub-page.js"; import { ArrowLeft, BarChart3, BrainCircuit, + Gauge, Globe2, Layers, Link2, @@ -52,7 +54,8 @@ export type MemorySubPageId = | "skills" | "analytics" | "logs" - | "sources"; + | "sources" + | "token-stats"; interface MemoryNavSection { titleKey: MessageKey; @@ -81,6 +84,7 @@ const memoryNavSections: MemoryNavSection[] = [ titleKey: "memory.nav.insights", items: [ { id: "analytics", labelKey: "memory.nav.analytics", icon: }, + { id: "token-stats", labelKey: "memory.nav.tokenStats", icon: }, { id: "logs", labelKey: "memory.nav.logs", icon: } ] }, @@ -165,6 +169,7 @@ export function MemoryPage(props: MemoryPageProps) { /> ), analytics: , + "token-stats": , logs: , sources: }), @@ -359,6 +364,7 @@ function createPreviewChildByPage(t: (key: MessageKey) => string): Record{t("memory.worldModel.title")}, skills:
{t("memory.skills.title")}
, analytics:
{t("memory.analytics.title")}
, + "token-stats":
{t("memory.tokenStats.title")}
, logs:
{t("memory.logs.title")}
, sources:
{t("memory.sourcesTitle")} {t("memory.scan")}
}; diff --git a/App/frontend/desktop/src/pages/memory-sources-page.tsx b/App/frontend/desktop/src/pages/memory-sources-page.tsx index 5848a4e53..d1761ef14 100644 --- a/App/frontend/desktop/src/pages/memory-sources-page.tsx +++ b/App/frontend/desktop/src/pages/memory-sources-page.tsx @@ -95,6 +95,7 @@ export function MemorySourcesContent(props: MemorySourcesContentProps = {}) { const scanPercent = scanProgress && hasDeterminateScanProgress ? formatActiveScanPercent(scanProgress.current, scanProgress.total) : 0; const scannableSources = state.agentSources.items.filter((source) => source.available); const memoryServiceAddress = formatMemoryServiceAddress(clients?.runtimeConfig.memory?.baseUrl); + const remoteMemoryService = clients?.runtimeConfig.memory?.ownership === "remote"; useEffect(() => { setMemoryServiceStatus((current) => current === "checking" ? memoryServiceStatusFromBootstrap(state.bootstrap?.health.memory) : current); @@ -218,26 +219,44 @@ export function MemorySourcesContent(props: MemorySourcesContentProps = {}) { setMemoryServiceError(""); void (async () => { try { - await restart(); + const result = await restart(); clearMemoryPanelCache(); try { const health = await clients.memoryRuntime.health(); if (health.ok && health.storage.ready) { setMemoryServiceStatus("ok"); - setMemoryServiceMessage(t("memory.restartServiceDone")); + setMemoryServiceMessage(t( + result.action === "reconnected" + ? "memory.reconnectServiceDone" + : "memory.restartServiceDone" + )); return; } setMemoryServiceStatus("unavailable"); - setMemoryServiceError(t("memory.restartServiceStillUnavailable")); + setMemoryServiceError(t( + remoteMemoryService + ? "memory.reconnectServiceStillUnavailable" + : "memory.restartServiceStillUnavailable" + )); } catch (error) { setMemoryServiceStatus("unavailable"); - setMemoryServiceError(t("memory.restartServiceStillUnavailableWithReason", { reason: formatErrorMessage(error) })); + setMemoryServiceError(t( + remoteMemoryService + ? "memory.reconnectServiceStillUnavailableWithReason" + : "memory.restartServiceStillUnavailableWithReason", + { reason: formatErrorMessage(error) } + )); } } catch (error) { setMemoryServiceStatus("unavailable"); - setMemoryServiceError(t("memory.restartServiceFailed", { reason: formatErrorMessage(error) })); + setMemoryServiceError(t( + remoteMemoryService + ? "memory.reconnectServiceFailed" + : "memory.restartServiceFailed", + { reason: formatErrorMessage(error) } + )); } finally { setMemoryServiceBusy(false); } @@ -639,7 +658,11 @@ export function MemorySourcesContent(props: MemorySourcesContentProps = {}) { checkingLabel={t("common.loading")} value={memoryServiceAddress ?? t("memory.daemonAddressUnavailable")} description={t("memory.daemonDescription")} - actionLabel={t(memoryServiceBusy ? "memory.restartServiceBusy" : "memory.restartService")} + actionLabel={t( + remoteMemoryService + ? memoryServiceBusy ? "memory.reconnectServiceBusy" : "memory.reconnectService" + : memoryServiceBusy ? "memory.restartServiceBusy" : "memory.restartService" + )} actionTone="success" onAction={restartMemoryService} actionDisabled={!clients || memoryServiceBusy} @@ -1260,7 +1283,7 @@ function SourceStatusBadge(props: { source: Pick{t(labelKey)}; } -const NATIVE_PLUGIN_AGENT_SOURCE_IDS = new Set(["opencode", "openclaw", "hermes"]); +const NATIVE_PLUGIN_AGENT_SOURCE_IDS = new Set(["pi", "opencode", "openclaw", "hermes"]); const HOOK_AGENT_SOURCE_IDS = new Set(["codex", "claude_code", "cursor"]); export function resolveAgentSourceStatusLabelKey(source: Pick): MessageKey { diff --git a/App/frontend/desktop/src/pages/memory/memory-prototype-icons.tsx b/App/frontend/desktop/src/pages/memory/memory-prototype-icons.tsx index a76001a3f..ea0f68895 100644 --- a/App/frontend/desktop/src/pages/memory/memory-prototype-icons.tsx +++ b/App/frontend/desktop/src/pages/memory/memory-prototype-icons.tsx @@ -550,3 +550,14 @@ export function ImagePlus(props: MemoryIconProps) { ); } + +export function Gauge(props: MemoryIconProps) { + return ( + + + + + + + ); +} diff --git a/App/frontend/desktop/src/pages/memory/tests/fixtures.ts b/App/frontend/desktop/src/pages/memory/tests/fixtures.ts index 27abc3880..f3ed16d60 100644 --- a/App/frontend/desktop/src/pages/memory/tests/fixtures.ts +++ b/App/frontend/desktop/src/pages/memory/tests/fixtures.ts @@ -45,6 +45,8 @@ export function createMemoryRuntimeClientStub(overrides: Partial { "Cursor", "Claude Code", "Codex", + "Pi", "OpenCode", "OpenClaw", "Hermes", diff --git a/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts b/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts index 2442b256b..b157e9c4c 100644 --- a/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts +++ b/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts @@ -398,6 +398,28 @@ export function createMockMemoryRuntimeClient(): MemoryRuntimeClient { return { item: detail.item, refs: {}, version: detail.version, etag: detail.etag }; }, + async getMemoryHistory(id) { + const detail = findMemoryDetail(id); + return { + id, + currentVersion: detail.version, + items: [{ seq: detail.version, version: detail.version, changeType: "created", source: "fixture", createdAt: detail.item.createdAt, after: {} }], + serverTime: now + }; + }, + + async restoreMemory(id, targetVersion, input) { + return { + ok: true, + id, + version: input.version + 1, + restoredVersion: targetVersion, + changeSeq: 47, + auditId: "audit-restore-fixture", + serverTime: now + }; + }, + async deleteMemory(id): Promise { return { ok: true, @@ -508,6 +530,40 @@ export function createMockMemoryRuntimeClient(): MemoryRuntimeClient { async getPanelAnalysis(): Promise { return mockPanelAnalysis; }, + async getProjectContextPack(projectId) { + return { + namespace: { projectId }, + conventions: [], + commands: [], + architectureFacts: [], + recentTasks: [], + userPreferences: [], + graph: { nodes: [], edges: [] }, + markdown: `# Project Memory Pack: ${projectId}`, + generatedAt: now + }; + }, + async getProjectContextState() { + return { namespaceId: "fixture-namespace", activeGoal: null, goals: [], workItems: [], focusedWorkItem: null, facts: [] }; + }, + async proposeProjectGoal(input) { + return { id: "fixture-goal", namespaceId: "fixture-namespace", userId: "fixture-user", projectId: input.namespace.projectId, title: input.title, summary: input.summary, detail: input.detail, acceptanceCriteria: input.acceptanceCriteria ?? [], constraints: input.constraints ?? [], status: "candidate" as const, version: 0, sourceMemoryIds: input.sourceMemoryIds ?? [], provenance: input.provenance, createdAt: now, updatedAt: now }; + }, + async approveProjectGoal(id, input) { + return { id, namespaceId: "fixture-namespace", userId: "fixture-user", projectId: input.namespace.projectId, title: "Fixture goal", summary: "", detail: "", acceptanceCriteria: [], constraints: [], status: "active" as const, version: 1, sourceMemoryIds: [], provenance: input.provenance, createdAt: now, updatedAt: now }; + }, + async rejectProjectGoal(id, input) { + return { id, namespaceId: "fixture-namespace", userId: "fixture-user", projectId: input.namespace.projectId, title: "Fixture goal", summary: "", detail: "", acceptanceCriteria: [], constraints: [], status: "archived" as const, version: 1, sourceMemoryIds: [], provenance: input.provenance, createdAt: now, updatedAt: now }; + }, + async createProjectWorkItem(input) { + return { id: "fixture-work", namespaceId: "fixture-namespace", userId: "fixture-user", projectId: input.namespace.projectId, goalId: input.goalId, title: input.title, summary: input.summary, nextStep: input.nextStep, acceptanceCriteria: input.acceptanceCriteria ?? [], constraints: input.constraints ?? [], status: input.status ?? "pending", focused: false, sourceMemoryIds: input.sourceMemoryIds ?? [], provenance: input.provenance, createdAt: now, updatedAt: now }; + }, + async updateProjectWorkItem(id, input) { + return { id, namespaceId: "fixture-namespace", userId: "fixture-user", projectId: input.namespace.projectId, goalId: input.goalId ?? undefined, title: input.title ?? "Fixture work", summary: input.summary ?? "", nextStep: input.nextStep ?? "", acceptanceCriteria: input.acceptanceCriteria ?? [], constraints: input.constraints ?? [], status: input.status ?? "pending", focused: false, sourceMemoryIds: input.sourceMemoryIds ?? [], provenance: input.provenance, createdAt: now, updatedAt: now }; + }, + async setProjectFocus() { + return null; + }, async listPanelItems(input): Promise { return filterMemoryItems(input); }, @@ -560,6 +616,7 @@ function filterMemoryItems(input: PanelItemsInput): PanelItemsOutput { "cursor", "claude_code", "codex", + "pi", "opencode", "openclaw", "hermes" diff --git a/App/frontend/desktop/src/pages/memory/tests/sources-sub-page-path.interaction.test.tsx b/App/frontend/desktop/src/pages/memory/tests/sources-sub-page-path.interaction.test.tsx index 9d30ab840..faff3048f 100644 --- a/App/frontend/desktop/src/pages/memory/tests/sources-sub-page-path.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/sources-sub-page-path.interaction.test.tsx @@ -40,7 +40,7 @@ describe("SourcesSubPage local data path", () => { runtimeConfig: { baseUrl: "http://127.0.0.1:18100", localToken: "local-token", - memory: { baseUrl: "http://127.0.0.1:18960" } + memory: { baseUrl: "http://127.0.0.1:18960", ownership: "remote" } }, localData: { getPath, @@ -69,6 +69,8 @@ describe("SourcesSubPage local data path", () => { expect(getPath).toHaveBeenCalledTimes(1); expect(listSources).toHaveBeenCalledTimes(1); expect(reveal).not.toHaveBeenCalled(); + expect(container.textContent).toContain("重新连接"); + expect(container.textContent).not.toContain("重启服务"); expect(container.textContent).not.toContain("~/.memmy/memory-service"); expect(container.textContent).not.toContain(windowsDataPath); diff --git a/App/frontend/desktop/src/pages/memory/tests/sources-sub-page.test.tsx b/App/frontend/desktop/src/pages/memory/tests/sources-sub-page.test.tsx index 026b70828..175aec5ab 100644 --- a/App/frontend/desktop/src/pages/memory/tests/sources-sub-page.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/sources-sub-page.test.tsx @@ -38,7 +38,7 @@ describe("SourcesSubPage", () => { }); it("同步按钮在扫描中旋转,完成后进入不可重复点击的勾选状态", () => { - const sourceIds = ["cursor", "claude_code", "codex", "opencode", "openclaw", "hermes", "workbuddy"]; + const sourceIds = ["cursor", "claude_code", "codex", "pi", "opencode", "openclaw", "hermes", "workbuddy"]; for (const sourceId of sourceIds) { const otherSourceId = sourceIds.find((candidate) => candidate !== sourceId)!; expect(resolveAgentSourceScanButtonState(sourceId, true, sourceId, new Set())).toBe("running"); @@ -72,14 +72,13 @@ describe("SourcesSubPage", () => { expect(html).toContain("跨Agent接入"); expect(html).toContain("memory-sources-page"); expect(html).toContain("各 Agent 通过 Hook 或插件接入 memmy-memory,并自动安装 Skill"); - expect(html).toContain("发现新 Agent 时自动接入"); - expect(html).toContain("自动安装接入组件;关闭后只出现在下方列表,由你手动接入"); + expect(html).toContain("新发现 Agent 自动安装 Hook/插件"); + expect(html).toContain("自动为新发现的 Agent 安装对应的 Hook 或插件,Skill 会随接入一并安装;关闭后,新发现的 Agent 仅出现在下方列表中等待你手动安装"); expect(html).toContain("~/.local/bin/memmy-memory"); expect(html).not.toContain("或安装原生插件接入记忆"); expect(html).toContain("memory-panel__header memory-panel__header--single-line"); expect(html).toContain("memory-panel__title"); expect(html).not.toContain("memory-panel__header-actions"); - expect(html).toContain("自动同步"); expect(html).toContain("同步新增"); expect(html).toContain("点击“同步新增”按钮后,只会读取上次同步后产生的新对话"); expect(html).not.toContain("上次扫描水位"); @@ -202,6 +201,8 @@ describe("SourcesSubPage", () => { expect(formatMemoryServiceAddress("http://localhost:18888/")).toBe("localhost:18888"); expect(formatMemoryServiceAddress(undefined)).toBeUndefined(); expect(zhCNMessages["memory.restartService"]).toBe("重启服务"); + expect(zhCNMessages["memory.reconnectService"]).toBe("重新连接"); + expect(enUSMessages["memory.reconnectService"]).toBe("Reconnect"); expect(zhCNMessages).not.toHaveProperty("memory.daemonAddress"); }); @@ -266,6 +267,8 @@ describe("SourcesSubPage", () => { expect(resolveAgentSourceConnectionAction(createSource("hermes", "skill_installed"))).toBe("install_plugin"); expect(resolveAgentSourceConnectionAction(createSource("hermes", "plugin_installed"))).toBe("remove_plugin"); expect(resolveAgentSourceConnectionAction(createSource("opencode", "plugin_installed"))).toBe("remove_plugin"); + expect(resolveAgentSourceConnectionAction(createSource("pi", "not_connected"))).toBe("install_plugin"); + expect(resolveAgentSourceConnectionAction(createSource("pi", "plugin_installed"))).toBe("remove_plugin"); expect(resolveAgentSourceConnectionAction(createSource("cursor", "not_connected"))).toBe("install_hook"); expect(resolveAgentSourceConnectionAction(createSource("codex", "skill_installed"))).toBe("install_hook"); expect(resolveAgentSourceConnectionAction(createSource("claude_code", "plugin_installed"))).toBe("remove_hook"); diff --git a/App/frontend/desktop/src/pages/memory/token-stats-sub-page.tsx b/App/frontend/desktop/src/pages/memory/token-stats-sub-page.tsx new file mode 100644 index 000000000..1cc26b636 --- /dev/null +++ b/App/frontend/desktop/src/pages/memory/token-stats-sub-page.tsx @@ -0,0 +1,250 @@ +import { useEffect, useState } from "react"; +import type { + AgentTokenStatsDto, + AgentTokenStatsResponse +} from "@memmy/local-api-contracts"; +import type { AgentTokenStatsClient } from "../../api/agent-token-stats-client.js"; +import { useTranslation } from "../../i18n/use-translation.js"; +import { Gauge } from "./memory-prototype-icons.js"; +import { type RemoteData, toErrorMessage } from "./remote-state.js"; + +export interface TokenStatsSubPageProps { + client: AgentTokenStatsClient | null; +} + +export function TokenStatsSubPage(props: TokenStatsSubPageProps) { + const { t } = useTranslation(); + const [state, setState] = useState>({ status: "loading" }); + const [selectedProject, setSelectedProject] = useState(null); + + useEffect(() => { + if (!props.client) { + setState({ status: "error", message: t("memory.clientNotReady") }); + return; + } + + let active = true; + setState({ status: "loading" }); + + props.client + .getStats() + .then((data) => { + if (!active) return; + setState({ status: "ready", data }); + // Auto-select first project if none selected + if (!selectedProject && data.projects.length > 0 && data.projects[0]) { + setSelectedProject(data.projects[0].project); + } + }) + .catch((error: unknown) => { + if (!active) return; + setState({ status: "error", message: toErrorMessage(error) }); + }); + + return () => { + active = false; + }; + }, [props.client, t, selectedProject]); + + if (state.status === "loading") { + return ( +
+
+

+ + {t("memory.tokenStats.title")} +

+

{t("memory.tokenStats.description")}

+
+
{t("memory.tokenStats.loading")}
+
+ ); + } + + if (state.status === "error") { + return ( +
+
+

+ + {t("memory.tokenStats.title")} +

+

{t("memory.tokenStats.description")}

+
+
{state.message}
+
+ ); + } + + const { data } = state; + const projects = data.projects; + + if (projects.length === 0) { + return ( +
+
+

+ + {t("memory.tokenStats.title")} +

+

{t("memory.tokenStats.description")}

+
+
{t("memory.tokenStats.empty")}
+
+ ); + } + + // Find selected project or use first + const currentProject = projects.find((p) => p.project === selectedProject) ?? projects[0]; + + if (!currentProject) { + return ( +
+
+

+ + {t("memory.tokenStats.title")} +

+

{t("memory.tokenStats.description")}

+
+
{t("memory.tokenStats.empty")}
+
+ ); + } + + return ( +
+
+

+ + {t("memory.tokenStats.title")} +

+

{t("memory.tokenStats.description")}

+
+ + {/* Project selector */} + {projects.length > 1 && ( +
+ + +
+ )} + + {/* Agent cards */} +
+ {currentProject.agents.map((agent) => ( + + ))} +
+ + {/* Combined totals */} +
+

{t("memory.tokenStats.combined")}

+
+ + + + + {currentProject.estimatedCost !== undefined && ( + + )} +
+
+ + {/* Scan timestamp */} +
+ Scanned at: {new Date(data.scannedAt).toLocaleString()} +
+
+ ); +} + +interface AgentCardProps { + agent: AgentTokenStatsDto; +} + +function AgentCard(props: AgentCardProps) { + const { t } = useTranslation(); + const { agent } = props; + + const agentLabel = + agent.agent === "pi" + ? t("memory.tokenStats.pi") + : agent.agent === "codex" + ? t("memory.tokenStats.codex") + : t("memory.tokenStats.claudeCode"); + + const icon = ; + + return ( +
+
+ {icon} +

{agentLabel}

+
+ + {!agent.available ? ( +
{t("memory.tokenStats.unavailable")}
+ ) : ( + <> +
+ + {t("memory.tokenStats.sessions")}: {agent.sessions} + + + {t("memory.tokenStats.apiCalls")}: {agent.apiCalls} + +
+ +
+ + + + + {agent.reasoningTokens !== undefined && agent.reasoningTokens > 0 && ( + + )} + + {agent.cost !== undefined && agent.cost > 0 && ( + + )} +
+ + )} +
+ ); +} + +interface StatItemProps { + label: string; + value: number | string; + highlight?: boolean; +} + +function StatItem(props: StatItemProps) { + const displayValue = typeof props.value === "number" ? props.value.toLocaleString() : props.value; + + return ( +
+
{props.label}
+
+ {displayValue} +
+
+ ); +} diff --git a/App/frontend/desktop/src/pages/project-context-pack-dialog.tsx b/App/frontend/desktop/src/pages/project-context-pack-dialog.tsx new file mode 100644 index 000000000..f8bd0f4f6 --- /dev/null +++ b/App/frontend/desktop/src/pages/project-context-pack-dialog.tsx @@ -0,0 +1,690 @@ +import { useEffect, useState, type ReactNode } from "react"; +import type { GetMemoryOutput, MemoryHistoryOutput, MemoryListItem, ProjectContextGoalDecisionInput, ProjectContextPackOutput, ProjectContextReadState, ProjectGoalRecord, ProjectWorkItemRecord, RuntimeNamespace } from "@memmy/local-api-contracts"; +import { ArrowLeft, BookOpen, Check, Copy, Network, RefreshCw, Target, X } from "lucide-react"; +import type { MemoryRuntimeClient } from "../api/memory-runtime-client.js"; +import { Modal } from "../components/modal.js"; +import type { MessageKey } from "../i18n/messages.js"; +import { ContextPackRelationGraph } from "./context-pack-relation-graph.js"; + +type Translate = (key: MessageKey) => string; + +type ContextPackState = + | { status: "loading"; pack: null } + | { status: "ready"; pack: ProjectContextPackOutput } + | { status: "error"; pack: null }; + +type MemoryDetailState = + | { status: "loading"; detail: null } + | { status: "ready"; detail: GetMemoryOutput } + | { status: "error"; detail: null }; + +type MemoryHistoryState = + | { status: "loading"; history: null } + | { status: "ready"; history: MemoryHistoryOutput } + | { status: "error"; history: null }; + +type RestoreState = + | { status: "idle" } + | { status: "restoring"; targetVersion: number } + | { status: "success"; targetVersion: number } + | { status: "error"; targetVersion: number }; +type GovernanceState = + | { status: "loading"; context: null; error: false } + | { status: "ready"; context: ProjectContextReadState; error: boolean } + | { status: "error"; context: null; error: true }; + + +type SelectedMemory = { projectId: string; memoryId: string }; + +const sections = [ + ["conventions", "home.contextPack.conventions"], + ["commands", "home.contextPack.commands"], + ["architectureFacts", "home.contextPack.architectureFacts"], + ["recentTasks", "home.contextPack.recentTasks"], + ["userPreferences", "home.contextPack.userPreferences"] +] as const; + +export function ProjectContextPackDialog(props: { + open: boolean; + projectId: string; + projectName: string; + client: Pick | null; + t: Translate; + onClose: () => void; +}) { + const [requestKey, setRequestKey] = useState(0); + const [state, setState] = useState({ status: "loading", pack: null }); + const [copied, setCopied] = useState(false); + const [selectedMemory, setSelectedMemory] = useState(null); + const [detailState, setDetailState] = useState(null); + const [detailRequestKey, setDetailRequestKey] = useState(0); + const [graphOpen, setGraphOpen] = useState(false); + const [historyState, setHistoryState] = useState(null); + const [pendingRestoreVersion, setPendingRestoreVersion] = useState(null); + const [restoreState, setRestoreState] = useState({ status: "idle" }); + const [governanceRequestKey, setGovernanceRequestKey] = useState(0); + const [governanceState, setGovernanceState] = useState({ status: "loading", context: null, error: false }); + const [pendingGovernanceAction, setPendingGovernanceAction] = useState(null); + + useEffect(() => { + if (!props.open || !props.client) return undefined; + let active = true; + setState({ status: "loading", pack: null }); + setCopied(false); + void props.client.getProjectContextPack(props.projectId) + .then((pack) => { + if (active) setState({ status: "ready", pack }); + }) + .catch(() => { + if (active) setState({ status: "error", pack: null }); + }); + return () => { active = false; }; + }, [props.client, props.open, props.projectId, requestKey]); + const namespace = projectNamespace(props.projectId); + + useEffect(() => { + if (!props.open || !props.client) return undefined; + let active = true; + setGovernanceState({ status: "loading", context: null, error: false }); + void props.client.getProjectContextState(namespace) + .then((context) => { if (active) setGovernanceState({ status: "ready", context, error: false }); }) + .catch(() => { if (active) setGovernanceState({ status: "error", context: null, error: true }); }); + return () => { active = false; }; + }, [props.client, props.open, props.projectId, governanceRequestKey]); + + const selectedMemoryId = selectedMemory?.projectId === props.projectId + ? selectedMemory.memoryId + : null; + + useEffect(() => { + if (!props.open || !props.client || !selectedMemoryId) { + setDetailState(null); + return undefined; + } + + const controller = new AbortController(); + setDetailState({ status: "loading", detail: null }); + setHistoryState({ status: "loading", history: null }); + void props.client.getMemory(selectedMemoryId, { signal: controller.signal }) + .then((detail) => { + if (!controller.signal.aborted) setDetailState({ status: "ready", detail }); + }) + .catch(() => { + if (!controller.signal.aborted) setDetailState({ status: "error", detail: null }); + }); + void props.client.getMemoryHistory(selectedMemoryId, { signal: controller.signal }) + .then((history) => { + if (!controller.signal.aborted) setHistoryState({ status: "ready", history }); + }) + .catch(() => { + if (!controller.signal.aborted) setHistoryState({ status: "error", history: null }); + }); + + return () => controller.abort(); + }, [props.client, props.open, props.projectId, selectedMemoryId, detailRequestKey]); + + useEffect(() => { + setPendingRestoreVersion(null); + setRestoreState({ status: "idle" }); + }, [selectedMemoryId]); + + useEffect(() => { + setSelectedMemory(null); + setDetailState(null); + setHistoryState(null); + setGraphOpen(false); + }, [props.projectId]); + + const pack = state.pack; + const itemCount = pack + ? sections.reduce((total, [key]) => total + pack[key].length, 0) + : 0; + + async function copyMarkdown() { + if (!pack) return; + await navigator.clipboard.writeText(pack.markdown); + setCopied(true); + } + + function openMemory(memoryId: string) { + setGraphOpen(false); + setSelectedMemory({ projectId: props.projectId, memoryId }); + } + + function closeDialog() { + setSelectedMemory(null); + setDetailState(null); + setHistoryState(null); + setGraphOpen(false); + props.onClose(); + } + + async function restoreSelectedMemory(targetVersion: number) { + if (!props.client || !selectedMemoryId || detailState?.status !== "ready") return; + setRestoreState({ status: "restoring", targetVersion }); + try { + await props.client.restoreMemory(selectedMemoryId, targetVersion, { + version: detailState.detail.version, + reason: "restored from desktop context pack" + }); + setPendingRestoreVersion(null); + setRestoreState({ status: "success", targetVersion }); + setDetailRequestKey((value) => value + 1); + } catch { + setRestoreState({ status: "error", targetVersion }); + } + } + async function runGovernanceAction(actionKey: string, action: (input: ProjectContextGoalDecisionInput) => Promise) { + if (!props.client || pendingGovernanceAction) return; + setPendingGovernanceAction(actionKey); + try { + await action(mutationEnvelope(props.projectId)); + const [context] = await Promise.all([ + props.client.getProjectContextState(namespace), + props.client.getProjectContextPack(props.projectId).then((nextPack) => setState({ status: "ready", pack: nextPack })) + ]); + setGovernanceState({ status: "ready", context, error: false }); + } catch { + setGovernanceState((current) => current.context + ? { status: "ready", context: current.context, error: true } + : { status: "error", context: null, error: true }); + } finally { + setPendingGovernanceAction(null); + } + } + + + const title = graphOpen + ? props.t("home.contextPack.graph.title") + : selectedMemoryId + ? props.t("home.contextPack.detail.title") + : props.t("home.contextPack.title"); + + return ( + } + closeLabel={props.t("common.close")} + closeContent={} + className={`project-context-pack-dialog${graphOpen ? " project-context-pack-dialog--graph" : ""}`} + bodyClassName="project-context-pack-dialog__body" + onClose={closeDialog} + > + {selectedMemoryId && graphOpen && pack ? ( + setGraphOpen(false)} + onOpenMemory={openMemory} + /> + ) : selectedMemoryId ? ( + setSelectedMemory(null)} + onOpenGraph={() => setGraphOpen(true)} + onOpenMemory={openMemory} + onRequestRestore={setPendingRestoreVersion} + onCancelRestore={() => setPendingRestoreVersion(null)} + onConfirmRestore={(version) => void restoreSelectedMemory(version)} + onRetry={() => setDetailRequestKey((value) => value + 1)} + /> + ) : state.status === "loading" ? ( + {props.t("home.contextPack.loading")} + ) : state.status === "error" ? ( + + {props.t("home.contextPack.error")} + + + ) : ( + <> +
+ {props.t("home.contextPack.generated")} + +
+ setGovernanceRequestKey((value) => value + 1)} + onApprove={(goal) => void runGovernanceAction(`approve:${goal.id}`, (input) => props.client!.approveProjectGoal(goal.id, input))} + onReject={(goal) => void runGovernanceAction(`reject:${goal.id}`, (input) => props.client!.rejectProjectGoal(goal.id, input))} + onFocus={(item) => void runGovernanceAction(`focus:${item.id}`, (input) => props.client!.setProjectFocus({ ...input, workItemId: item.id }))} + onClearFocus={() => void runGovernanceAction("focus:clear", (input) => props.client!.setProjectFocus({ ...input, workItemId: null }))} + /> + {itemCount === 0 ? {props.t("home.contextPack.empty")} : ( +
+ {sections.map(([key, label]) => { + const items = pack![key]; + if (items.length === 0) return null; + return ( +
+

{props.t(label)}

+
    + {items.map((item) => ( + + ))} +
+
+ ); + })} +
+ )} + + )} +
+ ); +} + +function ContextGovernance(props: { + state: GovernanceState; + pendingAction: string | null; + t: Translate; + onRetry: () => void; + onApprove: (goal: ProjectGoalRecord) => void; + onReject: (goal: ProjectGoalRecord) => void; + onFocus: (item: ProjectWorkItemRecord) => void; + onClearFocus: () => void; +}) { + if (props.state.status === "loading") return
{props.t("home.contextPack.governance.loading")}
; + if (!props.state.context) return ( +
+ {props.t("home.contextPack.governance.error")} + +
+ ); + const candidates = props.state.context.goals.filter((goal) => goal.status === "candidate"); + const focusedId = props.state.context.focusedWorkItem?.id; + return ( +
+

{props.t("home.contextPack.governance.title")}

+ {props.state.error ?

{props.t("home.contextPack.governance.mutationError")}

: null} +
+ {props.t("home.contextPack.governance.currentGoal")} + {props.state.context.activeGoal?.title ?? props.t("home.contextPack.governance.none")} + {props.t("home.contextPack.governance.currentFocus")} + {props.state.context.focusedWorkItem?.title ?? props.t("home.contextPack.governance.none")} + {focusedId ? : null} +
+ {candidates.map((goal) =>
{props.t("home.contextPack.governance.candidate")}{goal.title}
)} + {props.state.context.workItems.filter((item) => item.status !== "completed" && item.status !== "archived").map((item) =>
{props.t("home.contextPack.governance.workItem")}{item.title}{item.nextStep ? {item.nextStep} : null}
{item.id !== focusedId ? : null}
)} +
+ ); +} + +function projectNamespace(projectId: string): RuntimeNamespace { + return { source: "desktop", profileId: "default", projectId }; +} + +function mutationEnvelope(projectId: string) { + const requestId = `desktop-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + return { + namespace: projectNamespace(projectId), + source: "desktop", + adapterId: "memmy-desktop", + requestId, + provenance: { sourceAgent: "memmy-desktop", sourceMemoryIds: [], capturedAt: new Date().toISOString(), projectId, adapterId: "memmy-desktop", requestId } + }; +} + +function ContextPackItem(props: { + item: MemoryListItem | ProjectContextPackOutput["recentTasks"][number]; + onOpen: (memoryId: string) => void; +}) { + const title = props.item.title || ("summary" in props.item ? props.item.summary : "") || props.item.id; + const summary = "summary" in props.item ? props.item.summary : ""; + + return ( +
  • + {"memoryLayer" in props.item ? ( + + ) : ( +
    + {title} +
    + )} +
  • + ); +} + +function MemoryDetailView(props: { + state: MemoryDetailState | null; + historyState: MemoryHistoryState | null; + restoreState: RestoreState; + pendingRestoreVersion: number | null; + graph: ProjectContextPackOutput["graph"] | null; + memoryId: string; + t: Translate; + onBack: () => void; + onOpenGraph: () => void; + onOpenMemory: (memoryId: string) => void; + onRequestRestore: (version: number) => void; + onCancelRestore: () => void; + onConfirmRestore: (version: number) => void; + onRetry: () => void; +}) { + const hasGraphNode = props.graph?.nodes.some((node) => node.id === props.memoryId) ?? false; + return ( +
    +
    + + {hasGraphNode ? ( + + ) : null} +
    + {!props.state || props.state.status === "loading" ? ( + {props.t("home.contextPack.detail.loading")} + ) : props.state.status === "error" ? ( + + {props.t("home.contextPack.detail.error")} + + + ) : ( + + )} +
    + ); +} + +function MemoryDetailContent(props: { + detail: GetMemoryOutput; + historyState: MemoryHistoryState | null; + restoreState: RestoreState; + pendingRestoreVersion: number | null; + t: Translate; + onOpenMemory: (memoryId: string) => void; + onRequestRestore: (version: number) => void; + onCancelRestore: () => void; + onConfirmRestore: (version: number) => void; +}) { + const item = props.detail.item; + const evidenceIds = uniqueIds([ + ...item.sourceMemoryIds, + ...(item.policy?.evidenceMemoryIds ?? []), + ...(item.worldModel?.sourceMemoryIds ?? []), + ...(item.skill?.sourcePolicyIds ?? []), + ...(item.skill?.sourceWorldModelIds ?? []) + ]); + const source = item.provenance?.sourceAgent + ?? (typeof item.metadata.source === "string" ? item.metadata.source : undefined) + ?? props.t("home.contextPack.detail.unknown"); + + return ( +
    +
    +

    {item.title}

    + {item.summary && item.summary !== item.title ?

    {item.summary}

    : null} +
    +
    +

    {props.t("home.contextPack.detail.metadata")}

    +
    + + + + + + +
    +
    +
    +

    {props.t("home.contextPack.detail.content")}

    +

    {item.body || props.t("home.contextPack.detail.empty")}

    +
    + + + + {item.provenance ? ( +
    +

    {props.t("home.contextPack.detail.provenance")}

    +
    + + {item.provenance.repository ? : null} + {item.provenance.branch ? : null} + {item.provenance.commit ? : null} + +
    +
    + ) : null} +
    + ); +} + +function DetailField(props: { label: string; value: string }) { + return <>
    {props.label}
    {props.value}
    ; +} + +function IdSection(props: { + title: string; + ids: string[]; + empty: string; + onOpenMemory: (memoryId: string) => void; +}) { + return ( +
    +

    {props.title}

    + {props.ids.length > 0 ? ( +
      + {props.ids.map((id) => ( +
    • + +
    • + ))} +
    + ) :

    {props.empty}

    } +
    + ); +} + +function VersionRelations(props: { + detail: GetMemoryOutput; + t: Translate; + onOpenMemory: (memoryId: string) => void; +}) { + const supersession = props.detail.item.supersession; + const relatedIds = supersession + ? uniqueIds([...supersession.supersedesMemoryIds, ...(supersession.supersededByMemoryId ? [supersession.supersededByMemoryId] : [])]) + : []; + return ( +
    +

    {props.t("home.contextPack.detail.versionRelations")}

    + {relatedIds.length > 0 ? ( +
    + {supersession!.supersedesMemoryIds.length > 0 ? ( + + ) : null} + {supersession!.supersededByMemoryId ? ( + + ) : null} + {supersession!.reason ?

    {props.t("home.contextPack.detail.reason")}: {supersession!.reason}

    : null} +
    + ) :

    {props.t("home.contextPack.detail.none")}

    } +
    + ); +} + +function LinkedRelation(props: { + label: string; + ids: string[]; + onOpenMemory: (memoryId: string) => void; +}) { + return ( +
    + {props.label} + {props.ids.map((id) => ( + + ))} +
    + ); +} + +function MemoryHistorySection(props: { + currentVersion: number; + state: MemoryHistoryState | null; + restoreState: RestoreState; + pendingRestoreVersion: number | null; + t: Translate; + onRequestRestore: (version: number) => void; + onCancelRestore: () => void; + onConfirmRestore: (version: number) => void; +}) { + return ( +
    +

    {props.t("home.contextPack.history.title")}

    + {!props.state || props.state.status === "loading" ? ( +

    {props.t("home.contextPack.history.loading")}

    + ) : props.state.status === "error" ? ( +

    + {props.t("home.contextPack.history.error")} +

    + ) : props.state.history.items.length === 0 ? ( +

    {props.t("home.contextPack.history.empty")}

    + ) : ( +
    + {props.state.history.items.map((entry) => { + const version = entry.version; + const current = version === props.currentVersion; + const restorable = version !== undefined && entry.after !== undefined && !current; + const confirming = version !== undefined && props.pendingRestoreVersion === version; + const restoring = version !== undefined + && props.restoreState.status === "restoring" + && props.restoreState.targetVersion === version; + return ( +
    +
    + {version === undefined ? "-" : `v${version}`} · {historySnapshotTitle(entry.after, entry.changeType)} + {entry.changeType} · {entry.source} · {formatDateTime(entry.createdAt)} + {historySnapshotBody(entry.after) ?

    {historySnapshotBody(entry.after)}

    : null} +
    + {confirming ? ( +
    +

    {props.t("home.contextPack.history.confirm")}

    +
    + + +
    +
    + ) : ( + + )} +
    + ); + })} +
    + )} + {props.restoreState.status === "success" ? ( +

    + {props.t("home.contextPack.history.success")} v{props.restoreState.targetVersion} +

    + ) : null} + {props.restoreState.status === "error" ? ( +

    + {props.t("home.contextPack.history.restoreError")} +

    + ) : null} +
    + ); +} + +function historySnapshotTitle(snapshot: unknown, fallback: string): string { + const record = snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) + ? snapshot as Record + : null; + const info = record && typeof record.info === "object" && record.info !== null + ? record.info as Record + : null; + return typeof info?.title === "string" && info.title.trim() + ? info.title + : fallback; +} + +function historySnapshotBody(snapshot: unknown): string { + const record = snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) + ? snapshot as Record + : null; + return typeof record?.memoryValue === "string" ? record.memoryValue : ""; +} + +function statusLabel(status: GetMemoryOutput["item"]["status"], t: Translate): string { + const keys = { + activated: "memory.memories.status.activated", + resolving: "memory.memories.status.resolving", + archived: "memory.memories.status.archived", + deleted: "memory.memories.status.deleted" + } as const; + return t(keys[status]); +} + +function formatDateTime(value: string): string { + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); +} + +function uniqueIds(ids: string[]): string[] { + return [...new Set(ids)]; +} + +function ContextPackStatus(props: { children: ReactNode }) { + return
    {props.children}
    ; +} diff --git a/App/frontend/desktop/src/pages/tests/app-frame.test.tsx b/App/frontend/desktop/src/pages/tests/app-frame.test.tsx index 42e26bb47..2be848295 100644 --- a/App/frontend/desktop/src/pages/tests/app-frame.test.tsx +++ b/App/frontend/desktop/src/pages/tests/app-frame.test.tsx @@ -237,7 +237,7 @@ describe("AppFrame", () => { it("positions the task action menu as a top-level viewport overlay", () => { const overlayStyle = resolveSidebarMenuOverlayStyle( - { left: 196, right: 188, bottom: 424 }, + { left: 160, right: 188, bottom: 424 }, { width: 512, height: 768 }, { width: 128, height: 128, margin: 8, gap: 4 } ); @@ -959,13 +959,14 @@ describe("AppFrame", () => { }); it("groups active tasks by time buckets (today, yesterday, last 7 days, older)", () => { - const now = new Date("2026-07-02T14:00:00+08:00"); + const now = new Date(2026, 6, 2, 14); + const localTimestamp = (month: number, day: number, hour: number) => new Date(2026, month, day, hour).toISOString(); const groups = groupTasksByTime([ - task("today-1", { title: "今天的任务", updatedAt: "2026-07-02T10:00:00+08:00" }), - task("today-2", { title: "今天的另一个", updatedAt: "2026-07-02T01:00:00+08:00" }), - task("yesterday", { title: "昨天的任务", updatedAt: "2026-07-01T18:00:00+08:00" }), - task("week", { title: "三天前", updatedAt: "2026-06-29T12:00:00+08:00" }), - task("older", { title: "很久以前", updatedAt: "2026-06-20T12:00:00+08:00" }), + task("today-1", { title: "今天的任务", updatedAt: localTimestamp(6, 2, 10) }), + task("today-2", { title: "今天的另一个", updatedAt: localTimestamp(6, 2, 1) }), + task("yesterday", { title: "昨天的任务", updatedAt: localTimestamp(6, 1, 18) }), + task("week", { title: "三天前", updatedAt: localTimestamp(5, 29, 12) }), + task("older", { title: "很久以前", updatedAt: localTimestamp(5, 20, 12) }), task("no-date", { title: "无日期", updatedAt: null }) ], now); diff --git a/App/frontend/desktop/src/pages/tests/context-pack-relation-graph-layout.test.ts b/App/frontend/desktop/src/pages/tests/context-pack-relation-graph-layout.test.ts new file mode 100644 index 000000000..fb2f68d97 --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/context-pack-relation-graph-layout.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import type { ProjectContextPackOutput } from "@memmy/local-api-contracts"; +import { layoutMemoryRelationGraph } from "../context-pack-relation-graph-layout.js"; + +describe("layoutMemoryRelationGraph", () => { + it("places memory layers in stable columns and rows", () => { + const layout = layoutMemoryRelationGraph(graph(), "memory-l2-a"); + const l1 = layout.nodes.find((node) => node.id === "memory-l1"); + const l2a = layout.nodes.find((node) => node.id === "memory-l2-a"); + const l2b = layout.nodes.find((node) => node.id === "memory-l2-b"); + const skill = layout.nodes.find((node) => node.id === "memory-skill"); + + expect(l2a?.position.x).toBeGreaterThan(l1?.position.x ?? 0); + expect(skill?.position.x).toBeGreaterThan(l2a?.position.x ?? 0); + expect(l2b?.position.x).toBe(l2a?.position.x); + expect(l2b?.position.y).toBeGreaterThan(l2a?.position.y ?? 0); + expect(l2a?.data.anchor).toBe(true); + expect(skill?.data.external).toBe(true); + }); + + it("keeps typed relations and drops edges whose nodes are absent", () => { + const layout = layoutMemoryRelationGraph(graph(), "memory-l2-a"); + + expect(layout.edges).toContainEqual(expect.objectContaining({ + source: "memory-l1", + target: "memory-l2-a", + label: "source", + className: "context-pack-relation-edge context-pack-relation-edge--source" + })); + expect(layout.edges).toContainEqual(expect.objectContaining({ + source: "memory-l2-a", + target: "memory-l2-b", + label: "supersedes", + style: expect.objectContaining({ strokeDasharray: "5 4" }) + })); + expect(layout.edges.some((edge) => edge.target === "missing-memory")).toBe(false); + }); +}); + +function graph(): ProjectContextPackOutput["graph"] { + return { + nodes: [ + node("memory-l1", "L1"), + node("memory-l2-a", "L2"), + node("memory-l2-b", "L2"), + { ...node("memory-skill", "Skill"), external: true } + ], + edges: [ + { sourceId: "memory-l1", targetId: "memory-l2-a", relation: "source" }, + { sourceId: "memory-l2-a", targetId: "memory-l2-b", relation: "supersedes", reason: "new evidence" }, + { sourceId: "memory-l2-a", targetId: "missing-memory", relation: "source" } + ] + }; +} + +function node(id: string, memoryLayer: "L1" | "L2" | "L3" | "Skill") { + return { + id, + kind: memoryLayer === "Skill" ? "skill" as const : "policy" as const, + memoryLayer, + status: "activated" as const, + title: id, + summary: "", + tags: [], + createdAt: "2026-08-07T10:00:00.000Z", + updatedAt: "2026-08-08T12:00:00.000Z", + version: 1 + }; +} diff --git a/App/frontend/desktop/src/pages/tests/home-page-context-pack.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/home-page-context-pack.interaction.test.tsx new file mode 100644 index 000000000..3f4d850f9 --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/home-page-context-pack.interaction.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment happy-dom + +import { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProjectContextPackOutput } from "@memmy/local-api-contracts"; +import { AgentRuntimeBridge } from "../../app/agent-runtime-bridge.js"; +import { AppProviders, useApiClients } from "../../app/providers.js"; +import type { AppClients } from "../../api/client-types.js"; +import { agentActions } from "../../state/app-actions.js"; +import { useAppState } from "../../state/app-state.js"; +import { HomePage } from "../home-page.js"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("HomePage project context pack session coverage", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + Object.defineProperty(window, "localStorage", { configurable: true, value: createMemoryStorage() }); + Object.defineProperty(window, "sessionStorage", { configurable: true, value: createMemoryStorage() }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + document.body.replaceChildren(); + vi.restoreAllMocks(); + }); + + it("keeps the entry in running and historical project chats and closes it on project switch", async () => { + const getProjectContextPack = vi.fn(async (projectId: string) => contextPack(projectId)); + + await act(async () => { + root.render( + + + + + + + ); + }); + + expect(contextPackTrigger()).not.toBeNull(); + await act(async () => contextPackTrigger()?.click()); + expect(getProjectContextPack).toHaveBeenLastCalledWith("project-running"); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain("Running Project"); + + await act(async () => switchButton("history").click()); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(contextPackTrigger()).not.toBeNull(); + + await act(async () => switchButton("running").click()); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + await act(async () => switchButton("history").click()); + + await act(async () => contextPackTrigger()?.click()); + expect(getProjectContextPack).toHaveBeenLastCalledWith("project-history"); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain("History Project"); + + await act(async () => switchButton("standalone").click()); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(contextPackTrigger()).toBeNull(); + }); +}); + +function SessionHarness(props: { + getProjectContextPack: (projectId: string) => Promise; +}) { + const { dispatch } = useAppState(); + const { setClients } = useApiClients(); + + useEffect(() => { + setClients({ + memoryRuntime: { + getProjectContextPack: props.getProjectContextPack, + getProjectContextState: async (namespace: { projectId?: string }) => ({ + namespaceId: `local:${namespace.projectId ?? "unscoped"}`, + activeGoal: null, + goals: [], + workItems: [], + focusedWorkItem: null, + facts: [] + }) + } + } as unknown as AppClients); + dispatch(agentActions.sessionSnapshotApplied({ + projectRegistryState: "ready", + projects: [ + { id: "project-running", name: "Running Project", rootPath: "/running", pinned: false, createdAt: "2026-08-08T00:00:00.000Z" }, + { id: "project-history", name: "History Project", rootPath: "/history", pinned: false, createdAt: "2026-08-08T00:00:00.000Z" } + ], + sessions: [ + { key: "websocket:running", title: "Running", projectId: "project-running", cwd: "/running", run_started_at: 1 }, + { key: "websocket:history", title: "History", projectId: "project-history", cwd: "/history" }, + { key: "websocket:standalone", title: "Standalone", projectId: null, cwd: "/tmp" } + ] + })); + openChat(dispatch, "running", false); + }, [dispatch, props.getProjectContextPack, setClients]); + + return ( + <> + + + + + ); +} + +function openChat(dispatch: ReturnType["dispatch"], chatId: string, closed: boolean) { + const sessionKey = `websocket:${chatId}`; + const requestId = `request-${chatId}`; + dispatch(agentActions.historyLoading(sessionKey, chatId, requestId)); + dispatch(agentActions.historyLoaded({ + schemaVersion: 1, + sessionKey, + last_turn_closed: closed, + messages: [ + { role: "user", content: `${chatId} question` }, + { role: "assistant", content: `${chatId} answer` } + ] + }, requestId)); +} + +function contextPackTrigger(): HTMLButtonElement | null { + return document.querySelector(".home-context-pack-trigger"); +} + +function switchButton(target: string): HTMLButtonElement { + const button = document.querySelector(`[data-switch="${target}"]`); + if (!button) throw new Error(`Missing ${target} switch button`); + return button; +} + +function contextPack(projectId: string): ProjectContextPackOutput { + return { + namespace: { projectId }, + conventions: [], + commands: [], + architectureFacts: [], + recentTasks: [{ id: `task-${projectId}`, title: projectId, updatedAt: "2026-08-08T12:00:00.000Z" }], + userPreferences: [], + graph: { nodes: [], edges: [] }, + markdown: `# Project Memory Pack: ${projectId}`, + generatedAt: "2026-08-08T12:00:00.000Z" + }; +} + +function createMemoryStorage(): Storage { + const values = new Map(); + return { + get length() { return values.size; }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => Array.from(values.keys())[index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value) + }; +} diff --git a/App/frontend/desktop/src/pages/tests/project-context-pack-dialog.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/project-context-pack-dialog.interaction.test.tsx new file mode 100644 index 000000000..717b49f0d --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/project-context-pack-dialog.interaction.test.tsx @@ -0,0 +1,442 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GetMemoryOutput, MemoryListItem, ProjectContextPackOutput, ProjectContextReadState, ProjectGoalRecord } from "@memmy/local-api-contracts"; +import type { MemoryRuntimeClient } from "../../api/memory-runtime-client.js"; +import type { MessageKey } from "../../i18n/messages.js"; +import { ProjectContextPackDialog } from "../project-context-pack-dialog.js"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("ProjectContextPackDialog", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + document.body.replaceChildren(); + vi.restoreAllMocks(); + }); + + it("loads the selected project pack and copies its Markdown", async () => { + const writeText = vi.fn(async () => undefined); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + const getProjectContextPack = vi.fn(async () => contextPack()); + const getMemory = vi.fn(async () => memoryDetail()); + + await act(async () => { + root.render( + undefined} + /> + ); + }); + + expect(getProjectContextPack).toHaveBeenCalledWith("project-1"); + expect(document.body.textContent).toContain("Memmy Agent"); + expect(document.body.textContent).toContain("Ship desktop context pack entry"); + + const copyButton = Array.from(document.querySelectorAll("button")) + .find((button) => button.textContent?.includes("home.contextPack.copy")); + expect(copyButton).toBeDefined(); + await act(async () => copyButton?.click()); + expect(writeText).toHaveBeenCalledWith("# Project Memory Pack: project-1"); + expect(document.body.textContent).toContain("home.contextPack.copied"); + }); + + it("reloads when the open dialog is pointed at another project", async () => { + const getProjectContextPack = vi.fn(async (projectId: string) => contextPack(projectId)); + const getMemory = vi.fn(async () => memoryDetail()); + + await act(async () => { + root.render( + undefined} + /> + ); + }); + await act(async () => { + root.render( + undefined} + /> + ); + }); + + expect(getProjectContextPack.mock.calls.map(([projectId]) => projectId)).toEqual(["project-1", "project-2"]); + expect(document.body.textContent).toContain("Project Two"); + }); + + it("loads a clicked memory by id and renders its complete detail contract", async () => { + const getProjectContextPack = vi.fn(async () => contextPack()); + const getMemory = vi.fn(async () => memoryDetail()); + + await act(async () => { + root.render( + undefined} + /> + ); + }); + await act(async () => memoryButton().click()); + + expect(getMemory).toHaveBeenCalledWith("memory-1", { signal: expect.any(AbortSignal) }); + expect(document.body.textContent).toContain("Complete memory body"); + expect(document.body.textContent).toContain("L2"); + expect(document.body.textContent).toContain("codex"); + expect(document.body.textContent).toContain("memory-evidence-1"); + expect(document.body.textContent).toContain("memory-old-1"); + expect(document.body.textContent).toContain("memory-new-1"); + expect(document.body.textContent).toContain("repo/memmy-agent"); + }); + + it("opens the relation graph, selects a linked node, and jumps to its memory detail", async () => { + const getProjectContextPack = vi.fn(async () => contextPack()); + const getMemory = vi.fn(async (id: string) => memoryDetail(id)); + + await act(async () => { + root.render( + undefined} + /> + ); + }); + await act(async () => memoryButton().click()); + await act(async () => buttonContaining("home.contextPack.detail.openGraph").click()); + + expect(document.body.textContent).toContain("Related evidence memory"); + expect(document.body.textContent).toContain("home.contextPack.graph.locate"); + const linkedNode = document.querySelector('.react-flow__node[data-id="memory-evidence-1"]'); + expect(linkedNode).not.toBeNull(); + await act(async () => linkedNode?.click()); + expect(document.body.textContent).toContain("memory-evidence-1"); + + await act(async () => buttonContaining("home.contextPack.graph.openMemory").click()); + expect(getMemory.mock.calls.map(([id]) => id)).toEqual(["memory-1", "memory-evidence-1"]); + expect(document.body.textContent).toContain("Complete memory body: memory-evidence-1"); + }); + + it("confirms a historical restore and reloads detail from its recorded source version", async () => { + const getProjectContextPack = vi.fn(async () => contextPack()); + const getMemory = vi.fn(async (id: string) => memoryDetail(id)); + const restoreMemory = vi.fn(async (id: string, targetVersion: number) => ({ + ok: true as const, + id, + version: 4, + restoredVersion: targetVersion, + changeSeq: 4, + auditId: "audit-restore-1", + serverTime: "2026-08-08T13:00:00.000Z" + })); + + await act(async () => { + root.render( + undefined} + /> + ); + }); + await act(async () => memoryButton().click()); + await act(async () => buttonContaining("home.contextPack.history.restore").click()); + expect(document.body.textContent).toContain("home.contextPack.history.confirm"); + + await act(async () => buttonContaining("home.contextPack.history.confirmAction").click()); + + expect(restoreMemory).toHaveBeenCalledWith("memory-1", 1, { + version: 3, + reason: "restored from desktop context pack" + }); + expect(getMemory).toHaveBeenCalledTimes(2); + expect(document.body.textContent).toContain("home.contextPack.history.success"); + expect(document.body.textContent).toContain("v1"); + }); + it("loads governance state and approves a candidate goal", async () => { + const getProjectContextPack = vi.fn(async () => contextPack()); + const getProjectContextState = vi.fn(async () => contextState()); + const approveProjectGoal = vi.fn(async () => goalRecord("active")); + + await act(async () => { + root.render( undefined} />); + }); + + expect(getProjectContextState).toHaveBeenCalledWith(expect.objectContaining({ projectId: "project-1" })); + expect(document.body.textContent).toContain("home.contextPack.governance.approve"); + await act(async () => buttonContaining("home.contextPack.governance.approve").click()); + expect(approveProjectGoal).toHaveBeenCalledWith("goal-1", expect.objectContaining({ namespace: expect.objectContaining({ projectId: "project-1" }) })); + expect(getProjectContextPack).toHaveBeenCalledTimes(2); + }); + + it("sets and clears the focused work item through governance controls", async () => { + const getProjectContextState = vi.fn(async () => contextState()); + const setProjectFocus = vi.fn(async () => null); + await act(async () => { + root.render( contextPack()), getProjectContextState, setProjectFocus })} t={translate} onClose={() => undefined} />); + }); + await act(async () => buttonContaining("home.contextPack.governance.setFocus").click()); + expect(setProjectFocus).toHaveBeenCalledWith(expect.objectContaining({ workItemId: "work-1" })); + }); + + it("shows detail loading and recovers from a failed request", async () => { + const pending = deferred(); + let callCount = 0; + const getMemory = vi.fn((_id: string, _options?: { signal?: AbortSignal }): Promise => { + callCount += 1; + return callCount === 1 ? pending.promise : Promise.resolve(memoryDetail()); + }); + + await renderDialog({ getMemory }); + await act(async () => memoryButton().click()); + expect(document.body.textContent).toContain("home.contextPack.detail.loading"); + + await act(async () => pending.reject(new Error("service unavailable"))); + expect(document.body.textContent).toContain("home.contextPack.detail.error"); + + const retry = buttonContaining("common.retry"); + await act(async () => retry.click()); + expect(getMemory).toHaveBeenCalledTimes(2); + expect(document.body.textContent).toContain("Complete memory body"); + }); + + it("closes detail and aborts its old request when the project changes", async () => { + const pending = deferred(); + const historyPending = deferred>(); + const getProjectContextPack = vi.fn(async (projectId: string) => contextPack(projectId)); + const getMemory = vi.fn((_id: string, _options?: { signal?: AbortSignal }) => pending.promise); + const getMemoryHistory = vi.fn((_id: string, _options?: { signal?: AbortSignal }) => historyPending.promise); + const client = dialogClient({ getProjectContextPack, getMemory, getMemoryHistory }); + + await act(async () => { + root.render( undefined} />); + }); + await act(async () => memoryButton().click()); + const signal = getMemory.mock.calls[0]?.[1]?.signal; + const historySignal = getMemoryHistory.mock.calls[0]?.[1]?.signal; + expect(signal?.aborted).toBe(false); + expect(historySignal).toBe(signal); + + await act(async () => { + root.render( undefined} />); + }); + + expect(signal?.aborted).toBe(true); + expect(historySignal?.aborted).toBe(true); + expect(document.body.textContent).not.toContain("home.contextPack.detail.title"); + expect(document.body.textContent).toContain("Two"); + }); + + it("keeps an invalid memory response inside the detail error state", async () => { + const getMemory = vi.fn(async (_id: string, _options?: { signal?: AbortSignal }): Promise => { + throw new Error("Invalid input: memory detail contract mismatch"); + }); + await renderDialog({ getMemory }); + await act(async () => memoryButton().click()); + + expect(document.body.textContent).toContain("home.contextPack.detail.error"); + expect(document.body.textContent).toContain("home.contextPack.detail.back"); + expect(document.body.textContent).not.toContain("Complete memory body"); + }); + + async function renderDialog(input: { + getMemory: (id: string, options?: { signal?: AbortSignal }) => Promise; + }) { + await act(async () => { + root.render( + contextPack()), getMemory: input.getMemory })} + t={translate} + onClose={() => undefined} + /> + ); + }); + } +}); + +function translate(key: MessageKey): string { + return key; +} + +function contextPack(projectId = "project-1"): ProjectContextPackOutput { + return { + namespace: { projectId }, + conventions: [memoryListItem()], + commands: [], + architectureFacts: [], + recentTasks: [{ id: "episode-1", title: "Ship desktop context pack entry", updatedAt: "2026-08-08T12:00:00.000Z" }], + userPreferences: [], + graph: { + nodes: [ + memoryListItem(), + { ...memoryListItem("memory-evidence-1", "Related evidence memory"), external: true } + ], + edges: [ + { sourceId: "memory-evidence-1", targetId: "memory-1", relation: "source" }, + { sourceId: "memory-1", targetId: "memory-evidence-1", relation: "supersedes", reason: "New evidence" } + ] + }, + markdown: `# Project Memory Pack: ${projectId}`, + generatedAt: "2026-08-08T12:00:00.000Z" + }; +} + +function memoryListItem(id = "memory-1", title = "Use stable detail contracts"): MemoryListItem { + return { + id, + kind: "policy", + memoryLayer: "L2", + status: "activated", + title, + summary: "Load complete memory content by id.", + tags: ["architecture"], + createdAt: "2026-08-07T10:00:00.000Z", + updatedAt: "2026-08-08T12:00:00.000Z", + version: 3 + }; +} + +function memoryDetail(id = "memory-1"): GetMemoryOutput { + return { + item: { + ...memoryListItem(id, id === "memory-1" ? "Use stable detail contracts" : "Related evidence memory"), + body: `Complete memory body: ${id}`, + sourceMemoryIds: ["memory-source-1"], + policy: { evidenceMemoryIds: ["memory-evidence-1"], confidence: 0.9 }, + provenance: { + sourceAgent: "codex", + repository: "repo/memmy-agent", + branch: "main", + commit: "abc123", + sourceMemoryIds: ["memory-source-1"], + capturedAt: "2026-08-07T10:00:00.000Z" + }, + supersession: { + supersedesMemoryIds: ["memory-old-1"], + supersededByMemoryId: "memory-new-1", + reason: "New evidence" + }, + metadata: { source: "codex" } + }, + version: 3, + etag: "memory-1-v3" + }; +} + +function memoryHistory(id: string) { + return { + id, + currentVersion: 3, + items: [ + { + seq: 3, + version: 3, + changeType: "updated", + source: "panel.edit", + createdAt: "2026-08-08T12:00:00.000Z", + after: { info: { title: "Use stable detail contracts" }, memoryValue: "Current body" } + }, + { + seq: 1, + version: 1, + changeType: "created", + source: "turn_complete", + createdAt: "2026-08-07T10:00:00.000Z", + after: { info: { title: "Original contract" }, memoryValue: "Original body" } + } + ], + serverTime: "2026-08-08T12:00:00.000Z" + }; +} + +function goalRecord(status: ProjectGoalRecord["status"] = "candidate"): ProjectGoalRecord { + return { id: "goal-1", namespaceId: "namespace-1", userId: "user-1", projectId: "project-1", title: "Ship authoritative context", summary: "", detail: "", acceptanceCriteria: [], constraints: [], status, version: 1, sourceMemoryIds: ["memory-1"], provenance: {}, createdAt: "2026-08-08T12:00:00.000Z", updatedAt: "2026-08-08T12:00:00.000Z" }; +} + +function contextState(): ProjectContextReadState { + const workItem = { id: "work-1", namespaceId: "namespace-1", userId: "user-1", projectId: "project-1", goalId: "goal-1", title: "Wire desktop governance", summary: "", nextStep: "Approve and focus", acceptanceCriteria: [], constraints: [], status: "active" as const, focused: false, sourceMemoryIds: ["memory-1"], provenance: {}, createdAt: "2026-08-08T12:00:00.000Z", updatedAt: "2026-08-08T12:00:00.000Z" }; + return { namespaceId: "namespace-1", activeGoal: null, goals: [goalRecord()], workItems: [workItem], focusedWorkItem: null, facts: [] }; +} + +function dialogClient( + overrides: Partial> = {} +) { + return { + getProjectContextPack: async () => contextPack(), + getMemory: async (id: string) => memoryDetail(id), + getMemoryHistory: async (id: string) => memoryHistory(id), + restoreMemory: async (id: string, targetVersion: number) => ({ + ok: true as const, + id, + version: 4, + restoredVersion: targetVersion, + changeSeq: 4, + auditId: "audit-restore", + serverTime: "2026-08-08T13:00:00.000Z" + }), + getProjectContextState: async () => contextState(), + approveProjectGoal: async () => goalRecord("active"), + rejectProjectGoal: async () => goalRecord("archived"), + setProjectFocus: async () => null, + ...overrides + }; +} + +function memoryButton(): HTMLButtonElement { + return buttonContaining("Use stable detail contracts"); +} + +function buttonContaining(text: string): HTMLButtonElement { + const button = Array.from(document.querySelectorAll("button")) + .find((candidate) => candidate.textContent?.includes(text)); + if (!button) throw new Error(`Missing button containing: ${text}`); + return button; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} diff --git a/App/frontend/desktop/src/pages/tests/project-target-picker.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/project-target-picker.interaction.test.tsx index 0ed115695..c81841dd0 100644 --- a/App/frontend/desktop/src/pages/tests/project-target-picker.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/project-target-picker.interaction.test.tsx @@ -25,6 +25,7 @@ describe("ProjectTargetPicker interactions", () => { beforeEach(() => { Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: scrollIntoView }); + Object.defineProperty(window, "localStorage", { configurable: true, value: createMemoryStorage() }); container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -322,3 +323,17 @@ function getStandaloneButton(): HTMLButtonElement | null { return Array.from(document.querySelectorAll(".home-project-picker__actions [role='option']")) .find((button) => button.querySelector(".lucide-x")) ?? null; } + +function createMemoryStorage(): Storage { + const values = new Map(); + return { + get length() { + return values.size; + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => Array.from(values.keys())[index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value) + }; +} diff --git a/App/frontend/desktop/src/styles.css b/App/frontend/desktop/src/styles.css index df014538e..3f9cf1c04 100644 --- a/App/frontend/desktop/src/styles.css +++ b/App/frontend/desktop/src/styles.css @@ -336,6 +336,10 @@ body.memmy-window-fullscreen { background: transparent; } +.home-composer-toolbar--conversation { + padding: 4px 0 0; +} + .home-project-picker { position: relative; z-index: 45; @@ -647,6 +651,366 @@ body.memmy-window-fullscreen { line-height: 16px; } +.home-context-pack-trigger { + display: inline-flex; + height: 28px; + align-items: center; + gap: 5px; + padding: 0 7px; + border: 0; + border-radius: var(--radius-input); + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); + font-family: var(--font-sans); + font-size: var(--codex-text-sm); + line-height: 1; + cursor: pointer; +} + +.home-context-pack-trigger:hover, +.home-context-pack-trigger:focus-visible { + background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); + color: var(--color-action-sky-hover); +} + +.project-context-pack-dialog { + width: min(680px, calc(100vw - 32px)); + max-height: min(720px, calc(100vh - 48px)); +} + +.project-context-pack-dialog--graph { width: min(900px, calc(100vw - 32px)); } + +.project-context-pack-dialog__body { + min-height: 260px; + overflow-y: auto; +} + +.project-context-pack-dialog__status { + display: flex; + min-height: 240px; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 12px; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + font-size: var(--codex-text-sm); +} + +.project-context-pack-dialog__retry, +.project-context-pack-dialog__copy { + display: inline-flex; + align-items: center; + gap: 6px; + border: 0; + border-radius: var(--radius-input); + background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); + color: var(--color-action-sky-hover); + font-family: var(--font-sans); + font-size: var(--codex-text-sm); + font-weight: 500; + line-height: 16px; + cursor: pointer; +} + +.project-context-pack-dialog__retry { padding: 7px 10px; } + +.project-context-pack-dialog__summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-bottom: 12px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 52%, transparent); + font-size: var(--codex-text-xs); +} + +.project-context-pack-dialog__copy { flex-shrink: 0; padding: 6px 9px; } + +.project-context-governance { + display: grid; + gap: 10px; + padding: 14px 0; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); +} + +.project-context-governance__heading { display: flex; align-items: center; gap: 7px; color: var(--color-text-ink); } +.project-context-governance__heading h3 { margin: 0; font-size: var(--codex-text-sm); font-weight: 700; letter-spacing: 0; } +.project-context-governance__current { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; align-items: center; gap: 5px 10px; } +.project-context-governance__current span, +.project-context-governance__row span { color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); font-size: var(--codex-text-xs); } +.project-context-governance__current strong, +.project-context-governance__row strong { min-width: 0; overflow-wrap: anywhere; font-size: var(--codex-text-sm); font-weight: 600; } +.project-context-governance__row { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 10px; border: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); border-radius: var(--radius-input); } +.project-context-governance__row > div:first-child { display: grid; min-width: 0; gap: 2px; } +.project-context-governance__row small { color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); font-size: var(--codex-text-xs); overflow-wrap: anywhere; } +.project-context-governance__actions { display: flex; flex-shrink: 0; gap: 6px; } +.project-context-governance button, +.project-context-governance__status button { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 9px; border: 1px solid color-mix(in srgb, var(--color-border-stone) 70%, transparent); border-radius: var(--radius-input); background: var(--color-bg-elevated); color: var(--color-text-ink); font-family: inherit; font-size: var(--codex-text-xs); cursor: pointer; } +.project-context-governance button:disabled { opacity: .55; cursor: wait; } +.project-context-governance .project-context-governance__primary { border-color: transparent; background: var(--color-action-sky); color: white; } +.project-context-governance__status { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 0; color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); font-size: var(--codex-text-xs); } +.project-context-governance__status--error, +.project-context-governance__error { color: var(--color-status-error, #b42318); } +.project-context-governance__error { margin: 0; font-size: var(--codex-text-xs); } + +@media (max-width: 560px) { + .project-context-governance__current { grid-template-columns: 1fr; } + .project-context-governance__row { align-items: stretch; flex-direction: column; } + .project-context-governance__actions { justify-content: flex-end; } +} + +.project-context-pack-dialog__sections { display: grid; } + +.project-context-pack-dialog__section { + padding: 16px 0; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 35%, transparent); +} + +.project-context-pack-dialog__section:last-child { border-bottom: 0; } + +.project-context-pack-dialog__section h3 { + margin: 0 0 8px; + color: var(--color-text-ink); + font-size: var(--codex-text-sm); + font-weight: 700; + letter-spacing: 0; +} + +.project-context-pack-dialog__section ul { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; } +.project-context-pack-dialog__section li { min-width: 0; } +.project-context-pack-dialog__section strong { font-size: var(--codex-text-sm); font-weight: 600; overflow-wrap: anywhere; } +.project-context-pack-dialog__section li span { color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); font-size: var(--codex-text-xs); overflow-wrap: anywhere; } + +.project-context-pack-dialog__item { + display: grid; + width: 100%; + min-width: 0; + gap: 2px; + padding: 7px 8px; + border: 0; + border-radius: var(--radius-input); + background: transparent; + color: inherit; + font-family: inherit; + letter-spacing: 0; + text-align: left; + cursor: pointer; +} + +.project-context-pack-dialog__item:hover, +.project-context-pack-dialog__item:focus-visible { + background: color-mix(in srgb, var(--color-action-sky) 8%, transparent); + outline: none; +} + +.project-context-pack-dialog__item--static { cursor: default; } + +.project-context-pack-detail { display: grid; min-height: 240px; } + +.project-context-pack-detail__toolbar, +.context-pack-relation-graph__toolbar { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.project-context-pack-detail__back { + display: inline-flex; + width: fit-content; + align-items: center; + gap: 5px; + margin-bottom: 12px; + padding: 5px 7px; + border: 0; + border-radius: var(--radius-input); + background: transparent; + color: var(--color-action-sky-hover); + font-family: var(--font-sans); + font-size: var(--codex-text-xs); + cursor: pointer; +} + +.project-context-pack-detail__back:hover, +.project-context-pack-detail__back:focus-visible { background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); } + +.project-context-pack-detail__graph, +.context-pack-relation-graph__locate, +.context-pack-relation-graph__selection button { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 6px; + padding: 6px 8px; + border: 0; + border-radius: var(--radius-input); + background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); + color: var(--color-action-sky-hover); + font-family: var(--font-sans); + font-size: var(--codex-text-xs); + cursor: pointer; +} + +.project-context-pack-detail__graph:hover, +.project-context-pack-detail__graph:focus-visible, +.context-pack-relation-graph__locate:hover, +.context-pack-relation-graph__locate:focus-visible, +.context-pack-relation-graph__selection button:hover, +.context-pack-relation-graph__selection button:focus-visible { + background: color-mix(in srgb, var(--color-action-sky) 16%, transparent); +} + +.project-context-pack-detail__content { display: grid; gap: 18px; } +.project-context-pack-detail__content section { display: grid; gap: 8px; } +.project-context-pack-detail__heading { display: grid; gap: 5px; padding-bottom: 14px; border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); } +.project-context-pack-detail__heading h3 { margin: 0; font-size: var(--codex-text-base); letter-spacing: 0; overflow-wrap: anywhere; } +.project-context-pack-detail__heading p { margin: 0; color: color-mix(in srgb, var(--color-text-ink) 60%, transparent); font-size: var(--codex-text-sm); line-height: 1.55; white-space: pre-wrap; } +.project-context-pack-detail__content h4 { margin: 0; font-size: var(--codex-text-sm); letter-spacing: 0; } + +.project-context-pack-detail__grid { + display: grid; + grid-template-columns: minmax(96px, 0.35fr) minmax(0, 1fr); + gap: 7px 12px; + margin: 0; + font-size: var(--codex-text-xs); +} + +.project-context-pack-detail__grid dt { color: color-mix(in srgb, var(--color-text-ink) 52%, transparent); } +.project-context-pack-detail__grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; } +.project-context-pack-detail__body { margin: 0; font-size: var(--codex-text-sm); line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; } +.project-context-pack-detail__ids { display: grid; gap: 5px; margin: 0; padding: 0; list-style: none; } +.project-context-pack-detail__ids button, +.project-context-pack-detail__relations button { + max-width: 100%; + padding: 3px 5px; + border: 0; + border-radius: 3px; + background: color-mix(in srgb, var(--color-action-sky) 7%, transparent); + color: var(--color-action-sky-hover); + text-align: left; + cursor: pointer; +} + +.project-context-pack-detail__ids button:hover, +.project-context-pack-detail__ids button:focus-visible, +.project-context-pack-detail__relations button:hover, +.project-context-pack-detail__relations button:focus-visible { background: color-mix(in srgb, var(--color-action-sky) 13%, transparent); } +.project-context-pack-detail__ids code, +.project-context-pack-detail__relations code { font-size: var(--codex-text-xs); overflow-wrap: anywhere; } +.project-context-pack-detail__empty { margin: 0; color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); font-size: var(--codex-text-xs); } + +.project-context-pack-detail__relations { display: grid; gap: 8px; } +.project-context-pack-detail__relations > div { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.project-context-pack-detail__relations span { color: color-mix(in srgb, var(--color-text-ink) 52%, transparent); font-size: var(--codex-text-xs); } +.project-context-pack-detail__relations p { margin: 0; font-size: var(--codex-text-xs); } + +.project-context-pack-history__list { display: grid; border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 38%, transparent); } + +.project-context-pack-history__item { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + align-items: center; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 32%, transparent); +} + +.project-context-pack-history__summary { display: grid; min-width: 0; gap: 3px; } +.project-context-pack-history__summary strong { overflow-wrap: anywhere; font-size: var(--codex-text-xs); } +.project-context-pack-history__summary span { color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); font-size: 10px; } +.project-context-pack-history__summary p { display: -webkit-box; margin: 2px 0 0; overflow: hidden; color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); font-size: var(--codex-text-xs); line-height: 16px; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } + +.project-context-pack-history__restore, +.project-context-pack-history__confirm button { + padding: 6px 8px; + border: 0; + border-radius: var(--radius-input); + background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); + color: var(--color-action-sky-hover); + font-family: var(--font-sans); + font-size: var(--codex-text-xs); + cursor: pointer; +} + +.project-context-pack-history__restore:disabled, +.project-context-pack-history__confirm button:disabled { cursor: default; opacity: 0.48; } +.project-context-pack-history__confirm { display: grid; max-width: 270px; gap: 7px; } +.project-context-pack-history__confirm p { margin: 0; color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); font-size: var(--codex-text-xs); line-height: 16px; } +.project-context-pack-history__confirm > div { display: flex; justify-content: flex-end; gap: 6px; } +.project-context-pack-history__confirm button:first-child { background: transparent; color: color-mix(in srgb, var(--color-text-ink) 60%, transparent); } +.project-context-pack-history__feedback { margin: 0; color: #548b75; font-size: var(--codex-text-xs); } +.project-context-pack-history__feedback--error { color: var(--color-danger, #b54f4f); } + +.context-pack-relation-graph { display: grid; min-height: 0; gap: 10px; } +.context-pack-relation-graph__toolbar .project-context-pack-detail__back { margin-bottom: 0; } + +.context-pack-relation-graph__canvas { + width: 100%; + height: min(420px, calc(100vh - 260px)); + min-height: 300px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 42%, transparent); + border-radius: var(--radius-input); + background: color-mix(in srgb, var(--color-canvas-oat) 24%, var(--color-background-paper)); +} + +.context-pack-relation-graph__canvas .react-flow__node { cursor: pointer; } +.context-pack-relation-graph__canvas .react-flow__node.selected .context-pack-relation-node { box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 30%, transparent); } +.context-pack-relation-graph__canvas .react-flow__edge-text { font-size: 9px; fill: color-mix(in srgb, var(--color-text-ink) 48%, transparent); } +.context-pack-relation-graph__canvas .react-flow__edge-textbg { fill: var(--color-background-paper); fill-opacity: 0.9; } +.context-pack-relation-graph__canvas .context-pack-relation-edge--source path { stroke: #548b75; } +.context-pack-relation-graph__canvas .context-pack-relation-edge--supersedes path { stroke: #9b6d55; } + +.context-pack-relation-node { + display: grid; + width: 100%; + height: 100%; + align-content: center; + gap: 7px; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--color-text-ink) 18%, transparent); + border-left: 3px solid #548b75; + border-radius: var(--radius-input); + background: var(--color-background-paper); + color: var(--color-text-ink); +} + +.context-pack-relation-node[data-layer="L2"] { border-left-color: #4f7fa9; } +.context-pack-relation-node[data-layer="L3"] { border-left-color: #806f9e; } +.context-pack-relation-node[data-layer="Skill"] { border-left-color: #9b7851; } +.context-pack-relation-node[data-anchor="true"] { background: color-mix(in srgb, var(--color-action-sky) 7%, var(--color-background-paper)); } +.context-pack-relation-node[data-external="true"] { border-style: dashed; opacity: 0.8; } +.context-pack-relation-node__meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); font-size: 10px; } +.context-pack-relation-node strong { min-width: 0; overflow: hidden; font-size: var(--codex-text-xs); line-height: 16px; text-overflow: ellipsis; white-space: nowrap; } +.context-pack-relation-node__handle { width: 7px; height: 7px; border: 1px solid var(--color-background-paper); background: color-mix(in srgb, var(--color-text-ink) 42%, transparent); } + +.context-pack-relation-graph__selection { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 2px; +} + +.context-pack-relation-graph__selection > div { display: grid; min-width: 0; gap: 2px; } +.context-pack-relation-graph__selection span { color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); font-size: 10px; } +.context-pack-relation-graph__selection strong { overflow: hidden; font-size: var(--codex-text-sm); text-overflow: ellipsis; white-space: nowrap; } +.context-pack-relation-graph__selection code { overflow: hidden; color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } + +@media (max-width: 560px) { + .project-context-pack-detail__grid { grid-template-columns: 1fr; gap: 3px; } + .project-context-pack-detail__grid dd { margin-bottom: 6px; } + .project-context-pack-detail__toolbar, + .context-pack-relation-graph__toolbar, + .context-pack-relation-graph__selection { align-items: flex-start; flex-direction: column; } + .context-pack-relation-graph__canvas { height: 340px; min-height: 280px; } + .project-context-pack-history__item { grid-template-columns: 1fr; } + .project-context-pack-history__confirm { max-width: none; } +} + .border-content-panel { border: 1px solid var(--border-content-panel); box-shadow: none; diff --git a/App/memmy-agent/src/memmy-memory/hook.ts b/App/memmy-agent/src/memmy-memory/hook.ts index d6112b554..2adeff2e1 100644 --- a/App/memmy-agent/src/memmy-memory/hook.ts +++ b/App/memmy-agent/src/memmy-memory/hook.ts @@ -156,6 +156,7 @@ export class MemmyMemoryHook extends AgentHook implements MemmyMemoryToolRuntime sessionId, query: userText || "(conversation continued)", })); + turn.contextPacketId = stringOrUndefined(response?.contextPacketId); turn.episodeId = stringOrUndefined(response?.episodeId); turn.sourceMemoryIds = arrayOfStrings(response?.sourceMemoryIds); turn.hasInjectedContext = hasInjectedContextValue(response?.injectedContext); @@ -239,6 +240,7 @@ export class MemmyMemoryHook extends AgentHook implements MemmyMemoryToolRuntime const response = await this.client.completeTurn(turn.turnId, compact({ ...this.requestEnvelope(sessionKey, ctx), requestId: completeRequestId(turn.turnId, status, turn.userText, answer), + contextPacketId: turn.contextPacketId, sessionId: turn.sessionId, episodeId: turn.episodeId, query: turn.userText, diff --git a/App/memmy-agent/src/memmy-memory/types.ts b/App/memmy-agent/src/memmy-memory/types.ts index 6d4ebcd29..ff32fa8c6 100644 --- a/App/memmy-agent/src/memmy-memory/types.ts +++ b/App/memmy-agent/src/memmy-memory/types.ts @@ -57,6 +57,7 @@ export type MemmyMemoryTurnState = { turnId: string; userText: string; messageStartIndex: number; + contextPacketId?: string; episodeId?: string; sourceMemoryIds?: string[]; rawTurnId?: string; diff --git a/App/memmy-agent/tests/memmy-memory/hook.test.ts b/App/memmy-agent/tests/memmy-memory/hook.test.ts index 8114834ee..3f1f4b33d 100644 --- a/App/memmy-agent/tests/memmy-memory/hook.test.ts +++ b/App/memmy-agent/tests/memmy-memory/hook.test.ts @@ -13,8 +13,10 @@ function fakeClient() { startTurn: vi.fn(async (turnId: string, body: any) => ({ turnId, sessionId: body.sessionId, + episodeId: "ep-1", + contextPacketId: "ctx-packet-1", sourceMemoryIds: ["trace-source"], - injectedContext: { markdown: "Relevant prior memory." }, + injectedContext: { markdown: "## Confirmed project context\nGoal: Ship authoritative context.\n\n## Relevant prior memory\nRelevant prior memory." }, })), completeTurn: vi.fn(async () => ({ rawTurnId: "raw-1", l1MemoryId: "l1-1" })), closeSession: vi.fn(async (sessionId: string) => ({ ok: true, sessionId, status: "closed" })), @@ -98,7 +100,7 @@ describe("MemmyMemoryHook", () => { expect(messages[0].content).toBe("System prompt"); const userBlocks = messages[1].content as unknown as Array<{ type: string; text: string }>; expect(userBlocks.map((block) => block.text)).toEqual([ - '\nRelevant prior memory.\n', + '\n## Confirmed project context\nGoal: Ship authoritative context.\n\n## Relevant prior memory\nRelevant prior memory.\n', "", "Please continue\n\n", "", @@ -115,12 +117,13 @@ describe("MemmyMemoryHook", () => { const completeBody = (client.completeTurn as any).mock.calls[0][1]; expect(completeBody).toMatchObject({ sessionId: "session-generated-1", + episodeId: "ep-1", + contextPacketId: "ctx-packet-1", query: "Please continue", answer: "Done", sourceMemoryIds: ["trace-source"], status: "succeeded" }); - expect(completeBody).not.toHaveProperty("episodeId"); expect(completeBody.requestId).toMatch(/^memmy-agent-complete:/u); expect(hook.currentTurnId("cli:direct")).toBeNull(); }); diff --git a/App/shell/desktop/interface/src/index.ts b/App/shell/desktop/interface/src/index.ts index 39af7ddd6..3cd2602dd 100644 --- a/App/shell/desktop/interface/src/index.ts +++ b/App/shell/desktop/interface/src/index.ts @@ -12,6 +12,7 @@ export interface DesktopMenuBarIconResult { export interface DesktopMemoryServiceRestartResult { ok: true; baseUrl: string; + action: "restarted" | "reconnected"; } export interface DesktopAppInfo { diff --git a/App/shell/desktop/package.json b/App/shell/desktop/package.json index b09feb513..b38e9c422 100644 --- a/App/shell/desktop/package.json +++ b/App/shell/desktop/package.json @@ -29,7 +29,7 @@ "yaml": "^2.9.0" }, "devDependencies": { - "electron": "38.4.0", + "electron": "43.3.0", "electron-builder": "^26.0.12" } } diff --git a/App/shell/desktop/src/main/main.ts b/App/shell/desktop/src/main/main.ts index 516611be0..76a18f12c 100644 --- a/App/shell/desktop/src/main/main.ts +++ b/App/shell/desktop/src/main/main.ts @@ -41,7 +41,7 @@ import { } from "./window-mode.js"; import { preparePackagedRuntimeConfig, - restartExternalMemoryService, + reconnectExternalMemoryService, resolveAgentGatewayRuntimeConfig, startPackagedRuntimeServices, type PackagedRuntimeServices @@ -97,7 +97,11 @@ let menuBarTray: Tray | null = null; const MENU_BAR_TRAY_GUID = "8B2A0C33-45C0-4C43-8F1C-77F7D4FDF2D4"; let runtimeServices: PackagedRuntimeServices | null = null; let runtimeConfig: DesktopRuntimeConfig | null = null; -let memoryServiceControl: { baseUrl: string; token: string } | null = null; +let memoryServiceControl: { + baseUrl: string; + token: string; + ownership: "managed" | "remote"; +} | null = null; let memoryServiceRestart: Promise | null = null; let packagedRendererServer: PackagedRendererStaticServer | null = null; let packagedRendererBaseUrl: string | null = null; @@ -640,7 +644,11 @@ function showPackagedStartupError(error: unknown): void { */ async function startLocalApi(services: PackagedRuntimeServices | null): Promise { const databasePath = join(app.getPath("userData"), "app.sqlite"); - let memoryControl: { baseUrl: string; token: string }; + let memoryControl: { + baseUrl: string; + token: string; + ownership: "managed" | "remote"; + }; if (services) { process.env.MEMMY_CONFIG ??= services.memory.configPath; process.env.MEMMY_MEMORY_LAYER_URL = services.memory.baseUrl; @@ -648,7 +656,8 @@ async function startLocalApi(services: PackagedRuntimeServices | null): Promise< process.env.MEMMY_MEMORY_DB_PATH = services.memory.databasePath; memoryControl = { baseUrl: services.memory.baseUrl, - token: services.memory.token + token: services.memory.token, + ownership: services.memory.ownership }; } else { const memoryRuntime = await preparePackagedRuntimeConfig({ @@ -663,7 +672,8 @@ async function startLocalApi(services: PackagedRuntimeServices | null): Promise< process.env.MEMMY_MEMORY_DB_PATH ??= memoryRuntime.memoryDatabasePath; memoryControl = { baseUrl: memoryRuntime.memoryBaseUrl, - token: memoryRuntime.memoryToken + token: memoryRuntime.memoryToken, + ownership: memoryRuntime.memoryOwnership }; } memoryServiceControl = memoryControl; @@ -689,7 +699,8 @@ async function startLocalApi(services: PackagedRuntimeServices | null): Promise< return { ...localBackend.runtimeConfig, memory: { - baseUrl: memoryControl.baseUrl + baseUrl: memoryControl.baseUrl, + ownership: memoryControl.ownership }, agentGateway: agentGatewayConfig }; @@ -706,14 +717,17 @@ async function restartMemoryService(): Promise => { + let action: DesktopMemoryServiceRestartResult["action"]; if (runtimeServices) { - await runtimeServices.restartMemory(); + action = await runtimeServices.restartMemory(); } else { - await restartExternalMemoryService(control); + await reconnectExternalMemoryService(control); + action = "reconnected"; } return { ok: true, - baseUrl: control.baseUrl + baseUrl: control.baseUrl, + action }; })(); memoryServiceRestart = operation; diff --git a/App/shell/desktop/src/main/runtime-services.ts b/App/shell/desktop/src/main/runtime-services.ts index 20d15c803..22f4cbd2f 100644 --- a/App/shell/desktop/src/main/runtime-services.ts +++ b/App/shell/desktop/src/main/runtime-services.ts @@ -19,6 +19,8 @@ const STOP_MANAGED_CHILD_GRACE_MS = 1_000; type RuntimeEnv = Record; type ConfigRecord = Record; +export type MemoryServiceOwnership = "managed" | "remote"; +export type MemoryServiceRefreshAction = "restarted" | "reconnected"; export interface PackagedRuntimeServices { memory: { @@ -26,13 +28,14 @@ export interface PackagedRuntimeServices { token: string; databasePath: string; configPath: string; + ownership: MemoryServiceOwnership; }; agentGateway: { baseUrl: string; bootstrapSecret: string; configPath: string; }; - restartMemory(): Promise; + restartMemory(): Promise; close(): Promise; terminateSync(): void; } @@ -65,6 +68,7 @@ export interface PackagedRuntimeConfig { memoryToken: string; memoryListenHost: string; memoryListenPort: number; + memoryOwnership: MemoryServiceOwnership; agentGatewayBaseUrl: string; agentGatewayHealthHost: string; agentGatewayHealthPort: number; @@ -125,7 +129,7 @@ export async function startPackagedRuntimeServices( {}, browserPreparationAttemptId ); - let memoryRestart: Promise | null = null; + let memoryRestart: Promise | null = null; let browserPreparation: PackagedBrowserPreparation | null = null; let closing = false; @@ -149,7 +153,8 @@ export async function startPackagedRuntimeServices( baseUrl: runtimeConfig.memoryBaseUrl, token: runtimeConfig.memoryToken, databasePath: runtimeConfig.memoryDatabasePath, - configPath: runtimeConfig.configPath + configPath: runtimeConfig.configPath, + ownership: runtimeConfig.memoryOwnership }, agentGateway: { baseUrl: runtimeConfig.agentGatewayBaseUrl, @@ -161,12 +166,12 @@ export async function startPackagedRuntimeServices( throw new Error("Memmy is shutting down"); } if (!memoryRestart) { - memoryRestart = restartManagedMemoryService(entries, runtimeConfig, children, options) + memoryRestart = refreshMemoryService(entries, runtimeConfig, children, options) .finally(() => { memoryRestart = null; }); } - await memoryRestart; + return await memoryRestart; }, async close() { closing = true; @@ -209,6 +214,9 @@ export async function preparePackagedRuntimeConfig( const heartbeat = ensureRecord(gateway, "heartbeat"); const agents = ensureRecord(config, "agents"); const defaults = ensureRecord(agents, "defaults"); + const memoryOwnership = memoryServiceOwnership( + env.MEMMY_MEMORY_RUNTIME ?? stringValue(storage.runtime) ?? "managed" + ); let changed = false; if (!Object.prototype.hasOwnProperty.call(config, "fileMemory")) { @@ -236,6 +244,7 @@ export async function preparePackagedRuntimeConfig( changed = setMissing(storage, "backend", "sqlite") || changed; changed = setMissing(storage, "sqlitePath", memoryDatabasePath) || changed; changed = setMissing(storage, "endpoint", DEFAULT_MEMORY_URL) || changed; + changed = setMissing(storage, "runtime", memoryOwnership) || changed; changed = setMissing(websocket, "host", LOCAL_HOST) || changed; changed = setMissing(websocket, "port", DEFAULT_AGENT_WEBSOCKET_PORT) || changed; if (shouldFillMissingAgentSecret && !stringValue(websocket.tokenIssueSecret) && !stringValue(websocket.token)) { @@ -262,7 +271,9 @@ export async function preparePackagedRuntimeConfig( if (shouldEnsureDirectories) { await Promise.all([ mkdir(agentWorkspace, { recursive: true }), - mkdir(dirname(memoryDatabasePath), { recursive: true }) + ...(memoryOwnership === "managed" + ? [mkdir(dirname(memoryDatabasePath), { recursive: true })] + : []) ]); } @@ -289,6 +300,7 @@ export async function preparePackagedRuntimeConfig( memoryToken, memoryListenHost: listenHostFromUrl(memoryUrl), memoryListenPort: listenPortFromUrl(memoryUrl), + memoryOwnership, agentGatewayBaseUrl: `http://${clientHost(agentWebsocketHost)}:${agentWebsocketPort}`, agentGatewayHealthHost: gatewayHealthHost, agentGatewayHealthPort: gatewayHealthPort, @@ -533,6 +545,9 @@ async function ensureMemoryService( if (probe === "unexpected") { throw new Error(`Memory endpoint is occupied by an unexpected service: ${healthUrl}`); } + if (runtimeConfig.memoryOwnership === "remote") { + return; + } const memoryChild = spawnNodeService("memory", entries.memoryEntry, [ "--config", @@ -560,6 +575,24 @@ async function ensureMemoryService( await waitForHttpService("memory", healthUrl, memoryChild, healthHeaders); } +async function refreshMemoryService( + entries: RuntimeEntryPaths, + runtimeConfig: PackagedRuntimeConfig, + children: ManagedChild[], + options: StartPackagedRuntimeServicesOptions +): Promise { + if (runtimeConfig.memoryOwnership === "remote") { + await reconnectExternalMemoryService({ + baseUrl: runtimeConfig.memoryBaseUrl, + token: runtimeConfig.memoryToken + }); + return "reconnected"; + } + + await restartManagedMemoryService(entries, runtimeConfig, children, options); + return "restarted"; +} + async function restartManagedMemoryService( entries: RuntimeEntryPaths, runtimeConfig: PackagedRuntimeConfig, @@ -589,7 +622,7 @@ async function restartManagedMemoryService( await ensureMemoryService(entries, runtimeConfig, children, options); } -export async function restartExternalMemoryService(input: { +export async function reconnectExternalMemoryService(input: { baseUrl: string; token: string; }): Promise { @@ -602,10 +635,6 @@ export async function restartExternalMemoryService(input: { ? `Memory endpoint returned an unexpected response: ${healthUrl}` : `Memory service is not running: ${healthUrl}`); } - - await requestMemoryServiceShutdown(input); - await waitForHttpServiceStop(healthUrl, healthHeaders); - await waitForHttpServiceReady("memory", healthUrl, healthHeaders); } async function requestMemoryServiceShutdown(input: { baseUrl: string; token: string }): Promise { @@ -1190,6 +1219,13 @@ function memoryProfileName(value: unknown): "account" | "byok" | undefined { return value === "account" || value === "byok" ? value : undefined; } +function memoryServiceOwnership(value: unknown): MemoryServiceOwnership { + if (value === "managed" || value === "remote") { + return value; + } + throw new Error("memmyMemory.storage.runtime must be managed or remote"); +} + function isRecord(value: unknown): value is ConfigRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts index 08ef95828..05ae70737 100644 --- a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts +++ b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts @@ -580,7 +580,7 @@ describe("desktop packaged runtime boundaries", () => { expect(packageSource).not.toContain("/usr/local/bin when writable"); }); - it("restarts the Memory process through the desktop bridge", () => { + it("restarts managed Memory and only reconnects external Memory through the desktop bridge", () => { const mainSource = readFileSync(mainSourcePath, "utf8"); const preloadSource = readFileSync(preloadSourcePath, "utf8"); const runtimeSource = readFileSync(runtimeServicesPath, "utf8"); @@ -590,7 +590,10 @@ describe("desktop packaged runtime boundaries", () => { expect(mainSource).toContain('ipcMain.handle("memmy:restart-memory-service"'); expect(mainSource).toContain('ipcMain.removeHandler("memmy:restart-memory-service")'); expect(mainSource).toContain("await runtimeServices.restartMemory()"); + expect(mainSource).toContain("await reconnectExternalMemoryService(control)"); expect(runtimeSource).toContain("restartManagedMemoryService"); + expect(runtimeSource).toContain("runtimeConfig.memoryOwnership === \"remote\""); + expect(runtimeSource).toContain("reconnectExternalMemoryService"); expect(runtimeSource).toContain("/api/v1/admin/shutdown"); }); diff --git a/App/shell/desktop/tests/runtime-services.test.ts b/App/shell/desktop/tests/runtime-services.test.ts index d5c15a88f..6829a0d4b 100644 --- a/App/shell/desktop/tests/runtime-services.test.ts +++ b/App/shell/desktop/tests/runtime-services.test.ts @@ -10,7 +10,7 @@ import { AgentGatewaySupervisor, preparePackagedBrowser, preparePackagedRuntimeConfig, - restartExternalMemoryService, + reconnectExternalMemoryService, spawnNodeService, startPackagedBrowserPreparation, syncBundledAgentSkills, @@ -54,7 +54,7 @@ describe("packaged desktop runtime config", () => { await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); - it("requests a supervised Memory shutdown and waits for the replacement service", async () => { + it("health-checks an externally owned Memory service without shutting it down", async () => { let shutdownRequests = 0; let activeServer: Server; let port = 0; @@ -71,11 +71,6 @@ describe("packaged desktop runtime config", () => { shutdownRequests += 1; response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ accepted: true })); - response.once("finish", () => { - activeServer.close(); - activeServer.closeAllConnections(); - setTimeout(() => void startServer(), 250); - }); return; } response.writeHead(404); @@ -98,12 +93,12 @@ describe("packaged desktop runtime config", () => { }; await startServer(); - await restartExternalMemoryService({ + await reconnectExternalMemoryService({ baseUrl: `http://127.0.0.1:${port}`, token: "memory-token" }); - expect(shutdownRequests).toBe(1); + expect(shutdownRequests).toBe(0); }); it("creates missing packaged runtime config under the shared ~/.memmy home", async () => { @@ -123,6 +118,7 @@ describe("packaged desktop runtime config", () => { memoryBaseUrl: "http://127.0.0.1:18960", memoryListenHost: "127.0.0.1", memoryListenPort: 18960, + memoryOwnership: "managed", agentGatewayBaseUrl: "http://127.0.0.1:18980", agentGatewayBootstrapSecret: "stable-secret" }); @@ -157,7 +153,8 @@ describe("packaged desktop runtime config", () => { mode: "local", backend: "sqlite", sqlitePath: join(memmyHome, "memory-service", "memory.sqlite"), - endpoint: "http://127.0.0.1:18960" + endpoint: "http://127.0.0.1:18960", + runtime: "managed" } } }); @@ -165,6 +162,46 @@ describe("packaged desktop runtime config", () => { await expect(stat(join(memmyHome, "memory-service"))).resolves.toBeTruthy(); }); + it("treats remote Memory as externally owned even when it uses loopback", async () => { + const memmyHome = await makeTempRoot(); + const configPath = join(memmyHome, "config.yaml"); + await writeFile(configPath, YAML.stringify({ + memmyMemory: { + storage: { + runtime: "remote", + endpoint: "http://127.0.0.1:18960", + token: "docker-token" + } + } + }), "utf8"); + + const runtime = await preparePackagedRuntimeConfig({ + env: { MEMMY_HOME: memmyHome }, + secretFactory: () => "stable-secret" + }); + + expect(runtime).toMatchObject({ + memoryBaseUrl: "http://127.0.0.1:18960", + memoryToken: "docker-token", + memoryOwnership: "remote" + }); + await expect(stat(join(memmyHome, "workspace"))).resolves.toBeTruthy(); + await expect(stat(join(memmyHome, "memory-service"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects unknown Memory runtime ownership values", async () => { + const memmyHome = await makeTempRoot(); + const configPath = join(memmyHome, "config.yaml"); + await writeFile(configPath, YAML.stringify({ + memmyMemory: { storage: { runtime: "docker-ish" } } + }), "utf8"); + + await expect(preparePackagedRuntimeConfig({ + env: { MEMMY_HOME: memmyHome }, + secretFactory: () => "stable-secret" + })).rejects.toThrow("memmyMemory.storage.runtime must be managed or remote"); + }); + it("preserves existing user model, memory, and websocket settings", async () => { const memmyHome = await makeTempRoot(); const configPath = join(memmyHome, "config.yaml"); diff --git a/Memory/Dockerfile b/Memory/Dockerfile new file mode 100644 index 000000000..e38fa8a3d --- /dev/null +++ b/Memory/Dockerfile @@ -0,0 +1,36 @@ +FROM node:24-bookworm-slim AS builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY . . + +RUN npm ci \ + && npm run memory:build \ + && mkdir -p /runtime \ + && cp Memory/package.json /runtime/package.json \ + && cp Memory/package-lock.json /runtime/package-lock.json \ + && cp -R Memory/dist /runtime/dist \ + && cd /runtime \ + && npm ci --omit=dev \ + && npm audit --omit=dev --audit-level=high + +FROM node:24-bookworm-slim AS runtime + +ENV NODE_ENV=production \ + HOME=/home/node + +WORKDIR /app + +COPY --from=builder --chown=node:node /runtime ./ + +RUN mkdir -p /data /home/node/.memmy/memory-service/model-cache \ + && chown -R node:node /data /home/node/.memmy + +USER node + +EXPOSE 18960 + +ENTRYPOINT ["node", "dist/src/server/index.js"] diff --git a/Memory/package-lock.json b/Memory/package-lock.json new file mode 100644 index 000000000..cd9f6a21e --- /dev/null +++ b/Memory/package-lock.json @@ -0,0 +1,3381 @@ +{ + "name": "@memmy/memory", + "version": "1.0.4", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@memmy/memory", + "version": "1.0.4", + "dependencies": { + "@huggingface/transformers": "^3.8.0", + "better-sqlite3": "^12.6.3", + "dotenv": "^16.6.1", + "sharp": "0.35.3", + "sqlite-vec": "0.1.9", + "yaml": "^2.9.0" + }, + "bin": { + "memmy-memory": "dist/src/cli/index.js" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^25.9.1", + "tsx": "^4.22.3", + "typescript": "^6.0.3", + "vitest": "^4.1.7" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/jinja": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz", + "integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/transformers": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz", + "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.3", + "onnxruntime-node": "1.21.0", + "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", + "sharp": "^0.34.1" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz", + "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz", + "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "global-agent": "^3.0.0", + "onnxruntime-common": "1.21.0", + "tar": "^7.0.1" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/sqlite-vec": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec/-/sqlite-vec-0.1.9.tgz", + "integrity": "sha512-L7XJWRIBNvR9O5+vh1FQ+IGkh/3D2AzVksW5gdtk28m78Hy8skFD0pqReKH1Yp0/BUKRGcffgKvyO/EON5JXpA==", + "license": "MIT OR Apache", + "optionalDependencies": { + "sqlite-vec-darwin-arm64": "0.1.9", + "sqlite-vec-darwin-x64": "0.1.9", + "sqlite-vec-linux-arm64": "0.1.9", + "sqlite-vec-linux-x64": "0.1.9", + "sqlite-vec-windows-x64": "0.1.9" + } + }, + "node_modules/sqlite-vec-darwin-arm64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-darwin-arm64/-/sqlite-vec-darwin-arm64-0.1.9.tgz", + "integrity": "sha512-jSsZpE42OfBkGL/ItyJTVCUwl6o6Ka3U5rc4j+UBDIQzC1ulSSKMEhQLthsOnF/MdAf1MuAkYhkdKmmcjaIZQg==", + "cpu": [ + "arm64" + ], + "license": "MIT OR Apache", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/sqlite-vec-darwin-x64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-darwin-x64/-/sqlite-vec-darwin-x64-0.1.9.tgz", + "integrity": "sha512-KDlVyqQT7pnOhU1ymB9gs7dMbSoVmKHitT+k1/xkjarcX8bBqPxWrGlK/R+C5WmWkfvWwyq5FfXfiBYCBs6PlA==", + "cpu": [ + "x64" + ], + "license": "MIT OR Apache", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/sqlite-vec-linux-arm64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-linux-arm64/-/sqlite-vec-linux-arm64-0.1.9.tgz", + "integrity": "sha512-5wXVJ9c9kR4CHm/wVqXb/R+XUHTdpZ4nWbPHlS+gc9qQFVHs92Km4bPnCKX4rtcPMzvNis+SIzMJR1SCEwpuUw==", + "cpu": [ + "arm64" + ], + "license": "MIT OR Apache", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/sqlite-vec-linux-x64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-linux-x64/-/sqlite-vec-linux-x64-0.1.9.tgz", + "integrity": "sha512-w3tCH8xK2finW8fQJ/m8uqKodXUZ9KAuAar2UIhz4BHILfpE0WM/MTGCRfa7RjYbrYim5Luk3guvMOGI7T7JQA==", + "cpu": [ + "x64" + ], + "license": "MIT OR Apache", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/sqlite-vec-windows-x64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-windows-x64/-/sqlite-vec-windows-x64-0.1.9.tgz", + "integrity": "sha512-y3gEIyy/17bq2QFPQOWLE68TYWcRZkBQVA2XLrTPHNTOp55xJi/BBBmOm40tVMDMjtP+Elpk6UBUXdaq+46b0Q==", + "cpu": [ + "x64" + ], + "license": "MIT OR Apache", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.23.11", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.11.tgz", + "integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/Memory/package.json b/Memory/package.json index 65093945c..6a0d2e5a0 100644 --- a/Memory/package.json +++ b/Memory/package.json @@ -4,6 +4,13 @@ "private": true, "type": "module", "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + } + }, "bin": { "memmy-memory": "./dist/src/cli/index.js" }, @@ -22,7 +29,7 @@ "serve:local": "node dist/src/server/index.js", "serve:dev": "tsx src/server/index.ts", "worker:run": "node dist/src/cli/index.js raw POST /worker/run", - "test": "vitest run --dir tests", + "test": "vitest run --dir tests --testTimeout 20000", "typecheck": "tsc -p tsconfig.json --noEmit", "package:npm": "node src/cli/npm/build-package.mjs", "pack:npm": "npm run package:npm && npm pack ../dist/memmy-memory-npm", @@ -31,10 +38,16 @@ "engines": { "node": ">=20" }, + "overrides": { + "@huggingface/transformers": { + "sharp": "$sharp" + } + }, "dependencies": { "@huggingface/transformers": "^3.8.0", "better-sqlite3": "^12.6.3", "dotenv": "^16.6.1", + "sharp": "0.35.3", "sqlite-vec": "0.1.9", "yaml": "^2.9.0" }, diff --git a/Memory/readme.md b/Memory/readme.md index 0770505c7..527f98910 100644 --- a/Memory/readme.md +++ b/Memory/readme.md @@ -41,6 +41,55 @@ npm run memory:serve:dev -- \ The built-in Memory panel is available at `/` and `/viewer`. +## Docker + +The repository root includes a dedicated Node 24 Debian image and Compose +configuration. The image intentionally does not use Alpine because Memory has +native SQLite, sqlite-vec, and ONNX dependencies. + +Create a strong, unique token in the root `.env` before starting the service: + +```bash +docker run --rm node:24-bookworm-slim node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))" +docker compose up -d --build +docker compose ps +``` + +Set the generated value as `MEMMY_MEMORY_TOKEN`. Compose refuses to start when +the token is empty. The container listens on `0.0.0.0:18960`, while the default +published host port is restricted to `127.0.0.1:18960`. SQLite data and the +Hugging Face model cache use the `memory-data` and `memory-model-cache` named +volumes. + +For Docker Desktop on the same Windows computer, configure the desktop app to +use loopback and mark the service as remotely owned: + +```yaml +memmyMemory: + storage: + runtime: remote + endpoint: http://127.0.0.1:18960 + token: +``` + +`runtime: remote` means the desktop app only connects to and health-checks the +service. It never starts, stops, or restarts the container. Agent processes, +project history scanning, and Skill installation continue to run on the +Windows host. + +When another computer needs access, keep port 18960 off the public Internet and +put Caddy or Nginx in front of it with HTTPS. Point desktop clients at the HTTPS +LAN hostname instead of the server IP. The direct `192.168.x.x` address is only +needed when the container actually runs on another host. + +For a host-installed Caddy, the minimal reverse proxy is: + +```caddyfile +memory.example.lan { + reverse_proxy 127.0.0.1:18960 +} +``` + ## Configuration Unless `--config` is provided, the service checks these locations in order: @@ -57,6 +106,7 @@ memmyMemory: version: 1 activeProfile: byok storage: + runtime: managed mode: local backend: sqlite sqlitePath: ~/.memmy/memory-service/memory.sqlite diff --git a/Memory/src/cli/commands.ts b/Memory/src/cli/commands.ts index f7a162011..27e1383ab 100644 --- a/Memory/src/cli/commands.ts +++ b/Memory/src/cli/commands.ts @@ -16,6 +16,7 @@ import { renderCliOutput } from "./render/index.js"; import { initMemoryCli, installMemoryCli } from "./setup.js"; import { DEFAULT_MEMORY_URL, loadCliMemoryConfig } from "./config.js"; import { PROJECT_VERSION } from "./project-version.js"; +import { workspaceNamespaceFromOptions } from "./workspace.js"; type Method = "GET" | "POST" | "DELETE"; const CLI_NAME = "memmy-memory"; @@ -57,6 +58,18 @@ export async function runCommand(context: CommandContext): Promise { return serveMemory(parsed); } + if (words[0] === "namespace" && words[1] === "current") { + return currentNamespace(parsed); + } + + if (words[0] === "stats") { + return executeRequest({ method: "GET", path: "/panel/overview" }, requestOptions(parsed, context.fetch)); + } + + if (words[0] === "doctor") { + return runDoctor(parsed, context.fetch); + } + const getVerbose = words[0] === "get" && optionBoolean(options, "verbose") === true; const request = withSource(await mapTopLevelCommand(words, parsed), parsed); const result = await executeRequest(request, requestOptions(parsed, context.fetch)); @@ -194,7 +207,10 @@ async function addMemoryRequest(args: string[], parsed: ParsedArgs): Promise { + const workspace = workspaceNamespaceFromOptions({ + projectId: optionString(parsed.options, "project-id") ?? optionString(parsed.options, "project_id"), + workspaceId: optionString(parsed.options, "workspace-id") ?? optionString(parsed.options, "workspace_id"), + workspacePath: optionString(parsed.options, "workspace-path") ?? optionString(parsed.options, "workspace_path"), + noWorkspace: optionBoolean(parsed.options, "no-workspace") === true + }); + return { + source: optionString(parsed.options, "source") ?? "unknown", + projectId: workspace.projectId, + workspaceId: workspace.workspaceId, + workspacePath: workspace.workspacePath, + scoped: Boolean(workspace.projectId || workspace.workspaceId || workspace.workspacePath) + }; +} + +async function runDoctor(parsed: ParsedArgs, fetchImpl?: typeof fetch): Promise> { + const options = requestOptions(parsed, fetchImpl); + const [health, audit, evolution] = await Promise.all([ + executeRequest({ method: "GET", path: "/health" }, options), + executeRequest({ method: "GET", path: "/panel/namespace-audit" }, options), + executeRequest({ method: "GET", path: "/panel/evolution" }, options) + ]); + const auditSummary = isRecord(audit) && isRecord(audit.summary) ? audit.summary : {}; + const healthOk = isRecord(health) && health.ok === true; + const riskCount = Number(auditSummary.crossWorkspaceRisk ?? 0); + return { + ok: healthOk && riskCount === 0, + health, + namespace: currentNamespace(parsed), + audit, + evolution, + checks: { + service: healthOk ? "ok" : "failed", + workspaceIsolation: riskCount === 0 ? "ok" : "risk", + unknownSources: Number(auditSummary.unknownSource ?? 0), + missingAgentSourceTags: Number(auditSummary.missingAgentSourceTag ?? 0) + } + }; +} + function compactMemoryGetOutput(result: unknown): string | undefined { const detail = memoryDetailRecord(result); if (!detail) return undefined; @@ -437,8 +494,17 @@ async function requestBody( function requestOptions(parsed: ParsedArgs, fetchImpl?: typeof fetch): CliRequestOptions { const configPath = optionString(parsed.options, "config"); const userId = userIdOption(parsed) ?? loadCliMemoryConfig(configPath).config.userId; + const workspace = workspaceNamespaceFromOptions({ + projectId: optionString(parsed.options, "project-id") ?? optionString(parsed.options, "project_id"), + workspaceId: optionString(parsed.options, "workspace-id") ?? optionString(parsed.options, "workspace_id"), + workspacePath: optionString(parsed.options, "workspace-path") ?? optionString(parsed.options, "workspace_path"), + noWorkspace: optionBoolean(parsed.options, "no-workspace") === true + }); const headers: Record = {}; if (userId) headers["x-memmy-user-id"] = userId; + if (workspace.projectId) headers["x-memmy-project-id"] = workspace.projectId; + if (workspace.workspaceId) headers["x-memmy-workspace-id"] = workspace.workspaceId; + if (workspace.workspacePath) headers["x-memmy-workspace-path"] = workspace.workspacePath; return { url: optionString(parsed.options, "url"), token: optionString(parsed.options, "token"), @@ -550,6 +616,9 @@ function helpText(): string { " add Add a memory manually", " get Read one memory by id", " delete Delete one memory by id", + " doctor Check service, evolution, and isolation health", + " namespace current Show the current workspace namespace", + " stats --workspace Show statistics scoped to this workspace", " raw Call an exposed Memory API route directly", "", "Setup examples:", @@ -571,6 +640,10 @@ function helpText(): string { " --url Memory HTTP service URL", " --token Memory HTTP bearer token", " --user-id Memory namespace user id", + " --project-id Memory project namespace id", + " --workspace-id Memory workspace namespace id", + " --workspace-path Memory workspace path; defaults to nearest project root", + " --no-workspace Do not send workspace namespace headers", " --source Calling agent/source id", " --config Memmy config path", " --skip-agent-skills Initialize config without installing agent skills", diff --git a/Memory/src/cli/workspace.ts b/Memory/src/cli/workspace.ts new file mode 100644 index 000000000..0cc90f6ff --- /dev/null +++ b/Memory/src/cli/workspace.ts @@ -0,0 +1,53 @@ +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +export interface CliWorkspaceNamespace { + projectId?: string; + workspaceId?: string; + workspacePath?: string; +} + +export function workspaceNamespaceFromOptions(options: { + projectId?: string; + workspaceId?: string; + workspacePath?: string; + noWorkspace?: boolean; + cwd?: string; +}): CliWorkspaceNamespace { + if (options.noWorkspace) return {}; + const explicitWorkspacePath = cleanPath(options.workspacePath); + const workspacePath = explicitWorkspacePath ?? discoverWorkspacePath(options.cwd ?? process.cwd()); + return { + projectId: clean(options.projectId), + workspaceId: clean(options.workspaceId), + workspacePath + }; +} + +export function discoverWorkspacePath(cwd = process.cwd()): string | undefined { + let current = resolve(cwd); + for (;;) { + if (isProjectRoot(current)) return current; + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + +function isProjectRoot(path: string): boolean { + return existsSync(`${path}/.git`) || + existsSync(`${path}/package.json`) || + existsSync(`${path}/pyproject.toml`) || + existsSync(`${path}/go.mod`) || + existsSync(`${path}/Cargo.toml`); +} + +function clean(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed || undefined; +} + +function cleanPath(value: string | undefined): string | undefined { + const trimmed = clean(value); + return trimmed ? resolve(trimmed) : undefined; +} diff --git a/Memory/src/client/rest-client.ts b/Memory/src/client/rest-client.ts index 523b68a1b..52c7261b6 100644 --- a/Memory/src/client/rest-client.ts +++ b/Memory/src/client/rest-client.ts @@ -2,10 +2,12 @@ import type { HealthResponse, MemoryAddRequest, MemoryGovernanceRequest, + MemoryMarkdownImportRequest, MemoryReloadConfigRequest, MemoryReloadConfigResponse, MemorySearchRequest, RequestEnvelope, + SessionCheckpointRequest, SessionOpenRequest, TurnCompleteRequest, TurnStartRequest @@ -53,6 +55,10 @@ export class MemoryRestClient { return this.request("POST", `/api/v1/sessions/${encodeURIComponent(sessionId)}/close`, request); } + checkpointSession(sessionId: string, request: SessionCheckpointRequest): Promise { + return this.request("POST", `/api/v1/sessions/${encodeURIComponent(sessionId)}/checkpoint`, request); + } + startTurn(request: TurnStartRequest): Promise { return this.request("POST", "/api/v1/turns/start", request); } @@ -69,6 +75,14 @@ export class MemoryRestClient { return this.request("POST", "/api/v1/memory/add", request); } + exportMarkdown(includeArchived = false): Promise { + return this.request("GET", `/api/v1/memory/audit/markdown${queryString({ includeArchived })}`); + } + + importMarkdown(request: MemoryMarkdownImportRequest): Promise { + return this.request("POST", "/api/v1/memory/audit/markdown/import", request); + } + getMemory(id: string): Promise { return this.request("GET", `/api/v1/memory/${encodeURIComponent(id)}`); } diff --git a/Memory/src/index.ts b/Memory/src/index.ts index 68f60ca7f..b10754411 100644 --- a/Memory/src/index.ts +++ b/Memory/src/index.ts @@ -41,6 +41,9 @@ export type { } from "./storage/backend.js"; export { SCHEMA_VERSION, SCHEMA_MIGRATION_ID } from "./storage/schema.js"; export { MemoryService } from "./service/memory-service.js"; +export { ProjectContextService } from "./service/project-context/project-context-service.js"; +export { Repositories } from "./storage/repositories.js"; +export type * from "./service/project-context/project-context-service.js"; export { API_ROUTES, createMemoryHttpServer, listenMemoryHttpServer } from "./server/http.js"; export { DEFAULT_MEMMY_CONFIG, loadMemmyConfig, resolveEvolutionConfig } from "./config/index.js"; export { DEFAULT_NAMESPACE_SOURCE } from "./types.js"; @@ -49,3 +52,5 @@ export { createLlmClient } from "./model/llm.js"; export type * from "./types.js"; export type * from "./config/index.js"; export type * from "./model/types.js"; + +export type * from "./service/project-context/project-context-types.js"; \ No newline at end of file diff --git a/Memory/src/server/http.ts b/Memory/src/server/http.ts index 146263129..12a53acbf 100644 --- a/Memory/src/server/http.ts +++ b/Memory/src/server/http.ts @@ -3,20 +3,31 @@ import { randomUUID } from "node:crypto"; import type { AddressInfo } from "node:net"; import { createMemoryLogger, memoryErrorFields } from "../logging/logger.js"; import { memoryPanelHtml } from "../viewer/static.js"; +import type { + ProjectGoalDecisionRequest, + ProjectWorkItemCreateRequest, + ProjectWorkItemSelectRequest, + ProjectWorkItemUpdateRequest +} from "../service/project-context/project-context-service.js"; +import type { ProjectContextProposeGoalRequest } from "../service/project-context/project-context-types.js"; import type { MemoryAddRequest, MemoryGovernanceRequest, MemoryLayer, MemoryReloadConfigRequest, MemorySearchRequest, + MemoryMarkdownImportRequest, RequestEnvelope, RuntimeNamespace, + SessionCheckpointRequest, SessionOpenRequest, TurnCompleteRequest, TurnStartRequest } from "../types.js"; import { DEFAULT_NAMESPACE_SOURCE } from "../types.js"; import { MemoryService } from "../service/memory-service.js"; +import { createAgentTokenStatsService } from "../service/agent-token-stats-service.js"; +import { normalizeNamespace } from "../service/namespace/namespace-scope.js"; import { MemoryServiceError, statusForCode } from "../utils/error.js"; import { createPluginRuntimeAnalytics, @@ -31,29 +42,62 @@ import { const logger = createMemoryLogger("http"); const workerLogger = createMemoryLogger("worker"); +const agentTokenStatsService = createAgentTokenStatsService(); export const API_ROUTES = [ "GET /api/v1/health", "POST /api/v1/admin/reload-config", "POST /api/v1/admin/shutdown", "POST /api/v1/sessions/open", + "POST /api/v1/sessions/:sessionId/checkpoint", "POST /api/v1/sessions/:sessionId/close", "POST /api/v1/turns/start", "POST /api/v1/turns/:turnId/complete", "POST /api/v1/memory/search", "POST /api/v1/memory/add", + "GET /api/v1/memory/audit/markdown", + "POST /api/v1/memory/audit/markdown/import", "POST /api/v1/memory/processing/status", "POST /api/v1/memory/:id/processing/retry", + "POST /api/v1/memory/:id/quality", + "POST /api/v1/memory/:id/edit", + "GET /api/v1/memory/:id/history", + "POST /api/v1/memory/:id/history/:version/restore", + "POST /api/v1/memory/:id/archive", + "POST /api/v1/memory/:id/promote", + "GET /api/v1/panel/review/candidates", + "POST /api/v1/panel/review/candidates/:id/approve", + "POST /api/v1/panel/review/candidates/:id/reject", + "POST /api/v1/panel/review/candidates/bulk-approve", + "POST /api/v1/memory/:id/merge", "GET /api/v1/memory/:id", "DELETE /api/v1/memory/:id", "POST /api/v1/worker/run", + "POST /api/v1/worker/retry-failed", + "POST /api/v1/worker/promote-candidates", "POST /api/v1/worker/import-summaries/enqueue", "GET /api/v1/memory/logs", "GET /api/v1/panel/overview", + "GET /api/v1/panel/evolution", + "GET /api/v1/panel/context-pack", + "GET /api/v1/project-context/state", + "POST /api/v1/project-context/goals/propose", + "POST /api/v1/project-context/goals/:id/approve", + "POST /api/v1/project-context/goals/:id/reject", + "POST /api/v1/project-context/work-items", + "PATCH /api/v1/project-context/work-items/:id", + "PUT /api/v1/project-context/focus", + "GET /api/v1/panel/context-packs", + "GET /api/v1/panel/namespace-audit", "GET /api/v1/panel/analysis", + "GET /api/v1/panel/metrics", + "GET /api/v1/panel/status", + "GET /api/v1/panel/config", + "GET /api/v1/panel/activity", "GET /api/v1/panel/items", "GET /api/v1/panel/tasks", - "DELETE /api/v1/panel/tasks/:id" + "DELETE /api/v1/panel/tasks/:id", + "GET /api/v1/agent-token-stats" ] as const; export interface MemoryHttpServerOptions { @@ -401,14 +445,41 @@ async function routeRequest( requestId: request.requestId, adapterId: request.adapterId, namespace: request.namespace, + source: request.source ?? request.namespace?.source, + profileId: request.profileId ?? request.namespace?.profileId, + projectId: request.namespace?.projectId, + workspaceId: request.namespace?.workspaceId, sessionId: request.sessionId, - workspacePath: request.workspacePath + workspacePath: request.workspacePath ?? request.namespace?.workspacePath, + meta: isRecord(request.meta) ? request.meta : undefined, + protocolVersion: typeof request.protocolVersion === "string" ? request.protocolVersion : undefined, + provenance: isRecord(request.provenance) ? request.provenance : undefined }; return publicOpenSessionResponse( await service.idempotent("sessions.create", publicRequest, publicRequest, () => service.openSession(publicRequest)) ); } + const sessionCheckpoint = match(path, /^\/api\/v1\/sessions\/([^/]+)\/checkpoint$/); + if (method === "POST" && sessionCheckpoint) { + requireMemoryWrite(principal); + const request = requestWithPrincipal(body, "sessions.checkpoint", principal); + requireStringField(request, "task", "sessions.checkpoint"); + return service.checkpointSession(decodeMatchSegment(sessionCheckpoint, 1), { + namespace: request.namespace, + episodeId: request.episodeId, + task: request.task, + changes: Array.isArray(request.changes) ? request.changes.filter((item): item is string => typeof item === "string") : undefined, + validated: Array.isArray(request.validated) ? request.validated.filter((item): item is string => typeof item === "string") : undefined, + unverified: Array.isArray(request.unverified) ? request.unverified.filter((item): item is string => typeof item === "string") : undefined, + nextSteps: Array.isArray(request.nextSteps) ? request.nextSteps.filter((item): item is string => typeof item === "string") : undefined, + sourceTurnIds: Array.isArray(request.sourceTurnIds) ? request.sourceTurnIds.filter((item): item is string => typeof item === "string") : undefined, + sourceMemoryIds: Array.isArray(request.sourceMemoryIds) ? request.sourceMemoryIds.filter((item): item is string => typeof item === "string") : undefined, + tokenEstimate: typeof request.tokenEstimate === "number" && Number.isFinite(request.tokenEstimate) ? Math.max(0, Math.trunc(request.tokenEstimate)) : undefined, + createL1: request.createL1 !== false + }); + } + const sessionClose = match(path, /^\/api\/v1\/sessions\/([^/]+)\/close$/); if (method === "POST" && sessionClose) { requireMemoryWrite(principal); @@ -434,7 +505,9 @@ async function routeRequest( query: request.query, turnId: request.turnId, contextHints: request.contextHints, - contextBudget: request.contextBudget + contextBudget: request.contextBudget, + protocolVersion: typeof request.protocolVersion === "string" ? request.protocolVersion : undefined, + provenance: isRecord(request.provenance) ? request.provenance : undefined }; const result = await trackExternalHookRecall(pluginRuntimeAnalytics, request, () => service.idempotent("turn.start", publicRequest, { request: publicRequest }, () => @@ -467,6 +540,8 @@ async function routeRequest( toolResults: request.toolResults, artifacts: request.artifacts, sourceMemoryIds: request.sourceMemoryIds, + protocolVersion: typeof request.protocolVersion === "string" ? request.protocolVersion : undefined, + provenance: isRecord(request.provenance) ? request.provenance : undefined, usage: request.usage, status: request.status }; @@ -534,7 +609,11 @@ async function routeRequest( sessionId: request.sessionId, turnId: request.turnId, createdAt: typeof request.createdAt === "string" ? request.createdAt : undefined, - deferProcessing: request.deferProcessing === true + deferProcessing: request.deferProcessing === true, + sourceMemoryIds: parseOptionalStringArray(request.sourceMemoryIds, "memory.add sourceMemoryIds"), + provenance: isRecord(request.provenance) ? request.provenance : undefined, + supersedesMemoryId: typeof request.supersedesMemoryId === "string" ? request.supersedesMemoryId : undefined, + supersessionReason: typeof request.supersessionReason === "string" ? request.supersessionReason : undefined }; const result = await trackExternalToolCall( pluginRuntimeAnalytics, @@ -561,7 +640,7 @@ async function routeRequest( request.memoryIds, "worker.import-summaries.enqueue.memoryIds" ); - const result = service.enqueuePendingImportSummaries(10_000, memoryIds); + const result = service.enqueuePendingImportSummaries(10_000, memoryIds, { namespace: principal.namespace }); if (result.enqueued > 0) { autoWorker.schedule(); } @@ -575,7 +654,7 @@ async function routeRequest( targetMemoryIds?: unknown; priorityCohortOnly?: unknown; }; - return service.runWorkerOnce( + return service.runWorkerWithEvolutionSummary( parseNumberValue(request.limit) ?? parseNumber(url.searchParams.get("limit")) ?? 20, { ...request, @@ -585,6 +664,23 @@ async function routeRequest( ); } + if (method === "POST" && path === "/api/v1/worker/retry-failed") { + requireMemoryWrite(principal); + const request = envelopeWithPrincipal(asObject(body, "worker.retry-failed"), principal) as RequestEnvelope & { limit?: unknown }; + const limit = parseNumberValue(request.limit) ?? 100; + const retry = service.retryFailedWorkerJobs({ ...request, limit }); + const worker = await service.runWorkerWithEvolutionSummary(limit, request); + return { ...retry, worker, generated: worker.generated }; + } + + if (method === "POST" && path === "/api/v1/worker/promote-candidates") { + requireMemoryWrite(principal); + const request = envelopeWithPrincipal(asObject(body, "worker.promote-candidates"), principal) as RequestEnvelope & { limit?: unknown }; + const promotion = service.promoteCandidates(request); + const worker = await service.runWorkerWithEvolutionSummary(parseNumberValue(request.limit) ?? 100, request); + return { ...promotion, worker, generated: worker.generated }; + } + if (method === "GET" && path === "/api/v1/panel/overview") { requirePanelRead(principal); return service.panelOverviewSummary({ @@ -592,6 +688,76 @@ async function routeRequest( }); } + if (method === "GET" && path === "/api/v1/panel/evolution") { + requirePanelRead(principal); + return service.evolutionOverview({ namespace: principal.namespace }); + } + if (method === "GET" && path === "/api/v1/project-context/state") { + requirePanelRead(principal); + const namespace = projectContextNamespace(url, principal); + const state = service.readProjectContext(namespace); + return { ...state, activeGoal: state.activeGoal ?? null, focusedWorkItem: state.focusedWorkItem ?? null }; + } + if (method === "POST" && path === "/api/v1/project-context/goals/propose") { + requirePanelWrite(principal); + const request = projectContextProposeGoal(body, "project-context.goals.propose", principal); + return service.idempotent("project-context.goals.propose", request, request, () => service.proposeProjectGoal(request)); + } + const goalApprove = match(path, /^\/api\/v1\/project-context\/goals\/([^/]+)\/approve$/); + if (method === "POST" && goalApprove) { + requirePanelWrite(principal); + const request = projectContextGoalDecision(body, "project-context.goals.approve", principal); + const candidateId = decodeMatchSegment(goalApprove, 1); + return service.idempotent("project-context.goals.approve", request, { candidateId, request }, () => service.approveProjectGoal({ namespace: request.namespace, candidateId })); + } + const goalReject = match(path, /^\/api\/v1\/project-context\/goals\/([^/]+)\/reject$/); + if (method === "POST" && goalReject) { + requirePanelWrite(principal); + const request = projectContextGoalDecision(body, "project-context.goals.reject", principal); + const candidateId = decodeMatchSegment(goalReject, 1); + return service.idempotent("project-context.goals.reject", request, { candidateId, request }, () => service.rejectProjectGoal({ namespace: request.namespace, candidateId })); + } + if (method === "POST" && path === "/api/v1/project-context/work-items") { + requirePanelWrite(principal); + const request = projectContextWorkItemCreate(body, "project-context.work-items.create", principal); + return service.idempotent("project-context.work-items.create", request, request, () => service.createProjectWorkItem(request)); + } + const workItemUpdate = match(path, /^\/api\/v1\/project-context\/work-items\/([^/]+)$/); + if (method === "PATCH" && workItemUpdate) { + requirePanelWrite(principal); + const request = projectContextWorkItemUpdate(body, "project-context.work-items.update", principal); + const workItemId = decodeMatchSegment(workItemUpdate, 1); + return service.idempotent("project-context.work-items.update", request, { workItemId, request }, () => service.updateProjectWorkItem({ ...request, workItemId })); + } + if (method === "PUT" && path === "/api/v1/project-context/focus") { + requirePanelWrite(principal); + const request = projectContextFocus(body, "project-context.focus", principal); + return service.idempotent("project-context.focus", request, request, () => service.selectProjectWorkItem(request) ?? null); + } + if (method === "GET" && path === "/api/v1/panel/context-pack") { + requirePanelRead(principal); + const pack = service.projectContextPack({ namespace: principal.namespace }); + if (!principal.namespace) return pack; + const state = service.readProjectContext(principal.namespace); + return { + ...pack, + authoritative: { + state: { ...state, activeGoal: state.activeGoal ?? null, focusedWorkItem: state.focusedWorkItem ?? null }, + stable: service.renderStableProjectContext(principal.namespace) + } + }; + } + + if (method === "GET" && path === "/api/v1/panel/context-packs") { + requirePanelRead(principal); + return service.projectContextPacks({ namespace: principal.namespace }); + } + + if (method === "GET" && path === "/api/v1/panel/namespace-audit") { + requirePanelRead(principal); + return service.namespaceAudit({ namespace: principal.namespace }); + } + if (method === "GET" && path === "/api/v1/panel/analysis") { requirePanelRead(principal); return service.panelAnalysis({ @@ -599,6 +765,41 @@ async function routeRequest( }); } + if (method === "GET" && path === "/api/v1/panel/metrics") { + requirePanelRead(principal); + return service.serviceMetrics({ + namespace: principal.namespace + }); + } + + if (method === "GET" && path === "/api/v1/panel/status") { + requirePanelRead(principal); + return service.adminStatus({ + namespace: principal.namespace + }, [...API_ROUTES]); + } + + if (method === "GET" && path === "/api/v1/panel/config") { + requirePanelRead(principal); + return service.configStatus({ + namespace: principal.namespace + }); + } + + if (method === "GET" && path === "/api/v1/agent-token-stats") { + requirePanelRead(principal); + return agentTokenStatsService.getStats(); + } + + if (method === "GET" && path === "/api/v1/panel/activity") { + requirePanelRead(principal); + return service.serviceLogs({ + namespace: principal.namespace, + limit: parseNumber(url.searchParams.get("limit")), + cursor: url.searchParams.get("cursor") ?? undefined + }); + } + if (method === "GET" && path === "/api/v1/panel/items") { requirePanelRead(principal); return publicPanelItemsResponse(service.panelItems({ @@ -608,10 +809,46 @@ async function routeRequest( q: url.searchParams.get("q") ?? undefined, sourceAgent: url.searchParams.get("sourceAgent") ?? undefined, excludedSourceAgents: url.searchParams.getAll("excludedSourceAgents"), + projectId: url.searchParams.get("projectId") ?? undefined, + workspaceId: url.searchParams.get("workspaceId") ?? undefined, page: parseNumber(url.searchParams.get("page")) })); } + if (method === "GET" && path === "/api/v1/panel/review/candidates") { + requirePanelRead(principal); + const layer = parseLayer(url.searchParams.get("layer")); + return service.reviewCandidates({ namespace: principal.namespace, layer, limit: parseNumber(url.searchParams.get("limit")) }); + } + + const reviewApprove = match(path, /^\/api\/v1\/panel\/review\/candidates\/([^/]+)\/approve$/); + if (method === "POST" && reviewApprove) { + requireMemoryWrite(principal); + const request = envelopeWithPrincipal(asObject(body, "panel.review.approve"), principal) as MemoryGovernanceRequest & { content?: unknown; title?: unknown }; + return service.approveCandidate(decodeMatchSegment(reviewApprove, 1), { + ...request, + content: typeof request.content === "string" ? request.content : undefined, + title: typeof request.title === "string" ? request.title : undefined + }); + } + + const reviewReject = match(path, /^\/api\/v1\/panel\/review\/candidates\/([^/]+)\/reject$/); + if (method === "POST" && reviewReject) { + requireMemoryWrite(principal); + const request = envelopeWithPrincipal(asObject(body, "panel.review.reject"), principal) as MemoryGovernanceRequest; + return service.rejectCandidate(decodeMatchSegment(reviewReject, 1), request); + } + + if (method === "POST" && path === "/api/v1/panel/review/candidates/bulk-approve") { + requireMemoryWrite(principal); + const request = envelopeWithPrincipal(asObject(body, "panel.review.bulk-approve"), principal) as MemoryGovernanceRequest & { minimumConfidence?: unknown; layer?: unknown }; + return service.bulkApproveHighConfidenceCandidates({ + ...request, + minimumConfidence: typeof request.minimumConfidence === "number" ? request.minimumConfidence : undefined, + layer: parseLayer(typeof request.layer === "string" ? request.layer : null) + }); + } + if (method === "GET" && path === "/api/v1/panel/tasks") { requirePanelRead(principal); return publicPanelTasksResponse(service.panelTasks({ @@ -624,6 +861,7 @@ async function routeRequest( if (method === "GET" && path === "/api/v1/memory/logs") { requirePanelRead(principal); return service.apiLogs({ + namespace: principal.namespace, tools: parseApiLogTools(url.searchParams.get("tools")), sourceAgent: url.searchParams.get("sourceAgent") ?? undefined, excludedSourceAgents: url.searchParams.getAll("excludedSourceAgents"), @@ -659,7 +897,112 @@ async function routeRequest( return result; } + const memoryQuality = match(path, /^\/api\/v1\/memory\/([^/]+)\/quality$/); + if (method === "POST" && memoryQuality) { + requireMemoryWrite(principal); + const request = envelopeWithPrincipal(asObject(body, "memory.quality"), principal) as MemoryGovernanceRequest & { useful?: unknown }; + if (typeof request.useful !== "boolean") throw new MemoryServiceError("invalid_argument", "memory.quality useful must be boolean"); + return service.rateMemory(decodeMatchSegment(memoryQuality, 1), request.useful, request); + } + + const memoryEdit = match(path, /^\/api\/v1\/memory\/([^/]+)\/edit$/); + if (method === "POST" && memoryEdit) { + requireMemoryWrite(principal); + const request = envelopeWithPrincipal(asObject(body, "memory.edit"), principal) as MemoryGovernanceRequest & { + title?: unknown; + content?: unknown; + tags?: unknown; + version?: unknown; + }; + if (typeof request.title !== "string" || !request.title.trim()) { + throw new MemoryServiceError("invalid_argument", "memory.edit title is required"); + } + if (typeof request.content !== "string" || !request.content.trim()) { + throw new MemoryServiceError("invalid_argument", "memory.edit content is required"); + } + if (!Array.isArray(request.tags) || request.tags.some((tag) => typeof tag !== "string")) { + throw new MemoryServiceError("invalid_argument", "memory.edit tags must be an array of strings"); + } + if (!Number.isInteger(request.version) || Number(request.version) < 1) { + throw new MemoryServiceError("invalid_argument", "memory.edit version must be a positive integer"); + } + const result = service.editMemory(decodeMatchSegment(memoryEdit, 1), { + ...request, + title: request.title.trim(), + content: request.content.trim(), + tags: request.tags.map((tag) => String(tag).trim()).filter(Boolean) + }); + if (result.embeddingJobId) autoWorker.schedule(); + return result; + } + + const memoryHistory = match(path, /^\/api\/v1\/memory\/([^/]+)\/history$/); + if (method === "GET" && memoryHistory) { + requireMemoryRead(principal); + return service.memoryHistory(decodeMatchSegment(memoryHistory, 1), { + namespace: principal.namespace, + limit: parseNumber(url.searchParams.get("limit")) + }); + } + + const memoryRestore = match(path, /^\/api\/v1\/memory\/([^/]+)\/history\/(\d+)\/restore$/); + if (method === "POST" && memoryRestore) { + requireMemoryWrite(principal); + const request = envelopeWithPrincipal(asObject(body, "memory.restore"), principal) as MemoryGovernanceRequest; + if (!Number.isInteger(request.version) || Number(request.version) < 1) { + throw new MemoryServiceError("invalid_argument", "memory.restore version must be a positive integer"); + } + const result = service.restoreMemory( + decodeMatchSegment(memoryRestore, 1), + Number(decodeMatchSegment(memoryRestore, 2)), + request + ); + if (result.embeddingJobId) autoWorker.schedule(); + return result; + } + + const memoryArchive = match(path, /^\/api\/v1\/memory\/([^/]+)\/archive$/); + if (method === "POST" && memoryArchive) { + requireMemoryWrite(principal); + const request = envelopeWithPrincipal(asObject(body, "memory.archive"), principal) as MemoryGovernanceRequest; + return service.archiveMemory(decodeMatchSegment(memoryArchive, 1), request); + } + + const memoryPromote = match(path, /^\/api\/v1\/memory\/([^/]+)\/promote$/); + if (method === "POST" && memoryPromote) { + requireMemoryWrite(principal); + const request = envelopeWithPrincipal(asObject(body, "memory.promote"), principal) as MemoryGovernanceRequest; + return service.promoteL1ToL2(decodeMatchSegment(memoryPromote, 1), request); + } + + const memoryMerge = match(path, /^\/api\/v1\/memory\/([^/]+)\/merge$/); + if (method === "POST" && memoryMerge) { + requireMemoryWrite(principal); + const request = envelopeWithPrincipal(asObject(body, "memory.merge"), principal) as MemoryGovernanceRequest & { sourceMemoryId?: unknown }; + if (typeof request.sourceMemoryId !== "string" || !request.sourceMemoryId.trim()) throw new MemoryServiceError("invalid_argument", "memory.merge sourceMemoryId is required"); + return service.mergeMemories(decodeMatchSegment(memoryMerge, 1), request.sourceMemoryId, request); + } + const memoryGet = match(path, /^\/api\/v1\/memory\/([^/]+)$/); + if (method === "GET" && path === "/api/v1/memory/audit/markdown") { + requireMemoryRead(principal); + return service.exportMarkdown({ + namespace: principal.namespace, + includeArchived: url.searchParams.get("includeArchived") === "true" + }); + } + + if (method === "POST" && path === "/api/v1/memory/audit/markdown/import") { + requireMemoryWrite(principal); + const request = requestWithPrincipal(body, "memory.audit.markdown.import", principal); + requireStringField(request, "markdown", "memory.audit.markdown.import"); + return service.importMarkdown({ + namespace: request.namespace, + markdown: request.markdown, + apply: request.apply !== false + }); + } + if (method === "GET" && memoryGet) { requireMemoryRead(principal); return trackExternalToolCall( @@ -704,6 +1047,9 @@ function publicOpenSessionResponse(result: unknown): Record { const record = responseRecord(result); return { sessionId: record.sessionId, + projectId: record.projectId, + workspaceId: record.workspaceId, + workspacePath: record.workspacePath, status: record.status, resumed: record.resumed, serverTime: record.serverTime @@ -757,10 +1103,14 @@ function publicStartTurnResponse(result: unknown): Record { turnId: record.turnId, contextPacketId: record.contextPacketId, sessionId: record.sessionId, + episodeId: record.episodeId, + closedEpisodeIds: record.closedEpisodeIds, searchEventId: record.searchEventId, injectedContext: record.injectedContext, + projectContext: record.projectContext, sourceMemoryIds: record.sourceMemoryIds, hits: record.hits, + droppedDueToBudget: record.droppedDueToBudget, status: record.status, serverTime: record.serverTime }; @@ -780,6 +1130,7 @@ function publicSearchResponse(result: unknown): Record { searchEventId: record.searchEventId, hits: record.hits, sourceMemoryIds: record.sourceMemoryIds, + retrievalDebug: record.retrievalDebug, status: record.status, sections: Array.isArray(injectedContext.sections) ? injectedContext.sections : [], tokenEstimate: typeof injectedContext.tokenEstimate === "number" ? injectedContext.tokenEstimate : undefined, @@ -1040,6 +1391,10 @@ function requireMemoryWrite(principal: AuthPrincipal): void { function requirePanelRead(principal: AuthPrincipal): void { requireAnyScope(principal, ["panel:read", "panel:write", "memory:read", "memory:write", "admin:read", "admin:write"]); } +function requirePanelWrite(principal: AuthPrincipal): void { + requireAnyScope(principal, ["panel:write", "memory:write", "admin:write"]); +} + function requireAdminWrite(principal: AuthPrincipal): void { requireAnyScope(principal, ["admin:write"]); @@ -1083,7 +1438,17 @@ function envelopeWithPrincipal>( principal: AuthPrincipal ): T & RequestEnvelope { const existing = isRecord(body.namespace) ? body.namespace as unknown as RuntimeNamespace : undefined; - const namespace = mergeNamespaces(mergeNamespaces(existing, namespaceFromSource(body.source)), principal.namespace); + const mergedNamespace = mergeNamespaces(mergeNamespaces(existing, namespaceFromSource(body.source)), principal.namespace); + // Source/profile identify provenance, but cannot establish an isolation + // boundary. Keep those fields on the request only when a project, tenant, + // or workspace scope is present; legacy source-only REST calls resolve IDs + // through their session and remain compatible with the service API. + const namespace = mergedNamespace && ( + Boolean(mergedNamespace.projectId) || + Boolean(mergedNamespace.workspaceId) || + Boolean(mergedNamespace.workspacePath) || + Boolean(mergedNamespace.tenantId) + ) ? mergedNamespace : undefined; assertNamespaceScope(existing, principal.namespace); return { ...body, @@ -1091,6 +1456,78 @@ function envelopeWithPrincipal>( } as T & RequestEnvelope; } +function projectContextNamespace(url: URL, principal: AuthPrincipal): RuntimeNamespace { + const raw = url.searchParams.get("namespace"); + let requested: RuntimeNamespace | undefined; + if (raw) { + try { + const parsed = JSON.parse(raw) as unknown; + requested = isRecord(parsed) ? parsed as unknown as RuntimeNamespace : undefined; + } catch { + throw new MemoryServiceError("invalid_argument", "project-context namespace must be valid JSON"); + } + } + const namespace = mergeNamespaces(requested, principal.namespace); + assertNamespaceScope(requested, principal.namespace); + if (!namespace) throw new MemoryServiceError("invalid_argument", "project-context namespace is required"); + return namespace; +} + +function projectContextMutation(body: unknown, routeName: string, principal: AuthPrincipal): RequestEnvelope & Record & { namespace: RuntimeNamespace; provenance: Record } { + const raw = asObject(body, routeName); + if (!isRecord(raw.namespace)) throw new MemoryServiceError("invalid_argument", `${routeName} namespace is required`); + if (!isRecord(raw.provenance)) throw new MemoryServiceError("invalid_argument", `${routeName} provenance is required`); + requireStringField(raw, "source", routeName); + requireStringField(raw, "adapterId", routeName); + requireStringField(raw, "requestId", routeName); + requireStringField(raw.provenance, "sourceAgent", `${routeName} provenance`); + requireStringField(raw.provenance, "capturedAt", `${routeName} provenance`); + if (!Array.isArray(raw.provenance.sourceMemoryIds) || raw.provenance.sourceMemoryIds.some((id) => typeof id !== "string" || !id.trim())) throw new MemoryServiceError("invalid_argument", `${routeName} provenance sourceMemoryIds must be an array of strings`); + const request = envelopeWithPrincipal(raw, principal); + if (!request.namespace) throw new MemoryServiceError("invalid_argument", `${routeName} project namespace is required`); + return { ...request, namespace: request.namespace, provenance: raw.provenance }; +} + +function projectContextProposeGoal(body: unknown, routeName: string, principal: AuthPrincipal): ProjectContextProposeGoalRequest & RequestEnvelope { + const request = projectContextMutation(body, routeName, principal); + if (typeof request.title !== "string" || !request.title.trim() || typeof request.summary !== "string" || typeof request.detail !== "string") throw new MemoryServiceError("invalid_argument", `${routeName} goal fields are required`); + return { ...request, namespace: request.namespace, title: request.title, summary: request.summary, detail: request.detail, acceptanceCriteria: stringList(request.acceptanceCriteria, routeName), constraints: stringList(request.constraints, routeName), sourceMemoryIds: stringList(request.sourceMemoryIds, routeName), provenance: request.provenance }; +} + +function projectContextGoalDecision(body: unknown, routeName: string, principal: AuthPrincipal): ProjectGoalDecisionRequest & RequestEnvelope { + const request = projectContextMutation(body, routeName, principal); + return { ...request, namespace: request.namespace, candidateId: "" }; +} + +function projectContextWorkItemCreate(body: unknown, routeName: string, principal: AuthPrincipal): ProjectWorkItemCreateRequest & RequestEnvelope { + const request = projectContextMutation(body, routeName, principal); + if (typeof request.title !== "string" || !request.title.trim() || typeof request.summary !== "string" || typeof request.nextStep !== "string") throw new MemoryServiceError("invalid_argument", `${routeName} work item fields are required`); + return { ...request, namespace: request.namespace, title: request.title, summary: request.summary, nextStep: request.nextStep, goalId: optionalString(request.goalId), status: workItemStatus(request.status, routeName), acceptanceCriteria: stringList(request.acceptanceCriteria, routeName), constraints: stringList(request.constraints, routeName), sourceMemoryIds: stringList(request.sourceMemoryIds, routeName), provenance: request.provenance }; +} + +function projectContextWorkItemUpdate(body: unknown, routeName: string, principal: AuthPrincipal): Omit & RequestEnvelope { + const request = projectContextMutation(body, routeName, principal); + return { ...request, namespace: request.namespace, goalId: nullableString(request.goalId, routeName), title: nullableString(request.title, routeName), summary: nullableString(request.summary, routeName), nextStep: nullableString(request.nextStep, routeName), status: nullableWorkItemStatus(request.status, routeName), acceptanceCriteria: nullableStringList(request.acceptanceCriteria, routeName), constraints: nullableStringList(request.constraints, routeName), sourceMemoryIds: nullableStringList(request.sourceMemoryIds, routeName), provenance: request.provenance }; +} + +function projectContextFocus(body: unknown, routeName: string, principal: AuthPrincipal): ProjectWorkItemSelectRequest & RequestEnvelope { + const request = projectContextMutation(body, routeName, principal); + if (request.workItemId !== null && typeof request.workItemId !== "string") throw new MemoryServiceError("invalid_argument", `${routeName} workItemId must be a string or null`); + return { ...request, namespace: request.namespace, workItemId: request.workItemId }; +} + +function stringList(value: unknown, routeName: string): string[] | undefined { return parseOptionalStringArray(value, routeName); } +function nullableStringList(value: unknown, routeName: string): string[] | null | undefined { return value === null ? null : stringList(value, routeName); } +function optionalString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function nullableString(value: unknown, routeName: string): string | null | undefined { if (value === undefined || value === null || typeof value === "string") return value; throw new MemoryServiceError("invalid_argument", `${routeName} field must be a string or null`); } +function workItemStatus(value: unknown, routeName: string): ProjectWorkItemCreateRequest["status"] | undefined { + if (value === undefined) return undefined; + if (value === "pending" || value === "active" || value === "blocked" || value === "completed" || value === "archived") return value; + throw new MemoryServiceError("invalid_argument", `${routeName} status is invalid`); +} +function nullableWorkItemStatus(value: unknown, routeName: string): ProjectWorkItemUpdateRequest["status"] { + return value === null ? null : workItemStatus(value, routeName); +} function namespaceFromSource(source: unknown): RuntimeNamespace | undefined { if (typeof source !== "string" || !source.trim()) { return undefined; @@ -1121,8 +1558,20 @@ function assertNamespaceScope( requestNamespace: RuntimeNamespace | undefined, principalNamespace: RuntimeNamespace | undefined ): void { - void requestNamespace; - void principalNamespace; + if (!requestNamespace || !principalNamespace) return; + const requested = normalizeNamespace(requestNamespace); + const allowed = normalizeNamespace(principalNamespace); + const checks: Array<[string, string | undefined, string | undefined]> = [ + ["tenantId", requestNamespace.tenantId, principalNamespace.tenantId], + ["userId", requestNamespace.userId, principalNamespace.userId], + ["projectId", requested.projectId, allowed.projectId], + ["workspaceId", requested.workspaceId, allowed.workspaceId] + ]; + for (const [field, actual, expected] of checks) { + if (actual && expected && actual !== expected) { + throw new MemoryServiceError("forbidden", `request namespace exceeds token scope: ${field}`); + } + } } function requireStringField(record: object, field: string, routeName: string): void { diff --git a/Memory/src/server/index.ts b/Memory/src/server/index.ts index 868814079..97d617269 100644 --- a/Memory/src/server/index.ts +++ b/Memory/src/server/index.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { randomUUID } from "node:crypto"; import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -11,6 +12,7 @@ import { listenMemoryHttpServer } from "./http.js"; import { loadCloudServiceEnv } from "../cli/load-env.js"; const logger = createMemoryLogger("server"); +const SERVER_INSTANCE_ID = randomUUID(); export async function main(argv = process.argv.slice(2)): Promise { loadCloudServiceEnv(); @@ -93,6 +95,7 @@ export function acquireSqliteServerLock(input: { mkdirSync(dirname(lockPath), { recursive: true }); return acquireLockFile(lockPath, { pid: process.pid, + instanceId: SERVER_INSTANCE_ID, host: input.host, port: input.port, sqlitePath, @@ -126,7 +129,8 @@ function acquireLockFile(lockPath: string, payload: Record): Sq throw error; } const existing = readServerLock(lockPath); - if (!existing || !isProcessAlive(existing.pid)) { + const staleReusedPid = existing?.pid === process.pid && existing.instanceId !== SERVER_INSTANCE_ID; + if (!existing || staleReusedPid || !isProcessAlive(existing.pid)) { try { unlinkSync(lockPath); } catch (unlinkError) { @@ -146,9 +150,19 @@ function acquireLockFile(lockPath: string, payload: Record): Sq throw new Error(`failed to acquire Memory sqlite server lock: ${lockPath}`); } -function readServerLock(lockPath: string): { pid?: unknown; host?: unknown; port?: unknown } | undefined { +function readServerLock(lockPath: string): { + pid?: unknown; + instanceId?: unknown; + host?: unknown; + port?: unknown; +} | undefined { try { - return JSON.parse(readFileSync(lockPath, "utf8")) as { pid?: unknown; host?: unknown; port?: unknown }; + return JSON.parse(readFileSync(lockPath, "utf8")) as { + pid?: unknown; + instanceId?: unknown; + host?: unknown; + port?: unknown; + }; } catch { return undefined; } diff --git a/Memory/src/service/agent-token-stats-service.ts b/Memory/src/service/agent-token-stats-service.ts new file mode 100644 index 000000000..89f797664 --- /dev/null +++ b/Memory/src/service/agent-token-stats-service.ts @@ -0,0 +1,420 @@ +import { createReadStream } from "node:fs"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { createInterface } from "node:readline"; + +export type AgentKind = "pi" | "codex" | "claude_code"; + +export interface AgentTokenStats { + agent: AgentKind; + sessions: number; + apiCalls: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + reasoningTokens?: number; + totalTokens: number; + cost?: number; + available: boolean; +} + +export interface ProjectTokenStats { + project: string; + agents: AgentTokenStats[]; + combinedInputTokens: number; + combinedOutputTokens: number; + combinedCacheReadTokens: number; + combinedTotalTokens: number; + estimatedCost?: number; +} + +export interface MonthlyAgentTokenStats { + month: string; + projects: ProjectTokenStats[]; +} + +export interface AgentTokenStatsResponse { + projects: ProjectTokenStats[]; + monthly: MonthlyAgentTokenStats[]; + scannedAt: string; +} + +export interface AgentTokenStatsService { + getStats(): Promise; +} + +export interface CreateAgentTokenStatsServiceOptions { + homeDir?: string; + cacheTtlMs?: number; +} + +interface PerAgentAccumulator { + sessions: number; + apiCalls: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + reasoningTokens: number; + totalTokens: number; + cost: number; +} + +interface ProjectAccumulator { + pi: PerAgentAccumulator; + codex: PerAgentAccumulator; + claude_code: PerAgentAccumulator; +} + +interface UsageRecord { + month: string; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + reasoningTokens: number; + totalTokens: number; + cost: number; +} + +interface ScanState { + projects: Map; + monthlyProjects: Map>; +} + +const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000; + +export function createAgentTokenStatsService( + options: CreateAgentTokenStatsServiceOptions = {} +): AgentTokenStatsService { + const homeDir = options.homeDir ?? os.homedir(); + const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; + let cache: { expiresAt: number; value: AgentTokenStatsResponse } | undefined; + let pending: Promise | undefined; + + return { + async getStats() { + const now = Date.now(); + if (cache && cache.expiresAt > now) return cache.value; + if (pending) return pending; + + pending = scanAgentTokenStats(homeDir); + try { + const value = await pending; + cache = { expiresAt: Date.now() + cacheTtlMs, value }; + return value; + } finally { + pending = undefined; + } + } + }; +} + +async function scanAgentTokenStats(homeDir: string): Promise { + const state: ScanState = { + projects: new Map(), + monthlyProjects: new Map() + }; + await scanPiSessions(homeDir, state); + await scanCodexSessions(homeDir, state); + await scanClaudeCodeTranscripts(homeDir, state); + + return { + projects: buildProjectStats(state.projects), + monthly: [...state.monthlyProjects.entries()] + .sort(([left], [right]) => right.localeCompare(left)) + .map(([month, projects]) => ({ month, projects: buildProjectStats(projects) })), + scannedAt: new Date().toISOString() + }; +} + +function createEmptyAccumulator(): PerAgentAccumulator { + return { + sessions: 0, + apiCalls: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + cost: 0 + }; +} + +function getOrCreateProject( + projects: Map, + projectPath: string +): ProjectAccumulator { + let accumulator = projects.get(projectPath); + if (!accumulator) { + accumulator = { + pi: createEmptyAccumulator(), + codex: createEmptyAccumulator(), + claude_code: createEmptyAccumulator() + }; + projects.set(projectPath, accumulator); + } + return accumulator; +} + +function getMonthlyProject(state: ScanState, month: string, projectPath: string): ProjectAccumulator { + let projects = state.monthlyProjects.get(month); + if (!projects) { + projects = new Map(); + state.monthlyProjects.set(month, projects); + } + return getOrCreateProject(projects, projectPath); +} + +async function* readJsonlObjects(filePath: string): AsyncIterable> { + const stream = createReadStream(filePath, { encoding: "utf8" }); + const lines = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY }); + try { + for await (const line of lines) { + if (!line.trim()) continue; + try { + const value = JSON.parse(line); + if (value && typeof value === "object" && !Array.isArray(value)) { + yield value as Record; + } + } catch { + // Session files can contain an incomplete final line after an interrupted write. + } + } + } finally { + lines.close(); + stream.destroy(); + } +} + +async function scanPiSessions(homeDir: string, state: ScanState): Promise { + const root = path.join(homeDir, ".pi", "agent", "sessions"); + for (const entry of readDirectories(root)) { + if (!entry.name.startsWith("--") || !entry.name.endsWith("--")) continue; + const projectPath = decodePiPath(entry.name); + for (const file of readJsonlFiles(path.join(root, entry.name))) { + await processPiSessionFile(file, projectPath, state); + } + } +} + +async function processPiSessionFile( + filePath: string, + projectPath: string, + state: ScanState +): Promise { + const usedMonths = new Set(); + let hasUsage = false; + for await (const object of readJsonlObjects(filePath)) { + const usage = piUsageFromObject(object, filePath); + if (!usage) continue; + addUsage(getOrCreateProject(state.projects, projectPath).pi, usage); + addUsage(getMonthlyProject(state, usage.month, projectPath).pi, usage); + usedMonths.add(usage.month); + hasUsage = true; + } + if (!hasUsage) return; + getOrCreateProject(state.projects, projectPath).pi.sessions += 1; + for (const month of usedMonths) { + getMonthlyProject(state, month, projectPath).pi.sessions += 1; + } +} + +function piUsageFromObject(object: Record, filePath: string): UsageRecord | undefined { + let usage: Record | undefined; + if ( + (object.type === "assistant" || object.type === "compaction" || object.type === "branch_summary") + && isRecord(object.usage) + ) { + usage = object.usage; + } else if (object.type === "message" && isRecord(object.message)) { + const message = object.message; + if (message.role === "assistant" && isRecord(message.usage)) usage = message.usage; + } + if (!usage) return undefined; + + const cost = isRecord(usage.cost) + ? toNumber(usage.cost.total) + : isRecord(object.cost) + ? toNumber(object.cost.total) + : 0; + return { + month: monthFromTimestamp(object.timestamp, filePath), + inputTokens: toNumber(usage.input), + outputTokens: toNumber(usage.output), + cacheReadTokens: toNumber(usage.cacheRead), + cacheWriteTokens: toNumber(usage.cacheWrite), + reasoningTokens: toNumber(usage.reasoning), + totalTokens: toNumber(usage.totalTokens), + cost + }; +} + +async function scanCodexSessions(homeDir: string, state: ScanState): Promise { + const root = path.join(homeDir, ".codex", "sessions"); + for (const dateDirectory of walkDirectoryTree(root, 3)) { + for (const file of readJsonlFiles(dateDirectory, "rollout-")) { + await processCodexSessionFile(file, state); + } + } +} + +async function processCodexSessionFile(filePath: string, state: ScanState): Promise { + let cwd: string | undefined; + let lastUsage: UsageRecord | undefined; + for await (const object of readJsonlObjects(filePath)) { + if (object.type === "session_meta" && isRecord(object.payload) && typeof object.payload.cwd === "string") { + cwd = object.payload.cwd; + } + if (object.type !== "event_msg" || !isRecord(object.payload)) continue; + const payload = object.payload; + if (payload.type !== "token_count" || !isRecord(payload.info) || !isRecord(payload.info.total_token_usage)) continue; + const usage = payload.info.total_token_usage; + lastUsage = { + month: monthFromTimestamp(object.timestamp, filePath), + inputTokens: toNumber(usage.input_tokens), + outputTokens: toNumber(usage.output_tokens), + cacheReadTokens: toNumber(usage.cached_input_tokens), + cacheWriteTokens: toNumber(usage.cache_write_input_tokens), + reasoningTokens: toNumber(usage.reasoning_output_tokens), + totalTokens: toNumber(usage.total_tokens), + cost: 0 + }; + } + if (!cwd || !lastUsage) return; + const overall = getOrCreateProject(state.projects, cwd).codex; + const monthly = getMonthlyProject(state, lastUsage.month, cwd).codex; + addUsage(overall, lastUsage); + addUsage(monthly, lastUsage); + overall.sessions += 1; + monthly.sessions += 1; +} + +async function scanClaudeCodeTranscripts(homeDir: string, state: ScanState): Promise { + const root = path.join(homeDir, ".claude", "transcripts"); + for (const file of readJsonlFiles(root)) { + let cwd: string | undefined; + let month = monthFromTimestamp(undefined, file); + for await (const object of readJsonlObjects(file)) { + if (typeof object.cwd === "string") cwd = object.cwd; + if (object.timestamp !== undefined) month = monthFromTimestamp(object.timestamp, file); + if (cwd) break; + } + const projectPath = cwd ?? "claude-code-unknown"; + getOrCreateProject(state.projects, projectPath).claude_code.sessions += 1; + getMonthlyProject(state, month, projectPath).claude_code.sessions += 1; + } +} + +function addUsage(accumulator: PerAgentAccumulator, usage: UsageRecord): void { + accumulator.apiCalls += 1; + accumulator.inputTokens += usage.inputTokens; + accumulator.outputTokens += usage.outputTokens; + accumulator.cacheReadTokens += usage.cacheReadTokens; + accumulator.cacheWriteTokens += usage.cacheWriteTokens; + accumulator.reasoningTokens += usage.reasoningTokens; + accumulator.totalTokens += usage.totalTokens; + accumulator.cost += usage.cost; +} + +function buildProjectStats(projects: Map): ProjectTokenStats[] { + const result = [...projects.entries()].map(([project, accumulator]) => { + const agents = [ + buildAgentStats("pi", accumulator.pi, true), + buildAgentStats("codex", accumulator.codex, true), + buildAgentStats("claude_code", accumulator.claude_code, false) + ]; + const estimatedCost = agents.reduce((sum, agent) => sum + (agent.cost ?? 0), 0); + return { + project, + agents, + combinedInputTokens: agents.reduce((sum, agent) => sum + agent.inputTokens, 0), + combinedOutputTokens: agents.reduce((sum, agent) => sum + agent.outputTokens, 0), + combinedCacheReadTokens: agents.reduce((sum, agent) => sum + agent.cacheReadTokens, 0), + combinedTotalTokens: agents.reduce((sum, agent) => sum + agent.totalTokens, 0), + estimatedCost: estimatedCost > 0 ? estimatedCost : undefined + }; + }); + return result.sort((left, right) => right.combinedTotalTokens - left.combinedTotalTokens); +} + +function buildAgentStats(agent: AgentKind, accumulator: PerAgentAccumulator, available: boolean): AgentTokenStats { + return { + agent, + sessions: accumulator.sessions, + apiCalls: accumulator.apiCalls, + inputTokens: accumulator.inputTokens, + outputTokens: accumulator.outputTokens, + cacheReadTokens: accumulator.cacheReadTokens, + cacheWriteTokens: accumulator.cacheWriteTokens, + reasoningTokens: accumulator.reasoningTokens || undefined, + totalTokens: accumulator.totalTokens, + cost: accumulator.cost || undefined, + available + }; +} + +function monthFromTimestamp(timestamp: unknown, filePath: string): string { + if (typeof timestamp === "string" || typeof timestamp === "number") { + const date = new Date(timestamp); + if (!Number.isNaN(date.getTime())) return date.toISOString().slice(0, 7); + } + const match = path.basename(filePath).match(/(20\d{2})[-_/](0[1-9]|1[0-2])/); + if (match) return `${match[1]}-${match[2]}`; + try { + return fs.statSync(filePath).mtime.toISOString().slice(0, 7); + } catch { + return "unknown"; + } +} + +function decodePiPath(encoded: string): string { + return "/" + encoded.slice(2, -2).replace(/-/g, "/"); +} + +function readDirectories(root: string): fs.Dirent[] { + try { + return fs.readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()); + } catch { + return []; + } +} + +function readJsonlFiles(root: string, prefix = ""): string[] { + try { + return fs.readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith(".jsonl")) + .map((entry) => path.join(root, entry.name)); + } catch { + return []; + } +} + +function walkDirectoryTree(root: string, depth: number): string[] { + const results: string[] = []; + function walk(directory: string, currentDepth: number): void { + for (const entry of readDirectories(directory)) { + const fullPath = path.join(directory, entry.name); + if (currentDepth === depth) results.push(fullPath); + else walk(fullPath, currentDepth + 1); + } + } + walk(root, 1); + return results; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function toNumber(value: unknown): number { + if (typeof value === "number") return Number.isFinite(value) ? value : 0; + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} diff --git a/Memory/src/service/evolution/policy-induction.ts b/Memory/src/service/evolution/policy-induction.ts index 25ab5bfb8..ba6d4cdc8 100644 --- a/Memory/src/service/evolution/policy-induction.ts +++ b/Memory/src/service/evolution/policy-induction.ts @@ -20,6 +20,7 @@ import { isRecord } from "../../utils/json.js"; import { stableHash } from "../../utils/id.js"; import type { EnqueueJobInput } from "../worker/job-handlers.js"; import { logEvolutionDecision } from "./evolution-logging.js"; +import { extractProjectEvidence } from "./project-evidence.js"; export type PolicyDraft = ReturnType; export type PolicyEnhancementResult = @@ -293,7 +294,7 @@ export class PolicyInductionEngine { profileId: this.deps.profileIdFromMemory(source), layer: "L2", kind: "policy", - lifecycleStatus: draft.status, + lifecycleStatus: "candidate", memoryType: "LongTermMemory", key: draft.key, value: draft.body, @@ -304,7 +305,7 @@ export class PolicyInductionEngine { gain: draft.gain, raw_gain: draft.rawGain, policy_confidence: draft.confidence, - status: draft.status, + status: "candidate", source_memory_ids: draft.sourceTraceIds }, internal: { @@ -321,7 +322,7 @@ export class PolicyInductionEngine { gain: draft.gain, raw_gain: draft.rawGain, policy_confidence: draft.confidence, - status: draft.status, + status: "candidate", source_episode_ids: draft.sourceEpisodeIds, source_trace_ids: draft.sourceTraceIds, policy: { @@ -334,7 +335,7 @@ export class PolicyInductionEngine { gain: draft.gain, raw_gain: draft.rawGain, policy_confidence: draft.confidence, - status: draft.status, + status: "candidate", experience_type: "success_pattern", evidence_polarity: "positive", skill_eligible: true, @@ -752,7 +753,16 @@ export class PolicyInductionEngine { } isTraceEligibleForL2(trace: TraceMeta): boolean { - return trace.value >= this.deps.config.algorithm.l2Induction.minTraceValue && + const evidence = extractProjectEvidence({ + id: trace.id, + userText: trace.userText, + agentText: trace.agentText, + reflection: trace.reflection, + toolCalls: trace.toolCalls, + tags: trace.tags, + value: trace.value + }); + return evidence.eligible && trace.value >= this.deps.config.algorithm.l2Induction.minTraceValue && Boolean(trace.vecSummary ?? trace.vecAction); } diff --git a/Memory/src/service/evolution/project-evidence.ts b/Memory/src/service/evolution/project-evidence.ts new file mode 100644 index 000000000..784eaff87 --- /dev/null +++ b/Memory/src/service/evolution/project-evidence.ts @@ -0,0 +1,64 @@ +import type { ToolCallPayload } from "../../types.js"; +import { stableHash, stableStringify } from "../../utils/id.js"; + +export type ProjectEvidenceKind = "fact" | "decision" | "procedure" | "outcome" | "noise"; + +export interface ProjectEvidence { + kind: ProjectEvidenceKind; + eligible: boolean; + confidence: number; + subject: string; + claim: string; + sourceText: string; + stableKey: string; + reasons: string[]; +} + +export interface ProjectEvidenceInput { + id: string; + userText?: string; + agentText?: string; + reflection?: string | null; + toolCalls?: ToolCallPayload[]; + tags?: string[]; + value?: number; +} + +const INJECTED_CONTEXT_RE = /<\/?(?:codex_internal_context|memmy_memory_context|objective|current_user_request)\b/i; +const CHILD_AGENT_RE = /(?:focused child agent|spawned by a parent agent|child-agent instruction|subagent instruction)/i; +const META_NOISE_RE = /^(?:continue|keep working|go on|看看|继续|再看看|有了吗|什么情况|下一步(?:怎么|是什么)?工作)\s*[??!!。.]?$/i; +const QUESTION_RE = /[??]\s*$/; +const RESULT_RE = /(?:成功|失败|通过|报错|error|failed|passed|fixed|修复|完成|created|updated|deleted|exit code|status\s*[:=])/i; +const PROCEDURE_RE = /(?:run|执行|使用|调用|install|测试|部署|重启|patch|修改|命令|procedure|步骤|步骤|must|should|不要|必须)/i; + +export function extractProjectEvidence(input: ProjectEvidenceInput): ProjectEvidence { + const user = normalize(input.userText); + const agent = normalize(input.agentText); + const reflection = normalize(input.reflection); + const tools = input.toolCalls ?? []; + const reasons: string[] = []; + const sourceText = [user && `USER: ${user}`, agent && `AGENT: ${agent}`, reflection && `REFLECTION: ${reflection}`] + .filter(Boolean) + .join("\n"); + let kind: ProjectEvidenceKind = "fact"; + if (!sourceText || INJECTED_CONTEXT_RE.test(sourceText)) { + reasons.push("injected_or_empty"); + } else if (CHILD_AGENT_RE.test(sourceText)) { + reasons.push("agent_instruction_noise"); + } else if (META_NOISE_RE.test(user) || (QUESTION_RE.test(user) && !agent && tools.length === 0 && !reflection)) { + reasons.push("question_or_meta_noise"); + } + if (tools.length > 0 || PROCEDURE_RE.test(`${user}\n${agent}`)) kind = "procedure"; + if (RESULT_RE.test(`${agent}\n${reflection}`) || tools.some((tool) => Boolean(tool.output || tool.error))) kind = "outcome"; + if (/(?:we will|we decided|adopt|use|采用|决定|约定|规范|架构事实)/i.test(`${user}\n${agent}`)) kind = "decision"; + if (reasons.length > 0) kind = "noise"; + const confidence = kind === "noise" ? 0 : Math.min(1, 0.45 + (agent.length > 40 ? 0.2 : 0) + (tools.length > 0 ? 0.2 : 0) + (reflection ? 0.15 : 0)); + const claim = normalize(agent || user); + const subject = normalize(user).slice(0, 160); + const stableKey = `evidence:${stableHash(stableStringify({ kind, subject, claim }))}`; + return { kind, eligible: kind !== "noise" && confidence >= 0.6, confidence, subject, claim, sourceText, stableKey, reasons }; +} + +function normalize(value: string | null | undefined): string { + return (value ?? "").replace(/\s+/g, " ").trim(); +} diff --git a/Memory/src/service/evolution/skill-pipeline.ts b/Memory/src/service/evolution/skill-pipeline.ts index 5a40c31e6..da7194490 100644 --- a/Memory/src/service/evolution/skill-pipeline.ts +++ b/Memory/src/service/evolution/skill-pipeline.ts @@ -183,7 +183,7 @@ export class SkillPipeline { profileId: profileIdFromMemory(policyMemory), layer: "Skill", kind: "skill", - lifecycleStatus: verifiedDraft.status, + lifecycleStatus: "candidate", memoryType: "SkillMemory", key: verifiedDraft.key, value: verifiedDraft.invocationGuide, @@ -191,7 +191,7 @@ export class SkillPipeline { info: { name: verifiedDraft.name, eta: verifiedDraft.eta, - status: verifiedDraft.status, + status: "candidate", source_memory_ids: verifiedDraft.sourcePolicyIds }, internal: { @@ -211,7 +211,7 @@ export class SkillPipeline { skill: { name: verifiedDraft.name, eta: verifiedDraft.eta, - status: verifiedDraft.status, + status: "candidate", support: verifiedDraft.support, gain: verifiedDraft.gain, policy_content_hash: skillPolicyContentHash(policy), diff --git a/Memory/src/service/evolution/world-model-pipeline.ts b/Memory/src/service/evolution/world-model-pipeline.ts index 226ed923c..ff6234f5e 100644 --- a/Memory/src/service/evolution/world-model-pipeline.ts +++ b/Memory/src/service/evolution/world-model-pipeline.ts @@ -157,6 +157,7 @@ export class WorldModelPipeline { profileId: source ? profileIdFromMemory(source) : undefined, layer: "L3", kind: "world_model", + lifecycleStatus: "candidate", memoryType: "LongTermMemory", key: draft.key, value: draft.body, diff --git a/Memory/src/service/feedback/feedback-experience.ts b/Memory/src/service/feedback/feedback-experience.ts index 14a0d8b8c..2337d8d62 100644 --- a/Memory/src/service/feedback/feedback-experience.ts +++ b/Memory/src/service/feedback/feedback-experience.ts @@ -44,6 +44,8 @@ import { clip } from "../../utils/text.js"; import { nowIso } from "../../utils/time.js"; import { updatePolicyStats } from "../evolution/policy-induction.js"; import { + memoryFilterForNamespace, + namespaceIdFromContext as canonicalNamespaceIdFromContext, namespaceForMemory, namespaceForSession, normalizeNamespace, @@ -214,6 +216,7 @@ async feedback(request: FeedbackRequest): Promise { const attribution = this.resolveFeedbackAttribution(request, context); const attributedRequest: FeedbackRequest = { ...request, + namespace: context.namespace, l1MemoryId: request.l1MemoryId ?? attribution.l1MemoryId, rawTurnId: request.rawTurnId ?? attribution.rawTurnId, episodeId: request.episodeId ?? attribution.episodeId, @@ -546,7 +549,7 @@ async maybeCreateFeedbackExperience( trace }); const vector = await this.deps.embedder.embedOne(draft.vectorText, "query"); - const existing = this.findSimilarFeedbackExperience(draft, vector); + const existing = this.findSimilarFeedbackExperience(draft, vector, context.namespace); const at = feedback.createdAt; const saved = existing ? this.mergeFeedbackExperiencePolicy(existing, draft, vector, at) @@ -864,10 +867,15 @@ feedbackExperienceEpisodeContext( findSimilarFeedbackExperience( draft: FeedbackExperienceDraft, - vector: number[] + vector: number[], + namespace: RuntimeNamespace ): MemoryRow | null { let best: { memory: MemoryRow; score: number; policy: PolicyMeta } | null = null; - for (const memory of this.deps.repos.memories.list({ memoryLayer: "L2", status: ["activated", "resolving"] }, 1000)) { + for (const memory of this.deps.repos.memories.list({ + ...memoryFilterForNamespace(namespace), + memoryLayer: "L2", + status: ["activated", "resolving"] + }, 1000)) { const policy = policyMetaFromMemory(memory); if (!policy) continue; const sourceFeedbackIds = stringArray(memory.properties.internal_info.source_feedback_ids) @@ -1122,7 +1130,7 @@ feedbackCandidatePolicyIds(request: FeedbackRequest, feedback: FeedbackRecord): const recall = this.deps.repos.runtime.getRecallEvent(request.recallEventId); for (const id of recall?.injectedMemoryIds ?? []) { const memory = this.deps.repos.memories.get(id); - if (memory?.memoryLayer === "L2") ids.add(memory.id); + if (memory?.memoryLayer === "L2" && this.memoryMatchesNamespace(memory, request.namespace)) ids.add(memory.id); } } if (feedback.l1MemoryId) { @@ -1131,13 +1139,15 @@ feedbackCandidatePolicyIds(request: FeedbackRequest, feedback: FeedbackRecord): l1MemoryId: feedback.l1MemoryId, limit: 20 })) { - ids.add(link.l2MemoryId); + const memory = this.deps.repos.memories.get(link.l2MemoryId); + if (memory && this.memoryMatchesNamespace(memory, request.namespace)) ids.add(memory.id); } } if (ids.size === 0 && feedback.rationale) { for (const hit of this.deps.repos.memories.search( feedback.rationale, { + ...memoryFilterForNamespace(normalizeNamespace(request.namespace)), memoryLayer: "L2", status: "activated" }, @@ -1170,6 +1180,8 @@ feedbackRepairEvidence( if (request.recallEventId) { const recall = this.deps.repos.runtime.getRecallEvent(request.recallEventId); for (const id of recall?.injectedMemoryIds ?? []) { + const memory = this.deps.repos.memories.get(id); + if (!memory || !this.memoryMatchesNamespace(memory, request.namespace)) continue; if (feedback.polarity === "negative") { low.add(id); } else if (feedback.polarity === "positive") { @@ -1178,6 +1190,7 @@ feedbackRepairEvidence( } } for (const policy of this.deps.repos.memories.getMany(policyIds)) { + if (!this.memoryMatchesNamespace(policy, request.namespace)) continue; const meta = policyMetaFromMemory(policy); if (!meta) continue; for (const id of meta.sourceTraceIds) { @@ -1203,6 +1216,7 @@ feedbackRepairEvidence( for (const memory of this.deps.repos.memories.search( searchText, { + ...memoryFilterForNamespace(normalizeNamespace(request.namespace)), memoryLayer: "L1", status: "activated" }, @@ -1218,6 +1232,7 @@ feedbackRepairEvidence( for (const memory of this.deps.repos.memories.search( searchText, { + ...memoryFilterForNamespace(normalizeNamespace(request.namespace)), memoryLayer: "L1", status: "activated" }, @@ -1244,6 +1259,13 @@ feedbackRepairEvidence( }; } +memoryMatchesNamespace(memory: MemoryRow, namespace: RuntimeNamespace | undefined): boolean { + if (!namespace) return true; + const filter = memoryFilterForNamespace(namespace); + const actual = memoryFilterForNamespace(namespaceForMemory(memory)); + return actual.tenantId === filter.tenantId && actual.projectId === filter.projectId; + } + sessionRepairEvidence( sessionId: string, keyword: string | undefined, @@ -2846,13 +2868,7 @@ function namespaceIdFromMemory(memory: MemoryRow): string { } function namespaceIdFromContext(namespace: RuntimeNamespace): string { - return [ - namespace.tenantId, - namespace.userId, - namespace.projectId ?? namespace.workspaceId, - namespace.source, - namespace.profileId - ].filter(Boolean).join(":"); + return canonicalNamespaceIdFromContext(namespace); } function memoryStatusForLifecycleStatus(status: "candidate" | "active" | "archived"): "activated" | "resolving" | "archived" { diff --git a/Memory/src/service/governance/markdown-audit.ts b/Memory/src/service/governance/markdown-audit.ts new file mode 100644 index 000000000..47e61f43d --- /dev/null +++ b/Memory/src/service/governance/markdown-audit.ts @@ -0,0 +1,126 @@ +import { parse, stringify } from "yaml"; +import type { MemoryProvenance, MemoryRow, MemoryStatus, RuntimeNamespace } from "../../types.js"; +import { kindFromMemory } from "../../storage/repositories.js"; +import { detailFromMemory } from "../read-model/memory.js"; + +export interface MemoryMarkdownFrontMatter { + id: string; + kind: string; + memoryLayer: string; + status: MemoryStatus; + title: string; + tags: string[]; + version: number; + createdAt: string; + updatedAt: string; + provenance?: MemoryProvenance; + supersession?: { + supersedesMemoryIds: string[]; + supersededByMemoryId?: string; + reason?: string; + }; + audit: { + source: "memmy-memory"; + editable: ["title", "tags", "body"]; + }; +} + +export interface ParsedMemoryMarkdown { + frontMatter: MemoryMarkdownFrontMatter; + body: string; +} + +export function renderMemoryMarkdown(memory: MemoryRow): string { + const detail = detailFromMemory(memory); + const frontMatter: MemoryMarkdownFrontMatter = { + id: memory.id, + kind: kindFromMemory(memory), + memoryLayer: memory.memoryLayer, + status: memory.status, + title: detail.title, + tags: memory.tags, + version: memory.version, + createdAt: memory.createdAt, + updatedAt: memory.updatedAt, + ...(detail.provenance ? { provenance: detail.provenance } : {}), + ...(detail.supersession ? { supersession: detail.supersession } : {}), + audit: { + source: "memmy-memory", + editable: ["title", "tags", "body"] + } + }; + return `---\n${stringify(frontMatter).trimEnd()}\n---\n# ${detail.title}\n\n${memory.memoryValue.trim()}\n`; +} + +export function renderMemoryMarkdownBundle(memories: MemoryRow[]): string { + return memories.map(renderMemoryMarkdown).join("\n---\n\n"); +} + +export function parseMemoryMarkdownBundle(markdown: string): ParsedMemoryMarkdown[] { + const documents = markdown + .replace(/^\uFEFF/, "") + .split(/\n---\n(?=\n?---\n|id:|$)/g) + .map((value) => value.trim()) + .filter(Boolean); + return documents.map(parseMemoryMarkdown); +} + +export function parseMemoryMarkdown(markdown: string): ParsedMemoryMarkdown { + const match = markdown.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/); + if (!match) throw new Error("markdown audit document must contain YAML front matter"); + const parsed = parse(match[1] ?? "") as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("markdown audit front matter must be an object"); + } + const frontMatter = parsed as Partial; + if (typeof frontMatter.id !== "string" || !frontMatter.id.trim()) { + throw new Error("markdown audit front matter requires id"); + } + const body = stripTitleHeading(match[2] ?? ""); + return { + frontMatter: { + id: frontMatter.id.trim(), + kind: typeof frontMatter.kind === "string" ? frontMatter.kind : "trace", + memoryLayer: typeof frontMatter.memoryLayer === "string" ? frontMatter.memoryLayer : "L1", + status: frontMatter.status ?? "activated", + title: typeof frontMatter.title === "string" && frontMatter.title.trim() + ? frontMatter.title.trim() + : firstBodyLine(body) || frontMatter.id, + tags: Array.isArray(frontMatter.tags) + ? frontMatter.tags.filter((tag): tag is string => typeof tag === "string") + : [], + version: typeof frontMatter.version === "number" ? frontMatter.version : 1, + createdAt: typeof frontMatter.createdAt === "string" ? frontMatter.createdAt : new Date().toISOString(), + updatedAt: typeof frontMatter.updatedAt === "string" ? frontMatter.updatedAt : new Date().toISOString(), + ...(frontMatter.provenance ? { provenance: frontMatter.provenance } : {}), + ...(frontMatter.supersession ? { supersession: frontMatter.supersession } : {}), + audit: { + source: "memmy-memory", + editable: ["title", "tags", "body"] + } + }, + body + }; +} + +export function markdownNamespace(frontMatter: MemoryMarkdownFrontMatter): RuntimeNamespace | undefined { + const provenance = frontMatter.provenance; + if (!provenance) return undefined; + return { + source: provenance.sourceAgent, + profileId: provenance.profileId ?? "default", + projectId: provenance.projectId, + workspaceId: provenance.workspaceId, + workspacePath: provenance.workspacePath + }; +} + +function stripTitleHeading(value: string): string { + const lines = value.replace(/^\s+/, "").split(/\r?\n/); + if (lines[0]?.match(/^#\s+/)) lines.shift(); + return lines.join("\n").trim(); +} + +function firstBodyLine(value: string): string { + return value.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? ""; +} diff --git a/Memory/src/service/import/import-job-processor.ts b/Memory/src/service/import/import-job-processor.ts index b12608baf..ba5b5d4a1 100644 --- a/Memory/src/service/import/import-job-processor.ts +++ b/Memory/src/service/import/import-job-processor.ts @@ -59,7 +59,9 @@ export interface ImportJobProcessorDeps { assertMemoryAddEnabled(): void; assertMemoryInScope(memory: MemoryRow, namespace: unknown): void; sanitizeMemoryAddRequest(request: MemoryAddRequest): MemoryAddRequest; - resolveContext(request: MemoryAddRequest): { userId: string; namespace: { source?: string; projectId?: string; profileId?: string } }; + resolveContext(request: MemoryAddRequest): { userId: string; namespace: { + source?: string; tenantId?: string; projectId?: string; profileId?: string; workspaceId?: string; workspacePath?: string; + } }; requireSession(id: string): SessionRecord; assertSessionInScope(session: ReturnType, namespace: unknown): void; normalizeMemoryAddCreatedAt(value: string | undefined): string | undefined; @@ -158,6 +160,21 @@ export class ImportJobProcessor { source: request.source ?? "manual", title, summary: importSummary ?? firstLine(request.content), turn_id: request.turnId, ...(importTrace ? { plugin_algorithm: "memory.add.import_async.v2", trace: importTrace } : {}) }, + provenance: { + ...request.provenance, + sourceAgent: session?.source ?? request.source?.trim() ?? context.namespace.source, + tenantId: context.namespace.tenantId ?? "local", + profileId: session?.profileId ?? context.namespace.profileId, + projectId: session?.projectId ?? context.namespace.projectId, + workspaceId: session?.workspaceId ?? context.namespace.workspaceId, + workspacePath: session?.workspacePath ?? context.namespace.workspacePath, + sessionId: session?.id ?? request.sessionId, + turnId: request.turnId, + adapterId: request.adapterId, + requestId: request.requestId, + sourceMemoryIds: request.sourceMemoryIds ?? [], + capturedAt: at + }, createdAt: at }); @@ -187,7 +204,7 @@ export class ImportJobProcessor { return { upsert, changeSeq }; }); const inserted = persisted.upsert.memory; - if (persisted.upsert.created && !d.isAgentSourceImportMemoryAdd(request)) { + if (persisted.upsert.created && layer === "L1" && !d.isAgentSourceImportMemoryAdd(request)) { d.enqueueJob({ jobType: "episode_idle_close", userId: inserted.userId, sessionId: inserted.sessionId, dedupeKey: `episode_idle_close:memory.add:${inserted.id}`, payload: { triggerMemoryId: inserted.id, triggerSource: "memory.add", triggeredAt: receivedAt }, createdAt: receivedAt }); @@ -302,7 +319,11 @@ export class ImportJobProcessor { export function memoryHasImportPipeline(memory: MemoryRow): boolean { const algorithm = stringFromRecord(memory.properties.internal_info, "plugin_algorithm"); - return algorithm?.startsWith("memory.add.import_async.") === true || memory.tags.some((tag) => tag.trim().toLowerCase() === "agent-source"); + const provenance = isRecord(memory.properties.internal_info.provenance) + ? memory.properties.internal_info.provenance + : {}; + const adapterId = stringFromRecord(provenance, "adapterId"); + return algorithm?.startsWith("memory.add.import_async.") === true || adapterId?.startsWith("agent-source:") === true; } export function memoryNeedsImportSummary(memory: MemoryRow): boolean { diff --git a/Memory/src/service/memory-service.ts b/Memory/src/service/memory-service.ts index 3725b57ef..4e0e5a755 100644 --- a/Memory/src/service/memory-service.ts +++ b/Memory/src/service/memory-service.ts @@ -22,6 +22,7 @@ import { } from "../storage/backend.js"; import type { MemoryDb } from "../storage/db.js"; import { + MemoryVersionConflictError, Repositories, jobToRef, kindFromMemory, @@ -32,6 +33,7 @@ import { type SessionRecord } from "../storage/repositories.js"; import type { SerializedMemoryVector } from "../storage/sqlite-vec-store.js"; +import { DEFAULT_NAMESPACE_SOURCE } from "../types.js"; import type { FeedbackRequest, HealthResponse, @@ -42,9 +44,12 @@ import type { MemoryExportRequest, MemoryGovernanceRequest, MemoryImportRequest, + MemoryMarkdownExportRequest, + MemoryMarkdownImportRequest, MemoryKind, MemoryLayer, MemoryListItem, + MemoryProvenance, MemoryProcessingRecord, MemoryReloadConfigRequest, MemoryReloadConfigResponse, @@ -56,6 +61,8 @@ import type { RequestEnvelope, RetrievalMode, RuntimeNamespace, + SessionCheckpointPayload, + SessionCheckpointRequest, SessionCompactRequest, SessionOpenRequest, SkillUseRequest, @@ -64,7 +71,8 @@ import type { ToolCallPayload, ToolObserveRequest, TurnCompleteRequest, - TurnStartRequest + TurnStartRequest, + TurnStartResponse } from "../types.js"; import { MemoryServiceError } from "../utils/error.js"; import { newId,stableHash,stableStringify } from "../utils/id.js"; @@ -97,10 +105,20 @@ import { } from "./import/memory-import-pipeline.js"; import { recordApiLog } from "./model-audit/model-call-audit.js"; import { + parseMemoryMarkdownBundle, + renderMemoryMarkdownBundle +} from "./governance/markdown-audit.js"; +import { + hasProjectScope, + namespaceIdFromContext as canonicalNamespaceIdFromContext, namespaceForMemory, namespaceForRawTurn, namespaceForSession, - normalizeNamespace + memoryFilterForNamespace, + normalizeNamespace, + projectIdFromMemory, + sameProjectScope, + tenantIdFromSession } from "./namespace/namespace-scope.js"; import { EpisodeReadModel, @@ -114,6 +132,19 @@ import { procedureFromSkillMemory } from "./read-model/memory.js"; import { PanelReadModel } from "./read-model/panel-read.js"; +import { ProjectContextService } from "./project-context/project-context-service.js"; +import type { + ProjectContextProposeGoalRequest, + ProjectContextReadState, + ProjectContextStableResult, + ProjectGoalRecord, + ProjectWorkItemRecord +} from "./project-context/project-context-types.js"; +import type { + ProjectWorkItemCreateRequest, + ProjectWorkItemSelectRequest, + ProjectWorkItemUpdateRequest +} from "./project-context/project-context-service.js"; import { SkillReadModel } from "./read-model/skill.js"; @@ -216,6 +247,8 @@ type InternalMemorySearchRequest = MemorySearchRequest & { injectedContextQuery?: string; turnIntentDecision?: unknown; routeProposal?: unknown; + projectContextVersion?: number; + projectContextStatus?: string; recordEvent?: boolean; }; @@ -234,6 +267,7 @@ export class MemoryService { private readonly episodeReadModel: EpisodeReadModel; private readonly importJobs: ImportJobProcessor; private readonly panelReadModel: PanelReadModel; + private readonly projectContext: ProjectContextService; private readonly retrieval: RetrievalService; private readonly sessionTurns: SessionTurnService; private readonly skillReadModel: SkillReadModel; @@ -492,12 +526,14 @@ export class MemoryService { namespaceIdFromContext, withTimeout }); + this.projectContext = new ProjectContextService({ repositories: this.repos }); const sessionTurnOwner = this; this.sessionTurns = new SessionTurnService({ repos: this.repos, get config() { return sessionTurnOwner.config; }, get llm() { return sessionTurnOwner.llm; }, get skillLlm() { return sessionTurnOwner.skillLlm; }, + projectContext: this.projectContext, assertEpisodeInScope: this.assertEpisodeInScope.bind(this), assertMemoryAddEnabled: this.assertMemoryAddEnabled.bind(this), assertRawTurnInScope: this.assertRawTurnInScope.bind(this), @@ -781,24 +817,65 @@ export class MemoryService { return this.sessionTurns.compactSession(sessionId, request); } - async startTurn(request: TurnStartRequest & Record): Promise<{ - contextPacketId: string; - turnId: string; - sessionId: string; - searchEventId: string; - hits: RecallHit[]; - injectedContext: InjectedContext; - sourceMemoryIds: string[]; - droppedDueToBudget: Array<{ - id: string; - kind: MemoryKind; - memoryLayer: MemoryLayer; - reason: "token_budget"; + checkpointSession(sessionId: string, request: SessionCheckpointRequest): { + checkpointId: string; + checkpoint: SessionCheckpointPayload; + memorySnapshot: { + summary: string; + sourceTurnIds: string[]; + sourceMemoryIds: string[]; tokenEstimate?: number; - }>; - status: string[]; + }; + contextPacketId: string; + rawTurnId?: string; + l1MemoryId?: string; + changeSeq?: number; + syncCursor?: string; + jobs: JobRef[]; serverTime: string; - }> { + } { + if (!request.task.trim()) { + throw new MemoryServiceError("invalid_argument", "session checkpoint requires a non-empty task"); + } + const list = (value: string[] | undefined): string[] => Array.isArray(value) + ? value.map((item) => item.trim()).filter(Boolean).slice(0, 32) + : []; + const checkpoint: SessionCheckpointPayload = { + task: request.task.trim(), + changes: list(request.changes), + validated: list(request.validated), + unverified: list(request.unverified), + nextSteps: list(request.nextSteps) + }; + const summary = [ + `Task: ${checkpoint.task}`, + "Changes:", + ...(checkpoint.changes.length ? checkpoint.changes.map((item) => `- ${item}`) : ["- none recorded"]), + "Validated:", + ...(checkpoint.validated.length ? checkpoint.validated.map((item) => `- ${item}`) : ["- none recorded"]), + "Unverified:", + ...(checkpoint.unverified.length ? checkpoint.unverified.map((item) => `- ${item}`) : ["- none recorded"]), + "Next steps:", + ...(checkpoint.nextSteps.length ? checkpoint.nextSteps.map((item) => `- ${item}`) : ["- none recorded"]) + ].join("\n"); + const result = this.compactSession(sessionId, { + namespace: request.namespace, + episodeId: request.episodeId, + summary, + sourceTurnIds: request.sourceTurnIds, + sourceMemoryIds: request.sourceMemoryIds, + tokenEstimate: request.tokenEstimate, + createL1: request.createL1, + checkpoint + }); + return { + ...result, + checkpointId: result.rawTurnId ?? result.contextPacketId, + checkpoint + }; + } + + async startTurn(request: TurnStartRequest & Record): Promise { return this.sessionTurns.startTurn(request); } @@ -924,7 +1001,183 @@ export class MemoryService { createdAt: string; serverTime: string; } { - return this.importJobs.addMemory(request); + const previous = request.supersedesMemoryId + ? this.requireExistingMemory(request.supersedesMemoryId) + : undefined; + if (previous) { + this.assertMemoryInScope(previous, request.namespace); + if ((request.layer ?? "L1") !== previous.memoryLayer) { + throw new MemoryServiceError("conflict", "superseding memories must use the same memory layer"); + } + } + const added = this.importJobs.addMemory(request); + if (!previous) return added; + + const inserted = this.requireExistingMemory(added.id); + const at = nowIso(); + const supersession = this.repos.transaction(() => this.repos.memories.supersede({ + oldMemory: previous, + newMemory: inserted, + projectId: projectIdFromMemory(inserted), + reason: request.supersessionReason, + actor: request.namespace ? { ...request.namespace } : {}, + createdAt: at + })); + this.repos.runtime.appendChange({ + memoryId: supersession.oldMemory.id, + namespaceId: namespaceIdFromMemory(supersession.oldMemory), + kind: kindFromMemory(supersession.oldMemory), + op: "archived", + entityId: supersession.oldMemory.id, + userId: supersession.oldMemory.userId, + changeType: "superseded", + before: previous, + after: supersession.oldMemory, + source: "memory.supersede", + createdAt: at + }); + this.repos.runtime.insertAudit({ + userId: inserted.userId, + sessionId: inserted.sessionId, + actor: request.namespace ? { ...request.namespace } : {}, + action: "supersede", + targetKind: kindFromMemory(previous), + targetId: previous.id, + before: previous, + after: supersession.oldMemory, + meta: { + supersededByMemoryId: inserted.id, + relationId: supersession.relation.id, + reason: request.supersessionReason + }, + createdAt: at + }); + return added; + } + + exportMarkdown(request: MemoryMarkdownExportRequest = {}): { + markdown: string; + count: number; + projectId?: string; + generatedAt: string; + } { + this.assertMemorySearchEnabled(); + const context = this.resolveContext(request); + const memories = this.repos.memories.list({ + ...memoryFilterForNamespace(context.namespace), + status: request.includeArchived ? ["activated", "resolving", "archived"] : ["activated", "resolving"] + }, 100_000); + return { + markdown: renderMemoryMarkdownBundle(memories), + count: memories.length, + projectId: context.namespace.projectId, + generatedAt: nowIso() + }; + } + + importMarkdown(request: MemoryMarkdownImportRequest): { + applied: boolean; + count: number; + updated: string[]; + created: string[]; + rejected: Array<{ id: string; reason: string }>; + serverTime: string; + } { + this.assertMemoryAddEnabled(); + if (!request.markdown?.trim()) { + throw new MemoryServiceError("invalid_argument", "markdown audit import requires markdown"); + } + const context = this.resolveContext(request); + const documents = parseMemoryMarkdownBundle(request.markdown); + const updated: string[] = []; + const created: string[] = []; + const rejected: Array<{ id: string; reason: string }> = []; + for (const document of documents) { + try { + const existing = this.repos.memories.get(document.frontMatter.id); + if (existing) { + this.assertMemoryInScope(existing, request.namespace); + if (document.frontMatter.memoryLayer !== existing.memoryLayer) { + throw new MemoryServiceError("conflict", "markdown audit cannot change memory layer"); + } + if (request.apply !== false) { + const at = nowIso(); + const next = this.repos.memories.update({ + ...existing, + memoryValue: document.body, + tags: uniq(document.frontMatter.tags), + info: { ...existing.info, title: document.frontMatter.title, tags: uniq(document.frontMatter.tags) }, + properties: { + ...existing.properties, + tags: uniq(document.frontMatter.tags), + info: { ...existing.properties.info, title: document.frontMatter.title, tags: uniq(document.frontMatter.tags) }, + internal_info: { + ...existing.properties.internal_info, + title: document.frontMatter.title, + provenance: document.frontMatter.provenance ?? existing.properties.internal_info.provenance + } + }, + updatedAt: at, + contentHash: stableHash(document.body) + }); + this.repos.runtime.appendChange({ + memoryId: next.id, + namespaceId: namespaceIdFromMemory(next), + kind: kindFromMemory(next), + op: "updated", + entityId: next.id, + userId: next.userId, + changeType: "markdown_audit_update", + before: existing, + after: next, + source: "markdown.audit", + createdAt: at + }); + this.repos.runtime.insertAudit({ + userId: next.userId, + sessionId: next.sessionId, + actor: request.namespace ? { ...request.namespace } : {}, + action: "markdown_update", + targetKind: kindFromMemory(next), + targetId: next.id, + before: existing, + after: next, + meta: {}, + createdAt: at + }); + } + updated.push(existing.id); + continue; + } + if (request.apply === false) { + created.push(document.frontMatter.id); + continue; + } + const added = this.addMemory({ + namespace: request.namespace, + source: context.namespace.source, + content: document.body, + layer: document.frontMatter.memoryLayer as MemoryLayer, + title: document.frontMatter.title, + tags: document.frontMatter.tags, + provenance: document.frontMatter.provenance + }); + created.push(added.id); + } catch (error) { + rejected.push({ + id: document.frontMatter.id, + reason: error instanceof Error ? error.message : String(error) + }); + } + } + return { + applied: request.apply !== false, + count: documents.length, + updated, + created, + rejected, + serverTime: nowIso() + }; } timeline(input: RequestEnvelope & { @@ -1058,7 +1311,10 @@ export class MemoryService { this.repos.runtime.exportBundleTables(request.includeRawText === true), context.namespace ); - tables.memory_vectors = this.repos.vectors.exportRows().map((row) => ({ ...row })); + const scopedMemoryIds = new Set((tables.memories ?? []).map((row) => row.id).filter((id): id is string => typeof id === "string")); + tables.memory_vectors = this.repos.vectors.exportRows() + .filter((row) => scopedMemoryIds.has(row.memory_id)) + .map((row) => ({ ...row })); if (request.includeAudit === false) { delete tables.audit_logs; } @@ -1116,15 +1372,21 @@ export class MemoryService { throw new MemoryServiceError("invalid_argument", "import bundle must contain tables"); } const context = this.resolveContext(request); + const inputTables = request.bundle.tables as Record>>; + const enforceBundleScope = hasProjectScope(context.namespace) || Boolean(context.namespace.tenantId); + const scopedInputTables = enforceBundleScope ? scopeBundleTables(inputTables, context.namespace) : inputTables; + if (enforceBundleScope && bundleTableRowCount(scopedInputTables) !== bundleTableRowCount(inputTables)) { + throw new MemoryServiceError("not_found", "bundle contains records outside the requested namespace"); + } const importedAt = nowIso(); - const result = this.repos.runtime.importBundleTables(request.bundle.tables, { + const result = this.repos.runtime.importBundleTables(scopedInputTables, { conflictStrategy: request.conflictStrategy ?? "skip" }); - const importedVectors = importedMemoryVectors(request.bundle.tables.memory_vectors); + const importedVectors = importedMemoryVectors(scopedInputTables.memory_vectors); this.repos.vectors.importRows(importedVectors); result.inserted.memory_vectors = importedVectors.length; const reembedMemoryIds = importedReembedMemoryIds( - request.bundle.tables, + scopedInputTables, this.embedder.config.model ?? this.embedder.config.provider ); const audit = this.repos.runtime.insertAudit({ @@ -1416,7 +1678,7 @@ export class MemoryService { return this.panelReadModel.serviceLogs(input); } - apiLogs(input: { + apiLogs(input: RequestEnvelope & { tools?: Array<"memory_add" | "memory_search" | "skill_generate" | "skill_evolve">; sourceAgent?: string; excludedSourceAgents?: string[]; @@ -1504,6 +1766,68 @@ export class MemoryService { return this.panelReadModel.panelOverviewSummary(input); } + evolutionOverview(input: RequestEnvelope & { userId?: string } = {}) { + return this.panelReadModel.evolutionOverview(input); + } + + readProjectContext(namespace: RuntimeNamespace): ProjectContextReadState { + this.assertProjectContextScope(namespace); + return this.projectContext.read(namespace); + } + + proposeProjectGoal(input: ProjectContextProposeGoalRequest): ProjectGoalRecord { + this.assertProjectContextScope(input.namespace); + return this.projectContext.proposeGoal(input); + } + + approveProjectGoal(input: { namespace: RuntimeNamespace; candidateId: string }): ProjectGoalRecord { + this.assertProjectContextScope(input.namespace); + return this.projectContext.approveGoal(input); + } + + rejectProjectGoal(input: { namespace: RuntimeNamespace; candidateId: string }): ProjectGoalRecord { + this.assertProjectContextScope(input.namespace); + return this.projectContext.rejectGoal(input); + } + + createProjectWorkItem(input: ProjectWorkItemCreateRequest): ProjectWorkItemRecord { + this.assertProjectContextScope(input.namespace); + return this.projectContext.createWorkItem(input); + } + + updateProjectWorkItem(input: ProjectWorkItemUpdateRequest): ProjectWorkItemRecord { + this.assertProjectContextScope(input.namespace); + return this.projectContext.updateWorkItem(input); + } + + selectProjectWorkItem(input: ProjectWorkItemSelectRequest): ProjectWorkItemRecord | undefined { + this.assertProjectContextScope(input.namespace); + return this.projectContext.selectWorkItem(input); + } + + renderStableProjectContext(namespace: RuntimeNamespace, budget?: number): ProjectContextStableResult { + this.assertProjectContextScope(namespace); + return this.projectContext.renderStable(namespace, budget); + } + + private assertProjectContextScope(namespace: RuntimeNamespace): void { + if (!hasProjectScope(namespace)) { + throw new MemoryServiceError("invalid_argument", "project context requires projectId, workspaceId, or workspacePath"); + } + } + + namespaceAudit(input: RequestEnvelope & { userId?: string } = {}) { + return this.panelReadModel.namespaceAudit(input); + } + + projectContextPack(input: RequestEnvelope & { userId?: string } = {}) { + return this.panelReadModel.projectContextPack(input); + } + + projectContextPacks(input: RequestEnvelope & { userId?: string } = {}) { + return this.panelReadModel.projectContextPacks(input); + } + panelAnalysis(input: RequestEnvelope & { userId?: string } = {}): { metrics: { avgRecallScore: number; @@ -1531,6 +1855,8 @@ export class MemoryService { tags?: string[]; sourceAgent?: string; excludedSourceAgents?: string[]; + projectId?: string; + workspaceId?: string; page?: number; limit?: number; cursor?: string | number; @@ -1624,16 +1950,386 @@ export class MemoryService { return this.importJobs.retryMemoryProcessing(memoryId, request); } + rateMemory(id: string, useful: boolean, request: MemoryGovernanceRequest = {}) { + this.assertMemoryAddEnabled(); + const memory = this.requireExistingMemory(id); + this.assertMemoryInScope(memory, request.namespace); + const at = nowIso(); + const before = memory; + const opposite = useful ? "quality-not-useful" : "quality-useful"; + const tag = useful ? "quality-useful" : "quality-not-useful"; + const updated = this.repos.memories.update({ + ...memory, + tags: uniq([...memory.tags.filter((item) => item !== opposite), tag]), + info: { ...memory.info, quality_rating: useful ? "useful" : "not_useful", quality_rated_at: at }, + updatedAt: at + }); + const changeSeq = this.repos.runtime.appendChange({ + memoryId: updated.id, namespaceId: namespaceIdFromMemory(updated), kind: kindFromMemory(updated), op: "updated", + entityId: updated.id, userId: updated.userId, changeType: "quality_rating", before, after: updated, + source: "panel.quality", createdAt: at + }); + const audit = this.repos.runtime.insertAudit({ + userId: updated.userId, sessionId: updated.sessionId, actor: request.namespace ? { ...request.namespace } : {}, + action: useful ? "mark_useful" : "mark_not_useful", targetKind: kindFromMemory(updated), targetId: updated.id, + before, after: updated, meta: { reason: request.reason }, createdAt: at + }); + return { ok: true, id: updated.id, useful, changeSeq, auditId: audit.id, serverTime: at }; + } + + editMemory(id: string, request: MemoryGovernanceRequest & { + title: string; + content: string; + tags: string[]; + }) { + this.assertMemoryAddEnabled(); + const memory = this.requireExistingMemory(id); + this.assertMemoryInScope(memory, request.namespace); + const at = nowIso(); + const before = memory; + const title = request.title.trim(); + const content = request.content.trim(); + const tags = uniq(request.tags.map((tag) => tag.trim()).filter(Boolean)); + if (!Number.isInteger(request.version) || request.version! < 1) { + throw new MemoryServiceError("invalid_argument", "memory.edit version is required"); + } + let updated: MemoryRow; + try { + updated = this.repos.memories.update({ + ...memory, + memoryValue: content, + tags, + info: { ...memory.info, title, summary: content, tags }, + properties: { + ...memory.properties, + tags, + info: { ...memory.properties.info, title, summary: content, tags }, + internal_info: { ...memory.properties.internal_info, title, summary: content } + }, + contentHash: stableHash(content), + updatedAt: at + }, request.version); + } catch (error) { + if (error instanceof MemoryVersionConflictError) { + throw new MemoryServiceError("conflict", `memory was modified by another agent (current version ${error.actualVersion})`); + } + throw error; + } + const changeSeq = this.repos.runtime.appendChange({ + memoryId: updated.id, + namespaceId: namespaceIdFromMemory(updated), + kind: kindFromMemory(updated), + op: "updated", + entityId: updated.id, + userId: updated.userId, + changeType: "content_edit", + before, + after: updated, + source: "panel.edit", + createdAt: at + }); + const audit = this.repos.runtime.insertAudit({ + userId: updated.userId, + sessionId: updated.sessionId, + actor: request.namespace ? { ...request.namespace } : {}, + action: "edit_memory", + targetKind: kindFromMemory(updated), + targetId: updated.id, + before, + after: updated, + meta: { reason: request.reason, source: "context_pack" }, + createdAt: at + }); + let embeddingJobId: string | undefined; + if (this.config.algorithm.capture.embedAfterCapture) { + this.repos.memories.deleteVector(updated.id, updated.memoryLayer === "L1" ? "vec_summary" : "vec"); + embeddingJobId = this.workerHandlers.enqueueJob({ + jobType: "embedding", + userId: updated.userId, + sessionId: updated.sessionId, + targetMemoryId: updated.id, + payload: { source: "panel.edit", changeSeq, contentHash: updated.contentHash }, + createdAt: at + }).id; + } + return { ok: true, id: updated.id, version: updated.version, changeSeq, auditId: audit.id, embeddingJobId, serverTime: at }; + } + + memoryHistory(id: string, request: RequestEnvelope & { limit?: number } = {}) { + const memory = this.requireExistingMemory(id); + this.assertMemoryInScope(memory, request.namespace); + return { + id, + currentVersion: memory.version, + items: this.repos.runtime.listMemoryChanges(id, request.limit ?? 100).map((change) => ({ + seq: change.seq, + version: change.version, + changeType: change.changeType, + source: change.source, + createdAt: change.createdAt, + before: change.before, + after: change.after + })), + serverTime: nowIso() + }; + } + + restoreMemory(id: string, targetVersion: number, request: MemoryGovernanceRequest = {}) { + this.assertMemoryAddEnabled(); + if (!Number.isInteger(targetVersion) || targetVersion < 1) { + throw new MemoryServiceError("invalid_argument", "target version must be a positive integer"); + } + const memory = this.requireExistingMemory(id); + this.assertMemoryInScope(memory, request.namespace); + if (!Number.isInteger(request.version) || request.version! < 1) { + throw new MemoryServiceError("invalid_argument", "memory.restore version is required"); + } + const history = this.repos.runtime.listMemoryChanges(id, 500); + const target = history.find((change) => change.version === targetVersion && change.after && typeof change.after === "object")?.after; + if (!target || typeof target !== "object") throw new MemoryServiceError("not_found", `memory history version not found: ${targetVersion}`); + const at = nowIso(); + const before = memory; + let updated: MemoryRow; + try { + updated = this.repos.memories.update({ ...(target as MemoryRow), id, version: memory.version, updatedAt: at }, request.version); + } catch (error) { + if (error instanceof MemoryVersionConflictError) throw new MemoryServiceError("conflict", `memory was modified by another agent (current version ${error.actualVersion})`); + throw error; + } + const changeSeq = this.repos.runtime.appendChange({ memoryId: id, namespaceId: namespaceIdFromMemory(updated), kind: kindFromMemory(updated), op: "updated", entityId: id, userId: updated.userId, changeType: "restore", before, after: updated, source: "panel.restore", createdAt: at }); + const audit = this.repos.runtime.insertAudit({ userId: updated.userId, sessionId: updated.sessionId, actor: request.namespace ? { ...request.namespace } : {}, action: "restore_memory", targetKind: kindFromMemory(updated), targetId: id, before, after: updated, meta: { reason: request.reason, targetVersion }, createdAt: at }); + let embeddingJobId: string | undefined; + if (this.config.algorithm.capture.embedAfterCapture) { + this.repos.memories.deleteVector(updated.id, updated.memoryLayer === "L1" ? "vec_summary" : "vec"); + embeddingJobId = this.workerHandlers.enqueueJob({ jobType: "embedding", userId: updated.userId, sessionId: updated.sessionId, targetMemoryId: updated.id, payload: { source: "panel.restore", changeSeq, contentHash: updated.contentHash }, createdAt: at }).id; + } + return { ok: true, id, version: updated.version, restoredVersion: targetVersion, changeSeq, auditId: audit.id, embeddingJobId, serverTime: at }; + } + + reviewCandidates(request: RequestEnvelope & { layer?: MemoryLayer; limit?: number } = {}) { + const limit = Math.max(1, Math.min(request.limit ?? 100, 1000)); + const memories = this.repos.memories.list({ + ...request.namespace ? memoryFilterForNamespace(request.namespace) : {}, + memoryLayer: request.layer ?? ["L2", "L3", "Skill"], + status: "resolving" + }, limit); + return { + items: memories.map((memory) => candidateReviewCard(memory)), + total: memories.length, + serverTime: nowIso() + }; + } + + approveCandidate( + id: string, + request: MemoryGovernanceRequest & { content?: string; title?: string } = {} + ) { + this.assertMemoryAddEnabled(); + const memory = this.requireReviewCandidate(id, request); + const at = nowIso(); + const title = request.title?.trim(); + const content = request.content?.trim(); + const internal = memory.properties.internal_info; + const updated = this.repos.memories.update({ + ...memory, + status: "activated", + memoryValue: content || memory.memoryValue, + tags: uniq([...memory.tags, "review-approved"]), + info: { + ...memory.info, + ...(title ? { title } : {}), + review_status: "approved", + reviewed_at: at + }, + properties: { + ...memory.properties, + status: "activated", + tags: uniq([...(memory.properties.tags ?? []), "review-approved"]), + info: { + ...memory.properties.info, + ...(title ? { title } : {}), + review_status: "approved", + reviewed_at: at + }, + internal_info: { + ...internal, + ...(title ? { title } : {}), + review: { status: "approved", reviewed_at: at, edited: Boolean(title || content) } + } + }, + contentHash: content ? stableHash(content) : memory.contentHash, + updatedAt: at + }); + return this.recordCandidateReview(memory, updated, "approved", request); + } + + rejectCandidate(id: string, request: MemoryGovernanceRequest = {}) { + this.assertMemoryAddEnabled(); + const memory = this.requireReviewCandidate(id, request); + const at = nowIso(); + const internal = memory.properties.internal_info; + const updated = this.repos.memories.update({ + ...memory, + status: "archived", + tags: uniq([...memory.tags, "review-rejected"]), + info: { ...memory.info, review_status: "rejected", reviewed_at: at, review_reason: request.reason }, + properties: { + ...memory.properties, + status: "archived", + tags: uniq([...(memory.properties.tags ?? []), "review-rejected"]), + info: { ...memory.properties.info, review_status: "rejected", reviewed_at: at, review_reason: request.reason }, + internal_info: { ...internal, review: { status: "rejected", reviewed_at: at, reason: request.reason } } + }, + updatedAt: at + }); + return this.recordCandidateReview(memory, updated, "rejected", request); + } + + bulkApproveHighConfidenceCandidates( + request: MemoryGovernanceRequest & { minimumConfidence?: number; layer?: MemoryLayer } = {} + ) { + const minimumConfidence = Math.max(0, Math.min(request.minimumConfidence ?? 0.8, 1)); + const candidates = this.reviewCandidates({ ...request, layer: request.layer, limit: 1000 }).items + .filter((candidate) => candidate.confidence >= minimumConfidence); + const approved = candidates.map((candidate) => this.approveCandidate(candidate.id, request)); + return { approved: approved.length, minimumConfidence, ids: approved.map((item) => item.id), serverTime: nowIso() }; + } + + private requireReviewCandidate(id: string, request: MemoryGovernanceRequest): MemoryRow { + const memory = this.requireExistingMemory(id); + this.assertMemoryInScope(memory, request.namespace); + if (memory.memoryLayer === "L1" || memory.status !== "resolving") { + throw new MemoryServiceError("conflict", "only resolving L2/L3/Skill memories can be reviewed"); + } + return memory; + } + + private recordCandidateReview( + before: MemoryRow, + after: MemoryRow, + decision: "approved" | "rejected", + request: MemoryGovernanceRequest + ) { + const at = after.updatedAt; + const changeSeq = this.repos.runtime.appendChange({ + memoryId: after.id, namespaceId: namespaceIdFromMemory(after), kind: kindFromMemory(after), + op: decision === "approved" ? "updated" : "archived", entityId: after.id, userId: after.userId, + changeType: `review_${decision}`, before, after, source: "panel.review", createdAt: at + }); + const audit = this.repos.runtime.insertAudit({ + userId: after.userId, sessionId: after.sessionId, actor: request.namespace ? { ...request.namespace } : {}, + action: `review_${decision}`, targetKind: kindFromMemory(after), targetId: after.id, before, after, + meta: { reason: request.reason }, createdAt: at + }); + return { ok: true, id: after.id, layer: after.memoryLayer, status: after.status, decision, changeSeq, auditId: audit.id, serverTime: nowIso() }; + } + + mergeMemories(targetId: string, sourceId: string, request: MemoryGovernanceRequest = {}) { + this.assertMemoryAddEnabled(); + if (targetId === sourceId) throw new MemoryServiceError("invalid_argument", "merge source and target must differ"); + const target = this.requireExistingMemory(targetId); + const source = this.requireExistingMemory(sourceId); + this.assertMemoryInScope(target, request.namespace); + this.assertMemoryInScope(source, request.namespace); + if (target.memoryLayer !== source.memoryLayer) throw new MemoryServiceError("conflict", "merged memories must use the same layer"); + const at = nowIso(); + const merged = this.repos.transaction(() => this.repos.memories.supersede({ + oldMemory: source, newMemory: target, projectId: projectIdFromMemory(target), + reason: request.reason ?? `duplicate of ${target.id}`, actor: request.namespace ? { ...request.namespace } : {}, createdAt: at + })); + const changeSeq = this.repos.runtime.appendChange({ + memoryId: merged.oldMemory.id, namespaceId: namespaceIdFromMemory(merged.oldMemory), kind: kindFromMemory(merged.oldMemory), op: "archived", + entityId: merged.oldMemory.id, userId: merged.oldMemory.userId, changeType: "merged_duplicate", before: source, + after: merged.oldMemory, source: "panel.merge", createdAt: at + }); + const audit = this.repos.runtime.insertAudit({ + userId: target.userId, sessionId: target.sessionId, actor: request.namespace ? { ...request.namespace } : {}, action: "merge", + targetKind: kindFromMemory(target), targetId: target.id, before: { target, source }, after: merged, + meta: { sourceMemoryId: source.id, relationId: merged.relation.id, reason: request.reason }, createdAt: at + }); + return { ok: true, targetId, archivedSourceId: sourceId, relationId: merged.relation.id, changeSeq, auditId: audit.id, serverTime: at }; + } + + promoteL1ToL2(id: string, request: MemoryGovernanceRequest = {}) { + const source = this.requireExistingMemory(id); + this.assertMemoryInScope(source, request.namespace); + if (source.memoryLayer !== "L1") throw new MemoryServiceError("invalid_argument", "manual promotion requires an L1 memory"); + return this.addMemory({ + ...request, + content: source.memoryValue, + layer: "L2", + title: `Experience: ${firstLine(source.memoryValue).slice(0, 100)}`, + tags: uniq([...source.tags, "manual-promotion", "experience"]), + source: request.source ?? "memory-console", + sessionId: source.sessionId, + sourceMemoryIds: [source.id], + provenance: { sourceMemoryIds: [source.id] } + }); + } + + promoteCandidates(request: RequestEnvelope = {}) { + this.assertMemoryAddEnabled(); + const candidates = this.repos.runtime.listPendingCandidatePool({ now: nowIso(), limit: 10_000 }) + .filter((candidate) => { + const memory = this.repos.memories.get(candidate.sourceMemoryId); + return Boolean(memory && (!request.namespace || sameProjectScope(namespaceForMemory(memory), request.namespace))); + }); + const memoryIds = uniq(candidates.map((candidate) => candidate.sourceMemoryId)); + const jobs = memoryIds.map((memoryId) => { + const memory = this.requireExistingMemory(memoryId); + return this.enqueueJob({ + jobType: "l2_induction", userId: memory.userId, sessionId: memory.sessionId, targetMemoryId: memory.id, + payload: { sourceMemoryId: memory.id, reason: "manual_candidate_promotion" }, createdAt: nowIso() + }); + }); + return { accepted: jobs.length, candidateCount: candidates.length, memoryIds, jobs: jobs.map(jobToRef), serverTime: nowIso() }; + } + + retryFailedWorkerJobs(request: RequestEnvelope & { limit?: number } = {}) { + this.assertMemoryAddEnabled(); + const limit = Math.max(1, Math.min(request.limit ?? 100, 10_000)); + const failed = [ + ...this.panelReadModel.panelJobs({ ...request, status: "failed", limit }).items, + ...this.panelReadModel.panelJobs({ ...request, status: "dead_letter", limit }).items + ].slice(0, limit); + const retried = this.repos.runtime.retryFailedJobIds(failed.map((job) => job.id)); + for (const { before, after } of retried) this.workerHandlers.appendJobChange(after, "queued", before); + return { retried: retried.length, jobIds: retried.map((item) => item.after.id), serverTime: nowIso() }; + } + + async runWorkerWithEvolutionSummary( + limit = 100, + request: RequestEnvelope & { targetMemoryIds?: string[]; priorityCohortOnly?: boolean } = {} + ) { + const before = this.panelReadModel.panelOverviewSummary(request).layerCounts; + const worker = await this.runWorkerOnce(limit, request); + const after = this.panelReadModel.panelOverviewSummary(request).layerCounts; + return { + ...worker, + generated: { L2: Math.max(0, after.L2 - before.L2), L3: Math.max(0, after.L3 - before.L3), Skill: Math.max(0, after.Skill - before.Skill) } + }; + } + private restartFailedProcessing(at: string, limit = 10000): number { return this.importJobs.restartFailedProcessing(at, limit); } - enqueuePendingImportSummaries(limit = 10000, targetMemoryIds?: readonly string[]): { + enqueuePendingImportSummaries(limit = 10000, targetMemoryIds?: readonly string[], request: RequestEnvelope = {}): { enqueued: number; memoryIds: string[]; serverTime: string; } { - return this.importJobs.enqueuePendingImportSummaries(limit, targetMemoryIds); + const scopedTargetIds = request.namespace + ? targetMemoryIds + ? targetMemoryIds.filter((id) => { + const memory = this.repos.memories.get(id); + if (!memory) return false; + this.assertMemoryInScope(memory, request.namespace); + return true; + }) + : this.repos.memories.list({ ...memoryFilterForNamespace(request.namespace) }, 10_000).map((memory) => memory.id) + : targetMemoryIds; + return this.importJobs.enqueuePendingImportSummaries(limit, scopedTargetIds); } nextWorkerRunAt(): number | undefined { @@ -1651,6 +2347,18 @@ export class MemoryService { priorityCohortOnly?: boolean; } = {} ): ReturnType { + if (request.namespace && !request.targetMemoryIds) { + const targetMemoryIds = this.repos.memories.list({ ...memoryFilterForNamespace(request.namespace) }, 10_000) + .map((memory) => memory.id); + return this.workerRunner.runWorkerOnce(limit, { ...request, targetMemoryIds }); + } + if (request.namespace && request.targetMemoryIds) { + for (const id of request.targetMemoryIds) { + const memory = this.repos.memories.get(id); + if (!memory) continue; + this.assertMemoryInScope(memory, request.namespace); + } + } return this.workerRunner.runWorkerOnce(limit, request); } @@ -1689,8 +2397,11 @@ export class MemoryService { sessionId?: string; agentId?: string; appId?: string; + tenantId?: string; projectId?: string; profileId?: string; + workspacePath?: string; + provenance?: Partial; layer: MemoryLayer; kind: MemoryKind; lifecycleStatus?: "candidate" | "active" | "archived"; @@ -1703,12 +2414,42 @@ export class MemoryService { createdAt?: string; }): MemoryRow { const at = input.createdAt ?? nowIso(); - const tags = uniq(input.tags.filter(Boolean)); const memoryStatus = memoryStatusForLifecycleStatus(input.lifecycleStatus ?? "active"); const inputInfo = input.info ?? {}; + const inputInternal = input.internal ?? {}; + const semanticTags = uniq(input.tags.map((tag) => tag.trim()).filter(Boolean)); + const sourceSession = input.sessionId ? this.repos.runtime.getSession(input.sessionId) : undefined; + const sessionTenantId = sourceSession ? tenantIdFromSession(sourceSession) : undefined; + const tenantId = input.tenantId ?? sessionTenantId ?? stringFromMeta(inputInfo, "tenant_id") ?? input.provenance?.tenantId ?? "local"; + const sourceAgent = normalizeMemorySourceAgent( + input.provenance?.sourceAgent ?? input.agentId ?? stringFromMeta(inputInfo, "source") ?? DEFAULT_NAMESPACE_SOURCE + ); + const tags = normalizeMemoryTagsForSource(semanticTags, sourceAgent); + const provenance: MemoryProvenance = { + sourceAgent, + tenantId, + profileId: input.provenance?.profileId ?? input.profileId, + projectId: input.provenance?.projectId ?? input.projectId, + workspaceId: input.provenance?.workspaceId ?? input.appId, + workspacePath: input.provenance?.workspacePath ?? input.workspacePath, + sessionId: input.provenance?.sessionId ?? input.sessionId, + turnId: input.provenance?.turnId ?? stringFromMeta(inputInfo, "turn_id"), + adapterId: input.provenance?.adapterId, + requestId: input.provenance?.requestId, + sourceMemoryIds: uniq([ + ...(input.provenance?.sourceMemoryIds ?? []), + ...stringArray(inputInternal.source_memory_ids) + ]), + repository: input.provenance?.repository, + branch: input.provenance?.branch, + commit: input.provenance?.commit, + capturedAt: input.provenance?.capturedAt ?? at + }; const info = { ...inputInfo, tags: uniq([...tags, ...stringArray(inputInfo.tags)]), + ...(stringFromMeta(inputInfo, "source") ? { source: sourceAgent } : {}), + tenant_id: tenantId, ...(input.projectId ? { project_id: input.projectId } : {}), ...(input.profileId ? { profile_id: input.profileId } : {}) }; @@ -1736,7 +2477,11 @@ export class MemoryService { memory_layer: input.layer, memory_kind: input.kind, schema_version: 1, - ...(input.internal ?? {}) + ...inputInternal, + ...(isRecord(inputInternal.trace) + ? { trace: { ...inputInternal.trace, tags: semanticTags } } + : {}), + provenance } }, memoryLayer: input.layer, @@ -1773,7 +2518,14 @@ export class MemoryService { } private openSessionNoWrite(request: SessionOpenRequest): ReturnType { - const namespace = normalizeNamespace(request.namespace); + const namespace = normalizeNamespace({ + ...request.namespace, + source: request.source ?? request.namespace?.source ?? DEFAULT_NAMESPACE_SOURCE, + profileId: request.profileId ?? request.namespace?.profileId ?? "default", + projectId: request.projectId ?? request.namespace?.projectId, + workspaceId: request.workspaceId ?? request.namespace?.workspaceId, + workspacePath: request.workspacePath ?? request.namespace?.workspacePath + }); const existing = request.sessionId ? this.repos.runtime.getSession(request.sessionId) : namespace.sessionKey @@ -1811,8 +2563,8 @@ export class MemoryService { userId: namespace.userId, source: request.source ?? namespace.source, profileId: request.profileId ?? namespace.profileId, - projectId: request.projectId ?? namespace.projectId ?? namespace.workspaceId, - workspaceId: request.workspaceId ?? namespace.workspaceId, + projectId: namespace.projectId, + workspaceId: namespace.workspaceId, conversationId: stringFromMeta(request.meta, "conversationId"), status: "open", resumed: false, @@ -1859,14 +2611,32 @@ export class MemoryService { contextHints, injectedContextQuery: request.query }); + const noWriteNamespace = normalizeNamespace(request.namespace); + const noGoalMarkdown = '\nNo confirmed project goal.\n'; + const projectContext: ProjectContextStableResult = { + namespaceId: namespaceIdFromContext(noWriteNamespace), + status: "no_confirmed_goal", + version: 0, + goal: null, + focusedWorkItem: null, + facts: [], + markdown: noGoalMarkdown, + sourceMemoryIds: [], + generatedAt: nowIso() + }; + const supplementalMarkdown = search.injectedContext.markdown.trim(); return { contextPacketId: `ctx_${stableHash(`${request.sessionId}:unbound:${turnId}:${search.searchEventId}`).slice(0, 20)}`, turnId, sessionId: request.sessionId, searchEventId: search.searchEventId, hits: search.hits, - injectedContext: search.injectedContext, - sourceMemoryIds: search.sourceMemoryIds, + injectedContext: { + ...search.injectedContext, + markdown: supplementalMarkdown ? `${projectContext.markdown}\n\n${supplementalMarkdown}` : projectContext.markdown + }, + projectContext, + sourceMemoryIds: uniq([...projectContext.sourceMemoryIds, ...search.sourceMemoryIds]), droppedDueToBudget: search.droppedDueToBudget, status: uniq([...search.status, "memory_add:disabled:no_turn_write"]), serverTime: nowIso() @@ -1997,23 +2767,41 @@ export class MemoryService { } private assertSessionInScope(session: SessionRecord, namespace?: RuntimeNamespace): void { - void session; - void namespace; + if (namespace && !sameProjectScope(namespaceForSession(session), namespace)) { + throw new MemoryServiceError("not_found", `session not found: ${session.id}`); + } } private assertMemoryInScope(memory: MemoryRow, namespace?: RuntimeNamespace): void { - void memory; - void namespace; + if (namespace && !sameProjectScope(this.namespaceForMemoryScope(memory), namespace)) { + throw new MemoryServiceError("not_found", `memory not found: ${memory.id}`); + } } private assertEpisodeInScope(episode: EpisodeRecord, namespace?: RuntimeNamespace): void { - void episode; - void namespace; + const session = this.repos.runtime.getSession(episode.sessionId); + const actual = session + ? namespaceForSession(session) + : { source: DEFAULT_NAMESPACE_SOURCE, profileId: "default", userId: episode.userId, projectId: episode.projectId }; + if (namespace && !sameProjectScope(actual, namespace)) { + throw new MemoryServiceError("not_found", `episode not found: ${episode.id}`); + } } private assertRawTurnInScope(rawTurn: RawTurnRecord, namespace?: RuntimeNamespace): void { - void rawTurn; - void namespace; + const session = this.repos.runtime.getSession(rawTurn.sessionId); + const actual = session ? namespaceForSession(session) : namespaceForRawTurn(rawTurn); + if (namespace && !sameProjectScope(actual, namespace)) { + throw new MemoryServiceError("not_found", `raw turn not found: ${rawTurn.id}`); + } + } + + private namespaceForMemoryScope(memory: MemoryRow): RuntimeNamespace { + if (memory.sessionId) { + const session = this.repos.runtime.getSession(memory.sessionId); + if (session) return namespaceForSession(session); + } + return namespaceForMemory(memory); } @@ -2245,21 +3033,156 @@ function namespaceIdFromSession(session: SessionRecord): string { } function namespaceIdFromContext(namespace: RuntimeNamespace): string { - return [ - namespace.tenantId, - namespace.userId, - namespace.projectId ?? namespace.workspaceId, - namespace.source, - namespace.profileId - ].filter(Boolean).join(":"); + return canonicalNamespaceIdFromContext(namespace); } function scopeBundleTables( tables: Record>>, namespace: RuntimeNamespace ): Record>> { - void namespace; - return tables; + const normalized = normalizeNamespace(namespace); + const memoryIds = new Set(); + const sessionIds = new Set(); + const episodeIds = new Set(); + const rawTurnIds = new Set(); + + const memories = tables.memories ?? []; + for (const row of memories) { + const sessionId = stringField(row, "session_id"); + const projectId = memoryProjectId(row, tables.sessions); + const tenantId = memoryTenantId(row, tables.sessions); + if (sameProjectScope({ source: "unknown", profileId: "default", projectId, tenantId, userId: stringField(row, "user_id") }, normalized)) { + const id = stringField(row, "id"); + if (id) memoryIds.add(id); + if (sessionId) sessionIds.add(sessionId); + } + } + for (const row of tables.sessions ?? []) { + const id = stringField(row, "id"); + if (!id) continue; + const projectId = stringField(row, "project_id") ?? jsonStringField(row, "meta_json", "project_id"); + const tenantId = jsonStringField(row, "meta_json", "tenant_id") ?? jsonStringField(row, "meta_json", "tenantId"); + if (sameProjectScope({ source: stringField(row, "source") ?? "unknown", profileId: stringField(row, "profile_id") ?? "default", projectId, tenantId, userId: stringField(row, "user_id") }, normalized)) sessionIds.add(id); + } + for (const row of tables.episodes ?? []) { + const id = stringField(row, "id"); + const sessionId = stringField(row, "session_id"); + if (id && ((sessionId && sessionIds.has(sessionId)) || (!sessionId && sameProjectScope({ source: "unknown", profileId: "default", projectId: stringField(row, "project_id"), tenantId: "local", userId: stringField(row, "user_id") }, normalized)))) episodeIds.add(id); + } + for (const row of tables.raw_turns ?? []) { + const id = stringField(row, "id"); + const sessionId = stringField(row, "session_id"); + const episodeId = stringField(row, "episode_id"); + if (id && ((sessionId && sessionIds.has(sessionId)) || (episodeId && episodeIds.has(episodeId)))) rawTurnIds.add(id); + } + + const scoped = (table: string, rows: Array>): Array> => rows.filter((row) => { + if (table === "memories") return memoryIds.has(stringField(row, "id") ?? ""); + if (table === "memory_vectors") return memoryIds.has(stringField(row, "memory_id") ?? ""); + if (table === "sessions") return sessionIds.has(stringField(row, "id") ?? ""); + if (table === "episodes") return episodeIds.has(stringField(row, "id") ?? ""); + if (table === "raw_turns") return rawTurnIds.has(stringField(row, "id") ?? ""); + if (table === "memory_relations") return memoryIds.has(stringField(row, "source_memory_id") ?? "") && memoryIds.has(stringField(row, "target_memory_id") ?? ""); + if (table === "memory_change_log") return stringField(row, "namespace_id") === namespaceIdFromContext(normalized) || memoryIds.has(stringField(row, "memory_id") ?? "") || memoryIds.has(stringField(row, "entity_id") ?? ""); + if (table === "embedding_retry_queue" || table === "memory_processing_state") return memoryIds.has(stringField(row, "target_id") ?? stringField(row, "memory_id") ?? ""); + if (table === "trace_policy_links") return memoryIds.has(stringField(row, "l1_memory_id") ?? "") && memoryIds.has(stringField(row, "l2_memory_id") ?? ""); + if (table === "l2_candidate_pool") return memoryIds.has(stringField(row, "source_memory_id") ?? ""); + if (table === "skill_trials") return rowReferencesSets(row, sessionIds, episodeIds, rawTurnIds, memoryIds, ["skill_memory_id", "l1_memory_id"]); + if (table === "artifacts") return rowReferencesSets(row, sessionIds, episodeIds, rawTurnIds, memoryIds, []); + if (table === "feedback" || table === "decision_repairs" || table === "evolution_jobs") return rowReferencesSets(row, sessionIds, episodeIds, rawTurnIds, memoryIds, ["l1_memory_id", "target_memory_id"]); + if (table === "recall_events") return stringField(row, "namespace_id") === namespaceIdFromContext(normalized) || rowReferencesSets(row, sessionIds, episodeIds, rawTurnIds, memoryIds, []); + if (table === "api_logs" || table === "audit_logs") return bundleLogReferencesScope(row, sessionIds, episodeIds, rawTurnIds, memoryIds, normalized); + return false; + }); + + const result: Record>> = {}; + for (const [table, rows] of Object.entries(tables)) result[table] = scoped(table, rows); + return result; +} + +function bundleTableRowCount(tables: Record>>): number { + return Object.values(tables).reduce((total, rows) => total + rows.length, 0); +} + +function stringField(row: Record, key: string): string | undefined { + const value = row[key]; + return typeof value === "string" && value ? value : undefined; +} + +function jsonStringField(row: Record, jsonKey: string, key: string): string | undefined { + const raw = stringField(row, jsonKey); + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as Record; + return typeof parsed[key] === "string" && parsed[key] ? parsed[key] as string : undefined; + } catch { + return undefined; + } +} + +function memoryProjectId(row: Record, sessions: Array> | undefined): string | undefined { + const sessionId = stringField(row, "session_id"); + const session = sessions?.find((candidate) => stringField(candidate, "id") === sessionId); + return stringField(row, "project_id") ?? jsonStringField(row, "info_json", "project_id") ?? stringField(row, "app_id") ?? stringField(session ?? {}, "project_id"); +} + +function memoryTenantId(row: Record, sessions: Array> | undefined): string | undefined { + const sessionId = stringField(row, "session_id"); + const session = sessions?.find((candidate) => stringField(candidate, "id") === sessionId); + return jsonStringField(row, "info_json", "tenant_id") ?? jsonStringField(row, "info_json", "tenantId") ?? jsonStringField(session ?? {}, "meta_json", "tenant_id") ?? "local"; +} + +function rowReferencesSets( + row: Record, + sessions: Set, + episodes: Set, + rawTurns: Set, + memories: Set, + extraMemoryKeys: string[] +): boolean { + const refs = [ + ["session_id", sessions], ["episode_id", episodes], ["raw_turn_id", rawTurns], + ...extraMemoryKeys.map((key) => [key, memories] as const) + ] as Array<[string, Set]>; + return refs.some(([key, ids]) => { + const value = stringField(row, key); + return Boolean(value && ids.has(value)); + }); +} + +function bundleLogReferencesScope( + row: Record, + sessions: Set, + episodes: Set, + rawTurns: Set, + memories: Set, + namespace: RuntimeNamespace +): boolean { + const actor = parseBundleJson(row.actor_json); + const input = parseBundleJson(row.input_json); + const output = parseBundleJson(row.output_json); + const namespaceRecord = isRecord(actor) ? actor : undefined; + if (namespaceRecord && sameProjectScope({ source: "unknown", profileId: "default", projectId: stringValue(namespaceRecord, "projectId") ?? stringValue(namespaceRecord, "project_id"), tenantId: stringValue(namespaceRecord, "tenantId") ?? stringValue(namespaceRecord, "tenant_id") }, namespace)) return true; + return [row, input, output].some((value) => bundleValueReferencesSets(value, sessions, episodes, rawTurns, memories)); +} + +function bundleValueReferencesSets(value: unknown, sessions: Set, episodes: Set, rawTurns: Set, memories: Set): boolean { + if (Array.isArray(value)) return value.some((item) => bundleValueReferencesSets(item, sessions, episodes, rawTurns, memories)); + if (!isRecord(value)) return false; + for (const [key, next] of Object.entries(value)) { + if (typeof next === "string" && ((key.toLowerCase().includes("session") && sessions.has(next)) || (key.toLowerCase().includes("episode") && episodes.has(next)) || (key.toLowerCase().includes("raw_turn") && rawTurns.has(next)) || ((key.toLowerCase().includes("memory") || key.toLowerCase().includes("trace") || key.toLowerCase().includes("span")) && memories.has(next)))) return true; + if (bundleValueReferencesSets(next, sessions, episodes, rawTurns, memories)) return true; + } + return false; +} + +function parseBundleJson(value: unknown): unknown { + if (typeof value !== "string" || !value) return undefined; + try { return JSON.parse(value) as unknown; } catch { return undefined; } +} + +function stringValue(value: Record, key: string): string | undefined { + return typeof value[key] === "string" && value[key] ? value[key] as string : undefined; } function uniq(values: T[]): T[] { @@ -2333,6 +3256,67 @@ function cloneMemmyConfig(config: MemmyConfig): MemmyConfig { return structuredClone(config); } +function normalizeMemorySourceAgent(value: string | undefined): string { + const normalized = value?.trim(); + return normalized || DEFAULT_NAMESPACE_SOURCE; +} + +function normalizeMemoryTagsForSource(tags: string[], sourceAgent: string): string[] { + const base = tags.map((tag) => tag.trim()).filter(Boolean); + if (sourceAgent === DEFAULT_NAMESPACE_SOURCE) { + return uniq(base); + } + return uniq([...base, "agent-source", sourceAgent]); +} + +function candidateReviewCard(memory: MemoryRow) { + const internal = memory.properties.internal_info; + const policy = isRecord(internal.policy) ? internal.policy : {}; + const world = isRecord(internal.world_model) ? internal.world_model : {}; + const skill = isRecord(internal.skill) ? internal.skill : {}; + const confidence = clamp01(firstFiniteNumber( + memory.info.policy_confidence, + memory.info.confidence, + memory.info.eta, + policy.policy_confidence, + world.confidence, + skill.eta + ) ?? 0); + const evidenceIds = uniq([ + ...stringArray(internal.source_memory_ids), + ...stringArray(internal.source_trace_ids), + ...stringArray(internal.source_policy_ids), + ...stringArray(internal.evidence_anchor_ids) + ]).slice(0, 5); + const episodeIds = uniq([ + ...stringArray(internal.source_episode_ids), + ...stringArray(policy.source_episode_ids) + ]); + return { + id: memory.id, + suggestedLayer: memory.memoryLayer, + title: stringFromMeta(memory.info, "title") ?? stringFromMeta(internal, "title") ?? firstLine(memory.memoryValue), + conclusion: memory.memoryValue, + confidence, + confidenceLabel: confidence >= 0.8 ? "high" : confidence >= 0.6 ? "medium" : "low", + risk: confidence >= 0.8 && evidenceIds.length >= 2 ? "low" : confidence >= 0.6 ? "medium" : "high", + evidence: { + episodeCount: episodeIds.length, + l1Count: evidenceIds.filter((id) => id.startsWith("trace_")).length, + ids: evidenceIds + }, + updatedAt: memory.updatedAt + }; +} + +function firstFiniteNumber(...values: unknown[]): number | undefined { + return values.find((value): value is number => typeof value === "number" && Number.isFinite(value)); +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(value, 1)); +} + function memoryConfigLogFields(config: MemmyConfig): Record { const evolution = resolveEvolutionConfig(config); return { diff --git a/Memory/src/service/namespace/namespace-scope.ts b/Memory/src/service/namespace/namespace-scope.ts index 539ab1c1c..ab0933330 100644 --- a/Memory/src/service/namespace/namespace-scope.ts +++ b/Memory/src/service/namespace/namespace-scope.ts @@ -1,15 +1,20 @@ -import type { MemoryRow, RuntimeNamespace, SessionOpenRequest } from "../../types.js"; +import type { MemoryFilter, MemoryRow, RuntimeNamespace, SessionOpenRequest } from "../../types.js"; import { DEFAULT_NAMESPACE_SOURCE } from "../../types.js"; import type { RawTurnRecord, SessionRecord } from "../../storage/repositories.js"; +import { stableHash } from "../../utils/id.js"; + +export const GLOBAL_PROJECT_ID = "global"; export function normalizeNamespace(namespace?: RuntimeNamespace): RuntimeNamespace & { userId: string; source: string; profileId: string } { + const workspacePath = normalizeWorkspacePath(namespace?.workspacePath); + const workspaceId = clean(namespace?.workspaceId) ?? (workspacePath ? workspaceIdFromPath(workspacePath) : undefined); return { source: namespace?.source ?? DEFAULT_NAMESPACE_SOURCE, profileId: namespace?.profileId ?? "default", profileLabel: namespace?.profileLabel, - projectId: namespace?.projectId, - workspaceId: namespace?.workspaceId, - workspacePath: namespace?.workspacePath, + projectId: clean(namespace?.projectId) ?? workspaceId, + workspaceId, + workspacePath, sessionKey: namespace?.sessionKey, userId: namespace?.userId ?? "local-user", tenantId: namespace?.tenantId @@ -17,21 +22,41 @@ export function normalizeNamespace(namespace?: RuntimeNamespace): RuntimeNamespa } export function sessionScopeForOpenRequest(request: SessionOpenRequest, namespace: RuntimeNamespace): Partial> { + const resolved = normalizeNamespace({ + ...namespace, + source: request.source ?? namespace.source, + profileId: request.profileId ?? namespace.profileId, + projectId: request.projectId ?? namespace.projectId, + workspaceId: request.workspaceId ?? namespace.workspaceId, + workspacePath: request.workspacePath ?? namespace.workspacePath + }); return { - source: request.source ?? request.namespace?.source, - profileId: request.profileId ?? request.namespace?.profileId, - projectId: request.projectId ?? request.namespace?.projectId ?? request.namespace?.workspaceId, - workspaceId: request.workspaceId ?? request.namespace?.workspaceId, - workspacePath: request.workspacePath ?? request.namespace?.workspacePath ?? namespace.workspacePath + source: resolved.source, + profileId: resolved.profileId, + projectId: resolved.projectId, + workspaceId: resolved.workspaceId, + workspacePath: resolved.workspacePath }; } export function namespaceForSession(session: SessionRecord): RuntimeNamespace { - return { source: session.source, profileId: session.profileId, profileLabel: session.profileLabel, projectId: session.projectId, workspaceId: session.workspaceId, workspacePath: session.workspacePath, sessionKey: session.hostSessionKey, userId: session.userId }; + return { source: session.source, profileId: session.profileId, profileLabel: session.profileLabel, projectId: session.projectId, workspaceId: session.workspaceId, workspacePath: session.workspacePath, sessionKey: session.hostSessionKey, userId: session.userId, tenantId: tenantIdFromSession(session) }; } export function namespaceForMemory(memory: MemoryRow): RuntimeNamespace { - return { source: memory.agentId ?? DEFAULT_NAMESPACE_SOURCE, profileId: profileIdFromMemory(memory) ?? "default", projectId: projectIdFromMemory(memory), workspaceId: memory.appId, userId: memory.userId }; + return { source: memory.agentId ?? DEFAULT_NAMESPACE_SOURCE, profileId: profileIdFromMemory(memory) ?? "default", projectId: projectIdFromMemory(memory), workspaceId: memory.appId, userId: memory.userId, tenantId: tenantIdFromMemory(memory) }; +} + +export function tenantIdFromSession(session: SessionRecord): string | undefined { + const value = session.meta.tenant_id ?? session.meta.tenantId; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +export function tenantIdFromMemory(memory: MemoryRow): string | undefined { + const direct = memory.info.tenant_id ?? memory.info.tenantId; + if (typeof direct === "string" && direct.trim()) return direct.trim(); + const nested = memory.properties.info?.tenant_id ?? memory.properties.info?.tenantId; + return typeof nested === "string" && nested.trim() ? nested.trim() : undefined; } export function projectIdFromMemory(memory: MemoryRow): string | undefined { @@ -53,3 +78,68 @@ export function profileIdFromMemory(memory: MemoryRow): string | undefined { export function namespaceForRawTurn(rawTurn: RawTurnRecord): RuntimeNamespace { return { source: DEFAULT_NAMESPACE_SOURCE, profileId: "default", sessionKey: rawTurn.sessionId, userId: rawTurn.userId }; } + +/** + * Stable project boundary shared by every agent adapter. Agent source and + * profile are provenance, not isolation keys. + */ +export function namespaceIdFromContext(namespace: RuntimeNamespace): string { + const normalized = normalizeNamespace(namespace); + return [ + normalized.tenantId ?? "local", + normalized.projectId ?? "unscoped" + ].join(":"); +} + +export function workspaceIdFromPath(workspacePath: string): string { + const normalized = normalizeWorkspacePath(workspacePath); + if (!normalized) return ""; + return `workspace_${stableHash({ path: normalized }).slice(0, 24)}`; +} + +export function sameProjectScope( + actual: RuntimeNamespace, + requested: RuntimeNamespace | undefined +): boolean { + if (!requested) return true; + const expected = normalizeNamespace(requested); + const observed = normalizeNamespace(actual); + if ((observed.tenantId ?? "local") !== (expected.tenantId ?? "local")) return false; + + // A scoped request never inherits legacy/unscoped memories. Unscoped + // callers remain limited to the unscoped quarantine. + return (observed.projectId ?? "unscoped") === (expected.projectId ?? "unscoped"); +} + +export function hasProjectScope(namespace: RuntimeNamespace | undefined): boolean { + if (!namespace) return false; + return Boolean( + clean(namespace.projectId) || + clean(namespace.workspaceId) || + normalizeWorkspacePath(namespace.workspacePath) + ); +} + +export function memoryFilterForNamespace(namespace: RuntimeNamespace): MemoryFilter { + const normalized = normalizeNamespace(namespace); + return { + tenantId: normalized.tenantId ?? "local", + projectId: normalized.projectId ?? "unscoped" + }; +} + +function normalizeWorkspacePath(value: string | undefined): string | undefined { + const trimmed = clean(value); + if (!trimmed) return undefined; + let normalized = trimmed.replace(/\\/g, "/").replace(/\/{2,}/g, "/"); + if (/^[A-Z]:\//.test(normalized)) { + normalized = normalized[0]!.toLowerCase() + normalized.slice(1); + } + if (normalized.length > 1) normalized = normalized.replace(/\/+$/, ""); + return normalized || "/"; +} + +function clean(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed || undefined; +} diff --git a/Memory/src/service/project-context/project-context-service.ts b/Memory/src/service/project-context/project-context-service.ts new file mode 100644 index 000000000..0127a987f --- /dev/null +++ b/Memory/src/service/project-context/project-context-service.ts @@ -0,0 +1,367 @@ +import type { MemoryProvenance, RuntimeNamespace } from "../../types.js"; +import { newId } from "../../utils/id.js"; +import { nowIso } from "../../utils/time.js"; +import { namespaceIdFromContext, normalizeNamespace } from "../namespace/namespace-scope.js"; +import type { + ProjectContextProposeGoalRequest, + ProjectContextReadState, + ProjectContextRequest, + ProjectContextServiceOptions, + ProjectContextStableResult, + ProjectFactRecord, + ProjectGoalRecord, + ProjectWorkItemRecord, + ProjectWorkItemStatus +} from "./project-context-types.js"; + +export interface ProjectGoalDecisionRequest extends ProjectContextRequest { + candidateId: string; +} + +export interface ProjectWorkItemCreateRequest extends ProjectContextRequest { + goalId?: string; + title: string; + summary: string; + nextStep: string; + acceptanceCriteria?: string[]; + constraints?: string[]; + status?: ProjectWorkItemStatus; + sourceMemoryIds?: string[]; + provenance?: Partial & Record; +} + +export interface ProjectWorkItemUpdateRequest extends ProjectContextRequest { + workItemId: string; + goalId?: string | null; + title?: string | null; + summary?: string | null; + nextStep?: string | null; + acceptanceCriteria?: string[] | null; + constraints?: string[] | null; + status?: ProjectWorkItemStatus | null; + sourceMemoryIds?: string[] | null; + provenance?: (Partial & Record) | null; +} + +export interface ProjectWorkItemSelectRequest extends ProjectContextRequest { + workItemId: string | null; +} + + +export class ProjectContextService { + private readonly now: () => string; + private static readonly MIN_RENDER_BUDGET = 120; + private readonly id: (prefix: string) => string; + + constructor(private readonly options: ProjectContextServiceOptions) { + this.now = options.now ?? nowIso; + this.id = options.id ?? newId; + } + + read(namespace: RuntimeNamespace): ProjectContextReadState { + const namespaceId = this.namespaceId(namespace); + return { + namespaceId, + activeGoal: this.options.repositories.projectContext.getActiveGoal(namespaceId), + goals: this.options.repositories.projectContext.listGoals(namespaceId), + workItems: this.options.repositories.projectContext.listWorkItems(namespaceId), + focusedWorkItem: this.options.repositories.projectContext.getFocusedWorkItem(namespaceId), + facts: this.options.repositories.projectContext.listActiveFacts(namespaceId) + }; + } + + proposeGoal(request: ProjectContextProposeGoalRequest): ProjectGoalRecord { + const namespace = normalizeNamespace(request.namespace); + const at = this.now(); + const activeVersion = this.options.repositories.projectContext.getActiveGoal(this.namespaceId(namespace))?.version ?? 0; + return this.options.repositories.projectContext.insertGoal({ + ...identity(namespace), + id: this.id("project_goal"), + title: required(request.title, "goal title"), + summary: request.summary.trim(), + detail: request.detail, + acceptanceCriteria: cleanList(request.acceptanceCriteria), + constraints: cleanList(request.constraints), + status: "candidate", + version: activeVersion, + sourceMemoryIds: uniq(request.sourceMemoryIds), + provenance: request.provenance ?? {}, + createdAt: at, + updatedAt: at + }); + } + + approveGoal(request: ProjectGoalDecisionRequest): ProjectGoalRecord { + const namespaceId = this.namespaceId(request.namespace); + const candidate = this.requireCandidate(request.candidateId, namespaceId); + const at = this.now(); + return this.options.repositories.transaction(() => { + const current = this.options.repositories.projectContext.getActiveGoal(namespaceId); + const version = (current?.version ?? 0) + 1; + this.options.repositories.projectContext.archiveGoalCandidate(candidate.id, namespaceId, at); + const active: ProjectGoalRecord = { + ...candidate, + id: this.id("project_goal"), + status: "active", + version, + supersedesId: current?.id, + createdAt: at, + updatedAt: at + }; + return current + ? this.options.repositories.projectContext.replaceActiveGoal(active) + : this.options.repositories.projectContext.insertGoal(active); + }); + } + + rejectGoal(request: ProjectGoalDecisionRequest): ProjectGoalRecord { + const namespaceId = this.namespaceId(request.namespace); + this.requireCandidate(request.candidateId, namespaceId); + return this.options.repositories.projectContext.archiveGoalCandidate(request.candidateId, namespaceId, this.now()); + } + + createWorkItem(request: ProjectWorkItemCreateRequest): ProjectWorkItemRecord { + const namespace = normalizeNamespace(request.namespace); + const at = this.now(); + return this.options.repositories.projectContext.insertWorkItem({ + ...identity(namespace), + id: this.id("project_work_item"), + goalId: request.goalId, + title: required(request.title, "work item title"), + summary: request.summary.trim(), + nextStep: request.nextStep.trim(), + acceptanceCriteria: cleanList(request.acceptanceCriteria), + constraints: cleanList(request.constraints), + status: request.status ?? "pending", + focused: false, + sourceMemoryIds: uniq(request.sourceMemoryIds), + provenance: request.provenance ?? {}, + createdAt: at, + updatedAt: at + }); + } + + updateWorkItem(request: ProjectWorkItemUpdateRequest): ProjectWorkItemRecord { + const namespaceId = this.namespaceId(request.namespace); + const stored = this.options.repositories.projectContext.listWorkItems(namespaceId).find((item) => item.id === request.workItemId); + if (!stored) throw new Error(`work item not found in namespace: ${request.workItemId}`); + const status = request.status === undefined || request.status === null ? stored.status : request.status; + const next: ProjectWorkItemRecord = { + ...stored, + goalId: patchOptional(stored.goalId, request.goalId), + title: patchString(stored.title, request.title), + summary: patchString(stored.summary, request.summary), + nextStep: patchString(stored.nextStep, request.nextStep), + acceptanceCriteria: patchList(stored.acceptanceCriteria, request.acceptanceCriteria), + constraints: patchList(stored.constraints, request.constraints), + status, + focused: status === "completed" || status === "archived" ? false : stored.focused, + sourceMemoryIds: patchList(stored.sourceMemoryIds, request.sourceMemoryIds), + provenance: request.provenance === undefined ? stored.provenance : request.provenance ?? {}, + updatedAt: this.now() + }; + return this.options.repositories.projectContext.updateWorkItem(next); + } + + selectWorkItem(request: ProjectWorkItemSelectRequest): ProjectWorkItemRecord | undefined { + return this.options.repositories.projectContext.setFocusedWorkItem(this.namespaceId(request.namespace), request.workItemId, this.now()); + } + + renderStable(namespace: RuntimeNamespace, budget = 4_000): ProjectContextStableResult { + if (budget < ProjectContextService.MIN_RENDER_BUDGET) { + throw new RangeError(`project context render budget must be at least ${ProjectContextService.MIN_RENDER_BUDGET}`); + } + const state = this.read(namespace); + const generatedAt = this.now(); + if (!state.activeGoal) { + const noGoal = `\nNo confirmed project goal.\n`; + return { + namespaceId: state.namespaceId, + status: "no_confirmed_goal", + version: 0, + goal: null, + focusedWorkItem: null, + facts: state.facts, + markdown: noGoal.length <= budget ? noGoal : fitNoGoal(budget), + sourceMemoryIds: [], + generatedAt + }; + } + const conflictKeys = conflictingFactKeys(state.facts); + const status = conflictKeys.size > 0 ? "conflict" : "ready"; + const authoritativeFacts = state.facts.filter((fact) => !conflictKeys.has(factKey(fact))); + const markdown = fitSections( + state.activeGoal.version, + status, + renderSections(state.activeGoal, state.focusedWorkItem ?? null, authoritativeFacts), + budget + ); + return { + namespaceId: state.namespaceId, + status, + version: state.activeGoal.version, + goal: state.activeGoal, + focusedWorkItem: state.focusedWorkItem ?? null, + facts: state.facts, + markdown, + sourceMemoryIds: uniq([ + ...state.activeGoal.sourceMemoryIds, + ...(state.focusedWorkItem?.sourceMemoryIds ?? []), + ...state.facts.flatMap((fact) => fact.sourceMemoryIds) + ]), + generatedAt + }; + } + + private namespaceId(namespace: RuntimeNamespace): string { + return namespaceIdFromContext(namespace); + } + + private requireCandidate(id: string, namespaceId: string): ProjectGoalRecord { + const goal = this.options.repositories.projectContext.getGoal(id); + if (!goal || goal.namespaceId !== namespaceId) throw new Error("project goal namespace mismatch"); + if (goal.status !== "candidate") throw new Error("project goal is not a candidate"); + return goal; + } +} + +function identity(namespace: RuntimeNamespace & { userId: string; source: string; profileId: string }) { + return { + namespaceId: namespaceIdFromContext(namespace), + userId: namespace.userId, + projectId: namespace.projectId, + workspaceId: namespace.workspaceId, + workspacePath: namespace.workspacePath + }; +} + +function renderSections( + goal: ProjectGoalRecord, + focus: ProjectWorkItemRecord | null, + facts: ProjectFactRecord[] +): RenderSection[] { + const constraints = uniq([...goal.constraints, ...facts.filter((fact) => fact.kind === "constraint").map((fact) => fact.content)]); + const decisions = facts.filter((fact) => fact.kind === "decision").map((fact) => fact.content); + const acceptance = uniq([...goal.acceptanceCriteria, ...(focus?.acceptanceCriteria ?? [])]); + return [ + { label: "Constraint:", value: constraints.join("; ") || "none" }, + { label: "Goal:", value: goal.title }, + { label: "Goal summary:", value: goal.summary }, + { label: "Goal status:", value: `${goal.status}; version=${goal.version}` }, + { label: "Focus:", value: focus?.title ?? "No work item is explicitly focused." }, + { label: "Focus summary:", value: focus?.summary ?? "none" }, + { label: "Focus status:", value: focus?.status ?? "none" }, + { label: "Next step:", value: focus?.nextStep ?? "none" }, + { label: "Acceptance:", value: acceptance.join("; ") || "none" }, + { label: "Decision:", value: decisions.join("; ") || "none" }, + { label: "Metadata:", value: `goal_id=${goal.id}; namespace_id=${goal.namespaceId}; project_id=${goal.projectId ?? "none"}; workspace_id=${goal.workspaceId ?? "none"}; confirmed_updated_at=${goal.updatedAt}` } + ]; +} + +interface RenderSection { + label: string; + value: string; +} + +function fitSections(version: number, status: ProjectContextStableResult["status"], sections: RenderSection[], budget: number): string { + const open = ``; + const close = ""; + const normal = [open, ...sections.map((section) => `${section.label} ${section.value}`), close].join("\n"); + if (normal.length <= budget) return normal; + + const section = (label: string) => sections.find((candidate) => candidate.label === label)?.value ?? "none"; + const updatedAt = /(?:^|; )confirmed_updated_at=([^;]+)/.exec(section("Metadata:"))?.[1] ?? "unknown"; + const values = [ + section("Goal:"), + section("Constraint:"), + section("Focus status:"), + section("Focus:"), + section("Next step:"), + section("Acceptance:"), + updatedAt + ]; + const fixedLength = [open, "G=", "C=", "W=||", "A=", "U=", close].join("\n").length; + const lengths = fairLengths(values, fixedLength, budget); + return [ + open, + `G=${values[0]!.slice(0, lengths[0])}`, + `C=${values[1]!.slice(0, lengths[1])}`, + `W=${values[2]!.slice(0, lengths[2])}|${values[3]!.slice(0, lengths[3])}|${values[4]!.slice(0, lengths[4])}`, + `A=${values[5]!.slice(0, lengths[5])}`, + `U=${values[6]!.slice(0, lengths[6])}`, + close + ].join("\n"); +} + +function fairLengths(values: string[], fixedLength: number, budget: number): number[] { + const lengths = values.map(() => 0); + let remaining = budget - fixedLength; + while (remaining > 0) { + let allocated = false; + for (let index = 0; index < values.length && remaining > 0; index += 1) { + if (lengths[index]! >= values[index]!.length) continue; + lengths[index]! += 1; + remaining -= 1; + allocated = true; + } + if (!allocated) break; + } + return lengths; +} + +function fitNoGoal(budget: number): string { + const open = ''; + const close = ""; + const available = budget - open.length - close.length - 2; + return `${open}\n${"No confirmed project goal.".slice(0, Math.max(0, available))}\n${close}`; +} + +function conflictingFactKeys(facts: ProjectFactRecord[]): Set { + const values = new Map>(); + for (const fact of facts) { + const key = factKey(fact); + const group = values.get(key) ?? new Set(); + group.add(fact.content.trim().toLowerCase()); + values.set(key, group); + } + return new Set([...values].filter(([, group]) => group.size > 1).map(([key]) => key)); +} + +function factKey(fact: ProjectFactRecord): string { + const content = fact.content.trim().toLowerCase(); + const separator = content.indexOf(":"); + const domain = separator >= 0 ? content.slice(0, separator).trim() : content; + return `${fact.kind}:${domain}`; +} + +function required(value: string, label: string): string { + const clean = value.trim(); + if (!clean) throw new Error(`${label} is required`); + return clean; +} + +function bounded(value: string, max: number): string { + if (value.length <= max) return value; + if (max <= 3) return value.slice(0, Math.max(0, max)); + return `${value.slice(0, max - 3).trimEnd()}...`; +} + +function cleanList(values: string[] | undefined): string[] { + return uniq((values ?? []).map((value) => value.trim()).filter(Boolean)); +} + +function uniq(values: string[] | undefined): string[] { + return [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))]; +} + +function patchString(current: string, value: string | null | undefined): string { + return value === undefined ? current : value?.trim() ?? ""; +} + +function patchList(current: string[], value: string[] | null | undefined): string[] { + return value === undefined ? current : cleanList(value ?? []); +} + +function patchOptional(current: string | undefined, value: string | null | undefined): string | undefined { + return value === undefined ? current : value ?? undefined; +} diff --git a/Memory/src/service/project-context/project-context-types.ts b/Memory/src/service/project-context/project-context-types.ts new file mode 100644 index 000000000..a8c14e5c6 --- /dev/null +++ b/Memory/src/service/project-context/project-context-types.ts @@ -0,0 +1,107 @@ +import type { RuntimeNamespace } from "../../types.js"; +import type { Repositories } from "../../storage/repositories.js"; + +export type ProjectGoalStatus = "candidate" | "active" | "completed" | "archived"; +export type ProjectWorkItemStatus = "pending" | "active" | "blocked" | "completed" | "archived"; +export type ProjectFactKind = "decision" | "constraint"; +export type ProjectFactStatus = "candidate" | "active" | "superseded" | "archived"; + +export interface ProjectGoalRecord { + id: string; + namespaceId: string; + userId: string; + projectId?: string; + workspaceId?: string; + workspacePath?: string; + title: string; + summary: string; + detail: string; + acceptanceCriteria: string[]; + constraints: string[]; + status: ProjectGoalStatus; + version: number; + supersedesId?: string; + sourceMemoryIds: string[]; + provenance: Record; + createdAt: string; + updatedAt: string; +} + +export interface ProjectWorkItemRecord { + id: string; + namespaceId: string; + userId: string; + projectId?: string; + workspaceId?: string; + workspacePath?: string; + goalId?: string; + title: string; + summary: string; + nextStep: string; + acceptanceCriteria: string[]; + constraints: string[]; + status: ProjectWorkItemStatus; + focused: boolean; + sourceMemoryIds: string[]; + provenance: Record; + createdAt: string; + updatedAt: string; +} + +export interface ProjectFactRecord { + id: string; + namespaceId: string; + userId: string; + projectId?: string; + workspaceId?: string; + workspacePath?: string; + kind: ProjectFactKind; + content: string; + status: ProjectFactStatus; + supersedesId?: string; + sourceMemoryIds: string[]; + provenance: Record; + createdAt: string; + updatedAt: string; +} + +export interface ProjectContextRequest { + namespace: RuntimeNamespace; +} + +export interface ProjectContextProposeGoalRequest extends ProjectContextRequest { + title: string; + summary: string; + detail: string; + acceptanceCriteria?: string[]; + constraints?: string[]; + sourceMemoryIds?: string[]; + provenance?: Record; +} + +export interface ProjectContextReadState { + namespaceId: string; + activeGoal?: ProjectGoalRecord; + goals: ProjectGoalRecord[]; + workItems: ProjectWorkItemRecord[]; + focusedWorkItem?: ProjectWorkItemRecord; + facts: ProjectFactRecord[]; +} + +export interface ProjectContextStableResult { + namespaceId: string; + status: "ready" | "no_confirmed_goal" | "conflict"; + version: number; + goal: ProjectGoalRecord | null; + focusedWorkItem: ProjectWorkItemRecord | null; + facts: ProjectFactRecord[]; + markdown: string; + sourceMemoryIds: string[]; + generatedAt: string; +} + +export interface ProjectContextServiceOptions { + repositories: Repositories; + now?: () => string; + id?: (prefix: string) => string; +} diff --git a/Memory/src/service/read-model/episode.ts b/Memory/src/service/read-model/episode.ts index f9e0ba24a..ba61f6b7d 100644 --- a/Memory/src/service/read-model/episode.ts +++ b/Memory/src/service/read-model/episode.ts @@ -84,6 +84,16 @@ export interface EpisodeReadModelRepositories { getMany(ids: readonly string[]): MemoryRow[]; list(filter: { memoryLayer?: MemoryLayer[]; tags?: string[] }, limit: number, cursor: number): MemoryRow[]; toListItem(memory: MemoryRow): MemoryListItem; + relationsFor(id: string): Array<{ + id: string; + projectId?: string; + sourceMemoryId: string; + targetMemoryId: string; + relation: "supersedes"; + reason?: string; + actor: Record; + createdAt: string; + }>; }; processing: { get(memoryId: string): MemoryProcessingRecord | undefined; @@ -228,7 +238,10 @@ export class EpisodeReadModel { const memory = this.deps.repos.memories.get(id); if (!memory) throw this.deps.notFound(`memory not found: ${id}`); this.deps.assertMemoryInScope(memory, request.namespace); - const detail = this.deps.detailFromMemory(memory, this.deps.repos.processing.get(memory.id)); + const detail = { + ...this.deps.detailFromMemory(memory, this.deps.repos.processing.get(memory.id)), + relations: this.deps.repos.memories.relationsFor(memory.id) + }; const refs = this.refsForMemory(memory); const item = this.deps.memoryDetailWithLayerPayload(detail, memory); if (memory.properties.internal_info.memory_kind === "span") { diff --git a/Memory/src/service/read-model/memory.ts b/Memory/src/service/read-model/memory.ts index b77a84db4..4ad21255f 100644 --- a/Memory/src/service/read-model/memory.ts +++ b/Memory/src/service/read-model/memory.ts @@ -1,16 +1,26 @@ import type { MemoryDetailItem, MemoryProcessingRecord, MemoryRow } from "../../types.js"; import { kindFromMemory } from "../../storage/repositories.js"; import { policyMetaFromMemory, skillMetaFromMemory, traceMetaFromMemory, worldModelMetaFromMemory } from "../../algorithm/plugin-algorithms.js"; -import { panelSourceForMemory, panelTagsForMemory } from "./panel.js"; +import { panelNamespaceForMemory, panelSourceForMemory, panelTagsForMemory } from "./panel.js"; import { isRecord } from "../../utils/json.js"; import { firstLine } from "../../utils/text.js"; export function detailFromMemory(memory: MemoryRow, processing?: MemoryProcessingRecord): MemoryDetailItem { - const sourceMemoryIds = memory.properties.internal_info.source_memory_ids; + const provenance = isRecord(memory.properties.internal_info.provenance) + ? memory.properties.internal_info.provenance as unknown as MemoryDetailItem["provenance"] + : undefined; + const supersedesMemoryIds = stringArray(memory.properties.internal_info.supersedes_memory_ids); + const supersededByMemoryId = stringFromMaybeRecord(memory.properties.internal_info, "superseded_by_memory_id"); + const supersessionReason = stringFromMaybeRecord(memory.properties.internal_info, "supersession_reason"); return { id: memory.id, kind: kindFromMemory(memory), memoryLayer: memory.memoryLayer, status: memory.status, title: detailTitleForMemory(memory), summary: detailSummaryForMemory(memory), tags: panelTagsForMemory(memory, processing), updatedAt: memory.updatedAt, version: memory.version, processing, body: memory.memoryValue, createdAt: memory.createdAt, - sourceMemoryIds: stringArray(sourceMemoryIds), metadata: { source: panelSourceForMemory(memory), info: memory.info, properties: memory.properties } }; + sourceMemoryIds: sourceMemoryIdsFromMemory(memory), + provenance, + supersession: supersedesMemoryIds.length || supersededByMemoryId + ? { supersedesMemoryIds, supersededByMemoryId, reason: supersessionReason } + : undefined, + metadata: { source: panelSourceForMemory(memory), namespace: panelNamespaceForMemory(memory), info: memory.info, properties: memory.properties } }; } export function detailTitleForMemory(memory: MemoryRow): string { @@ -45,7 +55,12 @@ export function memoryDetailWithLayerPayload(detail: MemoryDetailItem, memory: M } export function memoryEtag(memory: MemoryRow): string { return `${memory.id}-v${memory.version}`; } -export function sourceMemoryIdsFromMemory(memory: MemoryRow): string[] { return stringArrayFromInternal(memory, "source_memory_ids").concat(stringArrayFromInternal(memory, "source_l1_memory_ids")).concat(stringArrayFromInternal(memory, "source_policy_ids")).concat(stringArrayFromInternal(memory, "evidence_anchor_ids")); } +export function sourceMemoryIdsFromMemory(memory: MemoryRow): string[] { + const provenance = isRecord(memory.properties.internal_info.provenance) + ? stringArray(memory.properties.internal_info.provenance.sourceMemoryIds) + : []; + return [...new Set(provenance.concat(stringArrayFromInternal(memory, "source_memory_ids"), stringArrayFromInternal(memory, "source_l1_memory_ids"), stringArrayFromInternal(memory, "source_policy_ids"), stringArrayFromInternal(memory, "evidence_anchor_ids")))]; +} export function procedureFromSkillMemory(memory: MemoryRow): string[] | undefined { const value = memory.properties.internal_info.procedure_json ?? memory.properties.internal_info.procedure; if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string"); if (typeof value === "string") { try { const parsed = JSON.parse(value) as unknown; if (Array.isArray(parsed)) return parsed.filter((item): item is string => typeof item === "string"); } catch { return value.split(/\r?\n/).map((line) => line.replace(/^[-*]\s*/, "").trim()).filter(Boolean); } } return undefined; } function firstReadableDetailMemoryLine(value: string): string | undefined { return value.split(/\r?\n/).map(cleanDetailDisplayText).find((line): line is string => Boolean(line && !isWorldSectionHeadingForDisplay(line) && !isInternalMemoryKeyForDisplay(line))); } diff --git a/Memory/src/service/read-model/panel-read.ts b/Memory/src/service/read-model/panel-read.ts index 794f951bb..b0fa10fa7 100644 --- a/Memory/src/service/read-model/panel-read.ts +++ b/Memory/src/service/read-model/panel-read.ts @@ -3,14 +3,24 @@ import { isRecord } from "../../utils/json.js"; import type { StorageBackendCapabilities } from "../../storage/backend.js"; import type { ApiLogRecord, + AuditLogRecord, ChangeLogRecord, + EmbeddingRetryRecord, EmbeddingRetryStatus, EpisodeRecord, EvolutionJobRecord, RawTurnRecord, Repositories } from "../../storage/repositories.js"; -import { detailSummaryForMemory } from "./memory.js"; +import { detailSummaryForMemory, sourceMemoryIdsFromMemory } from "./memory.js"; +import { + memoryFilterForNamespace, + namespaceForMemory, + namespaceForSession, + namespaceIdFromContext, + normalizeNamespace, + sameProjectScope +} from "../namespace/namespace-scope.js"; import type { HealthResponse, MemoryFilter, @@ -36,6 +46,8 @@ import { import { panelCountByDate, panelListItemFromMemory, + panelMemoryMatchesSourceFilter, + panelNamespaceDistribution, panelSourceDistribution } from "./panel.js"; @@ -92,13 +104,17 @@ export class PanelReadModel { items: ReturnType; serverTime: string; } { + const context = this.deps.resolveContext(input); + const limit = input.limit ?? 50; + const items = this.deps.repos.runtime.listAudit({ + userId: input.userId ?? context.userId, + targetKind: input.targetKind, + targetId: input.targetId, + limit: input.namespace ? 10_000 : limit + }).filter((audit) => this.auditMatchesNamespace(audit, input.namespace ? context.namespace : undefined)) + .slice(0, limit); return { - items: this.deps.repos.runtime.listAudit({ - userId: input.userId ?? this.deps.resolveContext(input).userId, - targetKind: input.targetKind, - targetId: input.targetId, - limit: input.limit - }), + items, serverTime: this.now() }; } @@ -131,11 +147,11 @@ export class PanelReadModel { limit, cursor: input.cursor }); - const audits = this.deps.repos.runtime.listAudit({ limit }); + const audits = this.auditLogs({ ...input, limit }).items; const jobs = [ - ...this.deps.repos.runtime.listJobs("failed", limit), - ...this.deps.repos.runtime.listJobs("dead_letter", limit) - ].slice(0, limit); + ...this.scopedJobs("failed", input, limit), + ...this.scopedJobs("dead_letter", input, limit) + ].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)).slice(0, limit); const entries = [ ...changes.items.map((change) => ({ type: "change" as const, @@ -181,7 +197,7 @@ export class PanelReadModel { }; } - apiLogs(input: { + apiLogs(input: RequestEnvelope & { tools?: Array<"memory_add" | "memory_search" | "skill_generate" | "skill_evolve">; sourceAgent?: string; excludedSourceAgents?: string[]; @@ -197,19 +213,26 @@ export class PanelReadModel { } { const limit = Math.max(1, Math.min(input.limit ?? 50, 500)); const offset = Math.max(0, input.offset ?? 0); + const queryLimit = input.namespace ? 10_000 : limit; const result = this.deps.repos.runtime.listApiLogs({ toolNames: input.tools, sourceAgent: input.sourceAgent, excludedSourceAgents: input.excludedSourceAgents, - limit, - offset + limit: queryLimit, + offset: input.namespace ? 0 : offset }); + const context = input.namespace ? this.deps.resolveContext(input) : undefined; + const scopedLogs = context + ? result.logs.filter((log) => this.apiLogMatchesNamespace(log, context.namespace)) + : result.logs; + const logs = input.namespace ? scopedLogs.slice(offset, offset + limit) : scopedLogs; + const total = input.namespace ? scopedLogs.length : result.total; return { - logs: result.logs.map((log) => this.withCurrentTraceSummaries(log)), - total: result.total, + logs: logs.map((log) => this.withCurrentTraceSummaries(log)), + total, limit, offset, - nextOffset: offset + result.logs.length < result.total ? offset + result.logs.length : undefined, + nextOffset: offset + logs.length < total ? offset + logs.length : undefined, serverTime: this.now() }; } @@ -269,7 +292,10 @@ export class PanelReadModel { schema: this.deps.schemaVersion(), memory: overview.stats, changeSeq: overview.latestChangeSeq, - feedback: { recent: this.deps.repos.runtime.listFeedback({ limit: 1000 }).length }, + feedback: { + recent: this.deps.repos.runtime.listFeedback({ limit: 1000 }) + .filter((feedback) => this.entityReferencesNamespace(feedback, input.namespace)).length + }, jobs: overview.stats.jobs, embeddingRetries: overview.stats.embeddingRetries, models: this.deps.models(), @@ -284,11 +310,13 @@ export class PanelReadModel { deadLetterJobs: EvolutionJobRecord[]; serverTime: string; } { + const failedJobs = this.scopedJobs("failed", input, 20); + const deadLetterJobs = this.scopedJobs("dead_letter", input, 20); return { health: this.deps.health(routes), overview: this.panelOverview(input), - failedJobs: this.deps.repos.runtime.listJobs("failed", 20), - deadLetterJobs: this.deps.repos.runtime.listJobs("dead_letter", 20), + failedJobs, + deadLetterJobs, serverTime: this.now() }; } @@ -308,7 +336,7 @@ export class PanelReadModel { }; } - panelOverview(_input: RequestEnvelope & { userId?: string } = {}): { + panelOverview(input: RequestEnvelope & { userId?: string } = {}): { stats: { byLayer: Record; byStatus: Record<"activated" | "resolving" | "archived" | "deleted", number>; @@ -324,16 +352,20 @@ export class PanelReadModel { etag: string; serverTime: string; } { - const byLayer = this.memoryLayerCounts(); - const byStatus = this.memoryStatusCounts(); - const latestChangeSeq = this.deps.repos.runtime.latestChangeSeq(); - const jobs = this.jobStatusCounts(); - const embeddingRetries = this.embeddingRetryStatusCounts(); + const memories = this.listAllMemoriesForStats(input); + const byLayer = this.memoryLayerCounts(memories); + const byStatus = this.memoryStatusCounts(memories); + const context = input.namespace ? this.deps.resolveContext(input) : undefined; + const namespaceId = context ? namespaceIdFromContext(context.namespace) : undefined; + const userId = input.userId ?? context?.userId; + const latestChangeSeq = this.deps.repos.runtime.latestChangeSeq(userId, namespaceId); + const jobs = this.jobStatusCounts(input); + const embeddingRetries = this.embeddingRetryStatusCounts(input); return { stats: { byLayer, byStatus, - episodes: this.episodeStatusCounts(), + episodes: this.episodeStatusCounts(input), jobs, embeddingRetries, lastChangeSeq: latestChangeSeq || undefined @@ -341,18 +373,20 @@ export class PanelReadModel { counts: byLayer, queuedJobs: jobs.queued, latestChangeSeq, - cursor: this.deps.encodeChangeCursor(latestChangeSeq), + cursor: this.deps.encodeChangeCursor(latestChangeSeq, context?.namespace), etag: `panel-overview-v${latestChangeSeq}`, serverTime: this.now() }; } - panelOverviewSummary(_input: RequestEnvelope & { userId?: string } = {}): { + panelOverviewSummary(input: RequestEnvelope & { userId?: string } = {}): { counts: { memories: number; skills: number; experiences: number; worldModels: number }; + layerCounts: Record; sourceDistribution: Array<{ source: string; count: number; percentage: number }>; + namespaceDistribution: Array<{ tenantId: string; projectId: string; workspaceId?: string; workspacePath?: string; label: string; count: number; percentage: number }>; dailyActivity: Array<{ date: string; count: number }>; } { - const memories = this.listAllMemoriesForStats(); + const memories = this.listAllMemoriesForStats(input); const dates = panelDateKeys(this.now(), PANEL_DAILY_ACTIVITY_DAYS); return { counts: { @@ -361,12 +395,194 @@ export class PanelReadModel { experiences: memories.filter((memory) => memory.memoryLayer === "L2").length, worldModels: memories.filter((memory) => memory.memoryLayer === "L3").length }, + layerCounts: this.memoryLayerCounts(memories), dailyActivity: panelCountByDate(memories, dates, (memory) => memory.createdAt), - sourceDistribution: panelSourceDistribution(memories) + sourceDistribution: panelSourceDistribution(memories), + namespaceDistribution: panelNamespaceDistribution(memories, (memory) => + memory.sessionId ? this.deps.repos.runtime.getSession(memory.sessionId) : undefined + ) + }; + } + + evolutionOverview(input: RequestEnvelope & { userId?: string } = {}): { + layers: Array<{ + layer: MemoryLayer; + count: number; + candidates: number; + failed: number; + queued: number; + recentJob?: EvolutionJobRecord; + }>; + l2Resolving: { active: boolean; count: number; reason: string }; + recentJobs: EvolutionJobRecord[]; + serverTime: string; + } { + const memories = this.listAllMemoriesForStats(input); + const candidates = this.deps.repos.runtime.listPendingCandidatePool({ + userId: input.userId, + now: this.now(), + limit: 10_000 + }).filter((candidate) => { + const memory = this.deps.repos.memories.get(candidate.sourceMemoryId); + return Boolean(memory && this.memoryMatchesRequest(memory, input)); + }); + const recentJobs = this.scopedJobs(undefined, input, 200); + const jobLayers: Record = { + L1: ["trace_summary", "import_summary", "reflection", "reward", "span_big_turn", "negative_experience", "embedding"], + L2: ["l2_association", "l2_induction"], + L3: ["l3_abstraction"], + Skill: ["skill_crystallization", "skill_trial_resolve"] + }; + const layers = (["L1", "L2", "L3", "Skill"] as const).map((layer) => { + const jobs = recentJobs.filter((job) => jobLayers[layer].includes(job.jobType)); + return { + layer, + count: memories.filter((memory) => memory.memoryLayer === layer).length, + candidates: layer === "L2" + ? candidates.length + : memories.filter((memory) => memory.memoryLayer === layer && memory.status === "resolving").length, + failed: jobs.filter((job) => job.status === "failed" || job.status === "dead_letter").length, + queued: jobs.filter((job) => job.status === "queued" || job.status === "leased").length, + recentJob: jobs[0] + }; + }); + const resolvingCount = memories.filter((memory) => memory.memoryLayer === "L2" && memory.status === "resolving").length; + const eligibleEpisodes = new Set(candidates.map((candidate) => + isRecord(candidate.evidence) && typeof candidate.evidence.episodeId === "string" + ? candidate.evidence.episodeId + : undefined + ).filter(Boolean)).size; + const requiredEpisodes = this.deps.config().algorithm.l2Induction.minEpisodesForInduction; + const reason = resolvingCount > 0 + ? `${resolvingCount} 条 L2 候选正在等待支持度、增益或试验门槛` + : candidates.length === 0 + ? "没有符合价值与向量条件的 L1 候选" + : eligibleEpisodes < requiredEpisodes + ? `候选证据不足:${eligibleEpisodes}/${requiredEpisodes} 个独立 episode` + : "候选已就绪,等待 l2_induction worker job"; + return { + layers, + l2Resolving: { active: resolvingCount > 0, count: resolvingCount, reason }, + recentJobs: recentJobs.slice(0, 10), + serverTime: this.now() + }; + } + + namespaceAudit(input: RequestEnvelope & { userId?: string } = {}): { + summary: { + total: number; + missingWorkspace: number; + unknownSource: number; + missingAgentSourceTag: number; + crossWorkspaceRisk: number; + }; + issues: Array<{ memoryId: string; issue: string; severity: "warning" | "risk"; projectId?: string; workspaceId?: string; source?: string }>; + serverTime: string; + } { + const memories = this.listAllMemoriesForStats(input); + const issues: Array<{ memoryId: string; issue: string; severity: "warning" | "risk"; projectId?: string; workspaceId?: string; source?: string }> = []; + let missingWorkspace = 0; + let unknownSource = 0; + let missingAgentSourceTag = 0; + let crossWorkspaceRisk = 0; + for (const memory of memories) { + const namespace = namespaceForMemory(memory); + const source = memory.agentId || (typeof memory.info.source === "string" ? memory.info.source : undefined) || "unknown"; + if (!namespace.workspaceId && !namespace.workspacePath) { + missingWorkspace += 1; + issues.push({ memoryId: memory.id, issue: "missing_workspace", severity: "risk", projectId: namespace.projectId, source }); + } + if (source === "unknown") { + unknownSource += 1; + issues.push({ memoryId: memory.id, issue: "unknown_source", severity: "warning", projectId: namespace.projectId, workspaceId: namespace.workspaceId, source }); + } + if (source !== "unknown" && !memory.tags.includes("agent-source")) { + missingAgentSourceTag += 1; + issues.push({ memoryId: memory.id, issue: "missing_agent_source_tag", severity: "warning", projectId: namespace.projectId, workspaceId: namespace.workspaceId, source }); + } + if (memory.sessionId) { + const session = this.deps.repos.runtime.getSession(memory.sessionId); + if (session && namespace.workspaceId && session.workspaceId && namespace.workspaceId !== session.workspaceId) { + crossWorkspaceRisk += 1; + issues.push({ memoryId: memory.id, issue: "workspace_mismatch", severity: "risk", projectId: namespace.projectId, workspaceId: namespace.workspaceId, source }); + } + } + } + return { + summary: { total: memories.length, missingWorkspace, unknownSource, missingAgentSourceTag, crossWorkspaceRisk: crossWorkspaceRisk + missingWorkspace }, + issues: issues.slice(0, 500), + serverTime: this.now() }; } - panelAnalysis(_input: RequestEnvelope & { userId?: string } = {}): { + projectContextPack(input: RequestEnvelope & { userId?: string } = {}): { + namespace: RuntimeNamespace; + conventions: MemoryListItem[]; + commands: MemoryListItem[]; + architectureFacts: MemoryListItem[]; + recentTasks: Array<{ id: string; title: string; updatedAt: string }>; + userPreferences: MemoryListItem[]; + graph: { + nodes: Array; + edges: Array<{ sourceId: string; targetId: string; relation: "source" | "supersedes"; reason?: string }>; + }; + markdown: string; + generatedAt: string; + } { + const context = this.deps.resolveContext(input); + const memories = this.listAllMemoriesForStats({ ...input, namespace: context.namespace }) + .filter((memory) => memory.status === "activated" || memory.status === "resolving"); + const select = (pattern: RegExp, limit = 8) => memories + .filter((memory) => pattern.test(`${memory.tags.join(" ")} ${detailSummaryForMemory(memory)} ${memory.memoryValue}`)) + .slice(0, limit) + .map((memory) => this.deps.repos.memories.toListItem(memory)); + const conventions = select(/convention|约定|规范|rule|必须|should/i); + const commands = select(/command|命令|npm |pnpm |yarn |cargo |pytest|docker |git /i); + const architectureFacts = select(/architecture|架构|module|模块|service|database|repository|api/i); + const userPreferences = select(/preference|偏好|喜欢|不喜欢|prefer/i); + const recentTasks = this.deps.repos.runtime.listEpisodes(context.userId, 20) + .filter((episode) => this.episodeMatchesNamespace(episode, context.namespace)) + .slice(0, 8) + .map((episode) => ({ id: episode.id, title: String(this.deps.episodeRef(episode).title ?? this.deps.episodeRef(episode).summary ?? episode.id), updatedAt: episode.updatedAt })); + const selectedIds = new Set([...conventions, ...commands, ...architectureFacts, ...userPreferences].map((item) => item.id)); + const selectedMemories = memories.filter((memory) => selectedIds.has(memory.id)); + const edges = selectedMemories.flatMap((memory) => [ + ...sourceMemoryIdsFromMemory(memory).map((sourceId) => ({ sourceId, targetId: memory.id, relation: "source" as const })), + ...this.deps.repos.memories.relationsFor(memory.id).map((relation) => ({ sourceId: relation.sourceMemoryId, targetId: relation.targetMemoryId, relation: "supersedes" as const, reason: relation.reason })) + ]).filter((edge, index, all) => all.findIndex((item) => item.sourceId === edge.sourceId && item.targetId === edge.targetId && item.relation === edge.relation) === index); + const graphIds = new Set([...selectedIds, ...edges.flatMap((edge) => [edge.sourceId, edge.targetId])]); + const graphNodes = this.deps.repos.memories.getMany([...graphIds]).map((memory) => ({ + ...this.deps.repos.memories.toListItem(memory), + ...(selectedIds.has(memory.id) ? {} : { external: true }) + })); + const section = (title: string, items: MemoryListItem[]) => `## ${title}\n${items.length ? items.map((item) => `- ${item.summary || item.title} (${item.id})`).join("\n") : "- 暂无"}`; + const markdown = [ + `# Project Memory Pack: ${context.namespace.projectId ?? context.namespace.workspaceId ?? "unscoped"}`, + section("当前项目约定", conventions), + section("常用命令", commands), + section("架构事实", architectureFacts), + `## 最近任务\n${recentTasks.length ? recentTasks.map((task) => `- ${task.title} (${task.id})`).join("\n") : "- 暂无"}`, + section("用户偏好", userPreferences) + ].join("\n\n"); + return { namespace: context.namespace, conventions, commands, architectureFacts, recentTasks, userPreferences, graph: { nodes: graphNodes, edges }, markdown, generatedAt: this.now() }; + } + + projectContextPacks(input: RequestEnvelope & { userId?: string } = {}) { + if (input.namespace) return { packs: [this.projectContextPack(input)], generatedAt: this.now() }; + const memories = this.listAllMemoriesForStats(input); + const namespaces = new Map(); + for (const memory of memories) { + const namespace = namespaceForMemory(memory); + const key = `${namespace.tenantId ?? "local"}:${namespace.projectId ?? "unscoped"}:${namespace.workspaceId ?? namespace.workspacePath ?? ""}`; + namespaces.set(key, namespace); + } + return { + packs: [...namespaces.values()].map((namespace) => this.projectContextPack({ ...input, namespace })), + generatedAt: this.now() + }; + } + + panelAnalysis(input: RequestEnvelope & { userId?: string } = {}): { metrics: { avgRecallScore: number; recallEvents: number; @@ -383,9 +599,9 @@ export class PanelReadModel { }; } { const dates = panelLastSevenDateKeys(this.now()); - const memories = this.listAllMemoriesForStats(); + const memories = this.listAllMemoriesForStats(input); const skillMemories = memories.filter((memory) => memory.memoryLayer === "Skill"); - const logs = this.deps.repos.runtime.listApiLogs({ limit: 10_000, offset: 0 }).logs + const logs = this.apiLogs({ ...input, limit: 10_000, offset: 0 }).logs .filter((log) => dates.includes(panelDateKey(log.calledAt))); const recallScores = logs .filter((log) => log.toolName === "memory_search") @@ -415,6 +631,8 @@ export class PanelReadModel { tags?: string[]; sourceAgent?: string; excludedSourceAgents?: string[]; + projectId?: string; + workspaceId?: string; page?: number; limit?: number; cursor?: string | number; @@ -432,32 +650,54 @@ export class PanelReadModel { } { const pageSize = normalizePanelItemsLimit(input.limit); const filter: MemoryFilter = { + ...(input.namespace ? memoryFilterForNamespace(input.namespace) : {}), + ...(input.userId ? { userId: input.userId } : {}), memoryLayer: input.layer, status: input.status, tags: input.tags, - agentId: input.sourceAgent, - excludedAgentIds: input.excludedSourceAgents + ...(input.namespace ? {} : { + projectId: input.projectId, + workspaceId: input.workspaceId + }) }; - const total = input.q?.trim() + const hasSourceFilter = Boolean( + input.sourceAgent?.trim() || input.excludedSourceAgents?.some((source) => source.trim()) + ); + const candidateTotal = input.q?.trim() ? this.deps.repos.memories.searchCount(input.q, { ...filter, status: filter.status ?? ["activated", "resolving"] }) : this.deps.repos.memories.count(filter); + const sourceFilteredMemories = hasSourceFilter + ? (input.q?.trim() + ? this.deps.repos.memories.getMany(this.deps.repos.memories.searchPanelIds( + input.q, + { ...filter, status: filter.status ?? ["activated", "resolving"] }, + candidateTotal, + 0 + ).map((hit) => hit.id)) + : this.deps.repos.memories.list(filter, candidateTotal, 0)) + .filter((memory) => panelMemoryMatchesSourceFilter(memory, input.sourceAgent, input.excludedSourceAgents)) + : undefined; + const total = sourceFilteredMemories?.length ?? candidateTotal; const totalPages = Math.max(1, Math.ceil(total / pageSize)); const requestedPage = normalizePageNumber(input.page); const page = Math.min(requestedPage, totalPages); const offset = normalizeOffsetCursor(input.cursor) ?? ((page - 1) * pageSize); - const memories = input.q?.trim() - ? this.deps.repos.memories.getMany(this.deps.repos.memories.searchPanelIds( - input.q, - { ...filter, status: filter.status ?? ["activated", "resolving"] }, - pageSize, - offset - ).map((hit) => hit.id)) - : this.deps.repos.memories.list(filter, pageSize, offset); + const memories = sourceFilteredMemories + ? sourceFilteredMemories.slice(offset, offset + pageSize) + : input.q?.trim() + ? this.deps.repos.memories.getMany(this.deps.repos.memories.searchPanelIds( + input.q, + { ...filter, status: filter.status ?? ["activated", "resolving"] }, + pageSize, + offset + ).map((hit) => hit.id)) + : this.deps.repos.memories.list(filter, pageSize, offset); return { items: memories.map((memory) => panelListItemFromMemory( this.deps.repos.memories.toListItem(memory), memory, - this.deps.repos.processing.get(memory.id) + this.deps.repos.processing.get(memory.id), + memory.sessionId ? this.deps.repos.runtime.getSession(memory.sessionId) : undefined )), page, pageSize, @@ -489,13 +729,16 @@ export class PanelReadModel { } { const pageSize = 20 as const; const query = input.q?.trim() || undefined; - const userId = input.namespace?.userId; - const total = this.deps.repos.runtime.countEpisodes(userId, query); + const context = input.namespace ? this.deps.resolveContext(input) : undefined; + const userId = input.namespace?.userId ?? context?.userId; + const episodes = this.deps.repos.runtime.listEpisodes(userId, 10_000, 0, query) + .filter((episode) => this.episodeMatchesNamespace(episode, context?.namespace)); + const total = episodes.length; const totalPages = Math.max(1, Math.ceil(total / pageSize)); const page = Math.min(normalizePageNumber(input.page), totalPages); - const episodes = this.deps.repos.runtime.listEpisodes(userId, pageSize, (page - 1) * pageSize, query); + const pageEpisodes = episodes.slice((page - 1) * pageSize, page * pageSize); return { - tasks: episodes.map((episode) => ({ + tasks: pageEpisodes.map((episode) => ({ id: episode.id, episode: this.deps.episodeRef(episode), memoryIds: episode.l1MemoryIds.filter((memoryId) => Boolean(this.deps.repos.memories.get(memoryId))), @@ -524,11 +767,14 @@ export class PanelReadModel { serverTime: string; } { const limit = input.limit ?? 50; - const cursorSeq = this.deps.decodeChangeCursor(input.cursor); - const items = this.deps.repos.runtime.listChanges(undefined, limit, cursorSeq); + const context = input.namespace ? this.deps.resolveContext(input) : undefined; + const namespaceId = context ? namespaceIdFromContext(context.namespace) : undefined; + const userId = input.userId ?? context?.userId; + const cursorSeq = this.deps.decodeChangeCursor(input.cursor, context?.namespace); + const items = this.deps.repos.runtime.listChanges(userId, limit, cursorSeq, namespaceId); const lastSeq = items.reduce((max, item) => Math.max(max, item.seq), cursorSeq); return { - cursor: this.deps.encodeChangeCursor(lastSeq), + cursor: this.deps.encodeChangeCursor(lastSeq, context?.namespace), changes: items.map(changeLogToPanelChange), hasMore: items.length === limit, items, @@ -546,7 +792,7 @@ export class PanelReadModel { nextCursor?: string; serverTime: string; } { - const items = this.deps.repos.runtime.listJobs(input.status, input.limit ?? 50); + const items = this.scopedJobs(input.status, input, input.limit ?? 50); return { jobs: items.map((job) => ({ ...job, @@ -557,47 +803,196 @@ export class PanelReadModel { }; } - private jobStatusCounts(): Record<"queued" | "leased" | "succeeded" | "failed" | "dead_letter", number> { + private scopedJobs( + status: EvolutionJobRecord["status"] | undefined, + input: RequestEnvelope & { userId?: string }, + limit: number + ): EvolutionJobRecord[] { + const context = input.namespace ? this.deps.resolveContext(input) : undefined; + const userId = input.userId ?? context?.userId; + return this.deps.repos.runtime.listJobs(status, input.namespace ? 10_000 : limit, userId) + .filter((job) => !userId || job.userId === userId) + .filter((job) => this.jobMatchesNamespace(job, context?.namespace)) + .slice(0, limit); + } + + private jobMatchesNamespace(job: EvolutionJobRecord, namespace: RuntimeNamespace | undefined): boolean { + if (!namespace) return true; + if (job.sessionId) { + const session = this.deps.repos.runtime.getSession(job.sessionId); + if (session) return sameProjectScope(namespaceForSession(session), namespace); + } + if (job.episodeId) { + const episode = this.deps.repos.runtime.getEpisode(job.episodeId); + if (episode) return this.episodeMatchesNamespace(episode, namespace); + } + if (job.targetMemoryId) { + const memory = this.deps.repos.memories.get(job.targetMemoryId); + if (memory) return sameProjectScope(namespaceForMemory(memory), namespace); + } + return this.entityReferencesNamespace(job.payload, namespace); + } + + private episodeMatchesNamespace(episode: EpisodeRecord, namespace: RuntimeNamespace | undefined): boolean { + if (!namespace) return true; + const session = this.deps.repos.runtime.getSession(episode.sessionId); + if (session) return sameProjectScope(namespaceForSession(session), namespace); + return sameProjectScope({ + source: "unknown", + profileId: "default", + userId: episode.userId, + tenantId: "local", + projectId: episode.projectId + }, namespace); + } + + private auditMatchesNamespace(audit: AuditLogRecord, namespace: RuntimeNamespace | undefined): boolean { + if (!namespace) return true; + if (audit.sessionId) { + const session = this.deps.repos.runtime.getSession(audit.sessionId); + if (session) return sameProjectScope(namespaceForSession(session), namespace); + } + if (this.entityReferencesNamespace(audit.actor, namespace)) return true; + return this.idMatchesNamespace(audit.targetId, namespace) || + this.entityReferencesNamespace(audit.after, namespace) || + this.entityReferencesNamespace(audit.before, namespace); + } + + private apiLogMatchesNamespace(log: ApiLogRecord, namespace: RuntimeNamespace): boolean { + try { + const input = JSON.parse(log.inputJson) as unknown; + const output = JSON.parse(log.outputJson) as unknown; + return this.entityReferencesNamespace(input, namespace) || this.entityReferencesNamespace(output, namespace); + } catch { + return false; + } + } + + private embeddingRetryMatchesNamespace(retry: EmbeddingRetryRecord, namespace: RuntimeNamespace | undefined): boolean { + if (!namespace) return true; + const memory = this.deps.repos.memories.get(retry.targetId); + return Boolean(memory && sameProjectScope(namespaceForMemory(memory), namespace)); + } + + private entityReferencesNamespace(value: unknown, namespace: RuntimeNamespace | undefined): boolean { + if (!namespace) return true; + if (!isRecord(value)) return false; + const embedded = isRecord(value.namespace) ? value.namespace : value; + const embeddedNamespace = runtimeNamespaceFromRecord(embedded); + if (embeddedNamespace && sameProjectScope(embeddedNamespace, namespace)) return true; + + for (const key of ["sessionId", "session_id", "episodeId", "episode_id", "rawTurnId", "raw_turn_id", "memoryId", "memory_id", "targetMemoryId", "target_memory_id", "traceId", "trace_id", "spanId", "span_id"]) { + const id = value[key]; + if (typeof id === "string" && this.idMatchesNamespace(id, namespace)) return true; + } + for (const key of ["details", "candidates", "filtered", "items"]) { + const entries = value[key]; + if (Array.isArray(entries) && entries.some((entry) => this.entityReferencesNamespace(entry, namespace))) return true; + } + return false; + } + + private idMatchesNamespace(id: string, namespace: RuntimeNamespace): boolean { + const session = this.deps.repos.runtime.getSession(id); + if (session) return sameProjectScope(namespaceForSession(session), namespace); + const episode = this.deps.repos.runtime.getEpisode(id); + if (episode) return this.episodeMatchesNamespace(episode, namespace); + const rawTurn = this.deps.repos.runtime.getRawTurn(id); + if (rawTurn) { + const rawSession = this.deps.repos.runtime.getSession(rawTurn.sessionId); + return Boolean(rawSession && sameProjectScope(namespaceForSession(rawSession), namespace)); + } + const memory = this.deps.repos.memories.get(id); + return Boolean(memory && sameProjectScope(namespaceForMemory(memory), namespace)); + } + + private jobStatusCounts(input: RequestEnvelope & { userId?: string }): Record<"queued" | "leased" | "succeeded" | "failed" | "dead_letter", number> { return { - queued: this.deps.repos.runtime.listJobs("queued", 1000).length, - leased: this.deps.repos.runtime.listJobs("leased", 1000).length, - succeeded: this.deps.repos.runtime.listJobs("succeeded", 1000).length, - failed: this.deps.repos.runtime.listJobs("failed", 1000).length, - dead_letter: this.deps.repos.runtime.listJobs("dead_letter", 1000).length + queued: this.scopedJobs("queued", input, 10_000).length, + leased: this.scopedJobs("leased", input, 10_000).length, + succeeded: this.scopedJobs("succeeded", input, 10_000).length, + failed: this.scopedJobs("failed", input, 10_000).length, + dead_letter: this.scopedJobs("dead_letter", input, 10_000).length }; } - private memoryLayerCounts(): Record { - return this.deps.repos.memories.countByLayer(); + private memoryLayerCounts(memories: ReturnType): Record { + return { + L1: memories.filter((memory) => memory.memoryLayer === "L1").length, + L2: memories.filter((memory) => memory.memoryLayer === "L2").length, + L3: memories.filter((memory) => memory.memoryLayer === "L3").length, + Skill: memories.filter((memory) => memory.memoryLayer === "Skill").length + }; } - private memoryStatusCounts(): Record<"activated" | "resolving" | "archived" | "deleted", number> { - return this.deps.repos.memories.countByStatus(); + private memoryStatusCounts(memories: ReturnType): Record<"activated" | "resolving" | "archived" | "deleted", number> { + return { + activated: memories.filter((memory) => memory.status === "activated").length, + resolving: memories.filter((memory) => memory.status === "resolving").length, + archived: memories.filter((memory) => memory.status === "archived").length, + deleted: memories.filter((memory) => memory.status === "deleted").length + }; } - private episodeStatusCounts(): Record<"open" | "processing" | "closed", number> { - return this.deps.repos.runtime.countEpisodesByStatus(); + private episodeStatusCounts(input: RequestEnvelope & { userId?: string }): Record<"open" | "processing" | "closed", number> { + const context = input.namespace ? this.deps.resolveContext(input) : undefined; + const episodes = this.deps.repos.runtime.listEpisodes(input.userId ?? context?.userId, 10_000) + .filter((episode) => this.episodeMatchesNamespace(episode, context?.namespace)); + return { + open: episodes.filter((episode) => episode.status === "open").length, + processing: episodes.filter((episode) => episode.status === "processing").length, + closed: episodes.filter((episode) => episode.status === "closed").length + }; } - private embeddingRetryStatusCounts(): Record<"pending" | "in_progress" | "succeeded" | "failed", number> { + private embeddingRetryStatusCounts(input: RequestEnvelope & { userId?: string }): Record<"pending" | "in_progress" | "succeeded" | "failed", number> { const statuses: EmbeddingRetryStatus[] = ["pending", "in_progress", "succeeded", "failed"]; const counts = { pending: 0, in_progress: 0, succeeded: 0, failed: 0 }; for (const status of statuses) { - counts[status] = this.deps.repos.runtime.countEmbeddingRetriesByStatus(status); + counts[status] = this.deps.repos.runtime.listEmbeddingRetries(status, 10_000, input.userId) + .filter((retry) => this.embeddingRetryMatchesNamespace(retry, input.namespace)).length; } return counts; } - private listAllMemoriesForStats() { + private listAllMemoriesForStats(input: RequestEnvelope & { userId?: string } = {}) { const rows = [] as ReturnType; const pageSize = 1000; + const context = input.namespace ? this.deps.resolveContext(input) : undefined; + const filter: MemoryFilter = { + ...(context ? memoryFilterForNamespace(context.namespace) : {}), + ...(input.userId ?? context?.userId ? { userId: input.userId ?? context?.userId } : {}) + }; for (let offset = 0;; offset += pageSize) { - const batch = this.deps.repos.memories.list({}, pageSize, offset); + const batch = this.deps.repos.memories.list(filter, pageSize, offset); rows.push(...batch); if (batch.length < pageSize) break; } return rows; } + + private memoryMatchesRequest(memory: Parameters[0], input: RequestEnvelope & { userId?: string }): boolean { + if (input.userId && memory.userId !== input.userId) return false; + return !input.namespace || sameProjectScope(namespaceForMemory(memory), input.namespace); + } +} + +function runtimeNamespaceFromRecord(value: Record): RuntimeNamespace | undefined { + const stringValue = (key: string): string | undefined => typeof value[key] === "string" ? value[key] as string : undefined; + const projectId = stringValue("projectId") ?? stringValue("project_id"); + const workspaceId = stringValue("workspaceId") ?? stringValue("workspace_id"); + const workspacePath = stringValue("workspacePath") ?? stringValue("workspace_path"); + const tenantId = stringValue("tenantId") ?? stringValue("tenant_id"); + if (!projectId && !workspaceId && !workspacePath && !tenantId) return undefined; + return normalizeNamespace({ + source: stringValue("source") ?? "unknown", + profileId: stringValue("profileId") ?? stringValue("profile_id") ?? "default", + userId: stringValue("userId") ?? stringValue("user_id"), + tenantId, + projectId, + workspaceId, + workspacePath + }); } export function redactConfig(value: unknown): unknown { diff --git a/Memory/src/service/read-model/panel.ts b/Memory/src/service/read-model/panel.ts index f548ca9ba..2143973d6 100644 --- a/Memory/src/service/read-model/panel.ts +++ b/Memory/src/service/read-model/panel.ts @@ -1,5 +1,6 @@ import type { MemoryListItem, MemoryProcessingRecord, MemoryRow } from "../../types.js"; import { isRecord } from "../../utils/json.js"; +import type { SessionRecord } from "../../storage/repositories.js"; import { IMPORT_FAILED_TAG, IMPORT_INDEXING_TAG, @@ -11,15 +12,18 @@ import { panelDateKey, panelRoundDecimal } from "./model-costs.js"; export function panelListItemFromMemory( item: MemoryListItem, memory: MemoryRow, - processing?: MemoryProcessingRecord + processing?: MemoryProcessingRecord, + session?: SessionRecord ): MemoryListItem { const spanGoal = panelSpanGoalForMemory(memory); + const namespace = panelNamespaceForMemory(memory, session); return { ...item, processing, metadata: { ...(item.metadata ?? {}), source: panelSourceForMemory(memory), + namespace, ...(spanGoal ? { spanGoal } : {}) }, tags: panelTagsForMemory(memory, processing) @@ -49,6 +53,79 @@ export function panelSourceDistribution(memories: MemoryRow[]): Array<{ source: .sort((a, b) => b.count - a.count || a.source.localeCompare(b.source)); } +export interface PanelNamespaceSummary { + tenantId: string; + projectId: string; + workspaceId?: string; + workspacePath?: string; + label: string; +} + +export function panelNamespaceForMemory(memory: MemoryRow, session?: SessionRecord): PanelNamespaceSummary { + const provenance = isRecord(memory.properties.internal_info.provenance) + ? memory.properties.internal_info.provenance + : {}; + const tenantId = firstString( + session?.meta.tenant_id, + session?.meta.tenantId, + memory.info.tenant_id, + memory.info.tenantId, + provenance.tenantId + ) ?? "local"; + const projectId = firstString( + session?.projectId, + memory.info.project_id, + memory.info.projectId, + provenance.projectId, + memory.appId + ) ?? "unscoped"; + const workspaceId = firstString( + session?.workspaceId, + memory.info.workspace_id, + memory.info.workspaceId, + provenance.workspaceId, + memory.appId + ); + const workspacePath = firstString( + session?.workspacePath, + memory.info.workspace_path, + memory.info.workspacePath, + provenance.workspacePath + ); + return { + tenantId, + projectId, + workspaceId, + workspacePath, + label: panelNamespaceLabel(projectId, workspacePath, workspaceId) + }; +} + +export function panelNamespaceDistribution( + memories: MemoryRow[], + sessionForMemory: (memory: MemoryRow) => SessionRecord | undefined +): Array { + const counts = new Map(); + for (const memory of memories) { + const namespace = panelNamespaceForMemory(memory, sessionForMemory(memory)); + const key = `${namespace.tenantId}:${namespace.projectId}:${namespace.workspaceId ?? ""}`; + const current = counts.get(key); + if (current) { + current.count += 1; + } else { + counts.set(key, { namespace, count: 1 }); + } + } + + return Array.from(counts.values()) + .map(({ namespace, count }) => ({ + ...namespace, + count, + percentage: memories.length > 0 ? panelRoundDecimal((count / memories.length) * 100, 1) : 0 + })) + .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)); +} + export function panelCountByDate( rows: T[], dates: string[], @@ -93,6 +170,18 @@ export function panelSourceForMemory(memory: MemoryRow): string { return explicitSources.some(panelIsInternalSourceValue) ? "memmy" : "unknown"; } +export function panelMemoryMatchesSourceFilter( + memory: MemoryRow, + sourceAgent: string | undefined, + excludedSourceAgents: readonly string[] | undefined +): boolean { + const sourceKey = panelSourceKey(panelSourceForMemory(memory)); + const selectedSourceKey = panelSourceKey(sourceAgent); + if (selectedSourceKey) return sourceKey === selectedSourceKey; + const excludedSourceKeys = new Set((excludedSourceAgents ?? []).map(panelSourceKey).filter(Boolean)); + return !excludedSourceKeys.has(sourceKey); +} + function panelNormalizeExplicitSource(value: unknown): string | undefined { if (typeof value !== "string" || !value.trim()) return undefined; const normalized = value.trim().toLowerCase(); @@ -100,17 +189,35 @@ function panelNormalizeExplicitSource(value: unknown): string | undefined { return panelNormalizeKnownSource(normalized) ?? normalized; } +function panelNamespaceLabel(projectId: string, workspacePath: string | undefined, workspaceId: string | undefined): string { + if (workspacePath) { + const parts = workspacePath.split("/").filter(Boolean); + return parts[parts.length - 1] || workspacePath; + } + if (projectId !== "unscoped") return projectId; + return workspaceId ?? "unscoped"; +} + function panelNormalizeKnownSource(value: unknown): string | undefined { if (typeof value !== "string" || !value.trim()) return undefined; - const normalized = value.trim().toLowerCase(); + const normalized = value.trim().toLowerCase().replace(/[\s_:/\\]+/gu, "-"); if (normalized === "claude" || normalized.startsWith("claude-")) return "claude-code"; if (normalized === "open-code" || normalized.startsWith("open-code-")) return "opencode"; - for (const source of ["hermes", "openclaw", "codex", "cursor", "claude-code", "opencode", "manual", "memmy"]) { + for (const source of ["hermes", "openclaw", "codex", "cursor", "claude-code", "opencode", "pi", "workbuddy", "omp", "manual", "memmy"]) { if (normalized === source || normalized.startsWith(`${source}-`)) return source; } return undefined; } +function panelSourceKey(value: unknown): string { + if (typeof value !== "string" || !value.trim()) return ""; + const normalized = value.trim().toLowerCase().replace(/[\s_:/\\-]+/gu, "_"); + if (normalized === "claude") return "claude_code"; + if (normalized === "open_code") return "opencode"; + if (normalized === "memmy_agent") return "memmy"; + return normalized; +} + function panelNormalizeSourceAgent(value: unknown): string | undefined { if (typeof value !== "string" || !value.trim()) return undefined; const normalized = value.trim().toLowerCase(); diff --git a/Memory/src/service/read-model/skill.ts b/Memory/src/service/read-model/skill.ts index f73d7ce3e..83dc50722 100644 --- a/Memory/src/service/read-model/skill.ts +++ b/Memory/src/service/read-model/skill.ts @@ -19,6 +19,7 @@ import type { RuntimeNamespace, SkillUseRequest } from "../../types.js"; +import { memoryFilterForNamespace } from "../namespace/namespace-scope.js"; export type SkillServiceErrorCode = | "invalid_argument" @@ -211,6 +212,8 @@ export class SkillReadModel { const limit = input.limit ?? 50; const cursor = input.cursor ?? 0; const filter: MemoryFilter = { + ...(input.namespace ? memoryFilterForNamespace(input.namespace) : {}), + ...(input.userId ? { userId: input.userId } : {}), memoryLayer: "Skill", status: ["activated", "resolving"], tags: input.tags diff --git a/Memory/src/service/retrieval/indexed-candidate-pool.ts b/Memory/src/service/retrieval/indexed-candidate-pool.ts index 7eeb499b3..d47588d49 100644 --- a/Memory/src/service/retrieval/indexed-candidate-pool.ts +++ b/Memory/src/service/retrieval/indexed-candidate-pool.ts @@ -14,6 +14,7 @@ import type { MemoryLayer, MemoryRow } from "../../types.js"; +import { projectIdFromMemory } from "../namespace/namespace-scope.js"; export function dedupeStrings(values: readonly string[]): string[] { const out: string[] = []; @@ -37,8 +38,10 @@ export class IndexedCandidatePool { retrievalCandidateCount(input: { layers: MemoryLayer[]; tags?: string[]; + scope: MemoryFilter; }): number { const baseFilter: MemoryFilter = { + ...input.scope, memoryLayer: input.layers, status: ["activated", "resolving"] }; @@ -48,9 +51,11 @@ export class IndexedCandidatePool { hasRetrievalVectorCandidates(input: { layers: MemoryLayer[]; tags?: string[]; + scope: MemoryFilter; }): boolean { if (input.layers.length === 0) return false; const baseFilter: MemoryFilter = { + ...input.scope, memoryLayer: input.layers, status: ["activated", "resolving"] }; @@ -63,6 +68,7 @@ export class IndexedCandidatePool { layers: MemoryLayer[]; tags?: string[]; targetSkillId?: string; + scope: MemoryFilter; config: { tier1TopK: number; tier2TopK: number; @@ -84,6 +90,7 @@ export class IndexedCandidatePool { for (const layer of layers) { const filter: MemoryFilter = { + ...input.scope, memoryLayer: layer, status: ["activated", "resolving"], ...(input.tags?.length ? { tags: input.tags } : {}) @@ -132,7 +139,10 @@ export class IndexedCandidatePool { channelScoresByMemory.set(hit.id, scores); } return { - memories: this.deps.repos.memories.getMany(candidateIds).filter((memory) => this.isMemoryReadyForRetrieval(memory)), + memories: this.deps.repos.memories.getMany(candidateIds).filter((memory) => + this.projectIdForMemory(memory) === input.scope.projectId && + this.isMemoryReadyForRetrieval(memory) + ), channelScoresByMemory }; } @@ -146,7 +156,7 @@ export class IndexedCandidatePool { tagFilter: "auto" | "on" | "off"; } ): MemorySearchIdHit[] { - const tags = config.tagFilter === "off" ? [] : compiledQuery.tags; + const tags = config.tagFilter === "off" ? [] : retrievalSemanticTags(compiledQuery.tags); const search = (anyOfTags?: string[]): MemorySearchIdHit[] => { const summary = this.deps.repos.memories.searchVectorIds(queryVector, "vec_summary", filter, vectorPool, { anyOfTags @@ -176,6 +186,16 @@ export class IndexedCandidatePool { return false; } + private projectIdForMemory(memory: MemoryRow): string { + const direct = projectIdFromMemory(memory); + if (direct) return direct; + if (memory.sessionId) { + const session = this.deps.repos.runtime.getSession(memory.sessionId); + if (session) return session.projectId ?? "unscoped"; + } + return memory.appId ?? "unscoped"; + } + private retrievalVectorPoolSize( layer: MemoryLayer, config: { @@ -215,3 +235,10 @@ export class IndexedCandidatePool { return total <= 0 ? [] : this.deps.repos.memories.list(filter, total); } } + +function retrievalSemanticTags(tags: readonly string[]): string[] { + const ignored = new Set(["agent-source", "codex", "pi", "hermes", "claude_code", "cursor", "opencode", "omp", "memmy"]); + return tags + .map((tag) => tag.trim()) + .filter((tag) => tag && !ignored.has(tag.toLowerCase())); +} diff --git a/Memory/src/service/retrieval/retrieval-service.ts b/Memory/src/service/retrieval/retrieval-service.ts index 36d0380e8..f5ce6ce79 100644 --- a/Memory/src/service/retrieval/retrieval-service.ts +++ b/Memory/src/service/retrieval/retrieval-service.ts @@ -52,6 +52,7 @@ import type { import { newId, stableHash } from "../../utils/id.js"; import { nowIso } from "../../utils/time.js"; import { recordApiLog } from "../model-audit/model-call-audit.js"; +import { memoryFilterForNamespace } from "../namespace/namespace-scope.js"; import { sourceMemoryIdsFromMemory } from "../read-model/memory.js"; @@ -75,6 +76,8 @@ type InternalMemorySearchRequest = MemorySearchRequest & { injectedContextQuery?: string; turnIntentDecision?: unknown; routeProposal?: unknown; + projectContextVersion?: number; + projectContextStatus?: string; recordEvent?: boolean; }; @@ -84,7 +87,7 @@ type RetrievalTimeFilter = NonNullable; const RETRIEVAL_QUERY_EXTRACT_TIMEOUT_MS = 60_000; -const RETRIEVAL_FILTER_TIMEOUT_MS = 30_000; +const RETRIEVAL_FILTER_TIMEOUT_MS = 20_000; const QUERY_REWRITE_TIMEOUT_MS = 30_000; @@ -1465,6 +1468,11 @@ export class RetrievalService { budget: number; total: number; }; + retrievalDebug: RetrievalResult["debug"] & { + candidateCount: number; + scopedProjectId?: string; + scopedWorkspaceId?: string; + }; status: string[]; verbose: boolean; serverTime: string; @@ -1506,6 +1514,7 @@ export class RetrievalService { ) : undefined; const tuning = this.retrievalTuningConfig(); + const scope = memoryFilterForNamespace(context.namespace); const allowedLayers = retrievalLayersForProfile(retrievalLayersForMode(retrievalMode), tuning); const semanticLayers = request.layers === undefined ? allowedLayers @@ -1517,10 +1526,13 @@ export class RetrievalService { ? 0 : this.candidatePool.retrievalCandidateCount({ layers: semanticLayers, - tags: request.tags + tags: request.tags, + scope }); const retrievalQuery = focusResearchRetrievalQuery(request.query, tuning.domain).text; - const queryExtract = candidateCount > 0 && !onboardingFirstReportHit + // Turn-start recall runs inside agent hook deadlines. Keep it to one LLM stage: + // filtering actual candidates is more useful here than extracting the query first. + const queryExtract = candidateCount > 0 && !onboardingFirstReportHit && retrievalMode !== "turn_start" ? await this.extractRetrievalQuery(retrievalQuery) : null; const queryVectorText = queryExtract?.queryVecText?.trim() || retrievalQuery; @@ -1538,7 +1550,8 @@ export class RetrievalService { ? this.retrieveTimeFilteredTraceMemories({ timeFilter, tags: request.tags, - limit: retrievalLimit + limit: retrievalLimit, + scope }) : await this.retrieveSearchMemories({ query: retrievalQuery, @@ -1549,7 +1562,8 @@ export class RetrievalService { limit: retrievalLimit, mode: retrievalMode, excludeTraceRawTurnIds: recentRawTurnIds, - targetSkillId: request.targetSkillId + targetSkillId: request.targetSkillId, + scope }); const retrieval = retrievalOutput.retrieval; const memories = retrievalOutput.memories; @@ -1558,10 +1572,12 @@ export class RetrievalService { ? { hits: retrieval.hits, status: ["first_report_handoff:latest_only"] } : timeFilter ? { hits: retrieval.hits, status: ["time_filter:l1"] } - : await this.filterRecallHits(queryVectorText, retrieval.hits); + : await this.filterRecallHits(queryVectorText, retrieval.hits, { + allowModelFallback: retrievalMode !== "turn_start" + }); const hits = onboardingFirstReportHit || timeFilter ? filteredHits.hits - : filterL1TraceSpanRecallHits(filteredHits.hits,memories); + : filterL1TraceSpanRecallHits(filteredHits.hits, memories); const contextPacket = timeFilter ? buildTimeFilteredInjectedContext( memories.filter((memory) => hits.some((hit) => hit.id === memory.id)), @@ -1629,6 +1645,12 @@ export class RetrievalService { budget: budgetAt - rerankAt, total: Date.now() - startedAt }, + retrievalDebug: { + ...retrieval.debug, + candidateCount: memories.length, + scopedProjectId: context.namespace.projectId, + scopedWorkspaceId: context.namespace.workspaceId + }, status: uniq([ ...filteredHits.status, ...(!this.deps.memoryAddEnabled() ? ["memory_add:disabled:no_recall_log"] : []) @@ -1681,8 +1703,10 @@ export class RetrievalService { timeFilter: RetrievalTimeFilter; tags?: string[]; limit: number; + scope: ReturnType; }): { retrieval: RetrievalResult; memories: MemoryRow[] } { const filter: MemoryFilter = { + ...input.scope, memoryLayer: "L1", status: ["activated", "resolving"], createdAtGte: input.timeFilter.startAt, @@ -1728,6 +1752,7 @@ export class RetrievalService { mode: RetrievalMode; excludeTraceRawTurnIds?: ReadonlySet; targetSkillId?: string; + scope: ReturnType; }): Promise<{ retrieval: RetrievalResult; memories: MemoryRow[] }> { if (input.limit <= 0 || input.layers.length === 0) { return { retrieval: emptyRetrievalResult(), memories: [] }; @@ -1743,7 +1768,8 @@ export class RetrievalService { }); const hasVectorCandidates = this.candidatePool.hasRetrievalVectorCandidates({ layers: input.layers, - tags: input.tags + tags: input.tags, + scope: input.scope }); const queryVector = hasVectorCandidates ? await this.queryVector(queryVectorText) : undefined; const candidatePool = await this.candidatePool.indexedRetrievalCandidatePool({ @@ -1752,6 +1778,7 @@ export class RetrievalService { layers: input.layers, tags: input.tags, targetSkillId: input.targetSkillId, + scope: input.scope, config }); const memories = candidatePool.memories; @@ -1803,7 +1830,11 @@ export class RetrievalService { }; } - private async filterRecallHits(query: string, hits: RecallHit[]): Promise<{ + private async filterRecallHits( + query: string, + hits: RecallHit[], + options: { allowModelFallback: boolean } + ): Promise<{ hits: RecallHit[]; status: string[]; }> { @@ -1869,7 +1900,8 @@ export class RetrievalService { try { result = await completeFilter(filterLlm, usesSummaryLlm); } catch (primaryError) { - const evolutionFallback = usesSummaryLlm && + const evolutionFallback = options.allowModelFallback && + usesSummaryLlm && this.deps.skillLlm.isConfigured() && this.deps.skillLlm !== filterLlm ? this.deps.skillLlm @@ -2174,6 +2206,7 @@ export class RetrievalService { startedAt: number ): ReturnType { const total = Date.now() - startedAt; + const context = this.deps.resolveContext(request); const tuning = this.retrievalTuningConfig(); const contextPacket = request.includeInjectedContext === false ? { @@ -2208,6 +2241,15 @@ export class RetrievalService { budget: 0, total }, + retrievalDebug: { + tierSizes: { tier1: 0, tier2: 0, tier3: 0 }, + kept: { tier1: 0, tier2: 0, tier3: 0 }, + topRelevance: 0, + droppedByThreshold: 0, + candidateCount: 0, + scopedProjectId: context.namespace.projectId, + scopedWorkspaceId: context.namespace.workspaceId + }, status: ["memory_search:disabled"], verbose: request.verbose === true, serverTime: nowIso() diff --git a/Memory/src/service/session/session-turn-service.ts b/Memory/src/service/session/session-turn-service.ts index 9ac6076d8..8b784e58f 100644 --- a/Memory/src/service/session/session-turn-service.ts +++ b/Memory/src/service/session/session-turn-service.ts @@ -41,7 +41,8 @@ import type { ToolCallPayload, ToolObserveRequest, TurnCompleteRequest, - TurnStartRequest + TurnStartRequest, + TurnStartResponse } from "../../types.js"; import { MemoryServiceError } from "../../utils/error.js"; import { newId,stableHash,stableStringify } from "../../utils/id.js"; @@ -52,8 +53,11 @@ import type { DecisionRepairLlmDraft, SynthesizeDecisionRepairDraft } from "../feedback/feedback-experience.js"; +import type { DecisionRepairSummary } from "../evolution/reward-pipeline.js"; +import type { ProjectContextService } from "../project-context/project-context-service.js"; import { recordApiLog } from "../model-audit/model-call-audit.js"; import { + memoryFilterForNamespace, namespaceForRawTurn, namespaceForSession, normalizeNamespace, @@ -82,9 +86,9 @@ type TraceMeta = NonNullable>; interface ToolFailureRecord { toolId: string; context: string; step: number; reason: string; ts: number; rawTurnId?: string; sessionId?: string; episodeId?: string; } interface ToolFailureState { toolId: string; context: string; firstSeen: number; lastSeen: number; windowStart: number; occurrences: ToolFailureRecord[]; } interface ToolFailureBurst extends ToolFailureState { contextHash: string; failureCount: number; } -interface DecisionRepairSummary { repairId?: string; contextHash?: string; skipped?: boolean; reason?: string; attachedPolicyIds?: string[]; } type SessionTurnDependencies = { repos: Repositories; + projectContext: ProjectContextService; readonly config: MemmyConfig; readonly llm: LlmClient; readonly skillLlm: LlmClient; @@ -268,6 +272,38 @@ function endTopicDecisionFromRawTurn(rawTurn: RawTurnRecord): EndTopicDecision | }; } +function persistedTurnStartResponse(rawTurn: RawTurnRecord): TurnStartResponse | undefined { + const turnStart = isRecord(rawTurn.messagePayload?.turn_start) + ? rawTurn.messagePayload.turn_start + : undefined; + const response = turnStart?.response; + if (!isPersistedTurnStartResponse(response)) return undefined; + return response; +} + +function isPersistedTurnStartResponse(value: unknown): value is TurnStartResponse { + if (!isRecord(value)) return false; + return typeof value.contextPacketId === "string" + && typeof value.turnId === "string" + && typeof value.sessionId === "string" + && typeof value.episodeId === "string" + && Array.isArray(value.closedEpisodeIds) + && typeof value.searchEventId === "string" + && Array.isArray(value.hits) + && isRecord(value.injectedContext) + && typeof value.injectedContext.markdown === "string" + && Array.isArray(value.injectedContext.sections) + && isRecord(value.projectContext) + && typeof value.projectContext.namespaceId === "string" + && typeof value.projectContext.version === "number" + && typeof value.projectContext.markdown === "string" + && Array.isArray(value.projectContext.sourceMemoryIds) + && Array.isArray(value.sourceMemoryIds) + && Array.isArray(value.droppedDueToBudget) + && Array.isArray(value.status) + && typeof value.serverTime === "string"; +} + function rawTurnIsExcludedFromMemory(rawTurn: RawTurnRecord): boolean { if (endTopicDecisionFromRawTurn(rawTurn)) { return true; @@ -380,7 +416,14 @@ export class SessionTurnService { return this.deps.withDuplicateFlag(existing.response) as ReturnType; } } - const namespace = normalizeNamespace(request.namespace); + const namespace = normalizeNamespace({ + ...request.namespace, + source: request.source ?? request.namespace?.source ?? "unknown", + profileId: request.profileId ?? request.namespace?.profileId ?? "default", + projectId: request.projectId ?? request.namespace?.projectId, + workspaceId: request.workspaceId ?? request.namespace?.workspaceId, + workspacePath: request.workspacePath ?? request.namespace?.workspacePath + }); const at = nowIso(); if (request.sessionId) { const existingSession = this.deps.repos.runtime.getSession(request.sessionId); @@ -453,13 +496,18 @@ export class SessionTurnService { source: request.source ?? namespace.source, profileId: request.profileId ?? namespace.profileId, profileLabel: namespace.profileLabel, - projectId: request.projectId ?? namespace.projectId ?? namespace.workspaceId, - workspaceId: request.workspaceId ?? namespace.workspaceId, - workspacePath: request.workspacePath ?? namespace.workspacePath, + projectId: namespace.projectId, + workspaceId: namespace.workspaceId, + workspacePath: namespace.workspacePath, hostSessionKey, conversationId: this.deps.stringFromMeta(request.meta, "conversationId"), status: "open" as const, - meta: request.meta ?? {}, + meta: { + ...(request.meta ?? {}), + ...(request.protocolVersion ? { protocolVersion: request.protocolVersion } : {}), + ...(request.provenance ? { provenance: request.provenance } : {}), + tenant_id: namespace.tenantId ?? "local" + }, openedAt: at, lastSeenAt: at, updatedAt: at @@ -615,7 +663,8 @@ export class SessionTurnService { contextPacketId, sourceTurnIds, sourceMemoryIds, - tokenEstimate: request.tokenEstimate + tokenEstimate: request.tokenEstimate, + ...(request.checkpoint ? { checkpoint: request.checkpoint } : {}) } }, status: "succeeded", @@ -772,24 +821,7 @@ export class SessionTurnService { }; } - async startTurn(request: TurnStartRequest & Record): Promise<{ - contextPacketId: string; - turnId: string; - sessionId: string; - searchEventId: string; - hits: RecallHit[]; - injectedContext: InjectedContext; - sourceMemoryIds: string[]; - droppedDueToBudget: Array<{ - id: string; - kind: MemoryKind; - memoryLayer: MemoryLayer; - reason: "token_budget"; - tokenEstimate?: number; - }>; - status: string[]; - serverTime: string; - }> { + async startTurn(request: TurnStartRequest & Record): Promise { request = sanitizeTurnStartRequest(request); if (!this.deps.memoryAddEnabled()) { return this.deps.startTurnNoWrite(request); @@ -797,6 +829,21 @@ export class SessionTurnService { const session = this.deps.requireOpenSession(request.sessionId); this.deps.assertSessionInScope(session, request.namespace); const turnId = request.turnId ?? newId("turn"); + const existingRawTurn = this.deps.repos.runtime.getRawTurnBySessionTurn(session.id, turnId); + if (existingRawTurn) { + this.deps.assertRawTurnInScope(existingRawTurn, request.namespace); + const persistedResponse = persistedTurnStartResponse(existingRawTurn); + if (persistedResponse) return persistedResponse; + } + const requestedContextBudget = typeof request.contextBudget === "number" ? request.contextBudget : undefined; + const projectContext = this.deps.projectContext.renderStable( + namespaceForSession(session), + requestedContextBudget === undefined ? 4_000 : Math.max(120, requestedContextBudget * 4) + ); + const projectTokenEstimate = Math.ceil(projectContext.markdown.length / 4); + const supplementalContextBudget = requestedContextBudget === undefined + ? undefined + : Math.max(0, requestedContextBudget - projectTokenEstimate); const intentDecision = classifyIntent(request.query); const endTopicDecision = explicitEndTopicDecision(request.query); const routeProposal = await this.proposeEpisodeRouteWithLlm( @@ -817,14 +864,51 @@ export class SessionTurnService { ? [] : this.deps.memoryLayersForIntent(intentDecision.kind), limit: this.deps.turnStartRetrievalLimit(), - contextBudget: typeof request.contextBudget === "number" ? request.contextBudget : undefined, + contextBudget: supplementalContextBudget, includeInjectedContext: true, retrievalMode: "turn_start", contextHints, injectedContextQuery: request.query, turnIntentDecision: intentDecision, - routeProposal + routeProposal, + projectContextVersion: projectContext.version, + projectContextStatus: projectContext.status }); + const supplementalMarkdown = search.injectedContext.markdown.trim(); + const combinedMarkdown = supplementalMarkdown + ? `${projectContext.markdown}\n\n${supplementalMarkdown}` + : projectContext.markdown; + const combinedTokenEstimate = Math.ceil(combinedMarkdown.length / 4); + const includeSupplemental = supplementalContextBudget !== 0 + && (requestedContextBudget === undefined || combinedTokenEstimate <= requestedContextBudget); + const droppedDueToBudget = includeSupplemental + ? search.droppedDueToBudget + : [...search.hits.reduce( + (droppedById: Map, hit: TurnStartResponse["hits"][number]) => { + if (droppedById.has(hit.id)) return droppedById; + const section = search.injectedContext.sections.find((candidate: InjectedContext["sections"][number]) => + candidate.id === `memory-${hit.id}` || candidate.memoryIds.includes(hit.id) + ); + droppedById.set(hit.id, { + id: hit.id, + kind: hit.kind, + memoryLayer: hit.memoryLayer, + reason: "token_budget", + ...(section?.tokenEstimate === undefined ? {} : { tokenEstimate: section.tokenEstimate }) + }); + return droppedById; + }, + new Map(search.droppedDueToBudget.map((dropped: TurnStartResponse["droppedDueToBudget"][number]) => [dropped.id, dropped])) + ).values()]; + const sourceMemoryIds = uniq([ + ...projectContext.sourceMemoryIds, + ...(includeSupplemental ? search.sourceMemoryIds : []) + ]); + const injectedContext: InjectedContext = { + ...(includeSupplemental ? search.injectedContext : { sections: [] }), + markdown: includeSupplemental ? combinedMarkdown : projectContext.markdown, + tokenEstimate: includeSupplemental ? combinedTokenEstimate : projectTokenEstimate + }; const contextPacketId = turnContextPacketId( session.id, routeProposal.baseEpisodeId, @@ -839,9 +923,10 @@ export class SessionTurnService { sessionId: session.id, searchEventId: search.searchEventId, hits: search.hits, - injectedContext: search.injectedContext, - sourceMemoryIds: search.sourceMemoryIds, - droppedDueToBudget: search.droppedDueToBudget, + injectedContext, + projectContext, + sourceMemoryIds, + droppedDueToBudget, status: [ ...search.status, ...(intentDecision.kind === "chitchat" || intentDecision.kind === "meta" @@ -898,6 +983,9 @@ export class SessionTurnService { if (existingRawTurn) { this.deps.assertRawTurnInScope(existingRawTurn, request.namespace); } + const persistedStartResponse = existingRawTurn + ? persistedTurnStartResponse(existingRawTurn) + : undefined; if (existingRawTurn && isRecord(existingRawTurn.messagePayload?.turn_complete)) { const at = nowIso(); const episode = this.deps.requireEpisode(existingRawTurn.episodeId); @@ -938,7 +1026,7 @@ export class SessionTurnService { const requestSourceMemoryIds = normalizeCompleteTurnSourceMemoryIds(request); const sourceMemoryIds = requestSourceMemoryIds.length > 0 ? requestSourceMemoryIds - : turnStartRecall?.injectedMemoryIds ?? []; + : persistedStartResponse?.sourceMemoryIds ?? turnStartRecall?.injectedMemoryIds ?? []; const completionRequest = sourceMemoryIds === requestSourceMemoryIds ? request : { ...request, sourceMemoryIds }; @@ -947,6 +1035,7 @@ export class SessionTurnService { const endTopicDecision = explicitEndTopicDecision(request.query) ?? (existingRawTurn ? endTopicDecisionFromRawTurn(existingRawTurn) : undefined); + const turnStartRecallRequest = isRecord(turnStartRecall?.request) ? turnStartRecall.request : {}; const at = nowIso(); const recalledProposal = turnRouteProposalFromRecallRequest(turnStartRecall?.request); let route: CommittedTurnRoute; @@ -1043,9 +1132,17 @@ export class SessionTurnService { const requestToolResults = normalizeCompleteTurnToolResults(completionRequest); const requestArtifacts = normalizeCompleteTurnArtifacts(completionRequest); const turnStartPayload = { + protocolVersion: request.protocolVersion, + provenance: request.provenance, intent_decision: intentDecision, routeProposal: recalledProposal ?? route.proposal, ...(route.proposalStale ? { routeProposalStale: true } : {}), + ...(typeof turnStartRecallRequest.projectContextVersion === "number" + ? { projectContextVersion: turnStartRecallRequest.projectContextVersion } + : {}), + ...(typeof turnStartRecallRequest.projectContextStatus === "string" + ? { projectContextStatus: turnStartRecallRequest.projectContextStatus } + : {}), ...(turnStartRecall ? { contextPacketId: turnContextPacketId( @@ -1065,7 +1162,8 @@ export class SessionTurnService { decision: committedEndTopicDecision } } - : {}) + : {}), + ...(persistedStartResponse ? { response: persistedStartResponse } : {}) }; const insertedRawTurn: RawTurnRecord = @@ -1088,7 +1186,9 @@ export class SessionTurnService { turn_start: turnStartPayload, turn_complete: { completed_at: at, - source_memory_ids: sourceMemoryIds + source_memory_ids: sourceMemoryIds, + protocolVersion: request.protocolVersion, + provenance: request.provenance } }, status: request.status ?? "succeeded", @@ -1172,6 +1272,21 @@ export class SessionTurnService { appId: session.workspaceId, projectId: session.projectId, profileId: session.profileId, + workspacePath: session.workspacePath, + provenance: { + ...request.provenance, + sourceAgent: session.source, + tenantId: namespaceForSession(session).tenantId ?? "local", + profileId: session.profileId, + projectId: session.projectId, + workspaceId: session.workspaceId, + workspacePath: session.workspacePath, + sessionId: session.id, + turnId: step.turnId, + adapterId: request.adapterId ?? request.provenance?.adapterId, + requestId: request.requestId ?? request.provenance?.requestId, + sourceMemoryIds: rawTurn.sourceMemoryIds + }, layer: "L1", kind: "trace", memoryType: "LongTermMemory", @@ -1744,6 +1859,7 @@ export class SessionTurnService { const policies = this.deps.repos.memories.search( query, { + ...memoryFilterForNamespace(namespaceForSession(input.session)), memoryLayer: "L2", status: "activated" }, @@ -1753,6 +1869,7 @@ export class SessionTurnService { const l1Hits = this.deps.repos.memories.search( query, { + ...memoryFilterForNamespace(namespaceForSession(input.session)), memoryLayer: "L1", status: "activated" }, @@ -2001,6 +2118,7 @@ export class SessionTurnService { const repairLayers = retrievalLayersForMode("decision_repair"); const candidates = this.deps.repos.memories.list( { + ...memoryFilterForNamespace(namespaceForSession(session)), memoryLayer: repairLayers, status: ["activated", "resolving"] }, diff --git a/Memory/src/service/trials/skill-trial-resolver.ts b/Memory/src/service/trials/skill-trial-resolver.ts index b02e327bf..c81dd331d 100644 --- a/Memory/src/service/trials/skill-trial-resolver.ts +++ b/Memory/src/service/trials/skill-trial-resolver.ts @@ -28,6 +28,7 @@ import { MemoryServiceError } from "../../utils/error.js"; import { nowIso } from "../../utils/time.js"; import { recordApiLog } from "../model-audit/model-call-audit.js"; import { + namespaceIdFromContext as canonicalNamespaceIdFromContext, namespaceForMemory } from "../namespace/namespace-scope.js"; import { @@ -453,13 +454,7 @@ function namespaceIdFromMemory(memory: MemoryRow): string { } function namespaceIdFromContext(namespace: RuntimeNamespace): string { - return [ - namespace.tenantId, - namespace.userId, - namespace.projectId ?? namespace.workspaceId, - namespace.source, - namespace.profileId - ].filter(Boolean).join(":"); + return canonicalNamespaceIdFromContext(namespace); } function numberOr(value: unknown, fallback: number): number { diff --git a/Memory/src/service/turn/turn-normalization.ts b/Memory/src/service/turn/turn-normalization.ts index 1b6dbd017..0bc1ce159 100644 --- a/Memory/src/service/turn/turn-normalization.ts +++ b/Memory/src/service/turn/turn-normalization.ts @@ -13,7 +13,7 @@ export function buildRepairSuggestionQuery(request: RepairSuggestionRequest): st export function sanitizeTurnStartRequest>(request: T): T { return { ...request, query: sanitizeMemmyProtocolText(String(request.query ?? "")) }; } export function sanitizeTurnCompleteRequest>(request: T): T { const toolCalls = Array.isArray(request.toolCalls) ? request.toolCalls : []; return { ...request, query: sanitizeMemmyProtocolText(String(request.query ?? "")), answer: sanitizeMemmyProtocolText(String(request.answer ?? "")), toolCalls: Array.isArray(request.toolCalls) ? request.toolCalls.map(sanitizeMemmyProtocolValue) : request.toolCalls, toolResults: Array.isArray(request.toolResults) ? request.toolResults.map((result, index) => sanitizeCompleteTurnToolResult(result, toolNameFromToolCall(toolCalls[index]))) : request.toolResults }; } export function sanitizeMemoryAddRequest(request: T): T { return { ...request, content: sanitizeMemmyProtocolText(request.content ?? ""), title: typeof request.title === "string" ? sanitizeMemmyProtocolText(request.title) : request.title }; } -export function completeObservedRawTurn(existing: RawTurnRecord, request: TurnCompleteRequest & Record, completedAt: string): RawTurnRecord { const toolCalls = normalizeCompleteTurnToolCalls(request); const toolResults = normalizeCompleteTurnToolResults(request); return { ...existing, userText: request.query ?? existing.userText, assistantText: request.answer, reasoningSummary: stringFromMaybeRecord(request, "reasoningSummary") ?? existing.reasoningSummary, toolCalls: toolCalls.length ? toolCalls : existing.toolCalls, toolResults: toolResults.length ? toolResults : existing.toolResults, sourceMemoryIds: normalizeCompleteTurnSourceMemoryIds(request, existing.sourceMemoryIds), usage: isRecord(request.usage) ? request.usage : existing.usage, messagePayload: { ...(existing.messagePayload ?? {}), turn_complete: { completed_at: completedAt, source_memory_ids: normalizeCompleteTurnSourceMemoryIds(request, existing.sourceMemoryIds) } }, status: request.status ?? "succeeded" }; } +export function completeObservedRawTurn(existing: RawTurnRecord, request: TurnCompleteRequest & Record, completedAt: string): RawTurnRecord { const toolCalls = normalizeCompleteTurnToolCalls(request); const toolResults = normalizeCompleteTurnToolResults(request); return { ...existing, userText: request.query ?? existing.userText, assistantText: request.answer, reasoningSummary: stringFromMaybeRecord(request, "reasoningSummary") ?? existing.reasoningSummary, toolCalls: toolCalls.length ? toolCalls : existing.toolCalls, toolResults: toolResults.length ? toolResults : existing.toolResults, sourceMemoryIds: normalizeCompleteTurnSourceMemoryIds(request, existing.sourceMemoryIds), usage: isRecord(request.usage) ? request.usage : existing.usage, messagePayload: { ...(existing.messagePayload ?? {}), turn_complete: { completed_at: completedAt, source_memory_ids: normalizeCompleteTurnSourceMemoryIds(request, existing.sourceMemoryIds), protocolVersion: request.protocolVersion, provenance: request.provenance } }, status: request.status ?? "succeeded" }; } export function normalizeCompleteTurnSourceMemoryIds(request: TurnCompleteRequest & Record, fallback: string[] = []): string[] { return Array.isArray(request.sourceMemoryIds) ? request.sourceMemoryIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) : fallback; } export function normalizeCompleteTurnArtifacts(request: TurnCompleteRequest): NormalizedCompleteTurnArtifact[] { if (!Array.isArray(request.artifacts)) return []; return request.artifacts.map((artifact) => { if (!isRecord(artifact)) return null; const normalized: NormalizedCompleteTurnArtifact = { kind: stringFromRecord(artifact, "kind") ?? "artifact", payload: artifact }; const uri = stringFromRecord(artifact, "uri"); if (uri) normalized.uri = uri; return normalized; }).filter((artifact): artifact is NormalizedCompleteTurnArtifact => Boolean(artifact)); } export function normalizeCompleteTurnToolCalls(request: TurnCompleteRequest): ToolCallPayload[] { const results = normalizeCompleteTurnToolResults(request); return (Array.isArray(request.toolCalls) ? request.toolCalls : []).map((call, index) => normalizeCompleteTurnToolCall(call, results[index])).filter((call): call is ToolCallPayload => Boolean(call)); } diff --git a/Memory/src/service/worker/job-handlers.ts b/Memory/src/service/worker/job-handlers.ts index a472e8398..abf8326e2 100644 --- a/Memory/src/service/worker/job-handlers.ts +++ b/Memory/src/service/worker/job-handlers.ts @@ -22,6 +22,7 @@ import { } from "../embedding/embedding-pipeline.js"; import { memoryHasImportPipeline } from "../import/import-job-processor.js"; import { + namespaceIdFromContext as canonicalNamespaceIdFromContext, namespaceForMemory, namespaceForSession } from "../namespace/namespace-scope.js"; @@ -579,11 +580,5 @@ function namespaceIdFromSession(session: SessionRecord): string { } function namespaceIdFromContext(namespace: RuntimeNamespace): string { - return [ - namespace.tenantId, - namespace.userId, - namespace.projectId ?? namespace.workspaceId, - namespace.source, - namespace.profileId - ].filter(Boolean).join(":"); + return canonicalNamespaceIdFromContext(namespace); } diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts index 975ac1a63..9b7a944ff 100644 --- a/Memory/src/storage/repositories.ts +++ b/Memory/src/storage/repositories.ts @@ -1,3 +1,4 @@ +import type { ProjectFactRecord, ProjectGoalRecord, ProjectWorkItemRecord } from "../service/project-context/project-context-types.js"; import type Database from "better-sqlite3"; import { retrievalDocumentForMemory } from "../algorithm/plugin-algorithms.js"; import type { @@ -17,7 +18,7 @@ import type { } from "../types.js"; import { DEFAULT_NAMESPACE_SOURCE } from "../types.js"; import { newId, stableHash } from "../utils/id.js"; -import { asStringArray, parseJson, toJson } from "../utils/json.js"; +import { asStringArray, isRecord, parseJson, toJson } from "../utils/json.js"; import { nowIso } from "../utils/time.js"; import { attachMemoryVectors, @@ -36,6 +37,10 @@ import { type SqlValue = string | number | Buffer | null; const BUNDLE_TABLES = [ "memories", + "memory_relations", + "project_context_goals", + "project_context_work_items", + "project_context_facts", "sessions", "episodes", "raw_turns", @@ -350,6 +355,25 @@ export interface AuditLogRecord { createdAt: string; } +export interface MemoryRelationRecord { + id: string; + projectId?: string; + sourceMemoryId: string; + targetMemoryId: string; + relation: "supersedes"; + reason?: string; + actor: Record; + createdAt: string; +} + +export class MemoryVersionConflictError extends Error { + readonly code = "memory_version_conflict"; + constructor(readonly memoryId: string, readonly expectedVersion: number, readonly actualVersion: number) { + super(`memory version conflict for ${memoryId}: expected ${expectedVersion}, actual ${actualVersion}`); + this.name = "MemoryVersionConflictError"; + } +} + interface SqlApiLogRow { id: number; tool_name: ApiLogRecord["toolName"]; @@ -389,15 +413,15 @@ export class MemoryRepository { return attachMemoryVectors(prepared.memory, prepared.vectors); } - update(memory: MemoryRow): MemoryRow { - return this.updateRow(memory, true); + update(memory: MemoryRow, expectedVersion?: number): MemoryRow { + return this.updateRow(memory, true, expectedVersion); } updateMaintenance(memory: MemoryRow): MemoryRow { return this.updateRow(memory, false); } - private updateRow(memory: MemoryRow, bumpVersion: boolean): MemoryRow { + private updateRow(memory: MemoryRow, bumpVersion: boolean, expectedVersion?: number): MemoryRow { const existing = this.get(memory.id); if (!existing) { throw new Error(`memory not found: ${memory.id}`); @@ -407,7 +431,7 @@ export class MemoryRepository { ...prepared.memory, version: bumpVersion ? memory.version + 1 : existing.version }; - this.db + const result = this.db .prepare( `UPDATE memories SET timeline = @timeline, @@ -429,9 +453,13 @@ export class MemoryRepository { version = @version, updated_at = @updatedAt, deleted_at = @deletedAt - WHERE id = @id` + WHERE id = @id AND (@expectedVersion IS NULL OR version = @expectedVersion)` ) - .run(memoryToSql(updated)); + .run({ ...memoryToSql(updated), expectedVersion: expectedVersion ?? null }); + if (result.changes === 0 && expectedVersion !== undefined) { + const actual = this.get(memory.id)?.version ?? 0; + throw new MemoryVersionConflictError(memory.id, expectedVersion, actual); + } const mergedVectors = mergeMemoryVectors( attachedMemoryVectorEntries(existing), prepared.vectorUpdates @@ -871,6 +899,112 @@ export class MemoryRepository { }); } + supersede(input: { + oldMemory: MemoryRow; + newMemory: MemoryRow; + projectId?: string; + reason?: string; + actor?: Record; + createdAt?: string; + }): { oldMemory: MemoryRow; newMemory: MemoryRow; relation: MemoryRelationRecord } { + const at = input.createdAt ?? nowIso(); + const oldInternal = input.oldMemory.properties.internal_info; + const newInternal = input.newMemory.properties.internal_info; + const updatedOld = this.update({ + ...input.oldMemory, + status: "archived", + info: { + ...input.oldMemory.info, + superseded_by_memory_id: input.newMemory.id, + supersession_reason: input.reason + }, + properties: { + ...input.oldMemory.properties, + status: "archived", + internal_info: { + ...oldInternal, + superseded_by_memory_id: input.newMemory.id, + supersession_reason: input.reason + } + }, + updatedAt: at + }); + const updatedNew = this.update({ + ...input.newMemory, + info: { + ...input.newMemory.info, + supersedes_memory_ids: uniq([ + ...asStringArray(input.newMemory.info.supersedes_memory_ids), + input.oldMemory.id + ]), + supersession_reason: input.reason + }, + properties: { + ...input.newMemory.properties, + internal_info: { + ...newInternal, + supersedes_memory_ids: uniq([ + ...asStringArray(newInternal.supersedes_memory_ids), + input.oldMemory.id + ]), + supersession_reason: input.reason + } + }, + updatedAt: at + }); + const relation: MemoryRelationRecord = { + id: `relation_${stableHash({ source: updatedNew.id, target: updatedOld.id, relation: "supersedes" }).slice(0, 24)}`, + projectId: input.projectId, + sourceMemoryId: updatedNew.id, + targetMemoryId: updatedOld.id, + relation: "supersedes", + reason: input.reason, + actor: input.actor ?? {}, + createdAt: at + }; + this.db.prepare( + `INSERT INTO memory_relations ( + id, project_id, source_memory_id, target_memory_id, relation, reason, actor_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(source_memory_id, target_memory_id, relation) DO UPDATE SET + reason = excluded.reason, + actor_json = excluded.actor_json, + created_at = excluded.created_at` + ).run( + relation.id, + relation.projectId ?? null, + relation.sourceMemoryId, + relation.targetMemoryId, + relation.relation, + relation.reason ?? null, + toJson(relation.actor), + relation.createdAt + ); + return { oldMemory: updatedOld, newMemory: updatedNew, relation }; + } + + relationsFor(id: string): MemoryRelationRecord[] { + const rows = this.db.prepare( + `SELECT * FROM memory_relations + WHERE source_memory_id = ? OR target_memory_id = ? + ORDER BY created_at ASC, id ASC` + ).all(id, id) as Array<{ + id: string; project_id: string | null; source_memory_id: string; + target_memory_id: string; relation: "supersedes"; reason: string | null; + actor_json: string; created_at: string; + }>; + return rows.map((row) => ({ + id: row.id, + projectId: row.project_id ?? undefined, + sourceMemoryId: row.source_memory_id, + targetMemoryId: row.target_memory_id, + relation: row.relation, + reason: row.reason ?? undefined, + actor: parseJson(row.actor_json, {}), + createdAt: row.created_at + })); + } + softDelete(id: string, deletedAt = nowIso()): MemoryRow | undefined { const memory = this.get(id); if (!memory) { @@ -935,6 +1069,7 @@ export class MemoryRepository { summary: listSummaryForMemory(memory), tags: memory.tags, metrics: listMetricsForMemory(memory), + metadata: { namespace: namespaceSummaryFromMemory(memory) }, createdAt: memory.createdAt, updatedAt: memory.updatedAt, version: memory.version @@ -1371,9 +1506,12 @@ export class RuntimeRepository { } countEpisodesByStatus(userId?: string): Record<"open" | "processing" | "closed", number> { - void userId; const clauses = ["1=1"]; const params: SqlValue[] = []; + if (userId) { + clauses.push("user_id = ?"); + params.push(userId); + } const rows = this.db .prepare( `SELECT status, COUNT(*) AS count @@ -1945,10 +2083,16 @@ export class RuntimeRepository { } latestChangeSeq(userId?: string, namespaceId?: string): number { - void userId; - void namespaceId; const clauses = ["1=1"]; const params: SqlValue[] = []; + if (userId) { + clauses.push("user_id = ?"); + params.push(userId); + } + if (namespaceId) { + clauses.push("namespace_id = ?"); + params.push(namespaceId); + } const row = this.db .prepare(`SELECT MAX(seq) AS seq FROM memory_change_log WHERE ${clauses.join(" AND ")}`) .get(...params) as @@ -1958,10 +2102,16 @@ export class RuntimeRepository { } listChanges(userId?: string, limit = 50, cursor?: number, namespaceId?: string): ChangeLogRecord[] { - void userId; - void namespaceId; const clauses = ["1=1"]; const params: SqlValue[] = []; + if (userId) { + clauses.push("user_id = ?"); + params.push(userId); + } + if (namespaceId) { + clauses.push("namespace_id = ?"); + params.push(namespaceId); + } if (cursor) { clauses.push("seq > ?"); params.push(cursor); @@ -1992,6 +2142,20 @@ export class RuntimeRepository { })); } + listMemoryChanges(memoryId: string, limit = 100): ChangeLogRecord[] { + const rows = this.db.prepare( + `SELECT * FROM memory_change_log WHERE memory_id = ? ORDER BY seq DESC LIMIT ?` + ).all(memoryId, Math.max(1, Math.min(limit, 500))) as SqlChangeRow[]; + return rows.map((row) => ({ + seq: row.seq, memoryId: row.memory_id, namespaceId: row.namespace_id ?? undefined, + kind: row.kind ?? undefined, op: row.op ?? undefined, entityId: row.entity_id ?? undefined, + userId: row.user_id, changeType: row.change_type, version: row.version ?? undefined, + before: row.before_json ? parseJson(row.before_json, undefined) : undefined, + after: row.after_json ? parseJson(row.after_json, undefined) : undefined, + source: row.source, createdAt: row.created_at + })); + } + saveIdempotency(key: string, requestHash: string, response: unknown, createdAt = nowIso()): void { this.db .prepare( @@ -2083,13 +2247,16 @@ export class RuntimeRepository { } listJobs(status?: JobStatus, limit = 50, userId?: string): EvolutionJobRecord[] { - void userId; const clauses: string[] = []; const params: SqlValue[] = []; if (status) { clauses.push("status = ?"); params.push(status); } + if (userId) { + clauses.push("user_id = ?"); + params.push(userId); + } const rows = this.db .prepare( `SELECT * @@ -2367,6 +2534,33 @@ export class RuntimeRepository { return transaction(); } + retryFailedJobIds( + jobIds: readonly string[], + at = nowIso() + ): Array<{ before: EvolutionJobRecord; after: EvolutionJobRecord }> { + const ids = uniq([...jobIds]); + if (ids.length === 0) return []; + const placeholders = ids.map(() => "?").join(", "); + const transaction = this.db.transaction(() => { + const rows = this.db.prepare( + `SELECT * FROM evolution_jobs + WHERE id IN (${placeholders}) AND status IN ('failed', 'dead_letter')` + ).all(...ids) as SqlJobRow[]; + for (const row of rows) { + this.db.prepare( + `UPDATE evolution_jobs + SET status = 'queued', attempts = 0, leased_until = NULL, last_error = NULL, updated_at = ? + WHERE id = ?` + ).run(at, row.id); + } + return rows.map((row) => ({ + before: jobFromSql(row), + after: jobFromSql({ ...row, status: "queued", attempts: 0, leased_until: null, last_error: null, updated_at: at }) + })); + }); + return transaction(); + } + requeueLeasedJobsAfterRestart( at = nowIso() ): Array<{ before: EvolutionJobRecord; after: EvolutionJobRecord }> { @@ -2516,13 +2710,16 @@ export class RuntimeRepository { userId?: string, offset = 0 ): EmbeddingRetryRecord[] { - void userId; const clauses: string[] = []; const params: SqlValue[] = []; if (status) { clauses.push("q.status = ?"); params.push(status); } + if (userId) { + clauses.push("m.user_id = ?"); + params.push(userId); + } const rows = this.db .prepare( `SELECT q.* @@ -2540,10 +2737,12 @@ export class RuntimeRepository { status: EmbeddingRetryStatus, userId?: string ): number { - void userId; const row = this.db - .prepare(`SELECT COUNT(*) AS count FROM embedding_retry_queue WHERE status = ?`) - .get(status) as { count: number } | undefined; + .prepare(`SELECT COUNT(*) AS count + FROM embedding_retry_queue q + LEFT JOIN memories m ON m.id = q.target_id + WHERE q.status = ?${userId ? " AND m.user_id = ?" : ""}`) + .get(...(userId ? [status, userId] : [status])) as { count: number } | undefined; return row?.count ?? 0; } @@ -2800,7 +2999,7 @@ export class RuntimeRepository { } = {}): SkillTrialRecord[] { const clauses = ["1=1"]; const params: SqlValue[] = []; - void input.userId; + addOptional("user_id", input.userId); addOptional("skill_memory_id", input.skillMemoryId); addOptional("session_id", input.sessionId); addOptional("episode_id", input.episodeId); @@ -3456,16 +3655,130 @@ export class RuntimeRepository { } } +export class ProjectContextRepository { + constructor(private readonly db: Database.Database) {} + + getActiveGoal(namespaceId: string): ProjectGoalRecord | undefined { + return goalFromSql(this.db.prepare(`SELECT * FROM project_context_goals WHERE namespace_id = ? AND status = 'active'`).get(namespaceId) as ProjectGoalSqlRow | undefined); + } + getGoal(id: string): ProjectGoalRecord | undefined { + return goalFromSql(this.db.prepare(`SELECT * FROM project_context_goals WHERE id = ?`).get(id) as ProjectGoalSqlRow | undefined); + } + listGoals(namespaceId: string): ProjectGoalRecord[] { + return (this.db.prepare(`SELECT * FROM project_context_goals WHERE namespace_id = ? ORDER BY version DESC`).all(namespaceId) as ProjectGoalSqlRow[]).map((row) => goalFromSql(row)!); + } + insertGoal(goal: ProjectGoalRecord): ProjectGoalRecord { + if (goal.supersedesId && this.getGoal(goal.supersedesId)?.namespaceId !== goal.namespaceId) throw new Error("project goal namespace mismatch"); + try { + this.db.prepare(`INSERT INTO project_context_goals (id, namespace_id, user_id, project_id, workspace_id, workspace_path, title, summary, detail, acceptance_criteria_json, constraints_json, status, version, supersedes_id, source_memory_ids_json, provenance_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(goal.id, goal.namespaceId, goal.userId, goal.projectId ?? null, goal.workspaceId ?? null, goal.workspacePath ?? null, goal.title, goal.summary, goal.detail, toJson(goal.acceptanceCriteria), toJson(goal.constraints), goal.status, goal.version, goal.supersedesId ?? null, toJson(goal.sourceMemoryIds), toJson(goal.provenance), goal.createdAt, goal.updatedAt); + } catch (error) { + if (error instanceof Error && (error.message.includes("uq_project_context_active_goal") || (error.message.includes("project_context_goals.namespace_id") && goal.status === "active"))) throw new Error(`active project goal already exists for namespace ${goal.namespaceId}`); + throw error; + } + return goal; + } + replaceActiveGoal(next: ProjectGoalRecord): ProjectGoalRecord { + return this.db.transaction(() => { + const previous = this.getActiveGoal(next.namespaceId); + if (next.status !== "active" || (previous && next.supersedesId !== previous.id)) throw new Error("replacement active project goal must supersede the current namespace goal"); + this.db.prepare(`UPDATE project_context_goals SET status = 'archived', updated_at = ? WHERE namespace_id = ? AND status = 'active'`).run(next.updatedAt, next.namespaceId); + return this.insertGoal(next); + })(); + } + archiveGoalCandidate(id: string, namespaceId: string, at: string): ProjectGoalRecord { + const goal = this.getGoal(id); + if (!goal || goal.namespaceId !== namespaceId) throw new Error("project goal namespace mismatch"); + if (goal.status !== "candidate") throw new Error("only candidate project goals can be archived"); + this.db.prepare(`UPDATE project_context_goals SET status = 'archived', updated_at = ? WHERE id = ? AND namespace_id = ? AND status = 'candidate'`).run(at, id, namespaceId); + return { ...goal, status: "archived", updatedAt: at }; + } + + getFocusedWorkItem(namespaceId: string): ProjectWorkItemRecord | undefined { + return workItemFromSql(this.db.prepare(`SELECT * FROM project_context_work_items WHERE namespace_id = ? AND focused = 1`).get(namespaceId) as ProjectWorkItemSqlRow | undefined); + } + listWorkItems(namespaceId: string): ProjectWorkItemRecord[] { + return (this.db.prepare(`SELECT * FROM project_context_work_items WHERE namespace_id = ? ORDER BY updated_at DESC, id ASC`).all(namespaceId) as ProjectWorkItemSqlRow[]).map((row) => workItemFromSql(row)!); + } + insertWorkItem(item: ProjectWorkItemRecord): ProjectWorkItemRecord { + this.assertWorkItemGoalNamespace(item); + this.db.prepare(`INSERT INTO project_context_work_items (id, namespace_id, user_id, project_id, workspace_id, workspace_path, goal_id, title, summary, next_step, acceptance_criteria_json, constraints_json, status, focused, source_memory_ids_json, provenance_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(item.id, item.namespaceId, item.userId, item.projectId ?? null, item.workspaceId ?? null, item.workspacePath ?? null, item.goalId ?? null, item.title, item.summary, item.nextStep, toJson(item.acceptanceCriteria), toJson(item.constraints), item.status, item.focused ? 1 : 0, toJson(item.sourceMemoryIds), toJson(item.provenance), item.createdAt, item.updatedAt); + return item; + } + updateWorkItem(item: ProjectWorkItemRecord): ProjectWorkItemRecord { + const stored = workItemFromSql(this.db.prepare(`SELECT * FROM project_context_work_items WHERE id = ?`).get(item.id) as ProjectWorkItemSqlRow | undefined); + if (!stored || stored.namespaceId !== item.namespaceId) throw new Error("work item namespace mismatch"); + const next = { ...item, userId: stored.userId, projectId: stored.projectId, workspaceId: stored.workspaceId, workspacePath: stored.workspacePath, createdAt: stored.createdAt }; + this.assertWorkItemGoalNamespace(next); + this.db.prepare(`UPDATE project_context_work_items SET goal_id = ?, title = ?, summary = ?, next_step = ?, acceptance_criteria_json = ?, constraints_json = ?, status = ?, focused = ?, source_memory_ids_json = ?, provenance_json = ?, updated_at = ? WHERE id = ?`).run(next.goalId ?? null, next.title, next.summary, next.nextStep, toJson(next.acceptanceCriteria), toJson(next.constraints), next.status, next.focused ? 1 : 0, toJson(next.sourceMemoryIds), toJson(next.provenance), next.updatedAt, next.id); + return next; + } + setFocusedWorkItem(namespaceId: string, itemId: string | null, at: string): ProjectWorkItemRecord | undefined { + return this.db.transaction(() => { + const item = itemId ? workItemFromSql(this.db.prepare(`SELECT * FROM project_context_work_items WHERE id = ?`).get(itemId) as ProjectWorkItemSqlRow | undefined) : undefined; + if (itemId && !item) throw new Error(`work item not found: ${itemId}`); + if (item && item.namespaceId !== namespaceId) throw new Error("work item namespace mismatch"); + this.db.prepare(`UPDATE project_context_work_items SET focused = 0, updated_at = ? WHERE namespace_id = ? AND focused = 1`).run(at, namespaceId); + if (!item) return undefined; + this.db.prepare(`UPDATE project_context_work_items SET focused = 1, updated_at = ? WHERE id = ?`).run(at, item.id); + return { ...item, focused: true, updatedAt: at }; + })(); + } + listActiveFacts(namespaceId: string): ProjectFactRecord[] { + return (this.db.prepare(`SELECT * FROM project_context_facts WHERE namespace_id = ? AND status = 'active' ORDER BY updated_at DESC, id ASC`).all(namespaceId) as ProjectFactSqlRow[]).map(factFromSql); + } + insertFact(fact: ProjectFactRecord): ProjectFactRecord { + if (fact.supersedesId) { + const previous = this.db.prepare(`SELECT namespace_id FROM project_context_facts WHERE id = ?`).get(fact.supersedesId) as { namespace_id: string } | undefined; + if (!previous || previous.namespace_id !== fact.namespaceId) throw new Error("fact namespace mismatch"); + } + this.db.prepare(`INSERT INTO project_context_facts (id, namespace_id, user_id, project_id, workspace_id, workspace_path, kind, content, status, supersedes_id, source_memory_ids_json, provenance_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(fact.id, fact.namespaceId, fact.userId, fact.projectId ?? null, fact.workspaceId ?? null, fact.workspacePath ?? null, fact.kind, fact.content, fact.status, fact.supersedesId ?? null, toJson(fact.sourceMemoryIds), toJson(fact.provenance), fact.createdAt, fact.updatedAt); + return fact; + } + supersedeFact(previousId: string, next: ProjectFactRecord): ProjectFactRecord { + return this.db.transaction(() => { + const previous = this.db.prepare(`SELECT namespace_id FROM project_context_facts WHERE id = ?`).get(previousId) as { namespace_id: string } | undefined; + if (!previous || previous.namespace_id !== next.namespaceId) throw new Error("fact namespace mismatch"); + if (next.supersedesId !== previousId || next.status !== "active") throw new Error("replacement fact must be active and supersede the previous fact"); + this.db.prepare(`UPDATE project_context_facts SET status = 'superseded', updated_at = ? WHERE id = ?`).run(next.updatedAt, previousId); + return this.insertFact(next); + })(); + } + private assertWorkItemGoalNamespace(item: ProjectWorkItemRecord): void { + if (item.goalId && this.getGoal(item.goalId)?.namespaceId !== item.namespaceId) throw new Error("work item namespace mismatch"); + } +} + +interface ProjectContextSqlBase { id: string; namespace_id: string; user_id: string; project_id: string | null; workspace_id: string | null; workspace_path: string | null; source_memory_ids_json: string; provenance_json: string; created_at: string; updated_at: string } +interface ProjectGoalSqlRow extends ProjectContextSqlBase { title: string; summary: string; detail: string; acceptance_criteria_json: string; constraints_json: string; status: ProjectGoalRecord["status"]; version: number; supersedes_id: string | null } +interface ProjectWorkItemSqlRow extends ProjectContextSqlBase { goal_id: string | null; title: string; summary: string; next_step: string; acceptance_criteria_json: string; constraints_json: string; status: ProjectWorkItemRecord["status"]; focused: number } +interface ProjectFactSqlRow extends ProjectContextSqlBase { kind: ProjectFactRecord["kind"]; content: string; status: ProjectFactRecord["status"]; supersedes_id: string | null } + +function projectContextBase(row: ProjectContextSqlBase) { + const provenance = parseJson(row.provenance_json, {}); + return { id: row.id, namespaceId: row.namespace_id, userId: row.user_id, projectId: row.project_id ?? undefined, workspaceId: row.workspace_id ?? undefined, workspacePath: row.workspace_path ?? undefined, sourceMemoryIds: asStringArray(parseJson(row.source_memory_ids_json, [])), provenance: isRecord(provenance) ? provenance : {}, createdAt: row.created_at, updatedAt: row.updated_at }; +} +function goalFromSql(row: ProjectGoalSqlRow | undefined): ProjectGoalRecord | undefined { + return row ? { ...projectContextBase(row), title: row.title, summary: row.summary, detail: row.detail, acceptanceCriteria: asStringArray(parseJson(row.acceptance_criteria_json, [])), constraints: asStringArray(parseJson(row.constraints_json, [])), status: row.status, version: row.version, supersedesId: row.supersedes_id ?? undefined } : undefined; +} +function workItemFromSql(row: ProjectWorkItemSqlRow | undefined): ProjectWorkItemRecord | undefined { + return row ? { ...projectContextBase(row), goalId: row.goal_id ?? undefined, title: row.title, summary: row.summary, nextStep: row.next_step, acceptanceCriteria: asStringArray(parseJson(row.acceptance_criteria_json, [])), constraints: asStringArray(parseJson(row.constraints_json, [])), status: row.status, focused: row.focused === 1 } : undefined; +} +function factFromSql(row: ProjectFactSqlRow): ProjectFactRecord { + return { ...projectContextBase(row), kind: row.kind, content: row.content, status: row.status, supersedesId: row.supersedes_id ?? undefined }; +} + export class Repositories { readonly memories: MemoryRepository; readonly processing: MemoryProcessingRepository; readonly runtime: RuntimeRepository; + readonly projectContext: ProjectContextRepository; readonly vectors: SqliteVecStore; constructor(readonly db: Database.Database) { this.vectors = new SqliteVecStore(db); this.memories = new MemoryRepository(db, this.vectors); this.processing = new MemoryProcessingRepository(db); + this.projectContext = new ProjectContextRepository(db); this.runtime = new RuntimeRepository(db); } @@ -3627,6 +3940,54 @@ function listSummaryForMemory(memory: MemoryRow): string { ) ?? ""; } +function namespaceSummaryFromMemory(memory: MemoryRow): { + tenantId: string; + projectId: string; + workspaceId?: string; + workspacePath?: string; + label: string; +} { + const provenance = isRecord(memory.properties.internal_info.provenance) + ? memory.properties.internal_info.provenance as Record + : {}; + const tenantId = firstString( + memory.info.tenant_id, + memory.info.tenantId, + provenance.tenantId + ) ?? "local"; + const projectId = firstString( + memory.info.project_id, + memory.info.projectId, + provenance.projectId, + memory.appId + ) ?? "unscoped"; + const workspaceId = firstString( + memory.info.workspace_id, + memory.info.workspaceId, + provenance.workspaceId, + memory.appId + ); + const workspacePath = firstString( + memory.info.workspace_path, + memory.info.workspacePath, + provenance.workspacePath + ); + return { + tenantId, + projectId, + workspaceId, + workspacePath, + label: workspacePath ? workspacePath.split("/").filter(Boolean).pop() || workspacePath : (projectId !== "unscoped" ? projectId : workspaceId ?? "unscoped") + }; +} + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return undefined; +} + function listMetricsForMemory(memory: MemoryRow): MemoryListItem["metrics"] | undefined { const internal = memory.properties.internal_info; const trace = recordValue(internal.trace); @@ -3877,7 +4238,10 @@ function buildMemoryWhere(filter: MemoryFilter): { where: string; params: SqlVal const clauses = ["deleted_at IS NULL"]; const params: SqlValue[] = []; + addTenantClause(filter.tenantId); addValueClause("user_id", filter.userId); + addProjectClause(filter.projectId); + addWorkspaceClause(filter.workspaceId); addValueClause("session_id", filter.sessionId); addValueClause("conversation_id", filter.conversationId); addAgentIdClause(filter.agentId, filter.excludedAgentIds); @@ -3902,6 +4266,72 @@ function buildMemoryWhere(filter: MemoryFilter): { where: string; params: SqlVal params.push(value); } + function addTenantClause(tenantId: string | undefined): void { + if (tenantId === undefined) return; + const effectiveTenant = `CASE + WHEN session_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM sessions AS tenant_session WHERE tenant_session.id = memories.session_id + ) THEN COALESCE( + NULLIF(json_extract((SELECT tenant_session.meta_json FROM sessions AS tenant_session WHERE tenant_session.id = memories.session_id), '$.tenant_id'), ''), + NULLIF(json_extract((SELECT tenant_session.meta_json FROM sessions AS tenant_session WHERE tenant_session.id = memories.session_id), '$.tenantId'), ''), + 'local' + ) + ELSE COALESCE( + NULLIF(json_extract(info_json, '$.tenant_id'), ''), + NULLIF(json_extract(info_json, '$.tenantId'), ''), + NULLIF(json_extract(properties_json, '$.internal_info.provenance.tenantId'), ''), + 'local' + ) + END`; + clauses.push(`${effectiveTenant} = ?`); + params.push(tenantId); + } + + function addProjectClause(projectId: string | undefined): void { + if (projectId === undefined) return; + const effectiveProject = `CASE + WHEN session_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM sessions AS memory_session WHERE memory_session.id = memories.session_id + ) THEN ( + SELECT memory_session.project_id FROM sessions AS memory_session WHERE memory_session.id = memories.session_id + ) + ELSE COALESCE( + NULLIF(json_extract(info_json, '$.project_id'), ''), + NULLIF(json_extract(info_json, '$.projectId'), ''), + NULLIF(app_id, '') + ) + END`; + if (projectId === "unscoped") { + clauses.push(`${effectiveProject} IS NULL`); + return; + } + clauses.push(`${effectiveProject} = ?`); + params.push(projectId); + } + + function addWorkspaceClause(workspaceId: string | undefined): void { + if (workspaceId === undefined) return; + const effectiveWorkspace = `CASE + WHEN session_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM sessions AS workspace_session WHERE workspace_session.id = memories.session_id + ) THEN ( + SELECT workspace_session.workspace_id FROM sessions AS workspace_session WHERE workspace_session.id = memories.session_id + ) + ELSE COALESCE( + NULLIF(json_extract(info_json, '$.workspace_id'), ''), + NULLIF(json_extract(info_json, '$.workspaceId'), ''), + NULLIF(json_extract(properties_json, '$.internal_info.provenance.workspaceId'), ''), + NULLIF(app_id, '') + ) + END`; + if (workspaceId === "unscoped") { + clauses.push(`${effectiveWorkspace} IS NULL`); + return; + } + clauses.push(`${effectiveWorkspace} = ?`); + params.push(workspaceId); + } + function addRangeClause(column: string, operator: ">=" | "<", value: string | undefined): void { if (value === undefined) return; clauses.push(`${column} ${operator} ?`); diff --git a/Memory/src/storage/schema.ts b/Memory/src/storage/schema.ts index 58a795978..756820eff 100644 --- a/Memory/src/storage/schema.ts +++ b/Memory/src/storage/schema.ts @@ -1,7 +1,7 @@ import type Database from "better-sqlite3"; -export const SCHEMA_VERSION = 4; -export const SCHEMA_MIGRATION_ID = "004_memory_processing_state"; +export const SCHEMA_VERSION = 6; +export const SCHEMA_MIGRATION_ID = "006_project_context"; const API_LOG_SOURCE_AGENT_MIGRATION_FROM_VERSION = 2; const PROCESSING_TAGS = new Set([ "摘要排队中", @@ -61,6 +61,22 @@ const statements = [ `CREATE INDEX IF NOT EXISTS idx_memories_key_layer ON memories (memory_key, memory_layer)`, + `CREATE TABLE IF NOT EXISTS memory_relations ( + id TEXT PRIMARY KEY, + project_id TEXT, + source_memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE, + target_memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE, + relation TEXT NOT NULL CHECK (relation IN ('supersedes')), + reason TEXT, + actor_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(actor_json)), + created_at TEXT NOT NULL, + UNIQUE (source_memory_id, target_memory_id, relation) + )`, + `CREATE INDEX IF NOT EXISTS idx_memory_relations_source + ON memory_relations (source_memory_id, relation, created_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_memory_relations_target + ON memory_relations (target_memory_id, relation, created_at DESC)`, + `CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5 ( id UNINDEXED, identifier, @@ -452,7 +468,73 @@ const statements = [ created_at TEXT NOT NULL )`, `CREATE INDEX IF NOT EXISTS idx_audit_logs_user_created - ON audit_logs (user_id, created_at DESC)` + ON audit_logs (user_id, created_at DESC)`, + `CREATE TABLE IF NOT EXISTS project_context_goals ( + id TEXT PRIMARY KEY, + namespace_id TEXT NOT NULL, + user_id TEXT NOT NULL, + project_id TEXT, + workspace_id TEXT, + workspace_path TEXT, + title TEXT NOT NULL, + summary TEXT NOT NULL, + detail TEXT NOT NULL, + acceptance_criteria_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(acceptance_criteria_json)), + constraints_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(constraints_json)), + status TEXT NOT NULL CHECK (status IN ('candidate', 'active', 'completed', 'archived')), + version INTEGER NOT NULL, + supersedes_id TEXT REFERENCES project_context_goals(id), + source_memory_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(source_memory_ids_json)), + provenance_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(provenance_json)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS uq_project_context_active_goal + ON project_context_goals(namespace_id) WHERE status = 'active'`, + `CREATE INDEX IF NOT EXISTS idx_project_context_goals_namespace + ON project_context_goals(namespace_id, updated_at DESC)`, + `CREATE TABLE IF NOT EXISTS project_context_work_items ( + id TEXT PRIMARY KEY, + namespace_id TEXT NOT NULL, + user_id TEXT NOT NULL, + project_id TEXT, + workspace_id TEXT, + workspace_path TEXT, + goal_id TEXT REFERENCES project_context_goals(id), + title TEXT NOT NULL, + summary TEXT NOT NULL, + next_step TEXT NOT NULL, + acceptance_criteria_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(acceptance_criteria_json)), + constraints_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(constraints_json)), + status TEXT NOT NULL CHECK (status IN ('pending', 'active', 'blocked', 'completed', 'archived')), + focused INTEGER NOT NULL DEFAULT 0 CHECK (focused IN (0, 1)), + source_memory_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(source_memory_ids_json)), + provenance_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(provenance_json)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS uq_project_context_focused_work_item + ON project_context_work_items(namespace_id) WHERE focused = 1`, + `CREATE INDEX IF NOT EXISTS idx_project_context_work_items_namespace + ON project_context_work_items(namespace_id, updated_at DESC)`, + `CREATE TABLE IF NOT EXISTS project_context_facts ( + id TEXT PRIMARY KEY, + namespace_id TEXT NOT NULL, + user_id TEXT NOT NULL, + project_id TEXT, + workspace_id TEXT, + workspace_path TEXT, + kind TEXT NOT NULL CHECK (kind IN ('decision', 'constraint')), + content TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('candidate', 'active', 'superseded', 'archived')), + supersedes_id TEXT REFERENCES project_context_facts(id), + source_memory_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(source_memory_ids_json)), + provenance_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(provenance_json)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_project_context_facts_namespace + ON project_context_facts(namespace_id, status, updated_at DESC)` ]; export function migrate(db: Database.Database): void { @@ -462,7 +544,7 @@ export function migrate(db: Database.Database): void { const hasMemories = tableExists(db, "memories"); const version = currentSchemaVersion(db); - if (hasMemories && version !== SCHEMA_VERSION && version !== 2 && version !== 3) { + if (hasMemories && version !== SCHEMA_VERSION && version !== 2 && version !== 3 && version !== 4 && version !== 5) { throw new Error( `Unsupported memory database schema version ${version}; the database was left unchanged` ); diff --git a/Memory/src/types.ts b/Memory/src/types.ts index 52a4d872e..3f66496e9 100644 --- a/Memory/src/types.ts +++ b/Memory/src/types.ts @@ -1,9 +1,30 @@ +import type { ProjectContextStableResult } from "./service/project-context/project-context-types.js"; + export type IsoTime = string; export const DEFAULT_NAMESPACE_SOURCE = "unknown"; export type Cursor = string; export type MemoryLayer = "L1" | "L2" | "L3" | "Skill"; export type MemoryKind = "trace" | "span" | "policy" | "world_model" | "skill"; export type MemoryStatus = "activated" | "resolving" | "archived" | "deleted"; +export type MemoryRelation = "supersedes"; + +export interface MemoryProvenance { + sourceAgent: string; + tenantId?: string; + profileId?: string; + projectId?: string; + workspaceId?: string; + workspacePath?: string; + sessionId?: string; + turnId?: string; + adapterId?: string; + requestId?: string; + sourceMemoryIds: string[]; + repository?: string; + branch?: string; + commit?: string; + capturedAt: IsoTime; +} export type RetrievalMode = | "search" | "turn_start" @@ -71,6 +92,16 @@ export interface RequestEnvelope { namespace?: RuntimeNamespace; } +export type { + ProjectContextProposeGoalRequest, + ProjectContextReadState, + ProjectContextRequest, + ProjectContextStableResult, + ProjectFactRecord, + ProjectGoalRecord, + ProjectWorkItemRecord +} from "./service/project-context/project-context-types.js"; + export interface ApiErrorBody { error: { code: @@ -129,7 +160,10 @@ export interface MemoryRow { } export interface MemoryFilter { + tenantId?: string; userId?: string; + projectId?: string; + workspaceId?: string; sessionId?: string; conversationId?: string; agentId?: string; @@ -195,6 +229,22 @@ export interface MemoryDetailItem extends MemoryListItem { createdAt: IsoTime; sourceMemoryIds: string[]; metadata: Record; + provenance?: MemoryProvenance; + relations?: Array<{ + id: string; + projectId?: string; + sourceMemoryId: string; + targetMemoryId: string; + relation: MemoryRelation; + reason?: string; + actor: Record; + createdAt: IsoTime; + }>; + supersession?: { + supersedesMemoryIds: string[]; + supersededByMemoryId?: string; + reason?: string; + }; } export interface RawTurnSummary { @@ -230,6 +280,28 @@ export interface SessionCompactRequest extends RequestEnvelope { sourceMemoryIds?: string[]; tokenEstimate?: number; createL1?: boolean; + checkpoint?: SessionCheckpointPayload; +} + +export interface SessionCheckpointPayload { + task: string; + changes: string[]; + validated: string[]; + unverified: string[]; + nextSteps: string[]; +} + +export interface SessionCheckpointRequest extends RequestEnvelope { + episodeId?: string; + task: string; + changes?: string[]; + validated?: string[]; + unverified?: string[]; + nextSteps?: string[]; + sourceTurnIds?: string[]; + sourceMemoryIds?: string[]; + tokenEstimate?: number; + createL1?: boolean; } export interface SessionOpenRequest extends RequestEnvelope { @@ -240,6 +312,22 @@ export interface SessionOpenRequest extends RequestEnvelope { workspacePath?: string; sessionId?: string; meta?: Record; + protocolVersion?: string; + provenance?: Partial; +} + +export interface TurnStartResponse { + contextPacketId: string; + turnId: string; + sessionId: string; + searchEventId: string; + hits: RecallHit[]; + injectedContext: InjectedContext; + projectContext: ProjectContextStableResult; + sourceMemoryIds: string[]; + droppedDueToBudget: Array<{ id: string; kind: MemoryKind; memoryLayer: MemoryLayer; reason: "token_budget"; tokenEstimate?: number }>; + status: string[]; + serverTime: string; } export interface TurnStartRequest extends RequestEnvelope { @@ -248,6 +336,8 @@ export interface TurnStartRequest extends RequestEnvelope { turnId?: string; contextHints?: Record; contextBudget?: number; + protocolVersion?: string; + provenance?: Partial; } export interface TurnCompleteRequest extends RequestEnvelope { @@ -261,6 +351,8 @@ export interface TurnCompleteRequest extends RequestEnvelope { toolResults?: unknown[]; artifacts?: unknown[]; sourceMemoryIds?: string[]; + protocolVersion?: string; + provenance?: Partial; usage?: Record; status?: "succeeded" | "failed" | "cancelled"; } @@ -327,6 +419,10 @@ export interface MemoryAddRequest extends RequestEnvelope { turnId?: string; createdAt?: string; deferProcessing?: boolean; + sourceMemoryIds?: string[]; + provenance?: Partial; + supersedesMemoryId?: string; + supersessionReason?: string; } export interface FeedbackTarget { @@ -376,6 +472,16 @@ export interface MemoryImportRequest extends RequestEnvelope { export interface MemoryGovernanceRequest extends RequestEnvelope { reason?: string; + version?: number; +} + +export interface MemoryMarkdownExportRequest extends RequestEnvelope { + includeArchived?: boolean; +} + +export interface MemoryMarkdownImportRequest extends RequestEnvelope { + markdown: string; + apply?: boolean; } export interface RawTurnRedactRequest extends RequestEnvelope { diff --git a/Memory/src/viewer/static.ts b/Memory/src/viewer/static.ts index aeba7d54a..5db3376e8 100644 --- a/Memory/src/viewer/static.ts +++ b/Memory/src/viewer/static.ts @@ -1,84 +1,108 @@ export function memoryPanelHtml(): string { return ` - + - Memmy Memory Panel + + + Memmy Memory Console -
    -

    Memmy Memory Panel

    -
    - -
    -
    -
    - -
    -
    - - - - - -
    -
    -
    -
    -

    Memories

    - Idle + +
    + +
    +
    +
    +

    Memory 概览

    +

    存储、检索与演化状态

    -
    - - - - - - - - - - -
    LayerMemoryStatusUpdated
    - +
    + + +
    -
    +
    + + +
    +
    +
    +
    +
    +

    30 天写入

    +
    +
    +
    +

    Agent 来源

    +
    +
    +
    +

    项目 / Workspace

    +
    +
    +
    +

    处理队列

    +
    +
    +
    +

    最近变化

    +
    +
    +
    +

    L1 → L2 → L3 → Skill 演化流水线

    +
    +
    +
    +

    待审核提炼

    +
    +
    +
    +

    项目上下文包

    由原始记忆实时生成
    +
    +
    +
    +

    来源与项目隔离审计

    +
    +
    -
    -
    -
    + +
    +
    + + + + + + +
    - - -
    -
    {}
    -
    - -
    -
    +
    +
    +

    Memories

    等待加载
    +
    + + + +
    记忆状态更新时间
    + +
    + +
    + +
    + + +
    +
    + + + + +
    +
    +

    API 活动

    时间工具Agent / 结果耗时
    + +
    +
    + +
    +
    + +
    +
    +

    Episodes

    + +
    +
    + +
    +

    Agent Token 用量统计

    Pi、Codex、Claude Code 各 Agent 的 Token 消耗

    +
    +
    +

    选择项目

    按月 Token 用量
    +
    +
    +
    +

    月度用量

    +
    +
    +
    +

    项目累计

    +
    +
    +
    +
    +
    +
    +
    + +
    +

    Agent 来源与项目隔离审计

    检查缺失 workspace、未知来源、旧来源标签与跨项目泄漏风险

    +
    +

    审计问题

    最多显示 500 条
    Memory问题Project / WorkspaceSource
    +

    Workspace 上下文包

    +
    +
    + +
    +

    系统状态

    服务、存储、模型与脱敏配置

    +
    +

    Memory 服务

    +

    存储

    +

    模型

    运行配置
    +

    队列

    实时计数
    +

    脱敏配置

    {}
    +
    +
    + + + + + + + + `; diff --git a/Memory/tests/agent-token-stats-service.test.ts b/Memory/tests/agent-token-stats-service.test.ts new file mode 100644 index 000000000..486616388 --- /dev/null +++ b/Memory/tests/agent-token-stats-service.test.ts @@ -0,0 +1,133 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createAgentTokenStatsService } from "../src/service/agent-token-stats-service.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("agent token stats service", () => { + it("parses nested Pi usage and groups usage by month", async () => { + const homeDir = await createHome(); + const piDirectory = path.join(homeDir, ".pi", "agent", "sessions", "--workspace-demo--"); + await mkdir(piDirectory, { recursive: true }); + await writeJsonl(path.join(piDirectory, "pi-session.jsonl"), [ + { type: "session", timestamp: "2026-06-01T00:00:00.000Z", cwd: "/workspace/demo" }, + { + type: "message", + timestamp: "2026-06-12T10:00:00.000Z", + message: { + role: "assistant", + usage: { + input: 100, + output: 20, + cacheRead: 30, + cacheWrite: 5, + reasoning: 7, + totalTokens: 155, + cost: { total: 0.42 } + } + } + }, + { + type: "assistant", + timestamp: "2026-07-02T10:00:00.000Z", + usage: { input: 50, output: 10, cacheRead: 0, cacheWrite: 0, totalTokens: 60 }, + cost: { total: 0.1 } + } + ]); + + const response = await createAgentTokenStatsService({ homeDir, cacheTtlMs: 0 }).getStats(); + const project = response.projects.find((item) => item.project === "/workspace/demo"); + const pi = project?.agents.find((agent) => agent.agent === "pi"); + + expect(pi).toMatchObject({ + sessions: 1, + apiCalls: 2, + inputTokens: 150, + outputTokens: 30, + cacheReadTokens: 30, + cacheWriteTokens: 5, + reasoningTokens: 7, + totalTokens: 215 + }); + expect(pi?.cost).toBeCloseTo(0.52); + expect(response.monthly.map((entry) => entry.month)).toEqual(["2026-07", "2026-06"]); + expect(agentForMonth(response, "2026-06", "/workspace/demo", "pi")).toMatchObject({ + sessions: 1, + apiCalls: 1, + totalTokens: 155 + }); + expect(agentForMonth(response, "2026-07", "/workspace/demo", "pi")).toMatchObject({ + sessions: 1, + apiCalls: 1, + totalTokens: 60 + }); + }); + + it("uses the last cumulative Codex usage and assigns it to its timestamp month", async () => { + const homeDir = await createHome(); + const codexDirectory = path.join(homeDir, ".codex", "sessions", "2026", "07", "14"); + await mkdir(codexDirectory, { recursive: true }); + await writeJsonl(path.join(codexDirectory, "rollout-test.jsonl"), [ + { type: "session_meta", timestamp: "2026-07-14T09:00:00.000Z", payload: { cwd: "/workspace/demo" } }, + tokenCount("2026-07-14T09:01:00.000Z", 100), + tokenCount("2026-07-14T09:02:00.000Z", 250) + ]); + + const response = await createAgentTokenStatsService({ homeDir, cacheTtlMs: 0 }).getStats(); + expect(agentForMonth(response, "2026-07", "/workspace/demo", "codex")).toMatchObject({ + sessions: 1, + apiCalls: 1, + inputTokens: 200, + outputTokens: 50, + totalTokens: 250 + }); + }); +}); + +async function createHome(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), "memmy-token-stats-")); + temporaryDirectories.push(directory); + return directory; +} + +async function writeJsonl(filePath: string, entries: unknown[]): Promise { + await writeFile(filePath, entries.map((entry) => JSON.stringify(entry)).join("\n") + "\n", "utf8"); +} + +function tokenCount(timestamp: string, totalTokens: number) { + return { + type: "event_msg", + timestamp, + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: totalTokens - 50, + output_tokens: 50, + cached_input_tokens: 25, + cache_write_input_tokens: 0, + reasoning_output_tokens: 5, + total_tokens: totalTokens + } + } + } + }; +} + +function agentForMonth( + response: Awaited["getStats"]>>, + month: string, + project: string, + agent: "pi" | "codex" | "claude_code" +) { + return response.monthly + .find((entry) => entry.month === month) + ?.projects.find((item) => item.project === project) + ?.agents.find((item) => item.agent === agent); +} diff --git a/Memory/tests/cli-command-map.test.ts b/Memory/tests/cli-command-map.test.ts index 6c70b1cf7..1914f1871 100644 --- a/Memory/tests/cli-command-map.test.ts +++ b/Memory/tests/cli-command-map.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -354,6 +354,81 @@ describe("memmy CLI command map", () => { expect(requests[0]?.headers["x-memmy-user-id"]).toBe("user_cli_1"); }); + it("sends the nearest project root as the default workspace namespace header", async () => { + const root = mkdtempSync(join(tmpdir(), "memmy-cli-workspace-")); + roots.push(root); + writeFileSync(join(root, "package.json"), "{}"); + const nested = join(root, "src"); + const previousCwd = process.cwd(); + mkdirSync(nested, { recursive: true }); + process.chdir(nested); + try { + const { requests } = await runMappedCommand(["search", "test failures"]); + expect(requests[0]?.headers["x-memmy-workspace-path"]).toBe(root); + } finally { + process.chdir(previousCwd); + } + }); + + it("sends explicit project and workspace namespace headers", async () => { + const { requests } = await runMappedCommand([ + "search", "test failures", + "--project-id", "project-alpha", + "--workspace-id", "workspace-alpha", + "--workspace-path", "/work/project-alpha" + ]); + + expect(requests[0]?.headers["x-memmy-project-id"]).toBe("project-alpha"); + expect(requests[0]?.headers["x-memmy-workspace-id"]).toBe("workspace-alpha"); + expect(requests[0]?.headers["x-memmy-workspace-path"]).toBe("/work/project-alpha"); + }); + + it("can disable automatic workspace namespace headers", async () => { + const { requests } = await runMappedCommand([ + "search", "test failures", + "--no-workspace" + ]); + + expect(requests[0]?.headers["x-memmy-project-id"]).toBeUndefined(); + expect(requests[0]?.headers["x-memmy-workspace-id"]).toBeUndefined(); + expect(requests[0]?.headers["x-memmy-workspace-path"]).toBeUndefined(); + }); + + it("reports the current workspace namespace without an HTTP call", async () => { + const root = mkdtempSync(join(tmpdir(), "memmy-cli-namespace-")); + roots.push(root); + writeFileSync(join(root, "package.json"), "{}"); + const previousCwd = process.cwd(); + process.chdir(root); + try { + await expect(runCommand({ argv: ["namespace", "current", "--source", "codex"] })).resolves.toMatchObject({ + source: "codex", + workspacePath: root, + scoped: true + }); + } finally { + process.chdir(previousCwd); + } + }); + + it("maps workspace stats to the scoped overview endpoint", async () => { + const { requests } = await runMappedCommand(["stats", "--workspace", "--workspace-id", "workspace-alpha"]); + expect(requests[0]?.method).toBe("GET"); + expect(requests[0]?.url).toBe(`${baseUrl}/api/v1/panel/overview`); + expect(requests[0]?.headers["x-memmy-workspace-id"]).toBe("workspace-alpha"); + }); + + it("runs doctor health, audit, and evolution checks", async () => { + const requests: CapturedRequest[] = []; + const result = await runCommand({ argv: ["doctor", "--url", baseUrl, "--no-workspace"], fetch: mockFetch(requests) }); + expect(requests.map((request) => new URL(request.url).pathname)).toEqual([ + "/api/v1/health", + "/api/v1/panel/namespace-audit", + "/api/v1/panel/evolution" + ]); + expect(result).toMatchObject({ checks: { service: "ok", workspaceIsolation: "ok" } }); + }); + it("sends source in the request body for CLI memory commands", async () => { const { requests } = await runMappedCommand([ "turn", "start", "test failures", @@ -513,7 +588,11 @@ function mockFetch(requests: CapturedRequest[]): typeof fetch { ? JSON.parse(init.body) as unknown : undefined; requests.push({ method, url, headers, body }); - return new Response(JSON.stringify({ ok: true }), { + const path = new URL(url).pathname; + const response = path === "/api/v1/panel/namespace-audit" + ? { summary: { crossWorkspaceRisk: 0, unknownSource: 0, missingAgentSourceTag: 0 } } + : { ok: true }; + return new Response(JSON.stringify(response), { status: 200, headers: { "content-type": "application/json" diff --git a/Memory/tests/contract/memory-rest-service.test.ts b/Memory/tests/contract/memory-rest-service.test.ts index 844aea8de..f9c9df478 100644 --- a/Memory/tests/contract/memory-rest-service.test.ts +++ b/Memory/tests/contract/memory-rest-service.test.ts @@ -98,6 +98,93 @@ describe("MemoryService / REST contract", () => { }); db.close(); }); + it("serves all project-context routes with strict inputs, auth, idempotency, and null focus", async () => { + const { db, service } = createTestService(); + const server = createMemoryHttpServer({ service, auth: { localServiceToken: "memory-token" } }); + await withServerClosed(server, async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected TCP address"); + const base = `http://127.0.0.1:${address.port}`; + const namespace = { source: "codex", profileId: "default", userId: "rest-user", projectId: "project-4" }; + const provenance = { sourceAgent: "codex", sourceMemoryIds: [], capturedAt: "2026-06-08T10:00:00.000Z", adapterId: "adapter-4", requestId: "req-4" }; + const headers = { authorization: "Bearer memory-token", "content-type": "application/json" }; + const goalInput = { namespace, source: "codex", adapterId: "adapter-4", requestId: "req-goal", provenance: { ...provenance, requestId: "req-goal" }, title: "Ship Task 4", summary: "Finish context API", detail: "All routes are covered", acceptanceCriteria: ["REST works"], constraints: ["strict schemas"] }; + const createGoal = await fetch(`${base}/api/v1/project-context/goals/propose`, { method: "POST", headers, body: JSON.stringify(goalInput) }); + const goal = await createGoal.json() as { id: string; status: string }; + expect(createGoal.status).toBe(200); + expect(goal.status).toBe("candidate"); + const duplicateGoal = await fetch(`${base}/api/v1/project-context/goals/propose`, { method: "POST", headers, body: JSON.stringify(goalInput) }); + const duplicateGoalBody = await duplicateGoal.json() as { id: string; duplicate?: boolean }; + expect(duplicateGoalBody.id).toBe(goal.id); + expect(duplicateGoalBody.duplicate).toBe(true); + const state = await fetch(`${base}/api/v1/project-context/state?namespace=${encodeURIComponent(JSON.stringify(namespace))}`, { headers: { authorization: "Bearer memory-token" } }); + expect(state.status).toBe(200); + expect((await state.json()).goals).toHaveLength(1); + const decision = { namespace, source: "codex", adapterId: "adapter-4", requestId: "req-decision", provenance: { ...provenance, requestId: "req-decision" } }; + const rejectedCandidateResponse = await fetch(`${base}/api/v1/project-context/goals/propose`, { method: "POST", headers, body: JSON.stringify({ ...goalInput, requestId: "req-reject-candidate", provenance: { ...provenance, requestId: "req-reject-candidate" }, title: "Discard me" }) }); + const rejectedCandidate = await rejectedCandidateResponse.json() as { id: string }; + const rejected = await fetch(`${base}/api/v1/project-context/goals/${encodeURIComponent(rejectedCandidate.id)}/reject`, { method: "POST", headers, body: JSON.stringify({ ...decision, requestId: "req-reject", provenance: { ...provenance, requestId: "req-reject" } }) }); + expect((await rejected.json()).status).toBe("archived"); + const approved = await fetch(`${base}/api/v1/project-context/goals/${encodeURIComponent(goal.id)}/approve`, { method: "POST", headers, body: JSON.stringify(decision) }); + expect((await approved.json()).status).toBe("active"); + const workInput = { ...decision, requestId: "req-work", provenance: { ...provenance, requestId: "req-work" }, title: "Implement tests", summary: "Add route tests", nextStep: "Review failures" }; + const workResponse = await fetch(`${base}/api/v1/project-context/work-items`, { method: "POST", headers, body: JSON.stringify(workInput) }); + const work = await workResponse.json() as { id: string }; + expect(workResponse.status).toBe(200); + const update = await fetch(`${base}/api/v1/project-context/work-items/${encodeURIComponent(work.id)}`, { method: "PATCH", headers, body: JSON.stringify({ ...decision, requestId: "req-update", provenance: { ...provenance, requestId: "req-update" }, status: "active" }) }); + expect((await update.json()).status).toBe("active"); + const focused = await fetch(`${base}/api/v1/project-context/focus`, { method: "PUT", headers, body: JSON.stringify({ ...decision, requestId: "req-focus", provenance: { ...provenance, requestId: "req-focus" }, workItemId: work.id }) }); + expect((await focused.json()).focused).toBe(true); + const cleared = await fetch(`${base}/api/v1/project-context/focus`, { method: "PUT", headers, body: JSON.stringify({ ...decision, requestId: "req-clear", provenance: { ...provenance, requestId: "req-clear" }, workItemId: null }) }); + expect(await cleared.json()).toBeNull(); + const missing = await fetch(`${base}/api/v1/project-context/goals/propose`, { method: "POST", headers, body: JSON.stringify({ ...goalInput, requestId: "req-invalid", provenance: { ...provenance, requestId: "req-invalid" }, source: undefined }) }); + expect(missing.status).toBe(400); + const unauthorized = await fetch(`${base}/api/v1/project-context/state?namespace=${encodeURIComponent(JSON.stringify(namespace))}`); + for (const field of ["namespace", "source", "adapterId", "requestId", "provenance"] as const) { + const invalid: Record = { ...goalInput, requestId: `req-invalid-${field}`, provenance: { ...provenance, requestId: `req-invalid-${field}` } }; + delete invalid[field]; + const response = await fetch(`${base}/api/v1/project-context/goals/propose`, { method: "POST", headers, body: JSON.stringify(invalid) }); + expect(response.status, field).toBe(400); + } + expect(unauthorized.status).toBe(401); + }); + db.close(); + }); + + it("serves structured session checkpoints through the REST client", async () => { + const { db, service } = createTestService(); + const server = createMemoryHttpServer({ service }); + await withServerClosed(server, async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected TCP address"); + const client = new MemoryRestClient({ endpoint: `http://127.0.0.1:${address.port}` }); + const opened = await client.openSession({ + sessionId: "checkpoint-rest-session", + source: "codex", + workspacePath: "/work/checkpoint-rest" + }) as { sessionId: string }; + const result = await client.checkpointSession(opened.sessionId, { + task: "Prepare a resumable handoff", + changes: ["Stored structured fields"], + validated: ["REST route exercised"], + unverified: ["Production load"], + nextSteps: ["Resume from checkpoint"] + }) as { + checkpointId: string; + checkpoint: { task: string; nextSteps: string[] }; + l1MemoryId?: string; + }; + expect(result.checkpointId).toMatch(/^raw_/u); + expect(result.checkpoint).toMatchObject({ + task: "Prepare a resumable handoff", + nextSteps: ["Resume from checkpoint"] + }); + expect(result.l1MemoryId).toMatch(/^trace_/u); + }); + db.close(); + }); it("serves the manual memory-processing retry endpoint", async () => { const root = mkdtempSync(join(tmpdir(), "mindock-memory-http-processing-retry-")); @@ -214,10 +301,14 @@ describe("MemoryService / REST contract", () => { const started = await startResponse.json() as { searchEventId: string; turnId: string; + droppedDueToBudget: unknown[]; + projectContext: { version: number; status: string; goal: unknown; focusedWorkItem: unknown; markdown: string }; }; expect(startResponse.status).toBe(200); expect(started.turnId).toBe("cursor-http-turn"); - expect(started).not.toHaveProperty("episodeId"); + expect(started.projectContext).toMatchObject({ version: 0, status: "no_confirmed_goal", goal: null, focusedWorkItem: null }); + expect(started.projectContext.markdown).toContain(''); + expect(started.droppedDueToBudget).toEqual([]); const afterFirstStart = { episodes: (db.db.prepare("SELECT COUNT(*) AS count FROM episodes").get() as { count: number }).count, rawTurns: (db.db.prepare("SELECT COUNT(*) AS count FROM raw_turns").get() as { count: number }).count, @@ -272,7 +363,10 @@ describe("MemoryService / REST contract", () => { const completeResponse = await fetch(baseUrl + "/turns/cursor-http-turn/complete", { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + "x-memmy-workspace-path": "/tmp/hook-workspace" + }, body: JSON.stringify({ adapterId: "memmy-cursor-hook", requestId: "cursor-complete:http-fields", @@ -904,7 +998,7 @@ describe("MemoryService / REST contract", () => { } }) }); - expect(crossNamespace.status).toBe(200); + expect(crossNamespace.status).toBe(403); const sessionResponse = await fetch(`${baseUrl}/sessions/open`, { method: "POST", @@ -987,6 +1081,10 @@ describe("MemoryService / REST contract", () => { const body = await response.json() as { debug: { hits: Array<{ id: string; tags: string[] }>; + retrievalDebug: { + candidateCount: number; + tierSizes: Record; + }; }; }; @@ -994,7 +1092,183 @@ describe("MemoryService / REST contract", () => { expect(body.debug.hits).toHaveLength(1); expect(body.debug.hits[0]?.tags).toContain("conv-26"); expect(body.debug.hits[0]?.tags).not.toContain("conv-30"); + expect(body.debug.retrievalDebug.candidateCount).toBe(2); + expect(body.debug.retrievalDebug.tierSizes).toBeDefined(); + + }); + db.close(); + }); + + it("serves scoped Markdown audit export, preview, apply, and isolation", async () => { + const { db, service } = createTestService(); + const projectA = { + source: "codex", + profileId: "main", + userId: "markdown-http-user", + projectId: "project-a", + workspaceId: "workspace-a", + workspacePath: "/work/project-a" + }; + const projectB = { ...projectA, projectId: "project-b", workspaceId: "workspace-b", workspacePath: "/work/project-b" }; + service.addMemory({ namespace: projectA, layer: "L2", title: "Project A policy", content: "Only project A should be exported.", deferProcessing: true }); + service.addMemory({ namespace: projectB, layer: "L2", title: "Project B policy", content: "This must stay out of project A.", deferProcessing: true }); + const server = createMemoryHttpServer({ + service, + auth: { + cloudAccessTokens: { + "markdown-http-token": { + source: "codex", + profileId: "main", + userId: "markdown-http-user", + projectId: "project-a", + workspaceId: "workspace-a" + } + } + } + }); + await withServerClosed(server, async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected TCP address"); + const endpoint = `http://127.0.0.1:${address.port}/api/v1/memory/audit/markdown`; + const headers = { authorization: "Bearer markdown-http-token" }; + const exported = await fetch(endpoint, { headers }); + const exportedBody = await exported.json() as { markdown: string; count: number }; + expect(exported.status).toBe(200); + expect(exportedBody.count).toBe(1); + expect(exportedBody.markdown).toContain("Project A policy"); + expect(exportedBody.markdown).not.toContain("Project B policy"); + + const editedMarkdown = exportedBody.markdown + .replace("Project A policy", "Project A reviewed policy") + .replace("Only project A should be exported.", "Project A was reviewed through REST."); + const preview = await fetch(endpoint + "/import", { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ markdown: editedMarkdown, apply: false }) + }); + expect(preview.status).toBe(200); + expect(await preview.json()).toMatchObject({ applied: false, count: 1, updated: [expect.any(String)] }); + + const applied = await fetch(endpoint + "/import", { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ markdown: editedMarkdown, apply: true }) + }); + expect(applied.status).toBe(200); + expect(await applied.json()).toMatchObject({ applied: true, count: 1, updated: [expect.any(String)] }); + + const crossProject = await fetch(endpoint + "/import", { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ namespace: projectB, markdown: editedMarkdown, apply: true }) + }); + expect(crossProject.status).toBe(403); + }); + db.close(); + }); + + it("edits a context-pack source memory and regenerates the pack without losing provenance", async () => { + const { db, service } = createTestService(); + const namespace = { + source: "codex", + profileId: "main", + userId: "context-pack-editor", + projectId: "project-context", + workspaceId: "workspace-context", + workspacePath: "/work/project-context" + }; + const added = service.addMemory({ + namespace, + layer: "L2", + title: "Architecture service boundary", + content: "Architecture module uses a repository boundary.", + tags: ["architecture"], + deferProcessing: true + }); + const server = createMemoryHttpServer({ + service, + auth: { cloudAccessTokens: { "context-editor-token": namespace } } + }); + await withServerClosed(server, async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected TCP address"); + const baseUrl = `http://127.0.0.1:${address.port}/api/v1`; + const headers = { authorization: "Bearer context-editor-token" }; + + const beforePack = await fetch(`${baseUrl}/panel/context-packs`, { headers }); + const beforePackBody = await beforePack.json() as { packs: Array<{ architectureFacts: Array<{ id: string; title: string }> }> }; + expect(beforePackBody.packs[0]?.architectureFacts).toContainEqual(expect.objectContaining({ id: added.id, title: "Architecture service boundary" })); + const edit = await fetch(`${baseUrl}/memory/${encodeURIComponent(added.id)}/edit`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ + version: 1, + title: "Architecture API boundary", + content: "Architecture module now uses an API repository boundary.", + tags: ["architecture", "api"], + reason: "context pack correction" + }) + }); + expect(edit.status).toBe(200); + expect(await edit.json()).toMatchObject({ ok: true, id: added.id, version: 2 }); + + const detail = await fetch(`${baseUrl}/memory/${encodeURIComponent(added.id)}`, { headers }); + const detailBody = await detail.json() as { item: { id: string; title: string; body: string; version: number; provenance?: { sourceAgent?: string; workspaceId?: string } } }; + expect(detailBody.item).toMatchObject({ + id: added.id, + title: "Architecture API boundary", + body: "Architecture module now uses an API repository boundary.", + version: 2, + provenance: { sourceAgent: "codex", workspaceId: "workspace-context" } + }); + + const afterPack = await fetch(`${baseUrl}/panel/context-packs`, { headers }); + const afterPackBody = await afterPack.json() as { packs: Array<{ markdown: string; architectureFacts: Array<{ id: string; title: string; summary: string }> }> }; + expect(afterPackBody.packs[0]?.architectureFacts).toContainEqual(expect.objectContaining({ id: added.id, title: "Architecture API boundary", summary: "Architecture module now uses an API repository boundary." })); + expect(afterPackBody.packs[0]?.architectureFacts).not.toContainEqual(expect.objectContaining({ title: "Architecture service boundary" })); + expect(afterPackBody.packs[0]?.markdown).toContain("Architecture module now uses an API repository boundary."); + expect(afterPackBody.packs[0]?.markdown).not.toContain("Architecture module uses a repository boundary."); + + const staleEdit = await fetch(`${baseUrl}/memory/${encodeURIComponent(added.id)}/edit`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ version: 1, title: "Stale title", content: "This must not overwrite version 2.", tags: [] }) + }); + expect(staleEdit.status).toBe(409); + expect(await staleEdit.json()).toMatchObject({ error: { code: "conflict" } }); + + const history = await fetch(`${baseUrl}/memory/${encodeURIComponent(added.id)}/history`, { headers }); + expect(history.status).toBe(200); + expect(await history.json()).toMatchObject({ + id: added.id, + currentVersion: 2, + items: expect.arrayContaining([ + expect.objectContaining({ version: 1 }), + expect.objectContaining({ version: 2, changeType: "content_edit" }) + ]) + }); + + const restore = await fetch(`${baseUrl}/memory/${encodeURIComponent(added.id)}/history/1/restore`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ version: 2, reason: "restore original wording" }) + }); + expect(restore.status).toBe(200); + expect(await restore.json()).toMatchObject({ ok: true, id: added.id, version: 3, restoredVersion: 1 }); + + const restoredDetail = await fetch(`${baseUrl}/memory/${encodeURIComponent(added.id)}`, { headers }); + expect(await restoredDetail.json()).toMatchObject({ + item: { title: "Architecture service boundary", body: "Architecture module uses a repository boundary.", version: 3 } + }); + + const restoredHistory = await fetch(`${baseUrl}/memory/${encodeURIComponent(added.id)}/history`, { headers }); + expect(await restoredHistory.json()).toMatchObject({ + currentVersion: 3, + items: expect.arrayContaining([expect.objectContaining({ version: 3, changeType: "restore" })]) + }); }); db.close(); }); @@ -1135,9 +1409,9 @@ describe("MemoryService / REST contract", () => { authorization: "Bearer reader-b" } }); - const crossUserBody = await crossUserGet.json() as { id: string }; - expect(crossUserGet.status).toBe(200); - expect(crossUserBody.id).toBe(complete.l1MemoryId); + const crossUserBody = await crossUserGet.json() as { error: { code: string } }; + expect(crossUserGet.status).toBe(404); + expect(crossUserBody.error.code).toBe("not_found"); }); db.close(); }); diff --git a/Memory/tests/contract/rest-panel-events.test.ts b/Memory/tests/contract/rest-panel-events.test.ts index 6c2db7a56..216eed401 100644 --- a/Memory/tests/contract/rest-panel-events.test.ts +++ b/Memory/tests/contract/rest-panel-events.test.ts @@ -39,7 +39,7 @@ describe("REST panel contract", () => { const viewerHtml = await viewerResponse.text(); expect(viewerResponse.status).toBe(200); expect(viewerResponse.headers.get("content-type")).toContain("text/html"); - expect(viewerHtml).toContain("Memmy Memory Panel"); + expect(viewerHtml).toContain("Memmy Memory Console"); expect(viewerHtml).toContain("/api/v1/panel/items"); expect(viewerHtml).toContain("/api/v1/memory/"); expect(viewerHtml).not.toContain("EventSource"); @@ -77,11 +77,64 @@ describe("REST panel contract", () => { expect(search.injectedContext).toContain(completed.l1MemoryId); const overview = await client.panelOverview() as { counts: { memories: number } }; expect(overview.counts.memories).toBeGreaterThan(0); + const panelHeaders = { authorization: "Bearer panel-token" }; + const [analysisResponse, metricsResponse, statusResponse, configResponse, activityResponse, evolutionResponse, contextPackResponse, namespaceAuditResponse] = await Promise.all([ + fetch(`${endpoint}/api/v1/panel/analysis`, { headers: panelHeaders }), + fetch(`${endpoint}/api/v1/panel/metrics`, { headers: panelHeaders }), + fetch(`${endpoint}/api/v1/panel/status`, { headers: panelHeaders }), + fetch(`${endpoint}/api/v1/panel/config`, { headers: panelHeaders }), + fetch(`${endpoint}/api/v1/panel/activity?limit=10`, { headers: panelHeaders }), + fetch(`${endpoint}/api/v1/panel/evolution`, { headers: panelHeaders }), + fetch(`${endpoint}/api/v1/panel/context-packs`, { headers: panelHeaders }), + fetch(`${endpoint}/api/v1/panel/namespace-audit`, { headers: panelHeaders }) + ]); + expect([ + analysisResponse.status, + metricsResponse.status, + statusResponse.status, + configResponse.status, + activityResponse.status, + evolutionResponse.status, + contextPackResponse.status, + namespaceAuditResponse.status + ]).toEqual([200, 200, 200, 200, 200, 200, 200, 200]); + await expect(evolutionResponse.json()).resolves.toMatchObject({ l2Resolving: { active: expect.any(Boolean) } }); + await expect(contextPackResponse.json()).resolves.toMatchObject({ packs: [expect.objectContaining({ markdown: expect.stringContaining("Project Memory Pack") })] }); + await expect(namespaceAuditResponse.json()).resolves.toMatchObject({ summary: { total: expect.any(Number) } }); + const configStatus = await configResponse.json() as { redacted: boolean; config: Record }; + expect(configStatus.redacted).toBe(true); + expect(configStatus.config).toBeTypeOf("object"); const items = await client.panelItems({ layer: "L1" }) as { items: Array<{ id: string; metadata?: { source?: string } }> }; expect(items.items.map((item) => item.id)).toContain(completed.l1MemoryId); expect(items.items.find((item) => item.id === completed.l1MemoryId)?.metadata?.source).toBe("openclaw"); const detail = await client.getMemory(completed.l1MemoryId) as { item: { id: string } }; expect(detail.item.id).toBe(completed.l1MemoryId); + + const actionHeaders = { ...panelHeaders, "content-type": "application/json" }; + const qualityResponse = await fetch(`${endpoint}/api/v1/memory/${completed.l1MemoryId}/quality`, { + method: "POST", headers: actionHeaders, body: JSON.stringify({ useful: true }) + }); + await expect(qualityResponse.json()).resolves.toMatchObject({ ok: true, useful: true }); + const promoteResponse = await fetch(`${endpoint}/api/v1/memory/${completed.l1MemoryId}/promote`, { + method: "POST", headers: actionHeaders, body: JSON.stringify({ reason: "contract promotion" }) + }); + const promoted = await promoteResponse.json() as { id: string; memoryLayer: string }; + expect(promoted).toMatchObject({ memoryLayer: "L2" }); + + const duplicate = service.addMemory({ content: "Duplicate L1 for merge", layer: "L1", source: "openclaw" }); + const mergeResponse = await fetch(`${endpoint}/api/v1/memory/${completed.l1MemoryId}/merge`, { + method: "POST", headers: actionHeaders, body: JSON.stringify({ sourceMemoryId: duplicate.id }) + }); + await expect(mergeResponse.json()).resolves.toMatchObject({ ok: true, archivedSourceId: duplicate.id }); + + for (const path of ["run", "retry-failed", "promote-candidates"]) { + const response = await fetch(`${endpoint}/api/v1/worker/${path}`, { + method: "POST", headers: actionHeaders, body: JSON.stringify({ limit: 20 }) + }); + const result = await response.json() as { generated?: { L2: number; L3: number; Skill: number } }; + expect(response.status).toBe(200); + expect(result.generated).toMatchObject({ L2: expect.any(Number), L3: expect.any(Number), Skill: expect.any(Number) }); + } const deleted = await client.deleteMemory(completed.l1MemoryId) as { ok: boolean; id: string; diff --git a/Memory/tests/fixtures/project-evidence-dataset.ts b/Memory/tests/fixtures/project-evidence-dataset.ts new file mode 100644 index 000000000..31d9d4060 --- /dev/null +++ b/Memory/tests/fixtures/project-evidence-dataset.ts @@ -0,0 +1,41 @@ +import type { ProjectEvidenceInput } from "../../src/service/evolution/project-evidence.js"; + +export interface ProjectEvidenceEpisodeFixture { + id: string; + label: "noise" | "evidence"; + input: ProjectEvidenceInput; +} + +// Compact replay set sampled from the failure modes seen in imported agent traces. +export const PROJECT_EVIDENCE_DATASET: ProjectEvidenceEpisodeFixture[] = [ + { id: "ep-01", label: "noise", input: { id: "ep-01", userText: "继续" } }, + { id: "ep-02", label: "noise", input: { id: "ep-02", userText: "有了吗" } }, + { id: "ep-03", label: "noise", input: { id: "ep-03", userText: "What is the architecture?" } }, + { id: "ep-04", label: "noise", input: { id: "ep-04", userText: "看看现在 L2 L3 skill 的质量如何" } }, + { id: "ep-05", label: "noise", input: { id: "ep-05", userText: "continue" } }, + { id: "ep-06", label: "noise", input: { id: "ep-06", userText: "You are a focused child agent spawned by a parent agent." } }, + { id: "ep-07", label: "noise", input: { id: "ep-07", userText: "下一步什么工作" } }, + { id: "ep-08", label: "noise", input: { id: "ep-08", userText: "Can you look at the API?" } }, + { id: "ep-09", label: "noise", input: { id: "ep-09", userText: "再看看" } }, + { id: "ep-10", label: "noise", input: { id: "ep-10", userText: "historical context" } }, + { id: "ep-11", label: "noise", input: { id: "ep-11", userText: "What commands should we use?" } }, + { id: "ep-12", label: "noise", input: { id: "ep-12", userText: "继续检查数据库" } }, + { id: "ep-13", label: "noise", input: { id: "ep-13", userText: "Are there any skills?" } }, + { id: "ep-14", label: "noise", input: { id: "ep-14", userText: "看看这个项目" } }, + { id: "ep-15", label: "noise", input: { id: "ep-15", userText: "The next task is to continue." } }, + { id: "ep-16", label: "evidence", input: { id: "ep-16", userText: "Run the migration and verify the schema.", agentText: "Migration completed successfully; schema check passed.", toolCalls: [{ name: "shell", output: "exit code 0" }] } }, + { id: "ep-17", label: "evidence", input: { id: "ep-17", userText: "Use workspace_id for isolation.", agentText: "Implemented the namespace filter and tests pass.", reflection: "The API now enforces workspace scope." } }, + { id: "ep-18", label: "evidence", input: { id: "ep-18", userText: "Retry the failed worker job.", agentText: "The job succeeded on attempt two.", toolCalls: [{ name: "worker", output: "status: succeeded" }] } }, + { id: "ep-19", label: "evidence", input: { id: "ep-19", userText: "Add a stable dedupe key.", agentText: "The key is now derived from normalized namespace and signature.", reflection: "Equivalent inputs produce the same key." } }, + { id: "ep-20", label: "evidence", input: { id: "ep-20", userText: "Run the TypeScript tests.", agentText: "16 tests passed with exit code 0.", toolCalls: [{ name: "shell", output: "16 passed" }] } }, + { id: "ep-21", label: "evidence", input: { id: "ep-21", userText: "Do not activate a candidate automatically.", agentText: "Candidates remain resolving until the quality gate is met.", reflection: "Activation now requires explicit evidence." } }, + { id: "ep-22", label: "evidence", input: { id: "ep-22", userText: "Delete the generated project summaries.", agentText: "Removed 43 derived memories and preserved all L1 rows.", toolCalls: [{ name: "sqlite", output: "integrity_check: ok" }] } }, + { id: "ep-23", label: "evidence", input: { id: "ep-23", userText: "Use structured JSON from the evolution model.", agentText: "The client retries malformed JSON and validates the object shape.", reflection: "Unstructured completions are rejected." } }, + { id: "ep-24", label: "evidence", input: { id: "ep-24", userText: "Run the Docker health check.", agentText: "The service is healthy on port 18960.", toolCalls: [{ name: "docker", output: "healthy" }] } }, + { id: "ep-25", label: "evidence", input: { id: "ep-25", userText: "Keep raw L1 evidence immutable.", agentText: "The cleanup transaction changed only derived rows.", reflection: "L1 count is unchanged after replay." } }, + { id: "ep-26", label: "evidence", input: { id: "ep-26", userText: "Require two independent policy supports.", agentText: "The L3 gate skipped the single-policy cluster.", reflection: "A lone policy cannot establish a world model." } }, + { id: "ep-27", label: "evidence", input: { id: "ep-27", userText: "Generate the skill only after a passed trial.", agentText: "The skill stayed resolving until the trial passed.", toolCalls: [{ name: "trial", output: "pass" }] } }, + { id: "ep-28", label: "evidence", input: { id: "ep-28", userText: "Filter injected historical context.", agentText: "The extractor classified the trace as noise and skipped it.", reflection: "Prompt wrappers are not project evidence." } }, + { id: "ep-29", label: "evidence", input: { id: "ep-29", userText: "Replay the historical L1 set.", agentText: "The replay produced no project-synthesis records.", toolCalls: [{ name: "replay", output: "bad=0" }] } }, + { id: "ep-30", label: "evidence", input: { id: "ep-30", userText: "Persist evidence anchors on every skill.", agentText: "The generated skill references three source trace IDs.", reflection: "The provenance chain is inspectable." } } +]; diff --git a/Memory/tests/repository/project-context-repository.test.ts b/Memory/tests/repository/project-context-repository.test.ts new file mode 100644 index 000000000..37776623a --- /dev/null +++ b/Memory/tests/repository/project-context-repository.test.ts @@ -0,0 +1,157 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { MemoryDb } from "../../src/storage/db.js"; +import { ProjectContextRepository, Repositories } from "../../src/storage/repositories.js"; +import type { + ProjectFactRecord, + ProjectGoalRecord, + ProjectWorkItemRecord +} from "../../src/service/project-context/project-context-types.js"; + +const NOW = "2026-08-10T00:00:00.000Z"; + +function goal(overrides: Partial = {}): ProjectGoalRecord { + return { + id: "goal-1", + namespaceId: "ns-1", + userId: "user-1", + title: "Ship project context", + summary: "Persist authoritative context", + detail: "Persist authoritative context in SQLite", + acceptanceCriteria: ["Tests pass"], + constraints: ["Keep namespace scoped"], + status: "active", + version: 1, + supersedesId: undefined, + sourceMemoryIds: ["memory-1"], + provenance: { source: "test" }, + createdAt: NOW, + updatedAt: NOW, + ...overrides + }; +} + +function workItem(overrides: Partial = {}): ProjectWorkItemRecord { + return { + id: "work-1", + namespaceId: "ns-1", + userId: "user-1", + goalId: "goal-1", + title: "Add persistence", + summary: "Add SQLite records", + nextStep: "Write repository", + acceptanceCriteria: ["Records round trip"], + constraints: [], + status: "pending", + focused: false, + sourceMemoryIds: [], + provenance: { source: "test" }, + createdAt: NOW, + updatedAt: NOW, + ...overrides + }; +} + +function fact(overrides: Partial = {}): ProjectFactRecord { + return { + id: "fact-1", + namespaceId: "ns-1", + userId: "user-1", + kind: "decision", + content: "SQLite is authoritative", + status: "active", + supersedesId: undefined, + sourceMemoryIds: [], + provenance: { source: "test" }, + createdAt: NOW, + updatedAt: NOW, + ...overrides + }; +} + +function withRepo(run: (repo: ProjectContextRepository, db: MemoryDb["db"]) => T): T { + const root = mkdtempSync(join(tmpdir(), "project-context-repository-")); + const db = new MemoryDb({ path: join(root, "memory.sqlite") }); + try { + return run(new ProjectContextRepository(db.db), db.db); + } finally { + db.close(); + rmSync(root, { recursive: true, force: true }); + } +} + +describe("project context repository", () => { + it("allows only one active goal per namespace", () => withRepo((repo) => { + repo.insertGoal(goal({ id: "goal-1" })); + expect(() => repo.insertGoal(goal({ id: "goal-2", version: 2 }))) + .toThrow(/active project goal/i); + })); + + it("replaces the active goal by archiving the prior version transactionally", () => withRepo((repo) => { + repo.insertGoal(goal({ id: "goal-1" })); + const next = repo.replaceActiveGoal(goal({ id: "goal-2", version: 2, supersedesId: "goal-1", title: "Updated" })); + expect(next).toMatchObject({ id: "goal-2", status: "active", supersedesId: "goal-1" }); + expect(repo.getGoal("goal-1")?.status).toBe("archived"); + expect(repo.getActiveGoal("ns-1")?.id).toBe("goal-2"); + })); + + it("focuses exactly one work item without changing the goal", () => withRepo((repo) => { + repo.insertGoal(goal()); + repo.insertWorkItem(workItem({ id: "work-1" })); + repo.insertWorkItem(workItem({ id: "work-2" })); + repo.setFocusedWorkItem("ns-1", "work-1", NOW); + repo.setFocusedWorkItem("ns-1", "work-2", NOW); + expect(repo.getFocusedWorkItem("ns-1")?.id).toBe("work-2"); + expect(repo.getActiveGoal("ns-1")?.id).toBe("goal-1"); + })); + + it("rejects cross-namespace focus", () => withRepo((repo) => { + repo.insertWorkItem(workItem({ id: "work-other", namespaceId: "ns-2", goalId: undefined })); + expect(() => repo.setFocusedWorkItem("ns-1", "work-other", NOW)).toThrow(/namespace/i); + })); + + it("preserves stored identity fields when updating a work item", () => withRepo((repo) => { + repo.insertGoal(goal()); + repo.insertWorkItem(workItem({ projectId: "project-1", workspaceId: "workspace-1" })); + const updated = repo.updateWorkItem(workItem({ + projectId: "changed-project", + workspaceId: "changed-workspace", + title: "Updated title" + })); + expect(updated).toMatchObject({ + projectId: "project-1", + workspaceId: "workspace-1", + title: "Updated title" + }); + expect(repo.listWorkItems("ns-1")[0]).toEqual(updated); + })); + it("safely hydrates invalid JSON shapes", () => withRepo((repo, db) => { + repo.insertGoal(goal()); + db.prepare(` + UPDATE project_context_goals + SET acceptance_criteria_json = 'null', constraints_json = '{}', + source_memory_ids_json = '["memory-1", 2]', provenance_json = '[]' + WHERE id = 'goal-1' + `).run(); + expect(repo.getGoal("goal-1")).toMatchObject({ + acceptanceCriteria: [], constraints: [], sourceMemoryIds: ["memory-1"], provenance: {} + }); + })); + + it("includes project context records in bundle export order", () => withRepo((_repo, db) => { + const tables = new Repositories(db).runtime.exportBundleTables(); + const names = Object.keys(tables); + expect(names).toEqual(expect.arrayContaining([ + "project_context_goals", "project_context_work_items", "project_context_facts" + ])); + expect(names.indexOf("project_context_goals")).toBeLessThan(names.indexOf("project_context_work_items")); + })); + + it("supersedes a fact and lists only active facts", () => withRepo((repo) => { + repo.insertFact(fact()); + repo.supersedeFact("fact-1", fact({ id: "fact-2", content: "Postgres is authoritative", supersedesId: "fact-1" })); + expect(repo.listActiveFacts("ns-1").map((item) => item.id)).toEqual(["fact-2"]); + })); +}); diff --git a/Memory/tests/repository/sqlite-schema.test.ts b/Memory/tests/repository/sqlite-schema.test.ts index 8f1710ca8..0242e1133 100644 --- a/Memory/tests/repository/sqlite-schema.test.ts +++ b/Memory/tests/repository/sqlite-schema.test.ts @@ -84,7 +84,10 @@ describe("repository sqlite schema contract", () => { "memory_processing_state", "artifacts", "audit_logs", - "memory_vector_entries" + "memory_vector_entries", + "project_context_goals", + "project_context_work_items", + "project_context_facts", ])); expect(tables.map((table) => table.name)).not.toEqual(expect.arrayContaining([ "memory_embeddings", @@ -217,6 +220,18 @@ describe("repository sqlite schema contract", () => { expect(apiLogColumns.map((column) => column.name)).toContain("source_agent"); const apiLogIndexes = db.db.prepare(`PRAGMA index_list(api_logs)`).all() as Array<{ name: string }>; expect(apiLogIndexes.map((index) => index.name)).toContain("idx_api_logs_tool_source_time"); + const goalIndexes = db.db.prepare(`PRAGMA index_list(project_context_goals)`).all() as Array<{ name: string; partial: number }>; + expect(goalIndexes).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "uq_project_context_active_goal", partial: 1 }) + ])); + const workItemIndexes = db.db.prepare(`PRAGMA index_list(project_context_work_items)`).all() as Array<{ name: string; partial: number }>; + expect(workItemIndexes).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "uq_project_context_focused_work_item", partial: 1 }) + ])); + const workItemForeignKeys = db.db.prepare(`PRAGMA foreign_key_list(project_context_work_items)`).all() as Array<{ from: string; table: string }>; + expect(workItemForeignKeys).toEqual(expect.arrayContaining([ + expect.objectContaining({ from: "goal_id", table: "project_context_goals" }) + ])); db.close(); } finally { rmSync(root, { recursive: true, force: true }); diff --git a/Memory/tests/server-lock.test.ts b/Memory/tests/server-lock.test.ts index 48b8924e7..d33221815 100644 --- a/Memory/tests/server-lock.test.ts +++ b/Memory/tests/server-lock.test.ts @@ -60,4 +60,31 @@ describe("Memory server sqlite lock", () => { expect(payload.port).toBe(18991); lock?.release(); }); + + it("replaces a lock left by an earlier container that reused the same pid", () => { + const root = mkdtempSync(join(tmpdir(), "mindock-memory-server-reused-pid-lock-")); + roots.push(root); + const sqlitePath = join(root, "memory.sqlite"); + const lockPath = `${sqlitePath}.server.lock`; + writeFileSync(lockPath, JSON.stringify({ + pid: process.pid, + instanceId: "previous-container-instance", + host: "0.0.0.0", + port: 18960 + }), "utf8"); + + const lock = acquireSqliteServerLock({ + sqlitePath, + host: "0.0.0.0", + port: 18960 + }); + const payload = JSON.parse(readFileSync(lockPath, "utf8")) as { + pid: number; + instanceId: string; + }; + + expect(payload.pid).toBe(process.pid); + expect(payload.instanceId).not.toBe("previous-container-instance"); + lock?.release(); + }); }); diff --git a/Memory/tests/service/bundle/bundle.test.ts b/Memory/tests/service/bundle/bundle.test.ts index ccaf83c03..08d2d9a44 100644 --- a/Memory/tests/service/bundle/bundle.test.ts +++ b/Memory/tests/service/bundle/bundle.test.ts @@ -9,7 +9,7 @@ const { afterEach(cleanup); describe("MemoryService / bundle", () => { - it("exports bundles across namespaces", async () => { + it("exports only the requested namespace and its dependency rows", async () => { const { db, service } = createTestService(); const namespaceA = { source: "codex", @@ -56,25 +56,25 @@ describe("MemoryService / bundle", () => { const bundleA = service.exportBundle({ namespace: namespaceA }); const memoryIds = (bundleA.tables.memories as Array>).map((row) => row.id); expect(memoryIds).toContain(completeA.l1MemoryId); - expect(memoryIds).toContain(completeB.l1MemoryId); + expect(memoryIds).not.toContain(completeB.l1MemoryId); const sessionIds = (bundleA.tables.sessions as Array>).map((row) => row.id); - expect(sessionIds.sort()).toEqual([sessionA.sessionId, sessionB.sessionId].sort()); + expect(sessionIds).toEqual([sessionA.sessionId]); const rawTurnIds = (bundleA.tables.raw_turns as Array>).map((row) => row.id); expect(rawTurnIds).toContain(completeA.rawTurnId); - expect(rawTurnIds).toContain(completeB.rawTurnId); + expect(rawTurnIds).not.toContain(completeB.rawTurnId); const recallIds = (bundleA.tables.recall_events as Array>).map((row) => row.id); expect(recallIds).toContain(recallA.searchEventId); - expect(recallIds).toContain(recallB.searchEventId); + expect(recallIds).not.toContain(recallB.searchEventId); const artifactRawTurnIds = (bundleA.tables.artifacts as Array>) .map((row) => row.raw_turn_id); expect(artifactRawTurnIds).toEqual([completeA.rawTurnId]); const jobSessionIds = new Set((bundleA.tables.evolution_jobs as Array>) .map((row) => row.session_id)); - expect(jobSessionIds).toEqual(new Set([sessionA.sessionId, sessionB.sessionId])); + expect(jobSessionIds).toEqual(new Set([sessionA.sessionId])); const changeNamespaces = new Set((bundleA.tables.memory_change_log as Array>) .map((row) => row.namespace_id)); expect([...changeNamespaces].some((namespace) => String(namespace).includes("workspace-export-a"))).toBe(true); - expect([...changeNamespaces].some((namespace) => String(namespace).includes("workspace-export-b"))).toBe(true); + expect([...changeNamespaces].some((namespace) => String(namespace).includes("workspace-export-b"))).toBe(false); db.close(); }); diff --git a/Memory/tests/service/evolution/orchestration.test.ts b/Memory/tests/service/evolution/orchestration.test.ts index e4136f4b7..7e42413ae 100644 --- a/Memory/tests/service/evolution/orchestration.test.ts +++ b/Memory/tests/service/evolution/orchestration.test.ts @@ -181,7 +181,7 @@ describe("MemoryService / evolution / orchestration", () => { ).all() as Array<{ namespace_id: string | null; kind: string | null; op: string | null; entity_id: string | null }>; expect(workerMemoryChanges.length).toBeGreaterThan(0); for (const change of workerMemoryChanges) { - expect(change.namespace_id).toContain("user-2"); + expect(change.namespace_id).toBe("local:unscoped"); expect(change.kind).toMatch(/^(trace|policy|world_model|skill)$/); expect(change.op).toMatch(/^(created|updated)$/); expect(change.entity_id).toBeTruthy(); diff --git a/Memory/tests/service/evolution/project-evidence-dataset.test.ts b/Memory/tests/service/evolution/project-evidence-dataset.test.ts new file mode 100644 index 000000000..27beebaa1 --- /dev/null +++ b/Memory/tests/service/evolution/project-evidence-dataset.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { PROJECT_EVIDENCE_DATASET } from "../../fixtures/project-evidence-dataset.js"; +import { extractProjectEvidence } from "../../../src/service/evolution/project-evidence.js"; + +describe("project evidence replay quality set", () => { + it("contains 30 episodes with explicit expected labels", () => { + expect(PROJECT_EVIDENCE_DATASET).toHaveLength(30); + expect(new Set(PROJECT_EVIDENCE_DATASET.map((item) => item.label))).toEqual(new Set(["noise", "evidence"])); + }); + + it("keeps noise out and admits only evidence episodes", () => { + const results = PROJECT_EVIDENCE_DATASET.map((item) => ({ item, result: extractProjectEvidence(item.input) })); + expect(results.filter(({ item, result }) => item.label === "noise" && result.eligible)).toHaveLength(0); + expect(results.filter(({ item, result }) => item.label === "evidence" && !result.eligible)).toHaveLength(0); + }); +}); diff --git a/Memory/tests/service/evolution/project-evidence.test.ts b/Memory/tests/service/evolution/project-evidence.test.ts new file mode 100644 index 000000000..78f08636e --- /dev/null +++ b/Memory/tests/service/evolution/project-evidence.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { extractProjectEvidence } from "../../../src/service/evolution/project-evidence.js"; + +describe("project evidence extraction", () => { + it.each([ + "continue working", + "You are a focused child agent spawned by a parent agent.", + "What is the architecture?", + "继续" + ])("rejects %s as non-evidence", (userText) => { + expect(extractProjectEvidence({ id: "noise", userText }).eligible).toBe(false); + }); + + it("classifies a tool-backed successful procedure as eligible outcome evidence", () => { + const evidence = extractProjectEvidence({ + id: "trace-1", + userText: "Run the migration and verify the schema.", + agentText: "Migration completed successfully; the schema check passed.", + toolCalls: [{ name: "shell", output: "exit code 0" }] + }); + expect(evidence).toMatchObject({ kind: "outcome", eligible: true }); + expect(evidence.stableKey).toMatch(/^evidence:/); + }); + + it("uses the same stable key for equivalent evidence", () => { + const a = extractProjectEvidence({ id: "a", userText: "Run tests", agentText: "Tests passed" }); + const b = extractProjectEvidence({ id: "b", userText: " Run tests ", agentText: "Tests passed" }); + expect(a.stableKey).toBe(b.stableKey); + }); +}); diff --git a/Memory/tests/service/evolution/world-model.test.ts b/Memory/tests/service/evolution/world-model.test.ts index 15c3226d1..b1df6d151 100644 --- a/Memory/tests/service/evolution/world-model.test.ts +++ b/Memory/tests/service/evolution/world-model.test.ts @@ -441,7 +441,7 @@ describe("MemoryService / evolution / world model", () => { updated_at: archivedWorld.updated_at }); expect(fresh).toMatchObject({ - status: "activated", + status: "resolving", memory_key: firstWorld!.memory_key }); diff --git a/Memory/tests/service/governance/markdown-audit.test.ts b/Memory/tests/service/governance/markdown-audit.test.ts new file mode 100644 index 000000000..24696afb5 --- /dev/null +++ b/Memory/tests/service/governance/markdown-audit.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createMemoryServiceFixture } from "../../fixtures/memory-service-fixture.js"; + +const { cleanup, createTestService } = createMemoryServiceFixture(); + +afterEach(cleanup); + +describe("MemoryService / governance / markdown audit", () => { + it("exports editable front matter and applies a scoped body/title edit", () => { + const { db, service } = createTestService(); + const namespace = { + source: "codex", + profileId: "main", + userId: "markdown-user", + workspacePath: "/work/markdown-project" + }; + const added = service.addMemory({ + namespace, + layer: "L2", + title: "Original title", + tags: ["audit"], + content: "Original body for the audit file." + }); + const exported = service.exportMarkdown({ namespace }); + expect(exported.count).toBe(1); + expect(exported.markdown).toContain(`id: ${added.id}`); + expect(exported.markdown).toContain("editable:"); + expect(exported.markdown).toContain("Original body for the audit file."); + + const edited = exported.markdown + .replace("Original title", "Reviewed title") + .replace("Original body for the audit file.", "Reviewed body after human audit."); + const preview = service.importMarkdown({ namespace, markdown: edited, apply: false }); + expect(preview.updated).toEqual([added.id]); + expect(service.getMemory(added.id, { namespace })).toMatchObject({ id: added.id }); + + const applied = service.importMarkdown({ namespace, markdown: edited, apply: true }); + expect(applied.updated).toEqual([added.id]); + const detail = service.getMemory(added.id, { namespace }) as { title: string; body: string }; + expect(detail.title).toBe("Reviewed title"); + expect(detail.body).toBe("Reviewed body after human audit."); + expect(db.db.prepare("SELECT COUNT(*) AS count FROM audit_logs WHERE action = 'markdown_update'").get()).toMatchObject({ count: 1 }); + db.close(); + }); + + it("rejects a markdown document from another project", () => { + const { db, service } = createTestService(); + const a = { source: "codex", profileId: "main", userId: "markdown-user", workspacePath: "/work/markdown-a" }; + const b = { source: "codex", profileId: "main", userId: "markdown-user", workspacePath: "/work/markdown-b" }; + service.addMemory({ namespace: a, layer: "L2", title: "Scoped", content: "Only project A." }); + const exported = service.exportMarkdown({ namespace: a }); + const result = service.importMarkdown({ namespace: b, markdown: exported.markdown, apply: true }); + expect(result.updated).toEqual([]); + expect(result.created).toEqual([]); + expect(result.rejected).toHaveLength(1); + expect(result.rejected[0]?.reason).toMatch(/memory not found/); + db.close(); + }); +}); diff --git a/Memory/tests/service/governance/provenance-supersession.test.ts b/Memory/tests/service/governance/provenance-supersession.test.ts new file mode 100644 index 000000000..50970efb0 --- /dev/null +++ b/Memory/tests/service/governance/provenance-supersession.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createMemoryServiceFixture } from "../../fixtures/memory-service-fixture.js"; + +const { cleanup, createTestService } = createMemoryServiceFixture(); + +afterEach(cleanup); + +describe("MemoryService / governance / provenance and supersession", () => { + it("carries the agent lifecycle protocol provenance into captured L1 memory", async () => { + const { db, service } = createTestService(); + const namespace = { + source: "codex", + profileId: "main", + userId: "lifecycle-provenance-user", + workspacePath: "/work/lifecycle-provenance" + }; + const opened = service.openSession({ + namespace, + source: "codex", + sessionId: "lifecycle-provenance-session", + workspacePath: namespace.workspacePath, + protocolVersion: "memmy.agent.v1", + adapterId: "memmy-codex-hook", + requestId: "codex-open:lifecycle" + }); + const started = await service.startTurn({ + namespace, + protocolVersion: "memmy.agent.v1", + adapterId: "memmy-codex-hook", + requestId: "codex-start:lifecycle", + sessionId: opened.sessionId, + turnId: "lifecycle-turn", + query: "Capture the adapter provenance", + provenance: { + repository: "/work/memmy-agent", + branch: "feature/protocol", + commit: "abc123def456", + sourceMemoryIds: [], + sourceAgent: "codex", + capturedAt: "2026-08-04T10:00:00.000Z" + } + }); + const completed = service.completeTurn(started.turnId, { + namespace, + protocolVersion: "memmy.agent.v1", + adapterId: "memmy-codex-hook", + requestId: "codex-complete:lifecycle", + sessionId: opened.sessionId, + query: "Capture the adapter provenance", + answer: "The lifecycle provenance is now captured.", + sourceMemoryIds: started.sourceMemoryIds, + provenance: { + repository: "/work/memmy-agent", + branch: "feature/protocol", + commit: "abc123def456", + sourceMemoryIds: started.sourceMemoryIds, + sourceAgent: "codex", + capturedAt: "2026-08-04T10:01:00.000Z" + } + }); + + const detail = service.getMemory(completed.l1MemoryId, { namespace }) as { + provenance?: Record; + }; + expect(detail.provenance).toMatchObject({ + sourceAgent: "codex", + adapterId: "memmy-codex-hook", + requestId: "codex-complete:lifecycle", + workspacePath: "/work/lifecycle-provenance", + sessionId: opened.sessionId, + turnId: "lifecycle-turn", + repository: "/work/memmy-agent", + branch: "feature/protocol", + commit: "abc123def456", + sourceMemoryIds: started.sourceMemoryIds + }); + const rawTurn = db.db.prepare( + "SELECT message_payload_json FROM raw_turns WHERE id = ?" + ).get(completed.rawTurnId) as { message_payload_json: string }; + expect(JSON.parse(rawTurn.message_payload_json)).toMatchObject({ + turn_start: { protocolVersion: "memmy.agent.v1" }, + turn_complete: { protocolVersion: "memmy.agent.v1" } + }); + db.close(); + }); + + it("records provenance and supersedes an older memory without deleting its history", async () => { + const { db, service } = createTestService(); + const namespace = { + source: "codex", + profileId: "main", + userId: "governance-user", + workspacePath: "/work/governance-project" + }; + const old = service.addMemory({ + namespace, + source: "codex", + layer: "L2", + title: "Old migration policy", + content: "Use the old migration procedure for the legacy database.", + adapterId: "codex-memory-hook", + requestId: "add-old", + provenance: { + repository: "memmy-agent", + branch: "main", + commit: "abc123" + } + }); + const replacement = service.addMemory({ + namespace: { ...namespace, source: "pi", profileId: "pi-main" }, + source: "pi", + layer: "L2", + title: "Current migration policy", + content: "Use the current migration procedure and verify the SQLite backup.", + tags: ["architecture"], + adapterId: "pi-memory-extension", + requestId: "add-new", + supersedesMemoryId: old.id, + supersessionReason: "The old procedure predates the SQLite migration.", + sourceMemoryIds: [old.id], + provenance: { + repository: "memmy-agent", + branch: "feature/governance", + commit: "def456" + } + }); + + const oldDetail = service.getMemory(old.id, { namespace }) as Extract, { id: string }> & { supersession?: unknown; provenance?: unknown }; + const newDetail = service.getMemory(replacement.id, { namespace }) as Extract, { id: string }> & { supersession?: unknown; provenance?: unknown; relations?: Array> }; + expect(oldDetail.status).toBe("archived"); + expect(oldDetail.supersession).toMatchObject({ + supersededByMemoryId: replacement.id, + reason: "The old procedure predates the SQLite migration." + }); + expect(newDetail.supersession).toMatchObject({ + supersedesMemoryIds: [old.id], + reason: "The old procedure predates the SQLite migration." + }); + expect(newDetail.provenance).toMatchObject({ + sourceAgent: "pi", + adapterId: "pi-memory-extension", + requestId: "add-new", + repository: "memmy-agent", + branch: "feature/governance", + commit: "def456", + sourceMemoryIds: [old.id] + }); + expect(newDetail.relations).toEqual([ + expect.objectContaining({ + sourceMemoryId: replacement.id, + targetMemoryId: old.id, + relation: "supersedes" + }) + ]); + + const contextPack = service.projectContextPack({ namespace }); + expect(contextPack.graph.nodes).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: replacement.id }), + expect.objectContaining({ id: old.id, external: true }) + ])); + expect(contextPack.graph.edges).toEqual(expect.arrayContaining([ + expect.objectContaining({ sourceId: old.id, targetId: replacement.id, relation: "source" }), + expect.objectContaining({ sourceId: replacement.id, targetId: old.id, relation: "supersedes" }) + ])); + + const relations = db.db.prepare( + `SELECT source_memory_id, target_memory_id, relation, reason + FROM memory_relations + WHERE source_memory_id = ?` + ).all(replacement.id) as Array>; + expect(relations).toEqual([expect.objectContaining({ + source_memory_id: replacement.id, + target_memory_id: old.id, + relation: "supersedes" + })]); + + const recall = await service.search({ + namespace, + query: "old migration procedure legacy database", + layers: ["L2"], + limit: 10 + }); + expect(recall.hits.map((hit) => hit.id)).not.toContain(old.id); + expect(db.db.prepare("SELECT COUNT(*) AS count FROM audit_logs WHERE action = 'supersede'").get()).toMatchObject({ count: 1 }); + db.close(); + }); +}); diff --git a/Memory/tests/service/namespace/project-isolation.test.ts b/Memory/tests/service/namespace/project-isolation.test.ts new file mode 100644 index 000000000..791c647cd --- /dev/null +++ b/Memory/tests/service/namespace/project-isolation.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createMemoryServiceFixture } from "../../fixtures/memory-service-fixture.js"; + +const { cleanup, createTestService } = createMemoryServiceFixture(); + +afterEach(cleanup); + +describe("MemoryService / namespace / project isolation", () => { + it("shares a project across agents while isolating projects A, B, and blank C", async () => { + const { db, service } = createTestService(); + const projectA = { + source: "codex", + profileId: "codex-main", + userId: "local-user", + workspacePath: "/work/project-a" + }; + const projectAFromPi = { + source: "pi", + profileId: "pi-main", + userId: "local-user", + workspacePath: "/work/project-a/" + }; + const projectB = { + source: "claude-code", + profileId: "claude-main", + userId: "local-user", + workspacePath: "/work/project-b" + }; + const blankProjectC = { + source: "codex", + profileId: "codex-main", + userId: "local-user" + }; + + const added = service.addMemory({ + namespace: projectA, + source: "codex", + layer: "L2", + title: "Project A boundary marker", + content: "zircon canary sevenfox only belongs to project A" + }); + + const sameProject = await service.search({ + namespace: projectAFromPi, + query: "zircon canary sevenfox", + includeInjectedContext: true + }); + expect(sameProject.hits.map((hit) => hit.id)).toContain(added.id); + expect(sameProject.injectedContext.markdown).toContain("zircon canary sevenfox"); + + for (const namespace of [projectB, blankProjectC]) { + const result = await service.search({ + namespace, + query: "zircon canary sevenfox", + includeInjectedContext: true + }); + expect(result.hits).toEqual([]); + expect(result.candidateMemoryIds).toEqual([]); + expect(result.injectedContext.markdown).not.toContain("zircon canary sevenfox"); + expect(() => service.getMemory(added.id, { namespace })).toThrow(/memory not found/); + } + + expect(service.getMemory(added.id, { namespace: projectAFromPi }).id).toBe(added.id); + db.close(); + }); + + it("derives the same stable project id from normalized workspace paths", () => { + const { db, service } = createTestService(); + const first = service.openSession({ + namespace: { source: "codex", profileId: "default", userId: "local-user" }, + workspacePath: "C:\\Work\\Memmy\\" + }); + const second = service.openSession({ + namespace: { source: "pi", profileId: "main", userId: "local-user" }, + workspacePath: "c:/Work/Memmy" + }); + + expect(first.projectId).toMatch(/^workspace_[a-f0-9]{24}$/); + expect(second.projectId).toBe(first.projectId); + expect(second.workspaceId).toBe(first.workspaceId); + db.close(); + }); + + it("isolates tenants that use the same project id and persists tenant provenance", async () => { + const { db, service } = createTestService(); + const tenantA = { + source: "codex", + profileId: "default", + userId: "shared-user", + tenantId: "tenant-a", + projectId: "shared-project" + }; + const tenantB = { ...tenantA, tenantId: "tenant-b" }; + const added = service.addMemory({ + namespace: tenantA, + source: "codex", + layer: "L2", + title: "Tenant A marker", + content: "tenant alpha heliotrope marker" + }); + + expect((await service.search({ namespace: tenantA, query: "heliotrope marker" })).hits) + .toEqual(expect.arrayContaining([expect.objectContaining({ id: added.id })])); + expect((await service.search({ namespace: tenantB, query: "heliotrope marker" })).hits).toEqual([]); + expect(() => service.getMemory(added.id, { namespace: tenantB })).toThrow(/memory not found/); + const detail = service.getMemory(added.id, { namespace: tenantA }); + expect("provenance" in detail ? detail.provenance : undefined).toMatchObject({ + tenantId: "tenant-a", + projectId: "shared-project" + }); + + const opened = service.openSession({ namespace: tenantA }); + const session = db.db.prepare("SELECT meta_json FROM sessions WHERE id = ?") + .get(opened.sessionId) as { meta_json: string }; + expect(JSON.parse(session.meta_json)).toMatchObject({ tenant_id: "tenant-a" }); + db.close(); + }); + + it("treats a blank namespace as unscoped for known session, episode, and raw-turn ids", () => { + const { db, service } = createTestService(); + const scoped = { + source: "codex", + profileId: "default", + userId: "local-user", + tenantId: "tenant-a", + projectId: "private-project" + }; + const blank = { + source: "codex", + profileId: "default", + userId: "local-user", + tenantId: "tenant-a" + }; + const opened = service.openSession({ namespace: scoped }); + const completed = service.completeTurn("known-id-turn", { + sessionId: opened.sessionId, + query: "known id isolation", + answer: "keep this scoped" + }); + + expect(() => service.closeSession(opened.sessionId, { namespace: blank })).toThrow(/session not found/); + expect(() => service.deletePanelTask(completed.episodeId, { namespace: blank })).toThrow(/episode not found/); + expect(() => service.redactRawTurn(completed.rawTurnId, { namespace: blank })).toThrow(/raw turn not found/); + expect(service.closeSession(opened.sessionId, { namespace: scoped }).status).toBe("closed"); + db.close(); + }); +}); diff --git a/Memory/tests/service/project-context/project-context-service.test.ts b/Memory/tests/service/project-context/project-context-service.test.ts new file mode 100644 index 000000000..6be87d3cc --- /dev/null +++ b/Memory/tests/service/project-context/project-context-service.test.ts @@ -0,0 +1,275 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + MemoryDb, + MemoryService, + ProjectContextService, + type ProjectContextProposeGoalRequest, + type RuntimeNamespace +} from "../../../src/index.js"; +import { Repositories } from "../../../src/storage/repositories.js"; +import type { ProjectFactRecord } from "../../../src/service/project-context/project-context-types.js"; +import { namespaceIdFromContext } from "../../../src/service/namespace/namespace-scope.js"; + +const NOW = "2026-08-10T12:00:00.000Z"; +const ALPHA: RuntimeNamespace = { source: "codex", profileId: "default", tenantId: "tenant-a", userId: "user-a", projectId: "alpha" }; +const BETA: RuntimeNamespace = { ...ALPHA, projectId: "beta" }; + +function proposal(namespace: RuntimeNamespace = ALPHA, overrides: Partial = {}): ProjectContextProposeGoalRequest { + return { + namespace, + title: "Ship durable project context", + summary: "Keep the approved project goal available on every turn.", + detail: "Full authoritative implementation detail.", + acceptanceCriteria: ["Approved context renders", "Candidates stay hidden"], + constraints: ["Do not infer user intent"], + sourceMemoryIds: ["memory-a"], + provenance: { sourceAgent: "codex", capturedAt: NOW, sourceMemoryIds: ["memory-a"] }, + ...overrides + }; +} + +function withContext(run: (context: { service: ProjectContextService; memory: MemoryService; repos: Repositories }) => T): T { + const root = mkdtempSync(join(tmpdir(), "project-context-service-")); + const db = new MemoryDb({ path: join(root, "memory.sqlite") }); + const repos = new Repositories(db.db); + try { + return run({ service: new ProjectContextService({ repositories: repos, now: () => NOW }), memory: new MemoryService({ db, mode: "dev" }), repos }); + } finally { + db.close(); + rmSync(root, { recursive: true, force: true }); + } +} + +function approve(service: ProjectContextService, input = proposal()) { + const candidate = service.proposeGoal(input); + return service.approveGoal({ namespace: input.namespace, candidateId: candidate.id }); +} + +function insertFact(repos: Repositories, overrides: Partial = {}): ProjectFactRecord { + return repos.projectContext.insertFact({ + id: overrides.id ?? `fact-${Math.random()}`, + namespaceId: overrides.namespaceId ?? namespaceIdFromContext(ALPHA), + userId: "user-a", + projectId: "alpha", + kind: "constraint", + content: "runtime: node 22", + status: "active", + sourceMemoryIds: [], + provenance: {}, + createdAt: NOW, + updatedAt: NOW, + ...overrides + }); +} + +describe("ProjectContextService", () => { + it("returns the exact no_confirmed_goal status when no goal is approved", () => withContext(({ service }) => { + const candidate = service.proposeGoal(proposal()); + const stable = service.renderStable(ALPHA); + expect(candidate.status).toBe("candidate"); + expect(stable.status).toBe("no_confirmed_goal"); + expect(stable.goal).toBeNull(); + expect(stable.focusedWorkItem).toBeNull(); + expect(stable.markdown).toContain('status="no_confirmed_goal"'); + expect(stable.markdown).toContain("No confirmed project goal"); + expect(stable.markdown).not.toContain(candidate.title); + })); + + it("renders an approved goal even for an unrelated prompt", () => withContext(({ service }) => { + const active = approve(service); + const stable = service.renderStable(ALPHA); + expect(stable.status).toBe("ready"); + expect(stable.goal).toEqual(active); + expect(stable.markdown).toContain("Ship durable project context"); + })); + + it("returns full stable records while bounding only markdown", () => withContext(({ service }) => { + const detail = "implementation detail ".repeat(500); + const active = approve(service, proposal(ALPHA, { detail, summary: "summary ".repeat(200) })); + expect(service.read(ALPHA).activeGoal?.detail).toBe(detail); + const stable = service.renderStable(ALPHA); + expect(stable.goal).toEqual(active); + expect(stable.goal?.detail).toBe(detail); + expect(stable.markdown).not.toContain(detail); + })); + + it("changes focus explicitly without mutating the goal", () => withContext(({ service }) => { + const goal = approve(service); + const first = service.createWorkItem({ namespace: ALPHA, goalId: goal.id, title: "First", summary: "First item", nextStep: "Do first" }); + const second = service.createWorkItem({ namespace: ALPHA, goalId: goal.id, title: "Second", summary: "Second item", nextStep: "Do second" }); + expect(service.read(ALPHA).focusedWorkItem).toBeUndefined(); + service.selectWorkItem({ namespace: ALPHA, workItemId: first.id }); + service.selectWorkItem({ namespace: ALPHA, workItemId: second.id }); + const state = service.read(ALPHA); + expect(state.activeGoal).toEqual(goal); + expect(state.focusedWorkItem?.id).toBe(second.id); + expect(state.workItems.find((item) => item.id === first.id)?.focused).toBe(false); + })); + + it("clears focus when the focused work item becomes terminal", () => withContext(({ service }) => { + const goal = approve(service); + const item = service.createWorkItem({ namespace: ALPHA, goalId: goal.id, title: "Item", summary: "Summary", nextStep: "Next" }); + service.selectWorkItem({ namespace: ALPHA, workItemId: item.id }); + + const completed = service.updateWorkItem({ namespace: ALPHA, workItemId: item.id, status: "completed" }); + + expect(completed.focused).toBe(false); + expect(service.read(ALPHA).focusedWorkItem).toBeUndefined(); + expect(service.renderStable(ALPHA).markdown).toContain("No work item is explicitly focused"); + })); + + it("supports explicit focus clearing and renders a no-focus reminder", () => withContext(({ service }) => { + const goal = approve(service); + const item = service.createWorkItem({ namespace: ALPHA, goalId: goal.id, title: "Item", summary: "Summary", nextStep: "Next" }); + service.selectWorkItem({ namespace: ALPHA, workItemId: item.id }); + service.selectWorkItem({ namespace: ALPHA, workItemId: null }); + const stable = service.renderStable(ALPHA); + expect(stable.focusedWorkItem).toBeNull(); + expect(stable.markdown).toContain("No work item is explicitly focused"); + })); + + it("supersedes the active goal with monotonically increasing versions", () => withContext(({ service }) => { + const first = approve(service); + const candidate = service.proposeGoal(proposal(ALPHA, { title: "Second goal" })); + const second = service.approveGoal({ namespace: ALPHA, candidateId: candidate.id }); + const state = service.read(ALPHA); + expect(first.version).toBe(1); + expect(second).toMatchObject({ version: 2, supersedesId: first.id, status: "active" }); + expect(state.activeGoal?.id).toBe(second.id); + expect(state.goals.find((goal) => goal.id === first.id)?.status).toBe("archived"); + expect(state.goals.find((goal) => goal.id === candidate.id)?.status).toBe("archived"); + })); + + it("does not let candidates consume approval versions", () => withContext(({ service }) => { + const firstCandidate = service.proposeGoal(proposal(ALPHA, { title: "First candidate" })); + service.proposeGoal(proposal(ALPHA, { title: "Other candidate" })); + const first = service.approveGoal({ namespace: ALPHA, candidateId: firstCandidate.id }); + service.proposeGoal(proposal(ALPHA, { title: "Unapproved candidate" })); + const secondCandidate = service.proposeGoal(proposal(ALPHA, { title: "Second approved" })); + const second = service.approveGoal({ namespace: ALPHA, candidateId: secondCandidate.id }); + expect([first.version, second.version]).toEqual([1, 2]); + })); + + it("rejects and archives only the named candidate", () => withContext(({ service }) => { + const active = approve(service); + const rejected = service.proposeGoal(proposal(ALPHA, { title: "Rejected goal" })); + const retained = service.proposeGoal(proposal(ALPHA, { title: "Retained candidate" })); + expect(service.rejectGoal({ namespace: ALPHA, candidateId: rejected.id }).status).toBe("archived"); + const state = service.read(ALPHA); + expect(state.activeGoal?.id).toBe(active.id); + expect(state.goals.find((goal) => goal.id === retained.id)?.status).toBe("candidate"); + })); + + it.each([120, 240])("renders mandatory compact context deterministically within a %i-character budget", (budget) => withContext(({ service, repos }) => { + const long = (value: string) => `${value} ${"extended context ".repeat(100)}`; + const goal = approve(service, proposal(ALPHA, { + title: long("Goal title"), + summary: long("Goal summary"), + constraints: [long("goal constraint")], + acceptanceCriteria: [long("acceptance criterion")] + })); + const item = service.createWorkItem({ + namespace: ALPHA, + goalId: goal.id, + title: long("Focused item"), + summary: long("Focused summary"), + nextStep: long("Focused next step"), + acceptanceCriteria: [long("work acceptance")], + status: "active" + }); + service.selectWorkItem({ namespace: ALPHA, workItemId: item.id }); + insertFact(repos, { id: "constraint", kind: "constraint", content: long("runtime: node 22") }); + insertFact(repos, { id: "decision", kind: "decision", content: long("storage: sqlite") }); + + const constrained = service.renderStable(ALPHA, budget); + + expect(service.renderStable(ALPHA, budget).markdown).toBe(constrained.markdown); + expect(constrained.markdown).toMatch(/^]*>\n/); + expect(constrained.markdown).toMatch(/\nG=.+/); + expect(constrained.markdown).toMatch(/\nC=.+/); + expect(constrained.markdown).toMatch(/\nW=.+\|.+\|.+/); + expect(constrained.markdown).toMatch(/\nA=.+/); + expect(constrained.markdown).toMatch(/\nU=.+/); + expect(constrained.markdown).toMatch(/\n<\/memmy_project_context>$/); + expect(constrained.markdown).not.toContain("Decision:"); + expect(constrained.markdown).not.toContain("Metadata:"); + expect(constrained.markdown.length).toBeLessThanOrEqual(budget); + expect(constrained.goal).toEqual(goal); + expect(constrained.focusedWorkItem).toEqual({ ...item, focused: true }); + expect(constrained.facts).toHaveLength(2); + })); + + it("bounds the no-goal marker at the minimum supported budget", () => withContext(({ service }) => { + const stable = service.renderStable(ALPHA, 120); + expect(stable.markdown).toContain('status="no_confirmed_goal"'); + expect(stable.markdown.length).toBeLessThanOrEqual(120); + })); + + it("rejects budgets below the documented minimum", () => withContext(({ service }) => { + expect(() => service.renderStable(ALPHA, 119)).toThrow(/at least 120/); + })); + + it("marks conflicts, preserves their facts for review, and excludes them from authoritative prose", () => withContext(({ service, repos }) => { + approve(service); + insertFact(repos, { id: "node-20", content: "runtime: node 20" }); + insertFact(repos, { id: "node-22", content: "runtime: node 22" }); + insertFact(repos, { id: "storage", kind: "decision", content: "storage: sqlite" }); + const stable = service.renderStable(ALPHA); + expect(stable.status).toBe("conflict"); + expect(stable.facts).toHaveLength(3); + expect(stable.markdown).not.toContain("runtime: node 20"); + expect(stable.markdown).not.toContain("runtime: node 22"); + expect(stable.markdown).toContain("storage: sqlite"); + })); + + it("renders focused work details and confirmed goal metadata", () => withContext(({ service, repos }) => { + const goal = approve(service, proposal(ALPHA, { sourceMemoryIds: ["shared", "goal"] })); + const item = service.createWorkItem({ namespace: ALPHA, goalId: goal.id, title: "Item", summary: "Focused summary", nextStep: "Focused next", acceptanceCriteria: ["Focused accepted"], status: "blocked", sourceMemoryIds: ["shared", "work"] }); + service.selectWorkItem({ namespace: ALPHA, workItemId: item.id }); + insertFact(repos, { id: "fact", content: "runtime: node 22", sourceMemoryIds: ["shared", "fact"] }); + const stable = service.renderStable(ALPHA); + expect(stable.focusedWorkItem).toEqual(expect.objectContaining({ id: item.id, summary: "Focused summary", status: "blocked", nextStep: "Focused next", acceptanceCriteria: ["Focused accepted"] })); + expect(stable.focusedWorkItem).toEqual(service.read(ALPHA).focusedWorkItem); + expect(stable.markdown).toContain("Focus status: blocked"); + expect(stable.markdown).toContain("Focus summary: Focused summary"); + expect(stable.markdown).toContain(`Metadata: goal_id=${goal.id}`); + expect(stable.markdown).toContain(`confirmed_updated_at=${goal.updatedAt}`); + expect(stable.sourceMemoryIds).toEqual(["shared", "goal", "work", "fact"]); + })); + + it("isolates every read and mutation by canonical namespace", () => withContext(({ service }) => { + const alpha = approve(service); + const beta = approve(service, proposal(BETA, { title: "Beta goal" })); + expect(service.read(ALPHA).activeGoal?.id).toBe(alpha.id); + expect(service.read(BETA).activeGoal?.id).toBe(beta.id); + expect(() => service.approveGoal({ namespace: BETA, candidateId: service.proposeGoal(proposal(ALPHA)).id })).toThrow(/namespace/i); + })); + + it("exposes direct namespace reads and renders through MemoryService", () => withContext(({ memory }) => { + const candidate = memory.proposeProjectGoal(proposal()); + const active = memory.approveProjectGoal({ namespace: ALPHA, candidateId: candidate.id }); + const item = memory.createProjectWorkItem({ namespace: ALPHA, goalId: active.id, title: "Facade item", summary: "Summary", nextStep: "Next" }); + memory.updateProjectWorkItem({ namespace: ALPHA, workItemId: item.id, nextStep: null }); + memory.selectProjectWorkItem({ namespace: ALPHA, workItemId: item.id }); + expect(memory.readProjectContext(ALPHA).focusedWorkItem?.nextStep).toBe(""); + expect(memory.renderStableProjectContext(ALPHA, 500).markdown).toContain("Facade item"); + })); + + it("rejects every unscoped facade mutation, read, and render", () => withContext(({ memory }) => { + const unscoped: RuntimeNamespace = { source: "codex", profileId: "default", userId: "user-a" }; + const calls = [ + () => memory.proposeProjectGoal(proposal(unscoped)), + () => memory.approveProjectGoal({ namespace: unscoped, candidateId: "candidate" }), + () => memory.rejectProjectGoal({ namespace: unscoped, candidateId: "candidate" }), + () => memory.createProjectWorkItem({ namespace: unscoped, title: "Item", summary: "Summary", nextStep: "Next" }), + () => memory.updateProjectWorkItem({ namespace: unscoped, workItemId: "item", title: "Updated" }), + () => memory.selectProjectWorkItem({ namespace: unscoped, workItemId: null }), + () => memory.readProjectContext(unscoped), + () => memory.renderStableProjectContext(unscoped) + ]; + for (const call of calls) expect(call).toThrow(/projectId, workspaceId, or workspacePath/i); + })); +}); diff --git a/Memory/tests/service/read-model/panel-read.test.ts b/Memory/tests/service/read-model/panel-read.test.ts index f2777105f..216e6ccdb 100644 --- a/Memory/tests/service/read-model/panel-read.test.ts +++ b/Memory/tests/service/read-model/panel-read.test.ts @@ -314,6 +314,9 @@ describe("MemoryService / read model / panel", () => { const codexSession = service.openSession({ namespace: { source: "codex", profileId: "default", userId } }); + const ompSession = service.openSession({ + namespace: { source: "omp", profileId: "default", userId } + }); const cursorMemory = service.completeTurn("turn-panel-source-cursor", { sessionId: cursorSession.sessionId, query: "cursor panel source memory", @@ -329,6 +332,11 @@ describe("MemoryService / read model / panel", () => { query: "other panel source memory", answer: "other answer" }); + const ompMemory = service.completeTurn("turn-panel-source-omp", { + sessionId: ompSession.sessionId, + query: "omp panel source memory", + answer: "omp answer" + }); db.db.prepare("UPDATE memories SET agent_id = 'test_agent', session_id = NULL WHERE id = ?") .run(otherMemory.l1MemoryId); @@ -340,9 +348,13 @@ describe("MemoryService / read model / panel", () => { total: 1, items: [{ id: memmyMemory.l1MemoryId }] }); + expect(service.panelItems({ layer: "L1", sourceAgent: "omp", limit: 1 })).toMatchObject({ + total: 1, + items: [{ id: ompMemory.l1MemoryId, metadata: { source: "omp" } }] + }); expect(service.panelItems({ layer: "L1", - excludedSourceAgents: ["memmy-agent", "cursor", "claude_code", "codex", "opencode", "openclaw", "hermes"], + excludedSourceAgents: ["memmy-agent", "cursor", "claude_code", "codex", "pi", "opencode", "openclaw", "hermes", "workbuddy", "omp"], limit: 1 })).toMatchObject({ total: 1, @@ -351,6 +363,36 @@ describe("MemoryService / read model / panel", () => { db.close(); }); + it("derives OMP panel sources from imported session ids", () => { + const { db, service } = createTestService(); + const session = service.openSession({ + namespace: { source: "codex", profileId: "default", userId: "user-panel-omp" } + }); + const completed = service.completeTurn("turn-panel-omp", { + sessionId: session.sessionId, + query: "omp panel source memory", + answer: "omp answer" + }); + db.db.prepare("UPDATE memories SET agent_id = 'codex', session_id = 'omp::session-imported' WHERE id = ?") + .run(completed.l1MemoryId); + + expect(service.panelItems({ layer: "L1" })).toMatchObject({ + items: [{ id: completed.l1MemoryId, metadata: { source: "omp" } }] + }); + expect(service.panelOverviewSummary({}).sourceDistribution).toContainEqual( + expect.objectContaining({ source: "omp", count: 1 }) + ); + expect(service.panelItems({ layer: "L1", sourceAgent: "omp" })).toMatchObject({ + total: 1, + items: [{ id: completed.l1MemoryId, metadata: { source: "omp" } }] + }); + expect(service.panelItems({ layer: "L1", excludedSourceAgents: ["omp"] })).toMatchObject({ + total: 0, + items: [] + }); + db.close(); + }); + it("lists tasks from episodes, clamps pages, and deletes a whole task transactionally", () => { const { db, service } = createTestService(); const namespace = { @@ -508,12 +550,12 @@ describe("MemoryService / read model / panel", () => { const list = service.panelItems({ namespace, layer: "L1" }); const itemBeforeEmbedding = list.items.find((item) => item.id === complete.l1MemoryId); expect(itemBeforeEmbedding?.tags).toContain("摘要总结中"); - expect(itemBeforeEmbedding?.tags).not.toContain("openclaw"); + expect(itemBeforeEmbedding?.tags).toEqual(expect.arrayContaining(["agent-source", "openclaw"])); expect(itemBeforeEmbedding?.metadata?.source).toBe("openclaw"); const detail = service.getMemory(complete.l1MemoryId, { namespace }); expect(detail.item.tags).toContain("摘要总结中"); - expect(detail.item.tags).not.toContain("openclaw"); + expect(detail.item.tags).toEqual(expect.arrayContaining(["agent-source", "openclaw"])); expect(detail.item.metadata.source).toBe("openclaw"); expect(detail.refs.episode).toMatchObject({ id: complete.episodeId, @@ -532,7 +574,7 @@ describe("MemoryService / read model / panel", () => { db.close(); }); - it("shows panel change logs, jobs, and overview across namespaces", () => { + it("scopes panel change logs, jobs, and overview by namespace", () => { const { db, service } = createTestService(); const namespaceA = { source: "codex", @@ -561,29 +603,29 @@ describe("MemoryService / read model / panel", () => { const changesA = service.panelChanges({ namespace: namespaceA }); expect(changesA.changes.map((change) => change.id)).toContain(completeA.l1MemoryId); - expect(changesA.changes.map((change) => change.id)).toContain(completeB.l1MemoryId); + expect(changesA.changes.map((change) => change.id)).not.toContain(completeB.l1MemoryId); expect(changesA.changes.some((change) => change.kind === "job")).toBe(true); const jobIdsA = service.panelJobs({ namespace: namespaceA }).items.map((job) => job.id); expect(jobIdsA).toEqual(expect.arrayContaining(completeA.jobs.map((job) => job.jobId))); - expect(jobIdsA).toEqual(expect.arrayContaining(completeB.jobs.map((job) => job.jobId))); + expect(jobIdsA).not.toEqual(expect.arrayContaining(completeB.jobs.map((job) => job.jobId))); const overviewA = service.panelOverview({ namespace: namespaceA }); - expect(overviewA.stats.jobs.queued).toBe(completeA.jobs.length + completeB.jobs.length); - expect(overviewA.stats.byLayer.L1).toBe(completeA.l1MemoryIds.length + completeB.l1MemoryIds.length); - expect(overviewA.stats.byStatus.activated).toBe(completeA.l1MemoryIds.length + completeB.l1MemoryIds.length); - expect(overviewA.stats.episodes.open).toBe(2); + expect(overviewA.stats.jobs.queued).toBe(completeA.jobs.length); + expect(overviewA.stats.byLayer.L1).toBe(completeA.l1MemoryIds.length); + expect(overviewA.stats.byStatus.activated).toBe(completeA.l1MemoryIds.length); + expect(overviewA.stats.episodes.open).toBe(1); const changesB = service.panelChanges({ namespace: namespaceB }); expect(changesB.changes.map((change) => change.id)).toContain(completeB.l1MemoryId); - expect(changesB.changes.map((change) => change.id)).toContain(completeA.l1MemoryId); + expect(changesB.changes.map((change) => change.id)).not.toContain(completeA.l1MemoryId); expect(changesB.changes.some((change) => change.kind === "job")).toBe(true); const jobIdsB = service.panelJobs({ namespace: namespaceB }).items.map((job) => job.id); expect(jobIdsB).toEqual(expect.arrayContaining(completeB.jobs.map((job) => job.jobId))); - expect(jobIdsB).toEqual(expect.arrayContaining(completeA.jobs.map((job) => job.jobId))); + expect(jobIdsB).not.toEqual(expect.arrayContaining(completeA.jobs.map((job) => job.jobId))); const overviewB = service.panelOverview({ namespace: namespaceB }); - expect(overviewB.stats.jobs.queued).toBe(completeA.jobs.length + completeB.jobs.length); - expect(overviewB.stats.byLayer.L1).toBe(completeA.l1MemoryIds.length + completeB.l1MemoryIds.length); - expect(overviewB.stats.byStatus.activated).toBe(completeA.l1MemoryIds.length + completeB.l1MemoryIds.length); - expect(overviewB.stats.episodes.open).toBe(2); + expect(overviewB.stats.jobs.queued).toBe(completeB.jobs.length); + expect(overviewB.stats.byLayer.L1).toBe(completeB.l1MemoryIds.length); + expect(overviewB.stats.byStatus.activated).toBe(completeB.l1MemoryIds.length); + expect(overviewB.stats.episodes.open).toBe(1); db.close(); }); diff --git a/Memory/tests/service/read-model/skill-read.test.ts b/Memory/tests/service/read-model/skill-read.test.ts index 3e3aca225..a5951dd17 100644 --- a/Memory/tests/service/read-model/skill-read.test.ts +++ b/Memory/tests/service/read-model/skill-read.test.ts @@ -72,10 +72,10 @@ describe("MemoryService / read model / skill list", () => { expect(firstPage.skills).toHaveLength(2); expect(firstPage.nextCursor).toBe("2"); const secondPage = service.listSkills({ namespace: namespaceA, limit: 2, cursor: Number(firstPage.nextCursor) }); - expect(secondPage.skills).toHaveLength(2); + expect(secondPage.skills).toHaveLength(1); expect(secondPage.nextCursor).toBeUndefined(); const pagedIds = [...firstPage.skills, ...secondPage.skills].map((skill) => skill.id).sort(); - expect(pagedIds).toEqual(["skill_page_a_1", "skill_page_a_2", "skill_page_a_3", "skill_page_b_1"].sort()); + expect(pagedIds).toEqual(["skill_page_a_1", "skill_page_a_2", "skill_page_a_3"].sort()); const sqliteSkills = service.listSkills({ namespace: namespaceA, tags: ["sqlite"], limit: 10 }); expect(sqliteSkills.skills.map((skill) => skill.id).sort()).toEqual(["skill_page_a_1", "skill_page_a_3"]); expect(sqliteSkills.skills.find((skill) => skill.id === "skill_page_a_3")?.tags).toContain("sqlite"); diff --git a/Memory/tests/service/retrieval/injected-context.test.ts b/Memory/tests/service/retrieval/injected-context.test.ts index 2923a2ef8..e1d265c04 100644 --- a/Memory/tests/service/retrieval/injected-context.test.ts +++ b/Memory/tests/service/retrieval/injected-context.test.ts @@ -463,8 +463,8 @@ describe("MemoryService / retrieval / injected context", () => { }); expect(prepared.hits.length).toBeGreaterThan(1); - expect(prepared.sourceMemoryIds.length).toBeGreaterThanOrEqual(prepared.hits.length); - expect(prepared.droppedDueToBudget).toEqual([]); + expect(prepared.sourceMemoryIds).toEqual([]); + expect(prepared.droppedDueToBudget.map(({ id }) => id).sort()).toEqual(prepared.hits.map(({ id }) => id).sort()); expect(db.db.prepare( `SELECT turn_id, json_extract(request_json, '$.retrievalMode') AS retrieval_mode diff --git a/Memory/tests/service/retrieval/query-and-filter.test.ts b/Memory/tests/service/retrieval/query-and-filter.test.ts index f5094f36c..f36edeef7 100644 --- a/Memory/tests/service/retrieval/query-and-filter.test.ts +++ b/Memory/tests/service/retrieval/query-and-filter.test.ts @@ -51,7 +51,7 @@ describe("MemoryService / retrieval / query and filtering", () => { query: "Training turn should not retrieve memory." }); expect(start.hits).toEqual([]); - expect(start.injectedContext.markdown).toBe(""); + expect(start.injectedContext.markdown).toContain(''); expect(start.sourceMemoryIds).toEqual([]); expect(start.status).toContain("memory_search:disabled"); @@ -120,6 +120,59 @@ describe("MemoryService / retrieval / query and filtering", () => { db.close(); }); + it("keeps synchronous turn-start recall to one bounded LLM stage", async () => { + const summaryCalls: Array<{ + messages: Array<{ role: string; content: string }>; + options: { operation: string; timeoutMs?: number; maxRetries?: number }; + }> = []; + const evolutionCalls: typeof summaryCalls = []; + const config = { + ...DEFAULT_MEMMY_CONFIG, + algorithm: { + ...DEFAULT_MEMMY_CONFIG.algorithm, + retrieval: { + ...DEFAULT_MEMMY_CONFIG.algorithm.retrieval, + relativeThresholdFloor: 0, + smartSeed: false, + llmFilterEnabled: true, + llmFilterMinCandidates: 1 + } + } + }; + const { db, service } = createTestService({ + config, + llm: createQueryRewriteLlm(summaryCalls, []), + skillLlm: createQueryRewriteLlm(evolutionCalls, []), + embedder: createCapturingEmbedder([]) + }); + const namespace = { + source: "omp", + profileId: "jiang", + userId: "user-turn-start-llm-budget" + }; + service.addMemory({ + namespace, + layer: "L2", + title: "OMP handler deadline", + content: "Keep automatic memory recall within the OMP handler deadline." + }); + await service.runWorkerOnce(20); + const session = service.openSession({ namespace }); + + await service.startTurn({ + namespace, + sessionId: session.sessionId, + turnId: "turn-start-llm-budget", + query: "How should automatic memory recall stay within the OMP handler deadline?" + }); + + expect(summaryCalls.map((call) => call.options.operation)).toEqual([ + "retrieval.retrieval.filter.v5" + ]); + expect(summaryCalls[0]?.options.timeoutMs).toBe(20_000); + expect(evolutionCalls).toEqual([]); + }); + it("uses an extracted time range to inject at most 20 recent L1 summaries", async () => { const calls: Array<{ messages: LlmMessage[]; options: LlmCompletionOptions }> = []; const seenEmbeddings: string[] = []; @@ -324,7 +377,8 @@ describe("MemoryService / retrieval / query and filtering", () => { namespace: { source: "codex", profileId: "jiang", - userId: memory.userId + userId: memory.userId, + workspaceId: memory.appId }, query: "query with no lexical overlap", layers: ["L1"], @@ -928,7 +982,8 @@ describe("MemoryService / retrieval / query and filtering", () => { namespace: { source: "codex", profileId: "profile-a", - userId: "shared-recall-user" + userId: "shared-recall-user", + workspaceId: "workspace-recall" }, workspaceId: "workspace-recall" }); @@ -988,7 +1043,8 @@ describe("MemoryService / retrieval / query and filtering", () => { namespace: { source: "codex", profileId: "profile-a", - userId: "shared-recall-user" + userId: "shared-recall-user", + workspaceId: "workspace-recall" }, userId: "shared-recall-user", layers: ["L1"], @@ -1002,7 +1058,8 @@ describe("MemoryService / retrieval / query and filtering", () => { namespace: { source: "codex", profileId: "profile-a", - userId: "shared-recall-user" + userId: "shared-recall-user", + workspaceId: "workspace-recall" }, episodeId: profileAMemory.episodeId, limit: 10 @@ -1026,7 +1083,7 @@ function seededScoreTraceMemory(): MemoryRow { userId: "user-first-stage-score", sessionId: "session-first-stage-score", agentId: "codex", - appId: "workspace-first-stage-score", + appId: undefined, memoryType: "LongTermMemory", status: "activated", visibility: "private", @@ -1083,6 +1140,7 @@ function timeFilteredTraceMemory(input: { id: input.id, timeline: input.at, userId: "user-time-filter", + appId: undefined, sessionId: `session-${input.agentId}`, agentId: input.agentId, memoryKey: `trace:${input.id}`, diff --git a/Memory/tests/service/review-candidates.test.ts b/Memory/tests/service/review-candidates.test.ts new file mode 100644 index 000000000..ac5cb4bc5 --- /dev/null +++ b/Memory/tests/service/review-candidates.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createMemoryServiceFixture } from "../fixtures/memory-service-fixture.js"; + +const { cleanup, createTestService } = createMemoryServiceFixture(); + +afterEach(() => cleanup()); + +describe("candidate review workflow", () => { + it("keeps AI conclusions resolving until approve, supports edit-approve and reject", () => { + const { db, service } = createTestService(); + const namespace = { source: "codex", profileId: "default", userId: "review-user", projectId: "review-project", workspaceId: "review-workspace" }; + const first = service.addMemory({ namespace, layer: "L2", content: "Use the workspace namespace for every read.", source: "worker", deferProcessing: true }); + const second = service.addMemory({ namespace, layer: "Skill", content: "Run tests, then verify the result.", source: "worker", deferProcessing: true }); + for (const [memory, confidence] of [[first, 0.92], [second, 0.86]] as const) { + db.db.prepare(`UPDATE memories + SET status = 'resolving', + info_json = json_set(info_json, '$.confidence', ?), + properties_json = json_set(properties_json, '$.status', 'resolving') + WHERE id = ?`).run(confidence, memory.id); + } + + expect(service.reviewCandidates({ namespace }).items).toHaveLength(2); + const approved = service.approveCandidate(first.id, { namespace, content: "Every memory read must include workspace namespace." }); + expect(approved).toMatchObject({ decision: "approved", status: "activated", layer: "L2" }); + const rejected = service.rejectCandidate(second.id, { namespace, reason: "procedure is incomplete" }); + expect(rejected).toMatchObject({ decision: "rejected", status: "archived", layer: "Skill" }); + expect(service.reviewCandidates({ namespace }).items).toHaveLength(0); + expect(db.db.prepare("SELECT memory_value FROM memories WHERE id = ?").get(first.id)).toMatchObject({ memory_value: "Every memory read must include workspace namespace." }); + }); + + it("bulk approves only high-confidence candidates", () => { + const { db, service } = createTestService(); + const namespace = { source: "codex", profileId: "default", userId: "bulk-review-user", projectId: "bulk-review-project", workspaceId: "bulk-review-workspace" }; + const ids = [0.95, 0.55].map((confidence, index) => { + const memory = service.addMemory({ namespace, layer: "L2", content: `Conclusion ${index}`, source: "worker", deferProcessing: true }); + db.db.prepare(`UPDATE memories SET status='resolving', info_json=json_set(info_json, '$.confidence', ?), properties_json=json_set(properties_json, '$.status', 'resolving') WHERE id=?`).run(confidence, memory.id); + return memory.id; + }); + expect(service.bulkApproveHighConfidenceCandidates({ namespace, minimumConfidence: 0.8 })).toMatchObject({ approved: 1, ids: [ids[0]] }); + expect(db.db.prepare("SELECT status FROM memories WHERE id = ?").get(ids[1])).toMatchObject({ status: "resolving" }); + }); +}); diff --git a/Memory/tests/service/session/checkpoint.test.ts b/Memory/tests/service/session/checkpoint.test.ts new file mode 100644 index 000000000..fe10cdfb7 --- /dev/null +++ b/Memory/tests/service/session/checkpoint.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createMemoryServiceFixture } from "../../fixtures/memory-service-fixture.js"; + +const { cleanup, createTestService } = createMemoryServiceFixture(); + +afterEach(cleanup); + +describe("MemoryService / session / checkpoint", () => { + it("persists a structured handoff through the existing compact trace path", () => { + const { db, service } = createTestService(); + const namespace = { + source: "codex", + profileId: "main", + userId: "checkpoint-user", + workspacePath: "/work/checkpoint-project" + }; + const session = service.openSession({ namespace, sessionId: "checkpoint-session" }); + const result = service.checkpointSession(session.sessionId, { + namespace, + task: "Absorb the useful Remnic memory behaviors", + changes: ["Added project isolation", "Added Markdown audit"], + validated: ["Namespace tests pass"], + unverified: ["Full suite not run"], + nextSteps: ["Inspect graph relations", "Measure rerank filtering"], + tokenEstimate: 120 + }); + + expect(result.checkpointId).toBe(result.rawTurnId); + expect(result.checkpoint).toEqual({ + task: "Absorb the useful Remnic memory behaviors", + changes: ["Added project isolation", "Added Markdown audit"], + validated: ["Namespace tests pass"], + unverified: ["Full suite not run"], + nextSteps: ["Inspect graph relations", "Measure rerank filtering"] + }); + expect(result.memorySnapshot.summary).toContain("Task: Absorb the useful Remnic memory behaviors"); + expect(result.memorySnapshot.summary).toContain("- Full suite not run"); + expect(result.l1MemoryId).toMatch(/^trace_/u); + + const row = db.db.prepare( + "SELECT message_payload_json FROM raw_turns WHERE id = ?" + ).get(result.rawTurnId) as { message_payload_json: string }; + expect(JSON.parse(row.message_payload_json)).toMatchObject({ + compact: { + checkpoint: { + task: "Absorb the useful Remnic memory behaviors", + validated: ["Namespace tests pass"], + unverified: ["Full suite not run"] + } + } + }); + db.close(); + }); +}); + diff --git a/Memory/tests/service/session/session-lifecycle.test.ts b/Memory/tests/service/session/session-lifecycle.test.ts index 6d0a9405b..729f52ddd 100644 --- a/Memory/tests/service/session/session-lifecycle.test.ts +++ b/Memory/tests/service/session/session-lifecycle.test.ts @@ -17,7 +17,8 @@ describe("MemoryService / session / lifecycle", () => { namespace: { source: "codex", profileId: "jiang", - userId: "user-1" + userId: "user-1", + workspaceId: "workspace-1" }, workspaceId: "workspace-1", meta: { @@ -30,7 +31,8 @@ describe("MemoryService / session / lifecycle", () => { namespace: { source: "codex", profileId: "jiang", - userId: "user-1" + userId: "user-1", + workspaceId: "workspace-1" }, workspaceId: "workspace-1", meta: { @@ -207,7 +209,8 @@ describe("MemoryService / session / lifecycle", () => { namespace: { source: "codex", profileId: "jiang", - userId: "user-1" + userId: "user-1", + workspaceId: "workspace-1" }, query: "SQLite 记忆底座服务", includeInjectedContext: true @@ -229,7 +232,8 @@ describe("MemoryService / session / lifecycle", () => { namespace: { source: "codex", profileId: "jiang", - userId: "user-1" + userId: "user-1", + workspaceId: "workspace-1" }, sessionId: session.sessionId, retrievalMode: "turn_start", @@ -250,7 +254,7 @@ describe("MemoryService / session / lifecycle", () => { injected_memory_ids_json: string; outcome: string; } | undefined; - expect(recallRow?.namespace_id).toContain("user-1"); + expect(recallRow?.namespace_id).toBe("local:workspace-1"); expect(recallRow?.query_hash).toBeTruthy(); expect(JSON.parse(recallRow!.candidate_memory_ids_json)).toEqual(recall.candidateMemoryIds); expect(JSON.parse(recallRow!.injected_memory_ids_json)).toEqual(recall.sourceMemoryIds); diff --git a/Memory/tests/service/session/turn-capture.test.ts b/Memory/tests/service/session/turn-capture.test.ts index dc621de28..6320957be 100644 --- a/Memory/tests/service/session/turn-capture.test.ts +++ b/Memory/tests/service/session/turn-capture.test.ts @@ -127,6 +127,252 @@ describe("MemoryService / session / turn capture", () => { expect(completed.jobs.map((job) => job.jobType)).toContain("episode_idle_close"); db.close(); }); + it("prepends fresh authoritative project context and persists its metadata", async () => { + const { db, service } = createTestService(); + const namespace = { source: "codex", profileId: "default", userId: "project-turn-user", workspacePath: "/tmp/project-turn" }; + const supplemental = service.addMemory({ namespace, content: "Supplemental unrelated question guidance", layer: "L2" }); + const session = service.openSession({ namespace }); + const candidate = service.proposeProjectGoal({ namespace, title: "Ship context", summary: "Authoritative goal", detail: "Details", constraints: ["Hard constraint"], sourceMemoryIds: [supplemental.id] }); + const active = service.approveProjectGoal({ namespace, candidateId: candidate.id }); + const first = await service.startTurn({ sessionId: session.sessionId, query: "Supplemental unrelated question guidance", contextBudget: 300 }); + expect(first.projectContext.version).toBe(active.version); + expect(first.projectContext.status).toBe("ready"); + expect(first.projectContext.markdown).toContain("Ship context"); + expect(first.projectContext.markdown).toContain("Hard constraint"); + expect(first.injectedContext.markdown.indexOf(" id === supplemental.id)).toHaveLength(1); + const completed = service.completeTurn(first.turnId, { + sessionId: session.sessionId, + query: "Supplemental unrelated question guidance", + answer: "Persisted project context metadata.", + sourceMemoryIds: first.sourceMemoryIds + }); + const raw = db.db.prepare("SELECT source_memory_ids_json, message_payload_json FROM raw_turns WHERE id = ?").get(completed.rawTurnId) as { source_memory_ids_json: string; message_payload_json: string }; + const payload = JSON.parse(raw.message_payload_json) as { turn_start: Record }; + expect(JSON.parse(raw.source_memory_ids_json)).toEqual(first.sourceMemoryIds); + expect(payload.turn_start).toMatchObject({ + projectContextVersion: first.projectContext.version, + projectContextStatus: first.projectContext.status, + sourceMemoryIds: first.sourceMemoryIds + }); + const revision = service.proposeProjectGoal({ namespace, title: "Ship revision", summary: "Revised authoritative goal", detail: "Revision", sourceMemoryIds: [supplemental.id] }); + const revised = service.approveProjectGoal({ namespace, candidateId: revision.id }); + const next = await service.startTurn({ sessionId: session.sessionId, query: "Another unrelated question", contextBudget: 300 }); + expect(next.projectContext.version).toBe(revised.version); + expect(next.projectContext.version).toBeGreaterThan(first.projectContext.version); + const otherNamespace = { ...namespace, workspacePath: "/tmp/other" }; + const otherSession = service.openSession({ namespace: otherNamespace }); + const other = await service.startTurn({ sessionId: otherSession.sessionId, query: "Other workspace", contextBudget: 300 }); + expect(other.projectContext.status).toBe("no_confirmed_goal"); + expect(other.injectedContext.markdown).not.toContain("Ship revision"); + db.close(); + }); + + it("shares approved project context across CLI agents in the same workspace", async () => { + const { db, service } = createTestService(); + const workspacePath = "/tmp/cross-cli-project"; + const codexNamespace = { + source: "codex", + profileId: "codex-default", + userId: "cross-cli-user", + workspacePath + }; + const piNamespace = { + source: "pi", + profileId: "pi-default", + userId: "cross-cli-user", + workspacePath + }; + const candidate = service.proposeProjectGoal({ + namespace: codexNamespace, + title: "Ship cross-CLI context", + summary: "Keep every agent aligned to the approved project goal", + detail: "Codex approves the goal and Pi receives it automatically", + constraints: ["Do not treat historical recall as project authority"] + }); + const approved = service.approveProjectGoal({ + namespace: codexNamespace, + candidateId: candidate.id + }); + const workItem = service.createProjectWorkItem({ + namespace: codexNamespace, + goalId: approved.id, + title: "Verify Pi turn injection", + summary: "Open a Pi session in the same workspace", + nextStep: "Start the next Pi turn" + }); + service.selectProjectWorkItem({ + namespace: codexNamespace, + workItemId: workItem.id + }); + + const piSession = service.openSession({ namespace: piNamespace }); + const started = await service.startTurn({ + adapterId: "memmy-pi-hook", + requestId: "pi-start:cross-cli", + sessionId: piSession.sessionId, + query: "Continue the current project" + }); + + expect(started.projectContext).toMatchObject({ + status: "ready", + goal: { id: approved.id, title: "Ship cross-CLI context" }, + focusedWorkItem: { id: workItem.id, title: "Verify Pi turn injection" } + }); + expect(started.injectedContext.markdown).toContain("Ship cross-CLI context"); + expect(started.injectedContext.markdown).toContain("Verify Pi turn injection"); + expect(started.injectedContext.markdown).toContain("Do not treat historical recall as project authority"); + expect(started.injectedContext.markdown.indexOf(" { + const { db, service } = createTestService(); + const namespace = { source: "codex", profileId: "default", userId: "turn-budget-user", workspacePath: "/tmp/turn-budget" }; + const supplemental = service.addMemory({ namespace, content: "Budgeted supplemental retrieval guidance", layer: "L2" }); + const session = service.openSession({ namespace }); + const candidate = service.proposeProjectGoal({ + namespace, + title: "Budget authority", + summary: "Authoritative project context", + detail: "Keep authority ahead of supplemental recall" + }); + service.approveProjectGoal({ namespace, candidateId: candidate.id }); + const contextBudget = 300; + + const started = await service.startTurn({ + sessionId: session.sessionId, + query: "Budgeted supplemental retrieval guidance", + contextBudget + }); + + const projectEstimate = Math.ceil(started.projectContext.markdown.length / 4); + const recallRequest = db.db.prepare( + "SELECT request_json FROM recall_events WHERE id = ?" + ).get(started.searchEventId) as { request_json: string }; + expect(JSON.parse(recallRequest.request_json)).toMatchObject({ + contextBudget: contextBudget - projectEstimate + }); + expect(started.injectedContext.markdown).toContain("Budget authority"); + expect(started.injectedContext.markdown).toContain("Budgeted supplemental retrieval guidance"); + expect(started.injectedContext.tokenEstimate).toBe(Math.ceil(started.injectedContext.markdown.length / 4)); + expect(started.injectedContext.tokenEstimate).toBeLessThanOrEqual(contextBudget); + expect(started.hits.map((hit) => hit.id)).toContain(supplemental.id); + expect(started.droppedDueToBudget).not.toContainEqual(expect.objectContaining({ id: supplemental.id })); + db.close(); + }); + + it("keeps mandatory project context and suppresses supplemental recall for a tiny token budget", async () => { + const { db, service } = createTestService(); + const namespace = { source: "codex", profileId: "default", userId: "tiny-turn-budget-user", workspacePath: "/tmp/tiny-turn-budget" }; + const supplemental = service.addMemory({ namespace, content: "Tiny budget supplemental guidance", layer: "L2" }); + const session = service.openSession({ namespace }); + const candidate = service.proposeProjectGoal({ + namespace, + title: "Mandatory authority", + summary: "Must remain injected", + detail: "Even when the caller budget is below the renderer minimum" + }); + service.approveProjectGoal({ namespace, candidateId: candidate.id }); + + const request = { + sessionId: session.sessionId, + turnId: "tiny-budget-dropped-replay", + query: "Tiny budget supplemental guidance", + contextBudget: 5 + }; + const started = await service.startTurn(request); + + const recallRequest = db.db.prepare( + "SELECT request_json FROM recall_events WHERE id = ?" + ).get(started.searchEventId) as { request_json: string }; + expect(JSON.parse(recallRequest.request_json)).toMatchObject({ contextBudget: 0 }); + expect(started.injectedContext.markdown).toBe(started.projectContext.markdown); + expect(started.injectedContext.markdown).toContain(" hit.id)).toContain(supplemental.id); + expect(started.sourceMemoryIds).not.toContain(supplemental.id); + const droppedSupplemental = started.droppedDueToBudget.filter((dropped) => dropped.id === supplemental.id); + expect(droppedSupplemental).toHaveLength(1); + expect(droppedSupplemental[0]).toMatchObject({ + id: supplemental.id, + kind: "policy", + memoryLayer: "L2", + reason: "token_budget" + }); + expect(droppedSupplemental[0]?.tokenEstimate).toEqual(expect.any(Number)); + const retried = await service.startTurn({ ...request, query: "Changed after first start" }); + expect(retried.searchEventId).not.toBe(started.searchEventId); + expect(retried.projectContext.version).toBe(started.projectContext.version); + db.close(); + }); + it("recomputes an unkeyed turn-start request without creating a raw turn", async () => { + const { db, service } = createTestService(); + const namespace = { source: "codex", profileId: "default", userId: "turn-retry-user", workspacePath: "/tmp/turn-retry" }; + const originalSource = service.addMemory({ namespace, content: "Original retry retrieval source", layer: "L2" }); + const session = service.openSession({ namespace }); + const originalGoal = service.proposeProjectGoal({ + namespace, + title: "Original retry goal", + summary: "Keep the first packet stable", + detail: "Original project context", + sourceMemoryIds: [originalSource.id] + }); + service.approveProjectGoal({ namespace, candidateId: originalGoal.id }); + const request = { + sessionId: session.sessionId, + turnId: "turn-retry-persisted-packet", + query: "Original retry retrieval source", + contextBudget: 300 + }; + const first = await service.startTurn(request); + + const revision = service.proposeProjectGoal({ + namespace, + title: "Revised retry goal", + summary: "This must not replace the original packet", + detail: "Revised project context" + }); + service.approveProjectGoal({ namespace, candidateId: revision.id }); + const changedSource = service.addMemory({ namespace, content: "Changed retrieval source", layer: "L2" }); + const retried = await service.startTurn({ ...request, query: "Changed retrieval source" }); + expect(retried).not.toEqual(first); + expect(retried.contextPacketId).not.toBe(first.contextPacketId); + expect(retried.searchEventId).not.toBe(first.searchEventId); + expect(retried.projectContext.version).toBeGreaterThan(first.projectContext.version); + expect(retried.sourceMemoryIds).toContain(changedSource.id); + const recallCount = db.db.prepare("SELECT COUNT(*) AS count FROM recall_events WHERE session_id = ? AND turn_id = ?").pluck().get(session.sessionId, request.turnId); + expect(recallCount).toBe(2); + db.close(); + }); + + it("returns bounded no-goal project context when turn writes are disabled", async () => { + const root = createTestRoot("memmy-turn-no-write-project-context-"); + const db = new MemoryDb({ path: join(root, "memory.sqlite") }); + const service = createTestMemoryService({ + db, + mode: "dev", + config: { + ...DEFAULT_MEMMY_CONFIG, + algorithm: { ...DEFAULT_MEMMY_CONFIG.algorithm, enableMemoryAdd: false } + } + }); + const started = await service.startTurn({ + sessionId: "no-write-session", + namespace: { source: "codex", profileId: "default", userId: "no-write-user", workspacePath: "/tmp/no-write" }, + query: "Read-only turn", + contextBudget: 120 + }); + expect(started.projectContext).toMatchObject({ version: 0, status: "no_confirmed_goal", goal: null, focusedWorkItem: null, facts: [], sourceMemoryIds: [] }); + expect(started.projectContext.markdown.length).toBeLessThanOrEqual(120); + expect(started.injectedContext.markdown).toContain(''); + db.close(); + }); + + it("does not capture an interrupted started turn when a newer turn completes", async () => { const { db, service } = createTestService(); @@ -535,7 +781,7 @@ describe("MemoryService / session / turn capture", () => { expect(detail.item.body).toContain("User:\nRun pwd in the terminal."); expect(detail.item.body).toContain("Tool calls:\n- terminal_bash"); expect(detail.item.body).toContain("Agent:\nThe command completed."); - expect(detail.item.tags).toEqual(expect.arrayContaining(["shell", "terminal"])); + expect(detail.item.tags).toEqual(expect.arrayContaining(["shell", "terminal", "agent-source", "memmy-agent"])); expect(detail.item.tags).not.toContain("trace"); expect(detail.item.tags).not.toContain("turn"); expect(detail.item.tags).not.toContain("memmy"); @@ -619,6 +865,11 @@ describe("MemoryService / session / turn capture", () => { expect(db.db.prepare( `SELECT agent_id FROM memories WHERE id = ?` ).get(added.id)).toEqual({ agent_id: "unknown" }); + const addedTags = db.db.prepare( + `SELECT tags_json, info_json FROM memories WHERE id = ?` + ).get(added.id) as { tags_json: string; info_json: string }; + expect(JSON.parse(addedTags.tags_json)).not.toContain("agent-source"); + expect(JSON.parse(addedTags.info_json)).toMatchObject({ source: "unknown" }); expect(db.db.prepare( `SELECT tool_name, source_agent FROM api_logs ORDER BY called_at DESC, id DESC` ).all()).toEqual([ @@ -652,16 +903,20 @@ describe("MemoryService / session / turn capture", () => { }); const inserted = db.db.prepare( - `SELECT memory_value, info_json + `SELECT memory_value, agent_id, tags_json, info_json FROM memories WHERE id = ?` - ).get(added.id) as { memory_value: string; info_json: string }; + ).get(added.id) as { memory_value: string; agent_id: string; tags_json: string; info_json: string }; expect(inserted.memory_value).toBe("The user prefers dev-jiang for this project."); expect(inserted.memory_value).not.toContain("Historical User"); expect(inserted.memory_value).not.toContain("current_user_request"); + expect(inserted.agent_id).toBe("codex"); + expect(JSON.parse(inserted.tags_json)).toEqual(expect.arrayContaining(["agent-source", "codex"])); expect(JSON.parse(inserted.info_json)).toMatchObject({ + source: "codex", title: "Project branch preference", - summary: "The user prefers dev-jiang for this project." + summary: "The user prefers dev-jiang for this project.", + tags: expect.arrayContaining(["agent-source", "codex"]) }); db.close(); @@ -867,7 +1122,8 @@ describe("MemoryService / session / turn capture", () => { namespace: { source: "codex", profileId: "jiang", - userId: "user-structural" + userId: "user-structural", + workspaceId: "workspace-structural" }, workspaceId: "workspace-structural" }); @@ -903,7 +1159,8 @@ describe("MemoryService / session / turn capture", () => { namespace: { source: "codex", profileId: "jiang", - userId: "user-structural" + userId: "user-structural", + workspaceId: "workspace-structural" }, query: "pg_config executable not found", layers: ["L1"], diff --git a/Memory/tests/service/worker/worker-runtime.test.ts b/Memory/tests/service/worker/worker-runtime.test.ts index ab26b7407..b79e0b265 100644 --- a/Memory/tests/service/worker/worker-runtime.test.ts +++ b/Memory/tests/service/worker/worker-runtime.test.ts @@ -12,6 +12,46 @@ afterEach(() => { }); describe("MemoryService / worker / runtime", () => { + it("does not synthesize higher layers directly from workspace L1 keyword noise", async () => { + const { db, service } = createTestService(); + const namespace = { + source: "codex", + profileId: "default", + userId: "project-noise-user", + projectId: "project-noise", + workspaceId: "workspace-noise", + workspacePath: "/tmp/project-noise" + }; + const noise = [ + "Does the CLI filter by project and workspace namespace?", + "Can you look at the API architecture?", + "Should we run npm test now?", + "Continue checking the database service.", + "What commands did we discuss for docker?" + ]; + for (const [index, content] of noise.entries()) { + service.addMemory({ + namespace, + content, + layer: "L1", + source: "codex", + adapterId: `noise-${index}`, + deferProcessing: true + }); + } + + await service.runWorkerOnce(100, { namespace }); + + const layers = db.db.prepare( + `SELECT memory_layer AS layer, COUNT(*) AS count + FROM memories + GROUP BY memory_layer` + ).all() as Array<{ layer: string; count: number }>; + expect(layers).toEqual([{ layer: "L1", count: 5 }]); + + db.close(); + }); + it("selects the earliest worker wake across evolution and embedding queues", () => { const { db, service } = createTestService(); const repos = new Repositories(db.db); diff --git a/Memory/tests/viewer-static.test.ts b/Memory/tests/viewer-static.test.ts index 13c717dae..cf7ff1556 100644 --- a/Memory/tests/viewer-static.test.ts +++ b/Memory/tests/viewer-static.test.ts @@ -3,6 +3,16 @@ import { describe, expect, it } from "vitest"; import { memoryPanelHtml } from "../src/viewer/static.js"; describe("memoryPanelHtml", () => { + it("uses an inline favicon so the protected server does not receive browser favicon requests", () => { + expect(memoryPanelHtml()).toContain(''); + }); + + it("offers a Markdown download for the generated context pack", () => { + const html = memoryPanelHtml(); + expect(html).toContain('id="exportContextPack"'); + expect(html).toContain('link.download = "memmy-context-pack-" + name + ".md"'); + }); + it("strips generated Summary prefixes from displayed memory titles", async () => { const harness = createViewerHarness(); runViewerScript(harness); @@ -13,6 +23,29 @@ describe("memoryPanelHtml", () => { expect(harness.rowHtml()).not.toContain('
    Summary:'); }); + it("shows layer counts in the memory layer filter", async () => { + const harness = createViewerHarness(); + runViewerScript(harness); + await flushPromises(); + + const layerHtml = harness.element("layer").innerHTML; + expect(layerHtml).toContain("L1 (1,297)"); + expect(layerHtml).toContain("L2 (2)"); + }); + + it("shows only the selected workspace context pack", async () => { + const harness = createViewerHarness(); + runViewerScript(harness); + await flushPromises(); + + harness.element("contextPackScope").value = "workspace:workspace_1"; + const change = harness.element("contextPackScope").onchange as () => void; + change(); + + expect(harness.element("contextPackMarkdown").textContent).toBe("# Project Memory Pack: demo"); + expect(harness.element("contextPackMarkdown").textContent).not.toContain("other"); + }); + it("keeps the right JSON panel on the latest clicked memory detail", async () => { const harness = createViewerHarness(); runViewerScript(harness); @@ -46,6 +79,58 @@ describe("memoryPanelHtml", () => { expect(harness.element("detailJson").textContent).toContain('"source": "second"'); expect(harness.element("detailJson").textContent).not.toContain('"source": "first"'); }); + + it("uses a fragment token for API requests without leaving it in the address bar", async () => { + const harness = createViewerHarness(); + const stored = new Map(); + let replacedUrl = ""; + runViewerScript(harness, { + window: { + location: { + hash: "#token=panel-token", + pathname: "/", + search: "" + } + }, + sessionStorage: { + getItem: (key: string) => stored.get(key) ?? null, + setItem: (key: string, value: string) => stored.set(key, value) + }, + history: { + replaceState: (_state: unknown, _title: string, url: string) => { + replacedUrl = url; + } + } + }); + await flushPromises(); + + expect(stored.get("memmyMemoryToken")).toBe("panel-token"); + expect(replacedUrl).toBe("/"); + expect(harness.requests()).not.toHaveLength(0); + expect(harness.requests().every((request) => request.authorization === "Bearer panel-token")).toBe(true); + expect(harness.requests().every((request) => !request.path.includes("panel-token"))).toBe(true); + }); + + it("validates a manually entered token against a protected panel endpoint", async () => { + const harness = createViewerHarness(); + runViewerScript(harness, { + sessionStorage: { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined + } + }); + + harness.element("tokenInput").value = "manual-panel-token"; + const connect = harness.element("connectToken").onclick as () => Promise; + await connect(); + await flushPromises(); + + expect(harness.requests()[0]).toEqual({ + path: "/api/v1/panel/status", + authorization: "Bearer manual-panel-token" + }); + }); }); type FakeRow = FakeElement & { @@ -55,7 +140,10 @@ type FakeRow = FakeElement & { type DetailResolver = (body: unknown) => void; -function runViewerScript(harness: ReturnType): void { +function runViewerScript( + harness: ReturnType, + browserContext: Record = {} +): void { const match = memoryPanelHtml().match(/