diff --git a/.craft.yml b/.craft.yml index 8612b8182..ba617602e 100644 --- a/.craft.yml +++ b/.craft.yml @@ -26,6 +26,9 @@ targets: - name: npm id: "@sentry/junior-linear" includeNames: /^sentry-junior-linear-\d.*\.tgz$/ + - name: npm + id: "@sentry/junior-memory" + includeNames: /^sentry-junior-memory-\d.*\.tgz$/ - name: npm id: "@sentry/junior-notion" includeNames: /^sentry-junior-notion-\d.*\.tgz$/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e598f021..b1ffa35a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,7 @@ jobs: pnpm --filter @sentry/junior-github pack --pack-destination artifacts pnpm --filter @sentry/junior-hex pack --pack-destination artifacts pnpm --filter @sentry/junior-linear pack --pack-destination artifacts + pnpm --filter @sentry/junior-memory pack --pack-destination artifacts pnpm --filter @sentry/junior-notion pack --pack-destination artifacts pnpm --filter @sentry/junior-scheduler pack --pack-destination artifacts pnpm --filter @sentry/junior-maintenance pack --pack-destination artifacts diff --git a/AGENTS.md b/AGENTS.md index 9caaa7e25..770d322da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,8 @@ Co-Authored-By: (agent model name) - If evals fail from missing or expired Gateway/Vercel credentials, run `pnpm dev:env` to refresh secrets before retrying. - Use instrumentation conventions from `specs/instrumentation.md`. - Use OpenTelemetry semantic keys for logs; when no semantic key exists, use `app.*`. +- Plugin SQL migrations must be generated from Drizzle schema changes with the + package `db:generate` script; do not hand-edit migration SQL. - Keep release package lists aligned across `.craft.yml`, `scripts/bump-release-versions.mjs`, `.github/workflows/ci.yml`, `README.md`, and release docs; verify with `pnpm release:check`. - Minimize defensive programming — no fallbacks when systems are expected to work. Ensure errors are captured correctly. Use retries for expected network failures, nothing more. - Prefer minimal interfaces and simple components across the codebase. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a47c90c1..093a33b59 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -115,6 +115,7 @@ This repo uses Craft for manual lockstep npm releases of: - `@sentry/junior-hex` - `@sentry/junior-linear` - `@sentry/junior-maintenance` +- `@sentry/junior-memory` - `@sentry/junior-notion` - `@sentry/junior-scheduler` - `@sentry/junior-sentry` diff --git a/README.md b/README.md index c55413d06..084372606 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Start here: | `@sentry/junior-github` | GitHub plugin package for issue workflows | | `@sentry/junior-hex` | Hex plugin package for data warehouse query workflows | | `@sentry/junior-linear` | Linear plugin package for issue workflows | +| `@sentry/junior-memory` | Memory plugin package for long-term Junior memory storage | | `@sentry/junior-notion` | Notion plugin package for page search workflows | | `@sentry/junior-scheduler` | Scheduler plugin package for scheduled Junior tasks | | `@sentry/junior-maintenance` | Maintenance plugin package for updating and improving Junior apps | diff --git a/ast-grep/rules/no-drizzle-pg-enums.yml b/ast-grep/rules/no-drizzle-pg-enums.yml new file mode 100644 index 000000000..85f935c0a --- /dev/null +++ b/ast-grep/rules/no-drizzle-pg-enums.yml @@ -0,0 +1,15 @@ +id: no-drizzle-pg-enums +language: TypeScript +severity: error +message: Do not use Drizzle pgEnum. Use text columns with literal tuple typing and SQL CHECK constraints instead. +files: + - packages/**/*.ts + - packages/**/*.tsx +rule: + any: + - pattern: import { pgEnum } from "drizzle-orm/pg-core" + - pattern: import { $$$, pgEnum, $$$ } from "drizzle-orm/pg-core" + - pattern: import { pgEnum as $ALIAS } from "drizzle-orm/pg-core" + - pattern: import { $$$, pgEnum as $ALIAS, $$$ } from "drizzle-orm/pg-core" + - pattern: pgEnum($$$) + - pattern: $OBJ.pgEnum($$$) diff --git a/package.json b/package.json index 7787e18a5..0e1f01151 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "docs:dev": "pnpm --filter @sentry/junior-docs dev", "docs:build": "pnpm --filter @sentry/junior-docs build", "docs:check": "pnpm --filter @sentry/junior-docs check", - "package:lint": "for pkg in packages/junior packages/junior-plugin-api packages/junior-scheduler packages/junior-dashboard packages/junior-github packages/junior-agent-browser packages/junior-datadog packages/junior-hex packages/junior-linear packages/junior-maintenance packages/junior-notion packages/junior-sentry packages/junior-vercel; do pnpm exec publint \"$pkg\" || exit $?; done", + "package:lint": "for pkg in packages/junior packages/junior-plugin-api packages/junior-scheduler packages/junior-memory packages/junior-dashboard packages/junior-github packages/junior-agent-browser packages/junior-datadog packages/junior-hex packages/junior-linear packages/junior-maintenance packages/junior-notion packages/junior-sentry packages/junior-vercel; do pnpm exec publint \"$pkg\" || exit $?; done", "release:check": "node scripts/check-release-config.mjs", "start": "pnpm --filter @sentry/junior-example dev", "test": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior test && pnpm --filter @sentry/junior-dashboard test", @@ -29,7 +29,7 @@ "test:watch": "pnpm --filter @sentry/junior test:watch", "evals": "pnpm --filter @sentry/junior-evals evals", "evals:record": "pnpm --filter @sentry/junior-evals evals:record", - "typecheck": "pnpm --filter @sentry/junior-plugin-api typecheck && pnpm --filter @sentry/junior-scheduler typecheck && pnpm --filter @sentry/junior typecheck && pnpm --filter @sentry/junior-dashboard typecheck && pnpm --filter @sentry/junior-testing typecheck && pnpm --filter @sentry/junior-example typecheck", + "typecheck": "pnpm --filter @sentry/junior-plugin-api typecheck && pnpm --filter @sentry/junior-scheduler typecheck && pnpm --filter @sentry/junior-memory typecheck && pnpm --filter @sentry/junior typecheck && pnpm --filter @sentry/junior-dashboard typecheck && pnpm --filter @sentry/junior-testing typecheck && pnpm --filter @sentry/junior-example typecheck", "skills:check": "pnpm --filter @sentry/junior skills:check", "test:ci": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior test:coverage && pnpm --filter @sentry/junior-dashboard test:coverage" }, diff --git a/packages/docs/src/content/docs/contribute/releasing.md b/packages/docs/src/content/docs/contribute/releasing.md index add10d0f1..805dea9a0 100644 --- a/packages/docs/src/content/docs/contribute/releasing.md +++ b/packages/docs/src/content/docs/contribute/releasing.md @@ -20,6 +20,7 @@ Junior uses lockstep package releases for: - `@sentry/junior-hex` - `@sentry/junior-linear` - `@sentry/junior-maintenance` +- `@sentry/junior-memory` - `@sentry/junior-notion` - `@sentry/junior-scheduler` - `@sentry/junior-sentry` diff --git a/packages/junior-memory/drizzle.config.ts b/packages/junior-memory/drizzle.config.ts new file mode 100644 index 000000000..15a62b834 --- /dev/null +++ b/packages/junior-memory/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "postgresql", + out: "./migrations", + schema: "./src/db/schema.ts", +}); diff --git a/packages/junior-memory/migrations/0000_dizzy_millenium_guard.sql b/packages/junior-memory/migrations/0000_dizzy_millenium_guard.sql new file mode 100644 index 000000000..be2c9ea60 --- /dev/null +++ b/packages/junior-memory/migrations/0000_dizzy_millenium_guard.sql @@ -0,0 +1,37 @@ +CREATE TABLE "junior_memory_memories" ( + "id" text PRIMARY KEY NOT NULL, + "scope" text NOT NULL, + "scope_key" text NOT NULL, + "type" text NOT NULL, + "subject_type" text NOT NULL, + "subject_key" text, + "content" text NOT NULL, + "source_platform" text NOT NULL, + "source_key" text NOT NULL, + "idempotency_key" text, + "observed_at_ms" bigint NOT NULL, + "created_at_ms" bigint NOT NULL, + "expires_at_ms" bigint, + "superseded_at_ms" bigint, + "superseded_by_id" text, + "archived_at_ms" bigint, + "archive_reason" text, + CONSTRAINT "junior_memory_memories_scope_check" CHECK ("junior_memory_memories"."scope" IN ('personal', 'conversation')), + CONSTRAINT "junior_memory_memories_type_check" CHECK ("junior_memory_memories"."type" IN ( + 'preference', + 'identity', + 'relationship', + 'knowledge', + 'context', + 'event', + 'task', + 'observation' + )), + CONSTRAINT "junior_memory_memories_subject_type_check" CHECK ("junior_memory_memories"."subject_type" IN ('user', 'conversation', 'general')), + CONSTRAINT "junior_memory_memories_subject_key_check" CHECK (("junior_memory_memories"."subject_type" = 'general' AND "junior_memory_memories"."subject_key" IS NULL) OR ("junior_memory_memories"."subject_type" IN ('user', 'conversation') AND "junior_memory_memories"."subject_key" IS NOT NULL AND length("junior_memory_memories"."subject_key") > 0)), + CONSTRAINT "junior_memory_memories_source_platform_check" CHECK ("junior_memory_memories"."source_platform" IN ('slack', 'local')) +); +--> statement-breakpoint +CREATE INDEX "junior_memory_memories_visible_idx" ON "junior_memory_memories" USING btree ("scope","scope_key","created_at_ms" DESC NULLS LAST,"id") WHERE "junior_memory_memories"."archived_at_ms" IS NULL AND "junior_memory_memories"."superseded_at_ms" IS NULL AND "junior_memory_memories"."superseded_by_id" IS NULL;--> statement-breakpoint +CREATE INDEX "junior_memory_memories_expiration_idx" ON "junior_memory_memories" USING btree ("expires_at_ms") WHERE "junior_memory_memories"."archived_at_ms" IS NULL AND "junior_memory_memories"."expires_at_ms" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "junior_memory_memories_idempotency_idx" ON "junior_memory_memories" USING btree ("scope","scope_key","idempotency_key") WHERE "junior_memory_memories"."idempotency_key" IS NOT NULL; \ No newline at end of file diff --git a/packages/junior-memory/migrations/meta/0000_snapshot.json b/packages/junior-memory/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..75af75b1d --- /dev/null +++ b/packages/junior-memory/migrations/meta/0000_snapshot.json @@ -0,0 +1,234 @@ +{ + "id": "57a69a4f-1cc5-4597-a818-fb23e3a5bf17", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_memory_memories": { + "name": "junior_memory_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_key": { + "name": "subject_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_platform": { + "name": "source_platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at_ms": { + "name": "observed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expires_at_ms": { + "name": "expires_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "superseded_at_ms": { + "name": "superseded_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "superseded_by_id": { + "name": "superseded_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at_ms": { + "name": "archived_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "archive_reason": { + "name": "archive_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_memory_memories_visible_idx": { + "name": "junior_memory_memories_visible_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_memory_memories\".\"archived_at_ms\" IS NULL AND \"junior_memory_memories\".\"superseded_at_ms\" IS NULL AND \"junior_memory_memories\".\"superseded_by_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_memory_memories_expiration_idx": { + "name": "junior_memory_memories_expiration_idx", + "columns": [ + { + "expression": "expires_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_memory_memories\".\"archived_at_ms\" IS NULL AND \"junior_memory_memories\".\"expires_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_memory_memories_idempotency_idx": { + "name": "junior_memory_memories_idempotency_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"junior_memory_memories\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_memory_memories_scope_check": { + "name": "junior_memory_memories_scope_check", + "value": "\"junior_memory_memories\".\"scope\" IN ('personal', 'conversation')" + }, + "junior_memory_memories_type_check": { + "name": "junior_memory_memories_type_check", + "value": "\"junior_memory_memories\".\"type\" IN (\n 'preference',\n 'identity',\n 'relationship',\n 'knowledge',\n 'context',\n 'event',\n 'task',\n 'observation'\n )" + }, + "junior_memory_memories_subject_type_check": { + "name": "junior_memory_memories_subject_type_check", + "value": "\"junior_memory_memories\".\"subject_type\" IN ('user', 'conversation', 'general')" + }, + "junior_memory_memories_subject_key_check": { + "name": "junior_memory_memories_subject_key_check", + "value": "(\"junior_memory_memories\".\"subject_type\" = 'general' AND \"junior_memory_memories\".\"subject_key\" IS NULL) OR (\"junior_memory_memories\".\"subject_type\" IN ('user', 'conversation') AND \"junior_memory_memories\".\"subject_key\" IS NOT NULL AND length(\"junior_memory_memories\".\"subject_key\") > 0)" + }, + "junior_memory_memories_source_platform_check": { + "name": "junior_memory_memories_source_platform_check", + "value": "\"junior_memory_memories\".\"source_platform\" IN ('slack', 'local')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior-memory/migrations/meta/_journal.json b/packages/junior-memory/migrations/meta/_journal.json new file mode 100644 index 000000000..2bb5803e4 --- /dev/null +++ b/packages/junior-memory/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1781976138405, + "tag": "0000_dizzy_millenium_guard", + "breakpoints": true + } + ] +} diff --git a/packages/junior-memory/package.json b/packages/junior-memory/package.json new file mode 100644 index 000000000..cc6b39c18 --- /dev/null +++ b/packages/junior-memory/package.json @@ -0,0 +1,43 @@ +{ + "name": "@sentry/junior-memory", + "version": "0.75.0", + "private": false, + "publishConfig": { + "access": "public" + }, + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/getsentry/junior.git", + "directory": "packages/junior-memory" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "migrations", + "src" + ], + "scripts": { + "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", + "db:generate": "pnpm exec drizzle-kit generate --config drizzle.config.ts", + "prepare": "pnpm run build", + "prepack": "pnpm run build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sentry/junior-plugin-api": "workspace:*", + "drizzle-orm": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "drizzle-kit": "catalog:", + "tsup": "^8.5.1", + "typescript": "^6.0.3" + } +} diff --git a/packages/junior-memory/src/db/schema.ts b/packages/junior-memory/src/db/schema.ts new file mode 100644 index 000000000..77b018661 --- /dev/null +++ b/packages/junior-memory/src/db/schema.ts @@ -0,0 +1,90 @@ +/** + * Drizzle source of truth for memory plugin SQL migrations. + * + * Update this schema first, then regenerate packaged migrations with + * `pnpm --filter @sentry/junior-memory db:generate`. + */ +import { sql } from "drizzle-orm"; +import { + bigint, + check, + index, + pgTable, + text, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { + MEMORY_SCOPES, + MEMORY_SOURCE_PLATFORMS, + MEMORY_SUBJECT_TYPES, + MEMORY_TYPES, +} from "../types"; + +export const juniorMemoryMemories = pgTable( + "junior_memory_memories", + { + id: text("id").primaryKey(), + scope: text("scope", { enum: MEMORY_SCOPES }).notNull(), + scopeKey: text("scope_key").notNull(), + type: text("type", { enum: MEMORY_TYPES }).notNull(), + subjectType: text("subject_type", { enum: MEMORY_SUBJECT_TYPES }).notNull(), + subjectKey: text("subject_key"), + content: text("content").notNull(), + sourcePlatform: text("source_platform", { + enum: MEMORY_SOURCE_PLATFORMS, + }).notNull(), + sourceKey: text("source_key").notNull(), + idempotencyKey: text("idempotency_key"), + observedAtMs: bigint("observed_at_ms", { mode: "number" }).notNull(), + createdAtMs: bigint("created_at_ms", { mode: "number" }).notNull(), + expiresAtMs: bigint("expires_at_ms", { mode: "number" }), + supersededAtMs: bigint("superseded_at_ms", { mode: "number" }), + supersededById: text("superseded_by_id"), + archivedAtMs: bigint("archived_at_ms", { mode: "number" }), + archiveReason: text("archive_reason"), + }, + (table) => [ + index("junior_memory_memories_visible_idx") + .on(table.scope, table.scopeKey, table.createdAtMs.desc(), table.id) + .where( + sql`${table.archivedAtMs} IS NULL AND ${table.supersededAtMs} IS NULL AND ${table.supersededById} IS NULL`, + ), + index("junior_memory_memories_expiration_idx") + .on(table.expiresAtMs) + .where( + sql`${table.archivedAtMs} IS NULL AND ${table.expiresAtMs} IS NOT NULL`, + ), + uniqueIndex("junior_memory_memories_idempotency_idx") + .on(table.scope, table.scopeKey, table.idempotencyKey) + .where(sql`${table.idempotencyKey} IS NOT NULL`), + check( + "junior_memory_memories_scope_check", + sql`${table.scope} IN ('personal', 'conversation')`, + ), + check( + "junior_memory_memories_type_check", + sql`${table.type} IN ( + 'preference', + 'identity', + 'relationship', + 'knowledge', + 'context', + 'event', + 'task', + 'observation' + )`, + ), + check( + "junior_memory_memories_subject_type_check", + sql`${table.subjectType} IN ('user', 'conversation', 'general')`, + ), + check( + "junior_memory_memories_subject_key_check", + sql`(${table.subjectType} = 'general' AND ${table.subjectKey} IS NULL) OR (${table.subjectType} IN ('user', 'conversation') AND ${table.subjectKey} IS NOT NULL AND length(${table.subjectKey}) > 0)`, + ), + check( + "junior_memory_memories_source_platform_check", + sql`${table.sourcePlatform} IN ('slack', 'local')`, + ), + ], +); diff --git a/packages/junior-memory/src/index.ts b/packages/junior-memory/src/index.ts new file mode 100644 index 000000000..63e768fb8 --- /dev/null +++ b/packages/junior-memory/src/index.ts @@ -0,0 +1,13 @@ +export { createMemoryPlugin, memoryPlugin } from "./plugin"; +export { createMemoryStore } from "./store"; +export type { + ArchiveMemoryInput, + CreateMemoryInput, + CreateMemoryResult, + ListMemoriesInput, + MemoryRecord, + MemoryStore, + MemoryStoreOptions, + SearchMemoriesInput, +} from "./store"; +export type { MemoryRuntimeContext } from "./types"; diff --git a/packages/junior-memory/src/plugin.ts b/packages/junior-memory/src/plugin.ts new file mode 100644 index 000000000..51fa9e116 --- /dev/null +++ b/packages/junior-memory/src/plugin.ts @@ -0,0 +1,16 @@ +import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; + +/** Create Junior's trusted long-term memory plugin registration. */ +export function createMemoryPlugin() { + return defineJuniorPlugin({ + database: {}, + manifest: { + name: "memory", + displayName: "Memory", + description: "Long-term Junior memory storage and recall", + }, + packageName: "@sentry/junior-memory", + }); +} + +export const memoryPlugin = createMemoryPlugin(); diff --git a/packages/junior-memory/src/policy.ts b/packages/junior-memory/src/policy.ts new file mode 100644 index 000000000..122863f30 --- /dev/null +++ b/packages/junior-memory/src/policy.ts @@ -0,0 +1,66 @@ +import type { MemorySubjectType } from "./types"; + +const SECRET_PATTERNS = [ + /\b(?:api[_-]?key|secret|token|password|passwd|private[_-]?key)\b/i, + /\b(?:xox[baprs]-|gh[pousr]_|sk-[A-Za-z0-9_-]{12,})/, + /-----BEGIN [A-Z ]*PRIVATE KEY-----/, +]; + +// Conservative guard for obvious third-party profile facts in user memories. +const THIRD_PARTY_USER_SUBJECT_PATTERNS = [ + /^\s*(?!(?:User|Requester)\b)(?:[A-Z][a-z]+(?:\s+[A-Z][a-z]+)?|@[A-Za-z0-9._-]+)\s+(?:is|prefers|likes|owns|uses|works|has|wants|needs|leads|manages|reports)\b/, +]; + +// Deterministic V1 public-only rejection for obvious non-public categories. +const NON_PUBLIC_MEMORY_PATTERNS = [ + /\b(?:health|medical|medication|diagnos(?:is|ed)|disability|therapy|hospital|surgery|cancer|pregnan(?:t|cy)|family[-\s]?care)\b/i, + /\b(?:legal|lawsuit|attorney|immigration|visa|passport|ssn|social security|government id|driver'?s license)\b/i, + /\b(?:salary|compensation|bonus|equity grant|performance review|promotion|discipline|termination|fired|laid off|pip)\b/i, + /\b(?:religion|religious|politics|political|union activity|protected class|sexual orientation)\b/i, + /\b(?:debt|bankruptcy|financial hardship|divorce|dating|private life|interviewing elsewhere)\b/i, + /\b(?:unreliable|lazy|incompetent|angry|unstable|bad teammate|dislikes working with)\b/i, + /\b(?:gossip|venting|personal conflict|raw conversation summary)\b/i, +]; + +/** Return whether content matches the plugin's deterministic secret rejection. */ +export function containsMemorySecret(content: string): boolean { + return SECRET_PATTERNS.some((pattern) => pattern.test(content)); +} + +/** Validate deterministic write policy before memory content reaches storage. */ +export function validateMemoryWritePolicy(args: { + content: string; + subjectType: MemorySubjectType; +}): { ok: true } | { ok: false; reason: string } { + if (!args.content.trim()) { + return { ok: false, reason: "Memory content is required." }; + } + if (containsMemorySecret(args.content)) { + return { + ok: false, + reason: "Memory content appears to contain a secret.", + }; + } + if ( + NON_PUBLIC_MEMORY_PATTERNS.some((pattern) => pattern.test(args.content)) + ) { + return { + ok: false, + reason: + "Memory content appears to contain non-public or sensitive information.", + }; + } + if ( + args.subjectType === "user" && + THIRD_PARTY_USER_SUBJECT_PATTERNS.some((pattern) => + pattern.test(args.content), + ) + ) { + return { + ok: false, + reason: + "User-subject memories can only store first-person facts about the current requester.", + }; + } + return { ok: true }; +} diff --git a/packages/junior-memory/src/scope.ts b/packages/junior-memory/src/scope.ts new file mode 100644 index 000000000..1bda3f3dc --- /dev/null +++ b/packages/junior-memory/src/scope.ts @@ -0,0 +1,99 @@ +import type { + MemoryRuntimeContext, + MemoryScope, + MemorySubjectType, +} from "./types"; + +/** Runtime-derived visibility scope used for memory authorization checks. */ +export interface ResolvedMemoryScope { + scope: MemoryScope; + scopeKey: string; +} + +/** Runtime-derived subject classification stored for filtering and rendering. */ +export interface ResolvedMemorySubject { + subjectKey?: string; + subjectType: MemorySubjectType; +} + +function sourceConversationKey(ctx: MemoryRuntimeContext): string | undefined { + if (ctx.source.platform === "local") { + return ctx.source.conversationId; + } + const threadKey = ctx.source.threadTs ?? ctx.source.messageTs; + if (!threadKey) { + return undefined; + } + return `slack:${ctx.source.teamId}:${ctx.source.channelId}:${threadKey}`; +} + +function requesterScopeKey(ctx: MemoryRuntimeContext): string | undefined { + const requester = ctx.requester; + if (!requester?.userId) { + return undefined; + } + if (requester.platform === "slack") { + return `slack:${requester.teamId}:${requester.userId}`; + } + return `local:${requester.userId}`; +} + +/** Derive the authority-bearing key for a requested memory scope. */ +export function deriveMemoryScope( + ctx: MemoryRuntimeContext, + scope: MemoryScope, +): ResolvedMemoryScope { + if (scope === "personal") { + const scopeKey = requesterScopeKey(ctx); + if (!scopeKey) { + throw new Error("Personal memory requires requester context."); + } + return { scope, scopeKey }; + } + + const scopeKey = sourceConversationKey(ctx); + if (!scopeKey) { + throw new Error("Conversation memory requires conversation context."); + } + return { scope, scopeKey }; +} + +/** Derive the memory subject from the already-authorized write scope. */ +export function deriveMemorySubject( + ctx: MemoryRuntimeContext, + scope: ResolvedMemoryScope, +): ResolvedMemorySubject { + if (scope.scope === "personal") { + const subjectKey = requesterScopeKey(ctx); + if (!subjectKey) { + throw new Error("User-subject memory requires requester context."); + } + return { subjectType: "user", subjectKey }; + } + + const subjectKey = sourceConversationKey(ctx); + if (!subjectKey) { + throw new Error( + "Conversation-subject memory requires conversation context.", + ); + } + return { subjectType: "conversation", subjectKey }; +} + +/** Return every visible scope for memory retrieval in the current context. */ +export function deriveVisibleMemoryScopes( + ctx: MemoryRuntimeContext, +): ResolvedMemoryScope[] { + const scopes: ResolvedMemoryScope[] = []; + try { + scopes.push(deriveMemoryScope(ctx, "personal")); + } catch { + // Personal memory is optional when a runtime surface has no requester. + } + try { + scopes.push(deriveMemoryScope(ctx, "conversation")); + } catch { + // Conversation memory is optional for synthetic invocations. + } + return scopes; +} diff --git a/packages/junior-memory/src/store.ts b/packages/junior-memory/src/store.ts new file mode 100644 index 000000000..a0603f8d8 --- /dev/null +++ b/packages/junior-memory/src/store.ts @@ -0,0 +1,510 @@ +/** + * SQL-backed memory store boundary. + * + * This module owns row parsing plus visible create/list/search/archive + * operations. Visibility, expiration, and supersession are enforced before + * records leave the store. + */ +import { randomUUID } from "node:crypto"; +import type { PluginDb } from "@sentry/junior-plugin-api"; +import { z } from "zod"; +import { + MEMORY_SCOPES, + MEMORY_SOURCE_PLATFORMS, + MEMORY_SUBJECT_TYPES, + MEMORY_TYPES, + memoryRuntimeContextSchema, + type MemoryRuntimeContext, + type MemoryScope, +} from "./types"; +import { validateMemoryWritePolicy } from "./policy"; +import { + deriveMemoryScope, + deriveMemorySubject, + deriveVisibleMemoryScopes, + type ResolvedMemoryScope, +} from "./scope"; + +const DEFAULT_LIST_LIMIT = 50; +const DEFAULT_SEARCH_LIMIT = 10; +const MAX_MEMORY_CONTENT_CHARS = 4_000; + +const nonEmptyStringSchema = z.string().min(1); +const numberSchema = z.number().finite(); +const createMemoryInputSchema = z + .object({ + content: nonEmptyStringSchema, + expiresAtMs: numberSchema.optional(), + idempotencyKey: nonEmptyStringSchema, + }) + .strict(); +const listMemoriesInputSchema = z + .object({ + limit: numberSchema.optional(), + }) + .strict(); +const searchMemoriesInputSchema = z + .object({ + limit: numberSchema.optional(), + query: nonEmptyStringSchema, + }) + .strict(); +const archiveMemoryInputSchema = z + .object({ + id: nonEmptyStringSchema, + reason: nonEmptyStringSchema.optional(), + }) + .strict(); +const clockSchema = z.function({ input: [], output: numberSchema }).optional(); +const memoryStoreOptionsSchema = z + .object({ + now: clockSchema, + }) + .strict(); +const optionalNumberSchema = z.preprocess( + (value) => (value === null ? undefined : value), + z.coerce.number().optional(), +); +const optionalStringSchema = z.preprocess( + (value) => (value === null ? undefined : value), + z.string().optional(), +); +const optionalNonEmptyStringSchema = z.preprocess( + (value) => (value === null ? undefined : value), + z.string().min(1).optional(), +); +const memoryRowSchema = z + .object({ + id: z.string().min(1), + scope: z.enum(MEMORY_SCOPES), + scope_key: z.string().min(1), + type: z.enum(MEMORY_TYPES), + subject_type: z.enum(MEMORY_SUBJECT_TYPES), + subject_key: optionalNonEmptyStringSchema, + content: z.string().min(1), + source_platform: z.enum(MEMORY_SOURCE_PLATFORMS), + source_key: z.string().min(1), + idempotency_key: optionalStringSchema, + observed_at_ms: z.coerce.number(), + created_at_ms: z.coerce.number(), + expires_at_ms: optionalNumberSchema, + superseded_at_ms: optionalNumberSchema, + superseded_by_id: optionalStringSchema, + archived_at_ms: optionalNumberSchema, + archive_reason: optionalStringSchema, + }) + .strict() + .superRefine((row, ctx) => { + if (row.subject_type === "general") { + if (row.subject_key !== undefined) { + ctx.addIssue({ + code: "custom", + message: "General-subject memory rows must not have a subject key.", + path: ["subject_key"], + }); + } + return; + } + if (row.subject_key === undefined) { + ctx.addIssue({ + code: "custom", + message: "User and conversation memory rows require a subject key.", + path: ["subject_key"], + }); + } + }); + +const memoryRecordSchema = z + .object({ + archivedAtMs: numberSchema.optional(), + archiveReason: nonEmptyStringSchema.optional(), + content: nonEmptyStringSchema, + createdAtMs: numberSchema, + expiresAtMs: numberSchema.optional(), + id: nonEmptyStringSchema, + observedAtMs: numberSchema, + scope: z.enum(MEMORY_SCOPES), + subjectType: z.enum(MEMORY_SUBJECT_TYPES), + supersededAtMs: numberSchema.optional(), + supersededById: nonEmptyStringSchema.optional(), + type: z.enum(MEMORY_TYPES), + }) + .strict(); + +export type MemoryRecord = z.output; +export type CreateMemoryInput = z.output; + +/** Result of a memory write after idempotency checks. */ +export interface CreateMemoryResult { + created: boolean; + memory: MemoryRecord; +} + +export type ListMemoriesInput = z.output; + +export type SearchMemoriesInput = z.output; + +export type ArchiveMemoryInput = z.output; +export type MemoryStoreOptions = z.output; + +/** Context-bound storage operations for visible long-term memories. */ +export interface MemoryStore { + /** Archive a visible memory in the current runtime context. */ + archiveMemory(input: ArchiveMemoryInput): Promise; + /** Store a personal memory for the current requester. */ + createMemory(input: CreateMemoryInput): Promise; + /** Store a conversation memory for the current source conversation. */ + createConversationMemory( + input: CreateMemoryInput, + ): Promise; + /** List active memories visible in the current runtime context. */ + listMemories(input: ListMemoriesInput): Promise; + /** Search active memories visible in the current runtime context. */ + searchMemories(input: SearchMemoriesInput): Promise; +} + +function normalizeContent(content: string): string { + return content.replace(/\s+/g, " ").trim(); +} + +function boundedLimit(value: number | undefined, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback; + } + return Math.min(200, Math.max(1, Math.floor(value))); +} + +/** Build the durable source attribution key from runtime-owned source fields. */ +function sourceKey(ctx: MemoryRuntimeContext): string { + if (ctx.source.platform === "local") { + return ctx.source.conversationId; + } + const threadKey = ctx.source.threadTs ?? ctx.source.messageTs; + if (!threadKey) { + throw new Error( + "Memory source requires a Slack message or thread timestamp.", + ); + } + return `slack:${ctx.source.teamId}:${ctx.source.channelId}:${threadKey}`; +} + +/** Parse one SQL row into the public memory record projection. */ +function parseMemoryRow(row: unknown): MemoryRecord { + const parsed = memoryRowSchema.parse(row); + return memoryRecordSchema.parse({ + id: parsed.id, + scope: parsed.scope, + type: parsed.type, + subjectType: parsed.subject_type, + content: parsed.content, + observedAtMs: parsed.observed_at_ms, + createdAtMs: parsed.created_at_ms, + ...(parsed.expires_at_ms !== undefined + ? { expiresAtMs: parsed.expires_at_ms } + : {}), + ...(parsed.superseded_at_ms !== undefined + ? { supersededAtMs: parsed.superseded_at_ms } + : {}), + ...(parsed.superseded_by_id + ? { supersededById: parsed.superseded_by_id } + : {}), + ...(parsed.archived_at_ms !== undefined + ? { archivedAtMs: parsed.archived_at_ms } + : {}), + ...(parsed.archive_reason ? { archiveReason: parsed.archive_reason } : {}), + }); +} + +/** Build the scoped SQL predicate and ordered params for visible memory reads. */ +function visibleScopePredicate(scopes: ResolvedMemoryScope[]): { + params: string[]; + sql: string; +} { + if (scopes.length === 0) { + return { params: [], sql: "FALSE" }; + } + const params: string[] = []; + const clauses = scopes.map((scope) => { + params.push(scope.scope, scope.scopeKey); + return `(scope = $${params.length - 1} AND scope_key = $${params.length})`; + }); + return { params, sql: clauses.join(" OR ") }; +} + +/** Resolve retry attempts for the same scoped write idempotency key. */ +async function findByIdempotencyKey(args: { + db: PluginDb; + idempotencyKey: string; + scope: ResolvedMemoryScope; +}): Promise { + const rows = await args.db.query( + ` +SELECT * +FROM junior_memory_memories +WHERE scope = $1 + AND scope_key = $2 + AND idempotency_key = $3 +LIMIT 1 +`, + [args.scope.scope, args.scope.scopeKey, args.idempotencyKey], + ); + return rows[0] ? parseMemoryRow(rows[0]) : undefined; +} + +function searchScore(memory: MemoryRecord, terms: string[]): number { + const haystack = memory.content.toLowerCase(); + return terms.reduce( + (score, term) => score + (haystack.includes(term) ? 1 : 0), + 0, + ); +} + +function searchTerms(query: string): string[] { + return [ + ...new Set( + query + .toLowerCase() + .split(/[^a-z0-9_'-]+/) + .map((term) => term.trim()) + .filter((term) => term.length >= 2), + ), + ]; +} + +/** List active records for the runtime-derived visible scopes. */ +async function listVisibleMemories(args: { + db: PluginDb; + limit?: number; + nowMs: number; + scopes: ResolvedMemoryScope[]; +}): Promise { + const predicate = visibleScopePredicate(args.scopes); + const limit = boundedLimit(args.limit, DEFAULT_LIST_LIMIT); + const params: unknown[] = [...predicate.params, args.nowMs, limit]; + const rows = await args.db.query( + ` +SELECT * +FROM junior_memory_memories +WHERE (${predicate.sql}) + AND archived_at_ms IS NULL + AND superseded_at_ms IS NULL + AND superseded_by_id IS NULL + AND (expires_at_ms IS NULL OR expires_at_ms > $${predicate.params.length + 1}) +ORDER BY created_at_ms DESC, id ASC +LIMIT $${predicate.params.length + 2} +`, + params, + ); + return rows.map(parseMemoryRow); +} + +/** Search active visible records with the first-slice lexical matcher. */ +async function searchVisibleMemories(args: { + db: PluginDb; + nowMs: number; + query: string; + scopes: ResolvedMemoryScope[]; +}): Promise { + const terms = searchTerms(args.query); + if (terms.length === 0) { + return []; + } + const predicate = visibleScopePredicate(args.scopes); + const baseParamCount = predicate.params.length; + const termClauses = terms.map( + (_term, index) => `content ILIKE $${baseParamCount + 2 + index}`, + ); + const rows = await args.db.query( + ` +SELECT * +FROM junior_memory_memories +WHERE (${predicate.sql}) + AND archived_at_ms IS NULL + AND superseded_at_ms IS NULL + AND superseded_by_id IS NULL + AND (expires_at_ms IS NULL OR expires_at_ms > $${baseParamCount + 1}) + AND (${termClauses.join(" OR ")}) +`, + [...predicate.params, args.nowMs, ...terms.map((term) => `%${term}%`)], + ); + return rows.map(parseMemoryRow); +} + +/** Create a context-bound SQL-backed store for explicit memory operations. */ +export function createMemoryStore( + db: PluginDb, + context: MemoryRuntimeContext, + options: MemoryStoreOptions = {}, +): MemoryStore { + const runtimeContext = memoryRuntimeContextSchema.parse(context); + const parsedOptions = memoryStoreOptionsSchema.parse(options); + const getNowMs = parsedOptions.now ?? Date.now; + + /** Persist a memory under the plugin-derived scope and subject. */ + async function createScopedMemory( + rawInput: CreateMemoryInput, + scopeKind: MemoryScope, + ): Promise { + const input = createMemoryInputSchema.parse(rawInput); + const nowMs = getNowMs(); + const content = normalizeContent(input.content); + const scope = deriveMemoryScope(runtimeContext, scopeKind); + const subject = deriveMemorySubject(runtimeContext, scope); + const policy = validateMemoryWritePolicy({ + content, + subjectType: subject.subjectType, + }); + if (!policy.ok) { + throw new Error(policy.reason); + } + if (content.length > MAX_MEMORY_CONTENT_CHARS) { + throw new Error("Memory content exceeds the maximum length."); + } + + const id = `mem_${randomUUID()}`; + const rows = await db.query( + ` +INSERT INTO junior_memory_memories ( + id, + scope, + scope_key, + type, + subject_type, + subject_key, + content, + source_platform, + source_key, + idempotency_key, + observed_at_ms, + created_at_ms, + expires_at_ms +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13 +) +ON CONFLICT (scope, scope_key, idempotency_key) +WHERE idempotency_key IS NOT NULL +DO NOTHING +RETURNING * +`, + [ + id, + scope.scope, + scope.scopeKey, + "knowledge", + subject.subjectType, + subject.subjectKey, + content, + runtimeContext.source.platform, + sourceKey(runtimeContext), + input.idempotencyKey, + nowMs, + nowMs, + input.expiresAtMs, + ], + ); + if (rows[0]) { + return { created: true, memory: parseMemoryRow(rows[0]) }; + } + + const idempotent = await findByIdempotencyKey({ + db, + idempotencyKey: input.idempotencyKey, + scope, + }); + if (!idempotent) { + throw new Error("Memory idempotency conflict did not resolve."); + } + return { created: false, memory: idempotent }; + } + + return { + async createMemory(input) { + return await createScopedMemory(input, "personal"); + }, + + async createConversationMemory(input) { + return await createScopedMemory(input, "conversation"); + }, + + async listMemories(input) { + input = listMemoriesInputSchema.parse(input); + const nowMs = getNowMs(); + const scopes = deriveVisibleMemoryScopes(runtimeContext); + return await listVisibleMemories({ + db, + limit: input.limit, + nowMs, + scopes, + }); + }, + + async searchMemories(input) { + input = searchMemoriesInputSchema.parse(input); + const nowMs = getNowMs(); + const scopes = deriveVisibleMemoryScopes(runtimeContext); + const candidates = await searchVisibleMemories({ + db, + nowMs, + query: input.query, + scopes, + }); + const terms = searchTerms(input.query); + return candidates + .map((memory) => ({ memory, score: searchScore(memory, terms) })) + .filter((item) => item.score > 0) + .sort( + (left, right) => + right.score - left.score || + right.memory.createdAtMs - left.memory.createdAtMs || + left.memory.id.localeCompare(right.memory.id), + ) + .slice(0, boundedLimit(input.limit, DEFAULT_SEARCH_LIMIT)) + .map((item) => item.memory); + }, + + async archiveMemory(input) { + input = archiveMemoryInputSchema.parse(input); + const nowMs = getNowMs(); + const scopes = deriveVisibleMemoryScopes(runtimeContext); + const predicate = visibleScopePredicate(scopes); + const idPrefix = input.id.trim(); + if (!idPrefix) { + throw new Error("Memory id is required."); + } + const rows = await db.query( + ` +SELECT * +FROM junior_memory_memories +WHERE (${predicate.sql}) + AND archived_at_ms IS NULL + AND superseded_at_ms IS NULL + AND superseded_by_id IS NULL + AND (expires_at_ms IS NULL OR expires_at_ms > $${predicate.params.length + 1}) + AND (id = $${predicate.params.length + 2} OR id LIKE $${predicate.params.length + 3}) +ORDER BY id ASC +LIMIT 2 +`, + [...predicate.params, nowMs, idPrefix, `${idPrefix}%`], + ); + if (rows.length === 0) { + throw new Error("Memory was not found in the current context."); + } + if (rows.length > 1) { + throw new Error("Memory id prefix is ambiguous."); + } + const memory = parseMemoryRow(rows[0]); + const updated = await db.query( + ` +UPDATE junior_memory_memories +SET archived_at_ms = $1, + archive_reason = $2 +WHERE id = $3 +RETURNING * +`, + [nowMs, input.reason ?? "user_removed", memory.id], + ); + return parseMemoryRow(updated[0]); + }, + }; +} diff --git a/packages/junior-memory/src/types.ts b/packages/junior-memory/src/types.ts new file mode 100644 index 000000000..2ac6a4afa --- /dev/null +++ b/packages/junior-memory/src/types.ts @@ -0,0 +1,63 @@ +import { + localRequesterSchema, + localSourceSchema, + platformSchema, + slackRequesterSchema, + slackSourceSchema, +} from "@sentry/junior-plugin-api"; +import { z } from "zod"; + +export const MEMORY_TYPES = [ + "preference", + "identity", + "relationship", + "knowledge", + "context", + "event", + "task", + "observation", +] as const; + +export const MEMORY_SCOPES = ["personal", "conversation"] as const; +export const MEMORY_SUBJECT_TYPES = [ + "user", + "conversation", + "general", +] as const; +export const MEMORY_SOURCE_PLATFORMS = [ + "slack", + "local", +] as const satisfies readonly z.output[]; + +export type MemoryType = (typeof MEMORY_TYPES)[number]; +export type MemoryScope = (typeof MEMORY_SCOPES)[number]; +export type MemorySubjectType = (typeof MEMORY_SUBJECT_TYPES)[number]; +export type MemorySourcePlatform = (typeof MEMORY_SOURCE_PLATFORMS)[number]; + +const nonEmptyStringSchema = z.string().min(1); + +/** Runtime-owned memory invocation fields used for scope and source authority. */ +export const slackMemoryRuntimeContextSchema = z + .object({ + conversationId: nonEmptyStringSchema.optional(), + requester: slackRequesterSchema.optional(), + source: slackSourceSchema, + }) + .strict(); + +/** Runtime-owned local memory invocation fields used for scope and source authority. */ +export const localMemoryRuntimeContextSchema = z + .object({ + conversationId: nonEmptyStringSchema.optional(), + requester: localRequesterSchema.optional(), + source: localSourceSchema, + }) + .strict(); + +/** Runtime-owned memory invocation fields accepted by memory store operations. */ +export const memoryRuntimeContextSchema = z.union([ + slackMemoryRuntimeContextSchema, + localMemoryRuntimeContextSchema, +]); + +export type MemoryRuntimeContext = z.output; diff --git a/packages/junior-memory/tsconfig.build.json b/packages/junior-memory/tsconfig.build.json new file mode 100644 index 000000000..b398e67a8 --- /dev/null +++ b/packages/junior-memory/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "incremental": false, + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + } +} diff --git a/packages/junior-memory/tsconfig.json b/packages/junior-memory/tsconfig.json new file mode 100644 index 000000000..8c5ea2850 --- /dev/null +++ b/packages/junior-memory/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/junior-memory/tsup.config.ts b/packages/junior-memory/tsup.config.ts new file mode 100644 index 000000000..42bf368b0 --- /dev/null +++ b/packages/junior-memory/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + clean: true, + dts: false, + entry: ["src/index.ts"], + format: ["esm"], + sourcemap: true, + target: "node24", +}); diff --git a/packages/junior/package.json b/packages/junior/package.json index 9ce789ea6..398aa798d 100644 --- a/packages/junior/package.json +++ b/packages/junior/package.json @@ -85,6 +85,7 @@ "zod": "catalog:" }, "devDependencies": { + "@sentry/junior-memory": "workspace:*", "@sentry/junior-scheduler": "workspace:*", "@sentry/junior-test-fixtures": "workspace:*", "@sentry/junior-testing": "workspace:*", diff --git a/packages/junior/src/cli/init.ts b/packages/junior/src/cli/init.ts index a804956fa..17b0bfbbf 100644 --- a/packages/junior/src/cli/init.ts +++ b/packages/junior/src/cli/init.ts @@ -33,8 +33,10 @@ function writePluginsFile(targetDir: string): void { fs.writeFileSync( path.join(targetDir, "plugins.ts"), `import { defineJuniorPlugins } from "@sentry/junior"; +import { createMemoryPlugin } from "@sentry/junior-memory"; export const plugins = defineJuniorPlugins([ + createMemoryPlugin(), "@sentry/junior-maintenance", ]); `, @@ -151,6 +153,7 @@ export async function runInit( }, dependencies: { "@sentry/junior": "latest", + "@sentry/junior-memory": "latest", "@sentry/junior-maintenance": "latest", hono: "^4.12.0", }, diff --git a/packages/junior/tests/component/memory-plugin-storage.test.ts b/packages/junior/tests/component/memory-plugin-storage.test.ts new file mode 100644 index 000000000..0d7970b84 --- /dev/null +++ b/packages/junior/tests/component/memory-plugin-storage.test.ts @@ -0,0 +1,583 @@ +import path from "node:path"; +import { createMemoryState } from "@chat-adapter/state-memory"; +import { describe, expect, it, vi } from "vitest"; +import { createMemoryPlugin, createMemoryStore } from "@sentry/junior-memory"; +import { defineJuniorPlugins } from "@/plugins"; +import { + createPluginDbForExecutor, + migratePluginSchemas, + readPluginMigrations, +} from "@/chat/plugins/db"; +import { migratePluginsToSql } from "@/cli/upgrade/migrations/plugin-sql"; +import { createLocalJuniorSqlFixture } from "../fixtures/sql"; + +const TEST_NOW_MS = Date.parse("2026-06-19T12:00:00.000Z"); + +const NEON = vi.hoisted(() => ({ + executor: undefined as + | Awaited>["executor"] + | undefined, +})); + +vi.mock("@/chat/sql/executor", () => ({ + createJuniorSqlExecutor: vi.fn(() => { + if (!NEON.executor) { + throw new Error("Missing test SQL executor"); + } + return { + db: NEON.executor.db.bind(NEON.executor), + execute: NEON.executor.execute.bind(NEON.executor), + query: NEON.executor.query.bind(NEON.executor), + transaction: NEON.executor.transaction.bind(NEON.executor), + withLock: NEON.executor.withLock.bind(NEON.executor), + close: async () => {}, + }; + }), +})); + +function memoryMigrationsDir(): string { + return path.resolve(process.cwd(), "../junior-memory/migrations"); +} + +function slackContext( + overrides: { + channelId?: string; + teamId?: string; + threadTs?: string; + userId?: string; + } = {}, +) { + const teamId = overrides.teamId ?? "T123"; + const channelId = overrides.channelId ?? "C123"; + const threadTs = overrides.threadTs ?? "1718800000.000000"; + return { + conversationId: `slack:${channelId}:${threadTs}`, + requester: { + platform: "slack" as const, + teamId, + userId: overrides.userId ?? "U123", + }, + source: { + platform: "slack" as const, + teamId, + channelId, + messageTs: threadTs, + threadTs, + }, + }; +} + +function localContext( + overrides: { conversationId?: string; userId?: string } = {}, +) { + const conversationId = overrides.conversationId ?? "local:junior:memory-test"; + return { + conversationId, + requester: { + platform: "local" as const, + userId: overrides.userId ?? "local-user", + }, + source: { + platform: "local" as const, + conversationId, + }, + }; +} + +async function migrateMemorySchema( + fixture: Awaited>, +) { + await migratePluginSchemas( + fixture.executor, + readPluginMigrations({ + dir: memoryMigrationsDir(), + pluginName: "memory", + }), + ); +} + +describe("memory plugin SQL storage", () => { + it("applies packaged migrations through plugin discovery", async () => { + const stateAdapter = createMemoryState(); + await stateAdapter.connect(); + const fixture = await createLocalJuniorSqlFixture(); + NEON.executor = fixture.executor; + + try { + await expect( + migratePluginsToSql({ + io: { info: () => {} }, + pluginSet: defineJuniorPlugins([createMemoryPlugin()]), + sqlDatabaseUrl: "postgres://configured.example.test/neon", + stateAdapter, + }), + ).resolves.toEqual({ + existing: 0, + migrated: 1, + missing: 0, + scanned: 1, + }); + + await expect( + fixture.executor.query<{ table_name: string }>( + ` +SELECT table_name +FROM information_schema.tables +WHERE table_name = 'junior_memory_memories' +`, + ), + ).resolves.toEqual([{ table_name: "junior_memory_memories" }]); + } finally { + NEON.executor = undefined; + await stateAdapter.disconnect(); + await fixture.close(); + } + }, 15_000); + + it("persists, recalls, and archives visible memories", async () => { + const fixture = await createLocalJuniorSqlFixture(); + + try { + await migrateMemorySchema(fixture); + const requesterContext = slackContext(); + let nowMs = TEST_NOW_MS; + const store = createMemoryStore( + createPluginDbForExecutor(fixture.executor), + requesterContext, + { now: () => nowMs }, + ); + + const personal = await store.createMemory({ + content: "The requester prefers short PR summaries.", + idempotencyKey: "memory-test:personal", + }); + nowMs = TEST_NOW_MS + 1; + const conversation = await store.createConversationMemory({ + content: "The channel keeps deploy runbooks in Notion.", + idempotencyKey: "memory-test:conversation", + }); + + expect(personal.created).toBe(true); + expect(personal.memory).toMatchObject({ + subjectType: "user", + }); + expect(personal.memory).not.toHaveProperty("subjectKey"); + expect(personal.memory.supersededAtMs).toBeUndefined(); + expect(conversation.created).toBe(true); + expect(conversation.memory).toMatchObject({ + subjectType: "conversation", + }); + expect(conversation.memory).not.toHaveProperty("subjectKey"); + await expect( + fixture.executor.query<{ + id: string; + subject_key: string; + subject_type: string; + }>( + ` +SELECT id, subject_type, subject_key +FROM junior_memory_memories +ORDER BY created_at_ms ASC +`, + ), + ).resolves.toEqual([ + { + id: personal.memory.id, + subject_key: "slack:T123:U123", + subject_type: "user", + }, + { + id: conversation.memory.id, + subject_key: "slack:T123:C123:1718800000.000000", + subject_type: "conversation", + }, + ]); + + nowMs = TEST_NOW_MS + 3; + await expect(store.listMemories({})).resolves.toEqual([ + expect.objectContaining({ id: conversation.memory.id }), + expect.objectContaining({ id: personal.memory.id }), + ]); + + const otherRequesterStore = createMemoryStore( + createPluginDbForExecutor(fixture.executor), + slackContext({ userId: "U456" }), + { now: () => nowMs }, + ); + await expect(otherRequesterStore.listMemories({})).resolves.toEqual([ + expect.objectContaining({ id: conversation.memory.id }), + ]); + const otherConversationStore = createMemoryStore( + createPluginDbForExecutor(fixture.executor), + slackContext({ + channelId: "C999", + threadTs: "1718800001.000000", + userId: "U456", + }), + { now: () => nowMs }, + ); + await expect(otherConversationStore.listMemories({})).resolves.toEqual( + [], + ); + + await expect( + store.searchMemories({ + query: "where are runbooks", + }), + ).resolves.toEqual([ + expect.objectContaining({ id: conversation.memory.id }), + ]); + await expect( + otherConversationStore.searchMemories({ query: "runbooks" }), + ).resolves.toEqual([]); + nowMs = TEST_NOW_MS + 4; + await expect( + otherConversationStore.archiveMemory({ id: conversation.memory.id }), + ).rejects.toThrow("Memory was not found in the current context."); + const otherTeamStore = createMemoryStore( + createPluginDbForExecutor(fixture.executor), + slackContext({ teamId: "T999", userId: "U456" }), + { now: () => nowMs }, + ); + await expect(otherTeamStore.listMemories({})).resolves.toEqual([]); + + const archived = await store.archiveMemory({ + id: personal.memory.id.slice(0, 12), + }); + expect(archived).toMatchObject({ + id: personal.memory.id, + archivedAtMs: TEST_NOW_MS + 4, + }); + nowMs = TEST_NOW_MS + 5; + await expect(store.listMemories({})).resolves.toEqual([ + expect.objectContaining({ id: conversation.memory.id }), + ]); + await expect( + store.searchMemories({ query: "summaries" }), + ).resolves.toEqual([]); + } finally { + await fixture.close(); + } + }, 15_000); + + it("stores and filters local conversation memories by local context", async () => { + const fixture = await createLocalJuniorSqlFixture(); + + try { + await migrateMemorySchema(fixture); + let nowMs = TEST_NOW_MS; + const requesterContext = localContext(); + const store = createMemoryStore( + createPluginDbForExecutor(fixture.executor), + requesterContext, + { now: () => nowMs }, + ); + + const personal = await store.createMemory({ + content: "The requester prefers local CLI memory checks.", + idempotencyKey: "memory-test:local-personal", + }); + nowMs = TEST_NOW_MS + 1; + const conversation = await store.createConversationMemory({ + content: "This local session tracks memory plugin validation.", + idempotencyKey: "memory-test:local-conversation", + }); + + await expect(store.listMemories({})).resolves.toEqual([ + expect.objectContaining({ id: conversation.memory.id }), + expect.objectContaining({ id: personal.memory.id }), + ]); + await expect( + store.searchMemories({ query: "validation" }), + ).resolves.toEqual([ + expect.objectContaining({ id: conversation.memory.id }), + ]); + + const otherConversationStore = createMemoryStore( + createPluginDbForExecutor(fixture.executor), + localContext({ conversationId: "local:junior:other-memory-test" }), + { now: () => nowMs }, + ); + await expect(otherConversationStore.listMemories({})).resolves.toEqual([ + expect.objectContaining({ id: personal.memory.id }), + ]); + await expect( + otherConversationStore.archiveMemory({ id: conversation.memory.id }), + ).rejects.toThrow("Memory was not found in the current context."); + + nowMs = TEST_NOW_MS + 2; + const archived = await store.archiveMemory({ id: personal.memory.id }); + expect(archived).toMatchObject({ + archivedAtMs: TEST_NOW_MS + 2, + id: personal.memory.id, + }); + } finally { + await fixture.close(); + } + }, 15_000); + + it("returns the original memory for idempotent create retries", async () => { + const fixture = await createLocalJuniorSqlFixture(); + + try { + await migrateMemorySchema(fixture); + const requesterContext = slackContext(); + let nowMs = TEST_NOW_MS; + const store = createMemoryStore( + createPluginDbForExecutor(fixture.executor), + requesterContext, + { now: () => nowMs }, + ); + + const created = await store.createMemory({ + content: "Different content with the same retry key.", + idempotencyKey: "explicit-create-1", + }); + expect(created.memory.observedAtMs).toBe(TEST_NOW_MS); + + nowMs = TEST_NOW_MS + 1; + await expect( + store.createMemory({ + content: "Changed content with the same retry key.", + idempotencyKey: "explicit-create-1", + }), + ).resolves.toMatchObject({ + created: false, + memory: { id: created.memory.id, content: created.memory.content }, + }); + await expect( + fixture.executor.execute( + ` +INSERT INTO junior_memory_memories ( + id, + scope, + scope_key, + type, + subject_type, + subject_key, + content, + source_platform, + source_key, + idempotency_key, + observed_at_ms, + created_at_ms +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 +) +`, + [ + "mem_duplicate_idempotency", + "personal", + "slack:T123:U123", + "knowledge", + "user", + "slack:T123:U123", + "Duplicate raw insert with same retry key.", + "slack", + "slack:T123:C123:1718800000.000000", + "explicit-create-1", + nowMs, + nowMs, + ], + ), + ).rejects.toThrow("duplicate key value violates unique constraint"); + } finally { + await fixture.close(); + } + }, 15_000); + + it("treats expired memories as inactive for archive and recreate", async () => { + const fixture = await createLocalJuniorSqlFixture(); + + try { + await migrateMemorySchema(fixture); + const requesterContext = slackContext(); + let nowMs = TEST_NOW_MS; + const store = createMemoryStore( + createPluginDbForExecutor(fixture.executor), + requesterContext, + { now: () => nowMs }, + ); + + const expired = await store.createMemory({ + content: "The requester temporarily prefers quiet deploy reminders.", + expiresAtMs: TEST_NOW_MS + 10, + idempotencyKey: "memory-test:expires", + }); + + nowMs = TEST_NOW_MS + 11; + await expect( + store.archiveMemory({ + id: expired.memory.id, + }), + ).rejects.toThrow("Memory was not found in the current context."); + await expect(store.searchMemories({ query: "quiet" })).resolves.toEqual( + [], + ); + + nowMs = TEST_NOW_MS + 12; + const recreated = await store.createMemory({ + content: "The requester temporarily prefers quiet deploy reminders.", + idempotencyKey: "memory-test:expires-recreated", + }); + + expect(recreated).toMatchObject({ + created: true, + memory: { content: expired.memory.content }, + }); + expect(recreated.memory.id).not.toBe(expired.memory.id); + await expect(store.searchMemories({ query: "quiet" })).resolves.toEqual([ + expect.objectContaining({ id: recreated.memory.id }), + ]); + } finally { + await fixture.close(); + } + }, 15_000); + + it("searches active visible memories before applying the result limit", async () => { + const fixture = await createLocalJuniorSqlFixture(); + + try { + await migrateMemorySchema(fixture); + const requesterContext = slackContext(); + let nowMs = TEST_NOW_MS; + const store = createMemoryStore( + createPluginDbForExecutor(fixture.executor), + requesterContext, + { now: () => nowMs }, + ); + const target = await store.createConversationMemory({ + content: + "The oldest durable memory mentions release cutover rehearsal.", + idempotencyKey: "memory-test:search-target", + }); + + for (let index = 0; index < 205; index += 1) { + nowMs = TEST_NOW_MS + index + 1; + await store.createConversationMemory({ + content: `Recent unrelated memory ${index}`, + idempotencyKey: `memory-test:search-recent-${index}`, + }); + } + + nowMs = TEST_NOW_MS + 300; + await expect( + store.searchMemories({ + query: "cutover rehearsal", + }), + ).resolves.toEqual([expect.objectContaining({ id: target.memory.id })]); + } finally { + await fixture.close(); + } + }, 15_000); + + it("rejects deterministic policy violations before storage", async () => { + const fixture = await createLocalJuniorSqlFixture(); + + try { + await migrateMemorySchema(fixture); + const requesterContext = slackContext(); + const store = createMemoryStore( + createPluginDbForExecutor(fixture.executor), + requesterContext, + { now: () => TEST_NOW_MS }, + ); + + await expect( + store.createMemory({ + content: "The API token is ghp_example_secret", + idempotencyKey: "memory-test:secret", + }), + ).rejects.toThrow("Memory content appears to contain a secret."); + await expect( + store.createMemory({ + content: "I have a medical issue.", + idempotencyKey: "memory-test:medical", + }), + ).rejects.toThrow( + "Memory content appears to contain non-public or sensitive information.", + ); + await expect( + store.createConversationMemory({ + content: "Alice is interviewing elsewhere.", + idempotencyKey: "memory-test:interviewing", + }), + ).rejects.toThrow( + "Memory content appears to contain non-public or sensitive information.", + ); + + await expect( + store.createMemory({ + content: "David is on the billing team.", + idempotencyKey: "memory-test:third-party", + }), + ).rejects.toThrow( + "User-subject memories can only store first-person facts about the current requester.", + ); + await expect( + store.createMemory({ + content: "The requester prefers short PR summaries.", + idempotencyKey: "memory-test:smuggle", + scope: "conversation", + subjectKey: "slack:T123:U999", + subjectType: "general", + type: "preference", + } as unknown as Parameters[0]), + ).rejects.toThrow(/Invalid input|Unrecognized key/); + await expect( + store.listMemories({ + requester: { platform: "local", userId: "local-user" }, + } as unknown as Parameters[0]), + ).rejects.toThrow(/Invalid input|Unrecognized key/); + + await expect(store.listMemories({})).resolves.toEqual([]); + } finally { + await fixture.close(); + } + }, 15_000); + + it("rejects unsupported enum-like values at the storage boundary", async () => { + const fixture = await createLocalJuniorSqlFixture(); + + try { + await migrateMemorySchema(fixture); + + await expect( + fixture.executor.execute( + ` +INSERT INTO junior_memory_memories ( + id, + scope, + scope_key, + type, + subject_type, + subject_key, + content, + source_platform, + source_key, + observed_at_ms, + created_at_ms +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 +) +`, + [ + "mem_invalid_enum", + "workspace", + "slack:T123:U123", + "knowledge", + "general", + null, + "Unsupported scope value.", + "slack", + "slack:T123:C123:1718800000.000000", + TEST_NOW_MS, + TEST_NOW_MS, + ], + ), + ).rejects.toThrow("violates check constraint"); + } finally { + await fixture.close(); + } + }, 15_000); +}); diff --git a/packages/junior/tests/unit/cli/init-cli.test.ts b/packages/junior/tests/unit/cli/init-cli.test.ts index be9522984..5ebd7cf5c 100644 --- a/packages/junior/tests/unit/cli/init-cli.test.ts +++ b/packages/junior/tests/unit/cli/init-cli.test.ts @@ -80,13 +80,18 @@ describe("init cli", () => { expect(pluginsFile).toContain( 'import { defineJuniorPlugins } from "@sentry/junior";', ); + expect(pluginsFile).toContain( + 'import { createMemoryPlugin } from "@sentry/junior-memory";', + ); expect(pluginsFile).toContain("defineJuniorPlugins("); + expect(pluginsFile).toContain("createMemoryPlugin()"); expect(pluginsFile).toContain('"@sentry/junior-maintenance"'); const pkg = JSON.parse( fs.readFileSync(path.join(target, "package.json"), "utf8"), ); expect(pkg.dependencies["@sentry/junior"]).toBe("latest"); + expect(pkg.dependencies["@sentry/junior-memory"]).toBe("latest"); expect(pkg.dependencies["@sentry/junior-maintenance"]).toBe("latest"); expect(pkg.devDependencies.nitro).toBeDefined(); expect(pkg.devDependencies.vite).toBeDefined(); diff --git a/packages/junior/vitest.config.ts b/packages/junior/vitest.config.ts index aae82d08e..747ae78d7 100644 --- a/packages/junior/vitest.config.ts +++ b/packages/junior/vitest.config.ts @@ -34,6 +34,10 @@ export default defineConfig({ __dirname, "../junior-plugin-api/src/index.ts", ), + "@sentry/junior-memory": path.resolve( + __dirname, + "../junior-memory/src/index.ts", + ), "@sentry/junior-scheduler": path.resolve( __dirname, "../junior-scheduler/src/index.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e824db060..e13a07696 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,9 @@ catalogs: "@sentry/starlight-theme": specifier: ^0.7.0 version: 0.7.0 + drizzle-kit: + specifier: ^0.31.8 + version: 0.31.10 drizzle-orm: specifier: ^0.45.2 version: 0.45.2 @@ -232,6 +235,9 @@ importers: "@emnapi/runtime": specifier: ^1.10.0 version: 1.10.0 + "@sentry/junior-memory": + specifier: workspace:* + version: file:packages/junior-memory(@neondatabase/serverless@1.1.0)(@types/pg@8.15.6)(pg@8.21.0) "@sentry/junior-scheduler": specifier: workspace:* version: file:packages/junior-scheduler(@neondatabase/serverless@1.1.0)(@types/pg@8.15.6)(pg@8.21.0) @@ -400,6 +406,31 @@ importers: packages/junior-maintenance: {} + packages/junior-memory: + dependencies: + "@sentry/junior-plugin-api": + specifier: workspace:* + version: link:../junior-plugin-api + drizzle-orm: + specifier: "catalog:" + version: 0.45.2 + zod: + specifier: "catalog:" + version: 4.4.3 + devDependencies: + "@types/node": + specifier: ^25.9.1 + version: 25.9.1 + drizzle-kit: + specifier: "catalog:" + version: 0.31.10 + tsup: + specifier: ^8.5.1 + version: 8.5.1(tsx@4.22.3)(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/junior-notion: {} packages/junior-plugin-api: @@ -1083,6 +1114,12 @@ packages: } engines: { node: ">=14" } + "@drizzle-team/brocli@0.10.2": + resolution: + { + integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==, + } + "@earendil-works/pi-agent-core@0.74.2": resolution: { @@ -1199,6 +1236,29 @@ packages: integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==, } + "@esbuild-kit/core-utils@3.3.2": + resolution: + { + integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==, + } + deprecated: "Merged into tsx: https://tsx.is" + + "@esbuild-kit/esm-loader@2.6.5": + resolution: + { + integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==, + } + deprecated: "Merged into tsx: https://tsx.is" + + "@esbuild/aix-ppc64@0.25.12": + resolution: + { + integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==, + } + engines: { node: ">=18" } + cpu: [ppc64] + os: [aix] + "@esbuild/aix-ppc64@0.27.0": resolution: { @@ -1226,6 +1286,24 @@ packages: cpu: [ppc64] os: [aix] + "@esbuild/android-arm64@0.18.20": + resolution: + { + integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==, + } + engines: { node: ">=12" } + cpu: [arm64] + os: [android] + + "@esbuild/android-arm64@0.25.12": + resolution: + { + integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [android] + "@esbuild/android-arm64@0.27.0": resolution: { @@ -1253,6 +1331,24 @@ packages: cpu: [arm64] os: [android] + "@esbuild/android-arm@0.18.20": + resolution: + { + integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==, + } + engines: { node: ">=12" } + cpu: [arm] + os: [android] + + "@esbuild/android-arm@0.25.12": + resolution: + { + integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==, + } + engines: { node: ">=18" } + cpu: [arm] + os: [android] + "@esbuild/android-arm@0.27.0": resolution: { @@ -1280,6 +1376,24 @@ packages: cpu: [arm] os: [android] + "@esbuild/android-x64@0.18.20": + resolution: + { + integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [android] + + "@esbuild/android-x64@0.25.12": + resolution: + { + integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [android] + "@esbuild/android-x64@0.27.0": resolution: { @@ -1307,6 +1421,24 @@ packages: cpu: [x64] os: [android] + "@esbuild/darwin-arm64@0.18.20": + resolution: + { + integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==, + } + engines: { node: ">=12" } + cpu: [arm64] + os: [darwin] + + "@esbuild/darwin-arm64@0.25.12": + resolution: + { + integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [darwin] + "@esbuild/darwin-arm64@0.27.0": resolution: { @@ -1334,6 +1466,24 @@ packages: cpu: [arm64] os: [darwin] + "@esbuild/darwin-x64@0.18.20": + resolution: + { + integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [darwin] + + "@esbuild/darwin-x64@0.25.12": + resolution: + { + integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [darwin] + "@esbuild/darwin-x64@0.27.0": resolution: { @@ -1361,6 +1511,24 @@ packages: cpu: [x64] os: [darwin] + "@esbuild/freebsd-arm64@0.18.20": + resolution: + { + integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==, + } + engines: { node: ">=12" } + cpu: [arm64] + os: [freebsd] + + "@esbuild/freebsd-arm64@0.25.12": + resolution: + { + integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [freebsd] + "@esbuild/freebsd-arm64@0.27.0": resolution: { @@ -1388,6 +1556,24 @@ packages: cpu: [arm64] os: [freebsd] + "@esbuild/freebsd-x64@0.18.20": + resolution: + { + integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [freebsd] + + "@esbuild/freebsd-x64@0.25.12": + resolution: + { + integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [freebsd] + "@esbuild/freebsd-x64@0.27.0": resolution: { @@ -1415,6 +1601,24 @@ packages: cpu: [x64] os: [freebsd] + "@esbuild/linux-arm64@0.18.20": + resolution: + { + integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==, + } + engines: { node: ">=12" } + cpu: [arm64] + os: [linux] + + "@esbuild/linux-arm64@0.25.12": + resolution: + { + integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [linux] + "@esbuild/linux-arm64@0.27.0": resolution: { @@ -1442,6 +1646,24 @@ packages: cpu: [arm64] os: [linux] + "@esbuild/linux-arm@0.18.20": + resolution: + { + integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==, + } + engines: { node: ">=12" } + cpu: [arm] + os: [linux] + + "@esbuild/linux-arm@0.25.12": + resolution: + { + integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==, + } + engines: { node: ">=18" } + cpu: [arm] + os: [linux] + "@esbuild/linux-arm@0.27.0": resolution: { @@ -1469,6 +1691,24 @@ packages: cpu: [arm] os: [linux] + "@esbuild/linux-ia32@0.18.20": + resolution: + { + integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==, + } + engines: { node: ">=12" } + cpu: [ia32] + os: [linux] + + "@esbuild/linux-ia32@0.25.12": + resolution: + { + integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==, + } + engines: { node: ">=18" } + cpu: [ia32] + os: [linux] + "@esbuild/linux-ia32@0.27.0": resolution: { @@ -1496,6 +1736,24 @@ packages: cpu: [ia32] os: [linux] + "@esbuild/linux-loong64@0.18.20": + resolution: + { + integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==, + } + engines: { node: ">=12" } + cpu: [loong64] + os: [linux] + + "@esbuild/linux-loong64@0.25.12": + resolution: + { + integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==, + } + engines: { node: ">=18" } + cpu: [loong64] + os: [linux] + "@esbuild/linux-loong64@0.27.0": resolution: { @@ -1523,6 +1781,24 @@ packages: cpu: [loong64] os: [linux] + "@esbuild/linux-mips64el@0.18.20": + resolution: + { + integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==, + } + engines: { node: ">=12" } + cpu: [mips64el] + os: [linux] + + "@esbuild/linux-mips64el@0.25.12": + resolution: + { + integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==, + } + engines: { node: ">=18" } + cpu: [mips64el] + os: [linux] + "@esbuild/linux-mips64el@0.27.0": resolution: { @@ -1550,6 +1826,24 @@ packages: cpu: [mips64el] os: [linux] + "@esbuild/linux-ppc64@0.18.20": + resolution: + { + integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==, + } + engines: { node: ">=12" } + cpu: [ppc64] + os: [linux] + + "@esbuild/linux-ppc64@0.25.12": + resolution: + { + integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==, + } + engines: { node: ">=18" } + cpu: [ppc64] + os: [linux] + "@esbuild/linux-ppc64@0.27.0": resolution: { @@ -1577,6 +1871,24 @@ packages: cpu: [ppc64] os: [linux] + "@esbuild/linux-riscv64@0.18.20": + resolution: + { + integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==, + } + engines: { node: ">=12" } + cpu: [riscv64] + os: [linux] + + "@esbuild/linux-riscv64@0.25.12": + resolution: + { + integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==, + } + engines: { node: ">=18" } + cpu: [riscv64] + os: [linux] + "@esbuild/linux-riscv64@0.27.0": resolution: { @@ -1604,6 +1916,24 @@ packages: cpu: [riscv64] os: [linux] + "@esbuild/linux-s390x@0.18.20": + resolution: + { + integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==, + } + engines: { node: ">=12" } + cpu: [s390x] + os: [linux] + + "@esbuild/linux-s390x@0.25.12": + resolution: + { + integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==, + } + engines: { node: ">=18" } + cpu: [s390x] + os: [linux] + "@esbuild/linux-s390x@0.27.0": resolution: { @@ -1631,6 +1961,24 @@ packages: cpu: [s390x] os: [linux] + "@esbuild/linux-x64@0.18.20": + resolution: + { + integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [linux] + + "@esbuild/linux-x64@0.25.12": + resolution: + { + integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [linux] + "@esbuild/linux-x64@0.27.0": resolution: { @@ -1658,6 +2006,15 @@ packages: cpu: [x64] os: [linux] + "@esbuild/netbsd-arm64@0.25.12": + resolution: + { + integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [netbsd] + "@esbuild/netbsd-arm64@0.27.0": resolution: { @@ -1685,6 +2042,24 @@ packages: cpu: [arm64] os: [netbsd] + "@esbuild/netbsd-x64@0.18.20": + resolution: + { + integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [netbsd] + + "@esbuild/netbsd-x64@0.25.12": + resolution: + { + integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [netbsd] + "@esbuild/netbsd-x64@0.27.0": resolution: { @@ -1712,6 +2087,15 @@ packages: cpu: [x64] os: [netbsd] + "@esbuild/openbsd-arm64@0.25.12": + resolution: + { + integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [openbsd] + "@esbuild/openbsd-arm64@0.27.0": resolution: { @@ -1739,6 +2123,24 @@ packages: cpu: [arm64] os: [openbsd] + "@esbuild/openbsd-x64@0.18.20": + resolution: + { + integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [openbsd] + + "@esbuild/openbsd-x64@0.25.12": + resolution: + { + integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [openbsd] + "@esbuild/openbsd-x64@0.27.0": resolution: { @@ -1766,6 +2168,15 @@ packages: cpu: [x64] os: [openbsd] + "@esbuild/openharmony-arm64@0.25.12": + resolution: + { + integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [openharmony] + "@esbuild/openharmony-arm64@0.27.0": resolution: { @@ -1784,14 +2195,32 @@ packages: cpu: [arm64] os: [openharmony] - "@esbuild/openharmony-arm64@0.28.0": + "@esbuild/openharmony-arm64@0.28.0": + resolution: + { + integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [openharmony] + + "@esbuild/sunos-x64@0.18.20": + resolution: + { + integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [sunos] + + "@esbuild/sunos-x64@0.25.12": resolution: { - integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==, + integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==, } engines: { node: ">=18" } - cpu: [arm64] - os: [openharmony] + cpu: [x64] + os: [sunos] "@esbuild/sunos-x64@0.27.0": resolution: @@ -1820,6 +2249,24 @@ packages: cpu: [x64] os: [sunos] + "@esbuild/win32-arm64@0.18.20": + resolution: + { + integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==, + } + engines: { node: ">=12" } + cpu: [arm64] + os: [win32] + + "@esbuild/win32-arm64@0.25.12": + resolution: + { + integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [win32] + "@esbuild/win32-arm64@0.27.0": resolution: { @@ -1847,6 +2294,24 @@ packages: cpu: [arm64] os: [win32] + "@esbuild/win32-ia32@0.18.20": + resolution: + { + integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==, + } + engines: { node: ">=12" } + cpu: [ia32] + os: [win32] + + "@esbuild/win32-ia32@0.25.12": + resolution: + { + integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==, + } + engines: { node: ">=18" } + cpu: [ia32] + os: [win32] + "@esbuild/win32-ia32@0.27.0": resolution: { @@ -1874,6 +2339,24 @@ packages: cpu: [ia32] os: [win32] + "@esbuild/win32-x64@0.18.20": + resolution: + { + integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [win32] + + "@esbuild/win32-x64@0.25.12": + resolution: + { + integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [win32] + "@esbuild/win32-x64@0.27.0": resolution: { @@ -3997,6 +4480,9 @@ packages: "@sentry/junior-dashboard@file:packages/junior-dashboard": resolution: { directory: packages/junior-dashboard, type: directory } + "@sentry/junior-memory@file:packages/junior-memory": + resolution: { directory: packages/junior-memory, type: directory } + "@sentry/junior-plugin-api@file:packages/junior-plugin-api": resolution: { directory: packages/junior-plugin-api, type: directory } @@ -5583,6 +6069,12 @@ packages: integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==, } + buffer-from@1.1.2: + resolution: + { + integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==, + } + buffer@5.7.1: resolution: { @@ -6325,6 +6817,13 @@ packages: integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==, } + drizzle-kit@0.31.10: + resolution: + { + integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==, + } + hasBin: true + drizzle-orm@0.45.2: resolution: { @@ -6601,6 +7100,22 @@ packages: integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==, } + esbuild@0.18.20: + resolution: + { + integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==, + } + engines: { node: ">=12" } + hasBin: true + + esbuild@0.25.12: + resolution: + { + integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==, + } + engines: { node: ">=18" } + hasBin: true + esbuild@0.27.0: resolution: { @@ -9620,14 +10135,6 @@ packages: engines: { node: ">=14" } hasBin: true - publint@0.3.21: - resolution: - { - integrity: sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==, - } - engines: { node: ">=18" } - hasBin: true - pretty-ms@7.0.1: resolution: { @@ -9695,6 +10202,14 @@ packages: } engines: { node: ">=10" } + publint@0.3.21: + resolution: + { + integrity: sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==, + } + engines: { node: ">=18" } + hasBin: true + pump@3.0.4: resolution: { @@ -10504,6 +11019,12 @@ packages: } engines: { node: ">=0.10.0" } + source-map-support@0.5.21: + resolution: + { + integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==, + } + source-map@0.6.1: resolution: { @@ -12663,6 +13184,8 @@ snapshots: "@ctrl/tinycolor@4.2.0": {} + "@drizzle-team/brocli@0.10.2": {} + "@earendil-works/pi-agent-core@0.74.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)": dependencies: "@earendil-works/pi-ai": 0.74.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) @@ -12746,6 +13269,19 @@ snapshots: dependencies: tslib: 2.8.1 + "@esbuild-kit/core-utils@3.3.2": + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + "@esbuild-kit/esm-loader@2.6.5": + dependencies: + "@esbuild-kit/core-utils": 3.3.2 + get-tsconfig: 4.14.0 + + "@esbuild/aix-ppc64@0.25.12": + optional: true + "@esbuild/aix-ppc64@0.27.0": optional: true @@ -12755,6 +13291,12 @@ snapshots: "@esbuild/aix-ppc64@0.28.0": optional: true + "@esbuild/android-arm64@0.18.20": + optional: true + + "@esbuild/android-arm64@0.25.12": + optional: true + "@esbuild/android-arm64@0.27.0": optional: true @@ -12764,6 +13306,12 @@ snapshots: "@esbuild/android-arm64@0.28.0": optional: true + "@esbuild/android-arm@0.18.20": + optional: true + + "@esbuild/android-arm@0.25.12": + optional: true + "@esbuild/android-arm@0.27.0": optional: true @@ -12773,6 +13321,12 @@ snapshots: "@esbuild/android-arm@0.28.0": optional: true + "@esbuild/android-x64@0.18.20": + optional: true + + "@esbuild/android-x64@0.25.12": + optional: true + "@esbuild/android-x64@0.27.0": optional: true @@ -12782,6 +13336,12 @@ snapshots: "@esbuild/android-x64@0.28.0": optional: true + "@esbuild/darwin-arm64@0.18.20": + optional: true + + "@esbuild/darwin-arm64@0.25.12": + optional: true + "@esbuild/darwin-arm64@0.27.0": optional: true @@ -12791,6 +13351,12 @@ snapshots: "@esbuild/darwin-arm64@0.28.0": optional: true + "@esbuild/darwin-x64@0.18.20": + optional: true + + "@esbuild/darwin-x64@0.25.12": + optional: true + "@esbuild/darwin-x64@0.27.0": optional: true @@ -12800,6 +13366,12 @@ snapshots: "@esbuild/darwin-x64@0.28.0": optional: true + "@esbuild/freebsd-arm64@0.18.20": + optional: true + + "@esbuild/freebsd-arm64@0.25.12": + optional: true + "@esbuild/freebsd-arm64@0.27.0": optional: true @@ -12809,6 +13381,12 @@ snapshots: "@esbuild/freebsd-arm64@0.28.0": optional: true + "@esbuild/freebsd-x64@0.18.20": + optional: true + + "@esbuild/freebsd-x64@0.25.12": + optional: true + "@esbuild/freebsd-x64@0.27.0": optional: true @@ -12818,6 +13396,12 @@ snapshots: "@esbuild/freebsd-x64@0.28.0": optional: true + "@esbuild/linux-arm64@0.18.20": + optional: true + + "@esbuild/linux-arm64@0.25.12": + optional: true + "@esbuild/linux-arm64@0.27.0": optional: true @@ -12827,6 +13411,12 @@ snapshots: "@esbuild/linux-arm64@0.28.0": optional: true + "@esbuild/linux-arm@0.18.20": + optional: true + + "@esbuild/linux-arm@0.25.12": + optional: true + "@esbuild/linux-arm@0.27.0": optional: true @@ -12836,6 +13426,12 @@ snapshots: "@esbuild/linux-arm@0.28.0": optional: true + "@esbuild/linux-ia32@0.18.20": + optional: true + + "@esbuild/linux-ia32@0.25.12": + optional: true + "@esbuild/linux-ia32@0.27.0": optional: true @@ -12845,6 +13441,12 @@ snapshots: "@esbuild/linux-ia32@0.28.0": optional: true + "@esbuild/linux-loong64@0.18.20": + optional: true + + "@esbuild/linux-loong64@0.25.12": + optional: true + "@esbuild/linux-loong64@0.27.0": optional: true @@ -12854,6 +13456,12 @@ snapshots: "@esbuild/linux-loong64@0.28.0": optional: true + "@esbuild/linux-mips64el@0.18.20": + optional: true + + "@esbuild/linux-mips64el@0.25.12": + optional: true + "@esbuild/linux-mips64el@0.27.0": optional: true @@ -12863,6 +13471,12 @@ snapshots: "@esbuild/linux-mips64el@0.28.0": optional: true + "@esbuild/linux-ppc64@0.18.20": + optional: true + + "@esbuild/linux-ppc64@0.25.12": + optional: true + "@esbuild/linux-ppc64@0.27.0": optional: true @@ -12872,6 +13486,12 @@ snapshots: "@esbuild/linux-ppc64@0.28.0": optional: true + "@esbuild/linux-riscv64@0.18.20": + optional: true + + "@esbuild/linux-riscv64@0.25.12": + optional: true + "@esbuild/linux-riscv64@0.27.0": optional: true @@ -12881,6 +13501,12 @@ snapshots: "@esbuild/linux-riscv64@0.28.0": optional: true + "@esbuild/linux-s390x@0.18.20": + optional: true + + "@esbuild/linux-s390x@0.25.12": + optional: true + "@esbuild/linux-s390x@0.27.0": optional: true @@ -12890,6 +13516,12 @@ snapshots: "@esbuild/linux-s390x@0.28.0": optional: true + "@esbuild/linux-x64@0.18.20": + optional: true + + "@esbuild/linux-x64@0.25.12": + optional: true + "@esbuild/linux-x64@0.27.0": optional: true @@ -12899,6 +13531,9 @@ snapshots: "@esbuild/linux-x64@0.28.0": optional: true + "@esbuild/netbsd-arm64@0.25.12": + optional: true + "@esbuild/netbsd-arm64@0.27.0": optional: true @@ -12908,6 +13543,12 @@ snapshots: "@esbuild/netbsd-arm64@0.28.0": optional: true + "@esbuild/netbsd-x64@0.18.20": + optional: true + + "@esbuild/netbsd-x64@0.25.12": + optional: true + "@esbuild/netbsd-x64@0.27.0": optional: true @@ -12917,6 +13558,9 @@ snapshots: "@esbuild/netbsd-x64@0.28.0": optional: true + "@esbuild/openbsd-arm64@0.25.12": + optional: true + "@esbuild/openbsd-arm64@0.27.0": optional: true @@ -12926,6 +13570,12 @@ snapshots: "@esbuild/openbsd-arm64@0.28.0": optional: true + "@esbuild/openbsd-x64@0.18.20": + optional: true + + "@esbuild/openbsd-x64@0.25.12": + optional: true + "@esbuild/openbsd-x64@0.27.0": optional: true @@ -12935,6 +13585,9 @@ snapshots: "@esbuild/openbsd-x64@0.28.0": optional: true + "@esbuild/openharmony-arm64@0.25.12": + optional: true + "@esbuild/openharmony-arm64@0.27.0": optional: true @@ -12944,6 +13597,12 @@ snapshots: "@esbuild/openharmony-arm64@0.28.0": optional: true + "@esbuild/sunos-x64@0.18.20": + optional: true + + "@esbuild/sunos-x64@0.25.12": + optional: true + "@esbuild/sunos-x64@0.27.0": optional: true @@ -12953,6 +13612,12 @@ snapshots: "@esbuild/sunos-x64@0.28.0": optional: true + "@esbuild/win32-arm64@0.18.20": + optional: true + + "@esbuild/win32-arm64@0.25.12": + optional: true + "@esbuild/win32-arm64@0.27.0": optional: true @@ -12962,6 +13627,12 @@ snapshots: "@esbuild/win32-arm64@0.28.0": optional: true + "@esbuild/win32-ia32@0.18.20": + optional: true + + "@esbuild/win32-ia32@0.25.12": + optional: true + "@esbuild/win32-ia32@0.27.0": optional: true @@ -12971,6 +13642,12 @@ snapshots: "@esbuild/win32-ia32@0.28.0": optional: true + "@esbuild/win32-x64@0.18.20": + optional: true + + "@esbuild/win32-x64@0.25.12": + optional: true + "@esbuild/win32-x64@0.27.0": optional: true @@ -14153,6 +14830,42 @@ snapshots: - xml2js - zephyr-agent + "@sentry/junior-memory@file:packages/junior-memory(@neondatabase/serverless@1.1.0)(@types/pg@8.15.6)(pg@8.21.0)": + dependencies: + "@sentry/junior-plugin-api": file:packages/junior-plugin-api(@neondatabase/serverless@1.1.0)(@types/pg@8.15.6)(pg@8.21.0) + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@types/pg@8.15.6)(pg@8.21.0) + zod: 4.4.3 + transitivePeerDependencies: + - "@aws-sdk/client-rds-data" + - "@cloudflare/workers-types" + - "@electric-sql/pglite" + - "@libsql/client" + - "@libsql/client-wasm" + - "@neondatabase/serverless" + - "@op-engineering/op-sqlite" + - "@opentelemetry/api" + - "@planetscale/database" + - "@prisma/client" + - "@tidbcloud/serverless" + - "@types/better-sqlite3" + - "@types/pg" + - "@types/sql.js" + - "@upstash/redis" + - "@vercel/postgres" + - "@xata.io/client" + - better-sqlite3 + - bun-types + - expo-sqlite + - gel + - knex + - kysely + - mysql2 + - pg + - postgres + - prisma + - sql.js + - sqlite3 + "@sentry/junior-plugin-api@file:packages/junior-plugin-api": dependencies: drizzle-orm: 0.45.2 @@ -15711,6 +16424,8 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -16070,6 +16785,13 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + drizzle-kit@0.31.10: + dependencies: + "@drizzle-team/brocli": 0.10.2 + "@esbuild-kit/esm-loader": 2.6.5 + esbuild: 0.25.12 + tsx: 4.22.3 + drizzle-orm@0.45.2: {} drizzle-orm@0.45.2(@electric-sql/pglite@0.4.6): @@ -16203,6 +16925,60 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 + esbuild@0.18.20: + optionalDependencies: + "@esbuild/android-arm": 0.18.20 + "@esbuild/android-arm64": 0.18.20 + "@esbuild/android-x64": 0.18.20 + "@esbuild/darwin-arm64": 0.18.20 + "@esbuild/darwin-x64": 0.18.20 + "@esbuild/freebsd-arm64": 0.18.20 + "@esbuild/freebsd-x64": 0.18.20 + "@esbuild/linux-arm": 0.18.20 + "@esbuild/linux-arm64": 0.18.20 + "@esbuild/linux-ia32": 0.18.20 + "@esbuild/linux-loong64": 0.18.20 + "@esbuild/linux-mips64el": 0.18.20 + "@esbuild/linux-ppc64": 0.18.20 + "@esbuild/linux-riscv64": 0.18.20 + "@esbuild/linux-s390x": 0.18.20 + "@esbuild/linux-x64": 0.18.20 + "@esbuild/netbsd-x64": 0.18.20 + "@esbuild/openbsd-x64": 0.18.20 + "@esbuild/sunos-x64": 0.18.20 + "@esbuild/win32-arm64": 0.18.20 + "@esbuild/win32-ia32": 0.18.20 + "@esbuild/win32-x64": 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + "@esbuild/aix-ppc64": 0.25.12 + "@esbuild/android-arm": 0.25.12 + "@esbuild/android-arm64": 0.25.12 + "@esbuild/android-x64": 0.25.12 + "@esbuild/darwin-arm64": 0.25.12 + "@esbuild/darwin-x64": 0.25.12 + "@esbuild/freebsd-arm64": 0.25.12 + "@esbuild/freebsd-x64": 0.25.12 + "@esbuild/linux-arm": 0.25.12 + "@esbuild/linux-arm64": 0.25.12 + "@esbuild/linux-ia32": 0.25.12 + "@esbuild/linux-loong64": 0.25.12 + "@esbuild/linux-mips64el": 0.25.12 + "@esbuild/linux-ppc64": 0.25.12 + "@esbuild/linux-riscv64": 0.25.12 + "@esbuild/linux-s390x": 0.25.12 + "@esbuild/linux-x64": 0.25.12 + "@esbuild/netbsd-arm64": 0.25.12 + "@esbuild/netbsd-x64": 0.25.12 + "@esbuild/openbsd-arm64": 0.25.12 + "@esbuild/openbsd-x64": 0.25.12 + "@esbuild/openharmony-arm64": 0.25.12 + "@esbuild/sunos-x64": 0.25.12 + "@esbuild/win32-arm64": 0.25.12 + "@esbuild/win32-ia32": 0.25.12 + "@esbuild/win32-x64": 0.25.12 + esbuild@0.27.0: optionalDependencies: "@esbuild/aix-ppc64": 0.27.0 @@ -18512,13 +19288,6 @@ snapshots: prettier@3.8.3: {} - publint@0.3.21: - dependencies: - "@publint/pack": 0.1.4 - package-manager-detector: 1.6.0 - picocolors: 1.1.1 - sade: 1.8.1 - pretty-ms@7.0.1: dependencies: parse-ms: 2.1.0 @@ -18571,6 +19340,13 @@ snapshots: proxy-from-env@2.1.0: {} + publint@0.3.21: + dependencies: + "@publint/pack": 0.1.4 + package-manager-detector: 1.6.0 + picocolors: 1.1.1 + sade: 1.8.1 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -19243,8 +20019,12 @@ snapshots: source-map-js@1.2.1: {} - source-map@0.6.1: - optional: true + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} source-map@0.7.6: {} diff --git a/scripts/bump-release-versions.mjs b/scripts/bump-release-versions.mjs index 39df61ecc..2103071b3 100644 --- a/scripts/bump-release-versions.mjs +++ b/scripts/bump-release-versions.mjs @@ -17,6 +17,7 @@ const files = [ "packages/junior-github/package.json", "packages/junior-hex/package.json", "packages/junior-linear/package.json", + "packages/junior-memory/package.json", "packages/junior-notion/package.json", "packages/junior-scheduler/package.json", "packages/junior-maintenance/package.json", diff --git a/specs/memory-plugin/admin.md b/specs/memory-plugin/admin.md index 77417554d..0d3de4cc5 100644 --- a/specs/memory-plugin/admin.md +++ b/specs/memory-plugin/admin.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-06-13 -- Last Edited: 2026-06-13 +- Last Edited: 2026-06-20 ## Purpose @@ -62,7 +62,7 @@ runtime code; display names and labels are not authorities. ### stats -Reports aggregate counts by scope type, memory type, sensitivity, archive +Reports aggregate counts by scope type, subject type, memory type, archive state, embedding status, repair status, and policy-hidden status. Default output must not include raw memory content. @@ -71,9 +71,9 @@ Default output must not include raw memory content. Lists memories for an explicit scope or query. -Default output should include ids, type, sensitivity, timestamps, archive -state, and short redacted previews. Full content requires an explicit flag such -as `--show-content`. +Default output should include ids, scope type, subject type, memory type, +timestamps, archive state, and short redacted previews. Full content requires +an explicit flag such as `--show-content`. ### show diff --git a/specs/memory-plugin/extraction.md b/specs/memory-plugin/extraction.md index 85aeed2db..717bcd4f9 100644 --- a/specs/memory-plugin/extraction.md +++ b/specs/memory-plugin/extraction.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-06-13 -- Last Edited: 2026-06-13 +- Last Edited: 2026-06-20 ## Purpose @@ -18,8 +18,9 @@ assistance without requiring the original conversation. A candidate may be stored only when all of these are true: 1. Install-level memory policy allows this category, scope, and source. -2. It is a concrete fact, preference, relationship, durable project fact, - durable workflow preference, or explicit user request to remember something. +2. It is a public/shareable concrete fact, preference, relationship, durable + project fact, durable workflow preference, or explicit user request to + remember something. 3. It is useful beyond the current turn or has an explicit expiration. 4. It is understandable without unresolved pronouns or hidden conversation context. @@ -29,6 +30,12 @@ A candidate may be stored only when all of these are true: connection string with credentials, payment card number, or similar secret. 8. It is not merely an assistant claim, assistant action, tool result summary, system capability, implementation detail, or prompt/routing rule. +9. Personal-scoped identity, preference, or relationship facts are first-person + facts authored by the current requester, not third-person profile facts + about someone else. +10. It has a valid subject type: `user` for the current requester, + `conversation` for the current conversation, or `general` for public + operational/domain knowledge. Examples that can be stored: @@ -42,6 +49,8 @@ Examples that must not be stored: - `The assistant searched GitHub.` - `The user asked a question about the memory system.` - `The OAuth token is xoxb-...` +- `David is on the billing team.` when proposed as David's personal memory by + someone other than David - `The user is somewhere next week.` - `The user has not decided what to do.` - `Junior can use the scheduler plugin.` @@ -101,12 +110,12 @@ The memory plugin's `extractMemories` task handler must: 6. Skip extraction when the bounded observation payload is unavailable, expired, malformed, or no longer visible to the plugin. 7. Run policy adjudication for extracted candidates. -8. Reject malformed, low-confidence, incoherent, duplicate, unsafe, or - out-of-scope facts. -9. Reject facts disallowed by install policy, including workplace-sensitive - categories. +8. Reject malformed, low-confidence, incoherent, semantically duplicative, + unsafe, or out-of-scope facts. +9. Reject facts disallowed by install policy, including non-public or + workplace-sensitive categories. 10. Convert relative times to absolute dates using `observed_at`. -11. Assign type, sensitivity, scope, and optional expiration. +11. Assign type, subject, scope, and optional expiration. 12. Run centralized secret detection immediately before writing memory rows. 13. Insert accepted memories transactionally. 14. Generate or queue embeddings for accepted rows when configured and allowed @@ -117,8 +126,9 @@ The memory plugin's `extractMemories` task handler must: beyond the accepted memory records. Extraction tasks must be idempotent. If the same completed turn is observed or -delivered more than once, source idempotency fields and duplicate detection must -prevent duplicate memories. +delivered more than once, source idempotency fields must prevent duplicate +memory writes. Semantic duplicate detection belongs in the extractor and +retrieval slices, not exact-content storage identity. The task handler must be safe to run in a separate serverless invocation from the original user turn. It must not depend on process memory, live Slack @@ -145,11 +155,17 @@ Extraction must follow these rules: 11. Reject workplace-sensitive categories disallowed by install policy, such as HR/performance, protected-class, health, legal, financial, gossip, or coworker speculation. -12. In V1 passive extraction, prefer conversation-scoped operational knowledge +12. Reject private or sensitive content instead of storing it under personal + scope. +13. In V1 passive extraction, prefer conversation-scoped operational knowledge over personal memory. -13. Preserve provenance for third-party claims when the source matters for +14. Personal-scoped memories must be public/shareable first-person facts from + the current author/requester. +15. Assign `user` subject only for the current author/requester; do not create + third-party user subjects in V1. +16. Preserve provenance for third-party claims when the source matters for correctness. -14. Store the minimum useful assertion rather than a direct quote or broad +17. Store the minimum useful assertion rather than a direct quote or broad summary. The plugin must have a deterministic post-extraction validation layer. The @@ -177,8 +193,7 @@ Policy adjudication output must be structured. It should include: - candidate id - decision: `allow` or `reject` - normalized rejection reason code when rejected -- optional adjusted memory type, sensitivity, scope, expiration, or content - rewrite +- optional adjusted memory type, subject, scope, expiration, or content rewrite - confidence The adjudicator may narrow, rewrite, or reject extracted candidates, but it may @@ -213,16 +228,20 @@ The detector must reject at least: - connection strings with embedded credentials If a user explicitly asks Junior to remember a secret, the correct behavior is -a model-visible rejection, not storage with `sensitive`. +a model-visible rejection, not storage with a special classification. ## Duplicate And Supersession Rules -Duplicate prevention is required before insertion: +Duplicate prevention is required before insertion where the relevant signal is +available: - same source observation id and same extracted fact index -- exact normalized content match in the same scope - high lexical or embedding similarity to an active memory in the same scope +V1 storage enforces source/fact idempotency. Exact normalized-content equality +is not a durable identity for memory facts and must not be the only duplicate +suppression strategy. + Supersession is allowed when a new memory clearly replaces an old memory in the same scope, such as a changed preference. Superseded memories remain archived in place and are excluded from recall and list results unless explicitly diff --git a/specs/memory-plugin/index.md b/specs/memory-plugin/index.md index 880c7a5ef..6fcf1df31 100644 --- a/specs/memory-plugin/index.md +++ b/specs/memory-plugin/index.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-06-13 -- Last Edited: 2026-06-13 +- Last Edited: 2026-06-20 ## Purpose @@ -18,6 +18,11 @@ hooks and plugin prompt session state are available through `../plugin-prompt-hooks.md`. Passive learning still depends on future `observeTurn` and plugin background task handler surfaces. +V1 stores only public/shareable memory content. Scope controls who can see a +record; it is not a content sensitivity model. Private, sensitive, secret, or +otherwise restricted content is rejected instead of being stored with a +classification label. + When automatic memory injection is enabled, the memory plugin makes relevant facts available before each response without making recall depend on the model choosing a search tool. When automatic memory injection is disabled, @@ -35,14 +40,16 @@ support user-directed memory management. - Passive learning through `observeTurn` plus a plugin background task handler. - Explicit `createMemory`, `removeMemory`, `listMemories`, and `searchMemories` tools. -- Scope, attribution, sensitivity, lifecycle, tool, model, and secret rejection - rules. +- Scope, attribution, lifecycle, tool, model, public-content, and secret + rejection rules. - V1 implementation order and verification requirements. ## Non-Goals - A core memory API outside the plugin system. -- A person graph, alias resolver, or multi-hop social retrieval. +- A canonical person graph, alias resolver, or multi-hop social retrieval. +- Storing private, sensitive, secret, or otherwise restricted memory content in + V1. - Cross-context recall between unrelated conversations. - Requiring search tools when automatic memory injection is enabled. - Storing conversation transcript history as memory. @@ -66,20 +73,47 @@ Read these files as one canonical spec: - [retrieval.md](./retrieval.md): automatic recall, tool-mediated recall, hybrid ranking, automatic injection mechanics, and performance strategy. - [extraction.md](./extraction.md): passive observation, background extraction, - storable-fact policy, duplicate detection, and supersession. + storable-fact policy, semantic duplicate detection, and supersession. - [tools.md](./tools.md): model-visible memory management and recall tools. - [admin.md](./admin.md): future operator/admin CLI command shape for memory inspection and repair. - [verification.md](./verification.md): failure model, observability, and test requirements. +## User Stories + +V1 memory must satisfy these product stories: + +1. As a requester, I can ask Junior to remember a public/shareable first-person + fact about me and have Junior recall it for me later. +2. As a requester, I cannot create a personal memory about another person, + because personal user-subject memories are owned by the current + author/requester. +3. As a public conversation participant, I can ask Junior to remember shared + operational knowledge for that conversation. +4. As a requester, I can list, search, and remove memories visible in the + current context without giving the model actor ids, Slack ids, or arbitrary + scope selectors. +5. As an installer, I can enable memory knowing private, sensitive, and secret + content is rejected rather than stored with a dormant classification. +6. As an operator, I can add embeddings, lexical indexes, or future graph + indexes as derived data without changing the authoritative memory ownership + model. + ## Design Inputs -The V1 shape is adapted from `~/src/ash/specs/memory/*`: use Ash's memory type -taxonomy, sensitivity split, centralized secret rejection, temporal rewriting, -and duplicate/supersession discipline, but omit Ash's person graph and -cross-context traversal until Junior has a stricter identity and disclosure -model for that behavior. +The V1 shape is informed by `~/src/ash/specs/memory/*` and a prior-art pass over +qmd, Mem0/OpenMemory, Supermemory, Zep/Graphiti, Cognee, Letta, and MemU. The +common durable-storage pattern is an authoritative scoped memory row plus +derived retrieval indexes, source attribution, lifecycle state, and optional +versioning or graph layers. V1 uses Ash's useful type taxonomy, centralized +secret rejection, temporal rewriting, and lifecycle/supersession discipline, +but does not copy Ash's sensitivity split, exact-content dedupe, or person +graph. + +Future graph/entity/fact indexes should be derived from authoritative memory +records and source attribution. They can be added as separate rebuildable tables +without changing the V1 memory row's authority model. External storage and retrieval assumptions are based on primary documentation: @@ -140,8 +174,8 @@ may receive memory text, and which retention defaults apply. V1 passive extraction targets workplace knowledge from conversations classified as `public` by Junior's existing conversation privacy/destination visibility contracts. Private, direct, unknown, or unsupported sources can still use -explicit memory tools when policy allows them, but passive learning from those -sources is out of scope for V1. +explicit memory tools for public/shareable memories when policy allows them, +but passive learning from those sources is out of scope for V1. V1 uses the default extraction guidance in `policy.md`. Install-provided extraction guidelines are out of scope for V1. @@ -190,8 +224,8 @@ scheduled task through the scheduler workflow. V1 passive extraction must not create `identity` or `relationship` memories about third parties. Those types are primarily for explicit personal memory, -such as the requester's own preferences, identity facts, or working -relationships that pass policy. +such as the requester's own public/shareable preferences, identity facts, or +working relationships that pass policy. ## Scope Model @@ -207,30 +241,65 @@ Rules: 1. Scope is derived from runtime context. Model-visible tool arguments never provide requester ids, team ids, channel ids, thread ids, or conversation ids. 2. Personal memory is the default for first-person facts in interactive turns. + The current author/requester must be the subject of any personal-scoped + identity, preference, or relationship fact. For example, `I am on the +billing team` may become a personal memory for that requester, while `David +is on the billing team` is not a valid personal memory when written by + someone else. 3. Conversation memory may be created only when the user explicitly frames the fact as shared team/channel/conversation knowledge or the passive extractor can prove the fact is about the current conversation rather than a person. 4. V1 does not recall memories across unrelated conversations, even if display names or Slack users appear to match. -5. Subject labels may be stored for later display and future person-graph work, - but they are not authorization principals in V1. +5. Subject fields describe what the memory is about; they do not broaden + visibility beyond the stored scope. -## Sensitivity +## Subject Model -Every memory has a sensitivity: +Scope answers who can see the memory. Subject answers what the memory is about. +V1 supports a small subject model rather than a graph: -| Sensitivity | Meaning | V1 disclosure | -| ----------- | ------------------------------------------------ | ---------------------------------------------------------------------------- | -| `public` | Normal preference or operational fact | visible within stored scope | -| `personal` | Private detail that should not be shared broadly | personal scope only unless explicitly conversation-scoped by the source user | -| `sensitive` | health, financial, legal, employment, or similar | personal scope only | +| Subject type | Meaning | Subject key | +| -------------- | -------------------------------------------- | ------------------------------------------- | +| `user` | public/shareable fact about the current user | current requester actor key | +| `conversation` | norm or fact about the current conversation | current source/destination conversation key | +| `general` | project, product, repository, or domain fact | none | -Sensitive memories must not be created as conversation-scoped passive memories. -If a user explicitly asks to store sensitive information as shared conversation -knowledge, the tool must reject the request with a model-visible input error -explaining that sensitive memories can only be stored personally. +Rules: + +1. `user` subject is allowed only for the current author/requester. V1 does not + let one participant create another user's personal profile memory. +2. `conversation` subject is derived from the current runtime conversation. +3. `general` subject is for public/shareable operational or domain knowledge + that is not primarily about a person or the conversation itself. +4. Subject keys are runtime-derived internal storage fields when present. + Model-visible tool arguments cannot provide arbitrary user ids, actor ids, + conversation ids, aliases, or display names as subjects. +5. Subject fields may be used for rendering, filtering, ranking, and future + derived graph construction, but authorization still comes from scope. +6. Third-party operational facts may appear in conversation-scoped `general` + memories when policy allows them, but V1 does not create `user` subject + memories for third parties. + +## Public-Only Content + +V1 does not store a sensitivity, classification, or privacy label on memory +records. A memory is eligible only if the accepted content is safe to recall +inside its stored scope without special private/sensitive handling. + +Rules: -Secrets are not a sensitivity class. Secrets are rejected and never stored. +1. `personal` scope means requester-owned visibility for public/shareable + first-person memories authored by that requester. It does not mean the + content may be private, sensitive, secret, or third-party profile data. +2. `conversation` scope means the memory may be recalled in the same + conversation. It must be appropriate as shared conversation knowledge. +3. Secrets are rejected and never stored. +4. Sensitive or private personal facts are rejected in V1, including explicit + user requests. +5. If Junior later supports non-public memory, it must add an intentional + storage, retrieval, prompt, admin, export, and deletion contract. Existing V1 + rows can be deterministically treated as public/shareable rows. ## Store Boundary @@ -240,10 +309,9 @@ behind a small store such as `MemoryStore`. The store boundary owns: - parsing database rows into memory records -- rejecting invalid enum values and malformed metadata +- rejecting invalid enum values and malformed rows - visibility filtering - create/archive/list operations -- duplicate detection - extraction idempotency - embedding row repair - expiration and supersession updates diff --git a/specs/memory-plugin/policy.md b/specs/memory-plugin/policy.md index a57014adc..443680fc6 100644 --- a/specs/memory-plugin/policy.md +++ b/specs/memory-plugin/policy.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-06-13 -- Last Edited: 2026-06-13 +- Last Edited: 2026-06-20 ## Purpose @@ -16,7 +16,7 @@ compliance risks. - What must be tunable by the installing app or workspace. - V1 passive extraction toggle and default extraction guidance. - Default workplace extraction guidance. -- Workplace-sensitive information categories. +- Public-memory eligibility and workplace-sensitive rejection categories. - How policy affects tools, passive extraction, retrieval, retention, models, and admin output. @@ -59,6 +59,7 @@ rules: - no secrets - runtime-derived scope only - source visibility checks +- public/shareable memory content only - `public` conversation visibility for passive capture only in V1 - policy toggle checks - provider allowlist checks @@ -81,8 +82,8 @@ Workplace-safe defaults should be conservative: `searchMemories` for recall. 5. Passive extraction from conversations classified as `direct`, `private`, `unknown`, or unsupported is out of scope for V1. -6. Sensitive memory should be personal-only and should be disabled for passive - extraction by default. +6. V1 does not store private or sensitive memory content, even in personal + scope. 7. Third-party personal facts about coworkers should not be passively stored by default. 8. Retention should prefer shorter TTLs for `context`, `event`, `task`, and @@ -105,8 +106,8 @@ Aim to extract: - explicit decisions, status changes, deadlines, launch windows, or deploy windows - channel-level norms, such as how a public channel tracks work or incidents -- explicit "remember this" requests that are appropriate for the current - channel scope +- explicit "remember this" requests that are appropriate for the requested + scope Avoid extracting: @@ -130,8 +131,8 @@ sources. ## Third-Party Facts -Third-party facts are allowed in V1 when they are operational knowledge from a -conversation classified as `public`, rather than personal claims. +Third-party facts are allowed in V1 only when they are operational knowledge +from a conversation classified as `public`, rather than personal claims. Useful third-party memories include: @@ -143,6 +144,8 @@ Useful third-party memories include: Unsafe third-party memories include: - `Bob is unreliable.` +- `David is on the billing team.` when written as personal memory by someone + other than David - `Sam is interviewing elsewhere.` - `Alice is dealing with a medical issue.` - `Dana dislikes working with Chris.` @@ -153,12 +156,28 @@ conversation fact, preserve provenance in the content. Prefer `The deploy freeze starts Friday` unless the conversation context makes it an accepted team fact. -## Workplace-Sensitive Categories +### Personal Scope Authorship -The extractor must be careful about information that can harm people if stored -or recalled out of context. +Personal-scoped memories may store public/shareable first-person facts only for +the current author/requester. The author can explicitly ask Junior to remember +`I prefer terse code reviews` or `I am the release captain for Project Atlas`. +Another participant cannot create a personal memory such as `David prefers +terse code reviews` or `David is the release captain` on David's behalf. -The default workplace policy should reject passive storage of: +Personal-scoped memories can also store public/shareable `general` subject +knowledge for the requester when explicitly requested, but they cannot target +another user as the subject. Third-person facts belong only in conversation +scope when they are clean operational knowledge for the current public +conversation and pass the normal third-party policy. They are not personal +memories for the named person. + +## Non-Public And Sensitive Categories + +V1 stores only public/shareable memory content. The extractor and explicit tool +path must reject information that requires private, sensitive, legal, +compliance, or secret handling. + +The default workplace policy should reject storage of: - health, disability, medical, or family-care details - legal issues, immigration status, or government identifiers @@ -172,9 +191,9 @@ The default workplace policy should reject passive storage of: - jokes, venting, gossip, conflict, or interpersonal commentary - raw conversation summaries whose future usefulness depends on hidden context -Explicit user requests to remember sensitive personal details must still follow -scope and sensitivity rules. Some installs may choose to reject those requests -entirely. +Explicit user requests to remember private or sensitive personal details are +rejected in V1. Personal scope controls who can see an allowed public/shareable +memory; it does not authorize storage of non-public content. ## Passive Extraction Policy @@ -210,7 +229,7 @@ Policy still disallows passive extraction by category, including: - personal facts about third parties - identity or relationship facts about third parties - non-operational conversation summaries -- sensitive facts +- non-public or sensitive facts - low-confidence inferences - facts without explicit durability @@ -240,15 +259,14 @@ automatic memory injection. Explicit `createMemory` requests are still subject to install policy. For example, passive extraction is limited to public-conversation workplace -knowledge in V1, but users may still explicitly store personal preferences when -the requested memory passes policy. An install may disallow all sensitive memory -writes, including explicit requests. +knowledge in V1, but users may still explicitly store public/shareable personal +preferences about themselves when the requested memory passes policy. The explicit tool path must run the same deterministic policy filter as passive extraction. Explicit user intent can make a fact eligible for storage under install policy, but it cannot override secret rejection, source/scope rules, -workplace-sensitive category rejection, provider policy, or sensitivity -restrictions. +workplace-sensitive category rejection, public-content restrictions, provider +and embedding policy, or retention and lifecycle policy. Tool errors should explain policy rejection at a high level without revealing hidden policy internals or sensitive content. diff --git a/specs/memory-plugin/retrieval.md b/specs/memory-plugin/retrieval.md index 92ad305eb..e092c761a 100644 --- a/specs/memory-plugin/retrieval.md +++ b/specs/memory-plugin/retrieval.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-06-13 -- Last Edited: 2026-06-19 +- Last Edited: 2026-06-20 ## Purpose @@ -59,12 +59,10 @@ Retrieval must filter by visibility before prompt rendering: - matching personal requester scope - matching conversation scope -- current install policy allows recall for the memory type, scope, and - sensitivity +- current install policy allows recall for the memory type, scope, and source - `archived_at is null` - `superseded_at is null` - `expires_at is null or expires_at > now()` -- sensitivity allowed in the current scope The query planner, vector index, model, and ranker are not authorization boundaries. @@ -76,18 +74,23 @@ visible. ### Ranking Pipeline -V1 uses hybrid retrieval without Ash's person graph: +The first storage slice uses lexical retrieval without a graph or vector index: 1. Build visible active candidate scopes. -2. Run lexical search against memory content and subject labels. -3. Run vector search when embeddings are configured and the user text can be - embedded. -4. Merge lexical and vector results with reciprocal-rank style fusion. -5. Apply small deterministic boosts for exact scope match, durable memory - types, high confidence, and recent observations. -6. For automatic injection only, drop memories already injected into the active +2. Run lexical search against memory content. +3. Apply small deterministic boosts for exact scope match, durable memory + types, subject match, and recent observations. +4. For automatic injection only, drop memories already injected into the active session projection. -7. Return the top memories within count and character budgets. +5. Return the top memories within count and character budgets. + +The later embedding slice may upgrade this to hybrid retrieval: + +1. Run vector search when embeddings are configured and the user text can be + embedded. +2. Merge lexical and vector results with reciprocal-rank style fusion. +3. Apply the same visibility filtering, deterministic boosts, and prompt + budgets. Vector results should be overfetched before final filtering and prompt formatting. Approximate vector search must be exact-reranked over visible diff --git a/specs/memory-plugin/security.md b/specs/memory-plugin/security.md index 2553f81a6..ca1454e05 100644 --- a/specs/memory-plugin/security.md +++ b/specs/memory-plugin/security.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-06-13 -- Last Edited: 2026-06-13 +- Last Edited: 2026-06-20 ## Purpose @@ -13,9 +13,9 @@ model calls, embeddings, logging, and multi-user visibility. ## Security Invariants 1. Runtime context, not model text, determines memory visibility. -2. Install-level policy determines which categories, scopes, and model providers - are allowed. -3. Secrets are rejected, not stored as sensitive memories. +2. Install-level policy determines which public/shareable categories, scopes, + subjects, and model providers are allowed. +3. Secrets are rejected, not stored with a special classification. 4. Memory content may be model-visible only inside the stored scope and current policy. 5. Retrieval ranking is not an authorization boundary. @@ -39,14 +39,15 @@ The store must derive authority-bearing fields from Junior runtime context: - source event or observation id The model may request memory operations, but it cannot choose authority fields. -Tool arguments can express content, requested scope class, query text, limit, or -expiration. They cannot express actor ids, workspace ids, channel ids, thread -ids, arbitrary owner ids, arbitrary conversation ids, or arbitrary scope -overrides for `searchMemories`. +Tool arguments can express content, query text, limit, or expiration. They +cannot express actor ids, workspace ids, channel ids, thread ids, arbitrary +owner ids, arbitrary conversation ids, requested scope classes, or arbitrary +scope overrides for `searchMemories`. -Display names, subject labels, aliases, and model-extracted subject text are -metadata. They are useful for rendering and future graph work, but they are not -authorization principals. +Subject type is stored so the plugin can distinguish user, conversation, and +general knowledge. Subject keys are runtime-derived when present. Display names, +aliases, and model-extracted subject text are metadata. They are useful for +rendering and future graph work, but they are not authorization principals. Admin CLI selectors also are not authorization by themselves. They identify the records an operator wants to inspect or repair; deployment/operator @@ -68,9 +69,10 @@ unknown, local CLI, and unsupported sources may still use explicit memory tools when policy allows them, but they must not feed passive extraction. Visibility classification must fail closed. -Sensitive memory is personal-only. Passive extraction must never create a -conversation-scoped sensitive memory. An explicit tool request to store -sensitive shared memory must fail with a model-visible input error. +Personal-scoped `user` subject memories may be created only by the current +author/requester and must contain public/shareable first-person content. An +explicit tool request to store private, sensitive, or third-party personal +profile content must fail with a model-visible input error. For workplace installs, passive third-party personal facts should be rejected. Third-party operational facts from public conversations may be stored only when @@ -93,7 +95,7 @@ payloads, raw OAuth data, or unrestricted transcripts through the plugin API. Install policy may disable extraction or embedding providers for private conversation text. -Embedding vectors inherit the same sensitivity, scope, lifecycle, policy, and +Embedding vectors inherit the same scope, subject, lifecycle, policy, and provider restrictions as their source memories. They must not be logged, reported, exported, retained, or exposed under weaker rules than memory content. @@ -126,7 +128,8 @@ Logs, spans, dashboards, and plugin operational reports may include: - memory operation name - memory id or bounded id prefix - scope type -- memory type and sensitivity enum +- subject type +- memory type - embedding provider/model/dimensions - extraction candidate counts - rejection reason codes diff --git a/specs/memory-plugin/storage.md b/specs/memory-plugin/storage.md index 4d00d91e0..1aaaa977e 100644 --- a/specs/memory-plugin/storage.md +++ b/specs/memory-plugin/storage.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-06-13 -- Last Edited: 2026-06-13 +- Last Edited: 2026-06-20 ## Purpose @@ -30,12 +30,15 @@ The plugin stores two classes of data: 2. **Retrieval indexes**: derived data, such as embeddings and lexical indexes, that can be deleted and rebuilt from memory records. -The implementation may use one table per class or split them further if needed, -but V1 should keep the shape simple: +The implementation may use one table per class or split them further if needed. +The first authoritative storage slice keeps the shape simple: - one authoritative memory-record table -- one derived embedding/vector table or equivalent vector index -- optional database-native lexical search support +- no canonical graph/entity/fact tables + +The embedding slice adds one derived embedding/vector table or equivalent vector +index. Optional database-native lexical search support may be added with the +retrieval slice. ### Memory Record Shape @@ -46,23 +49,45 @@ Required conceptual fields: - stable memory id - self-contained memory content -- normalized content hash for duplicate detection - memory type -- sensitivity - runtime-derived visibility scope +- subject type +- runtime-derived subject key when the subject is a user or conversation - runtime-derived source attribution -- observation or tool idempotency marker when available -- optional subject/display labels that are not authorization principals -- extraction confidence when learned passively +- observation or tool idempotency marker - observed timestamp - created timestamp - optional expiration timestamp - optional supersession link - archive timestamp and reason -- bounded operational metadata -Scope and source fields are authority-bearing. Display labels, subject labels, -model-generated summaries, and tool arguments are not. +The first storage slice intentionally keeps this authoritative row lean. +Subject/display labels, extraction confidence, and operational metadata should +be added only with the extraction, graph, or admin consumer that needs them. + +Scope and source fields are authority-bearing. Subject fields describe the +memory's topic, but they must not grant visibility beyond scope. The stored +subject key is runtime-derived internal metadata, not model-visible tool input +or ordinary memory output. Display labels, model-generated summaries, and tool +arguments are not authorities. + +V1 stores only public/shareable memory content, so the authoritative row must +not include a dormant sensitivity or classification column. If a future feature +supports non-public memory, it must add a new end-to-end contract; existing V1 +rows can be backfilled or interpreted as public/shareable without semantic +recategorization. + +### Derived Graph Data + +Graph/entity/fact storage is out of scope for V1. Prior-art systems that use a +graph still treat episodes, passages, or memory items as the source from which +graph edges are derived. + +If Junior later needs entity linking, relationship retrieval, or multi-hop +recall, it should add separate derived graph tables keyed by memory id and +source attribution. Those tables must be rebuildable from authoritative memory +records plus bounded source references. Graph nodes, aliases, display labels, +and model-extracted entity subjects must not become authorization principals. ### Visibility Data @@ -78,7 +103,7 @@ thread ids, or arbitrary conversation ids. The store must be able to filter active visible records by: - scope -- sensitivity +- plugin-derived subject type - current install policy - archive state - supersession state @@ -91,8 +116,9 @@ redelivery, and task retry. The store needs a stable source marker for a completed observation and the extracted fact's position or stable fact id inside that observation. -Duplicate suppression also needs active-scope content hashing and a later -semantic-similarity check when embeddings are available. +Semantic duplicate suppression needs extractor and retrieval context. It should +run before insertion when implemented, but V1 storage does not use exact-content +hashing as memory identity. ### Lexical Search @@ -108,7 +134,8 @@ before returning rows to prompt rendering or tools. Embeddings are derived retrieval data. They are not the authority for memory existence, visibility, or deletion. -Embedding rows or index entries must record: +When the embedding slice is implemented, embedding rows or index entries must +record: - memory id - provider id @@ -125,9 +152,9 @@ Changing provider, model, dimensions, or metric requires re-embedding active memories. Missing or stale embeddings degrade retrieval to lexical and recency ranking. -Vectors inherit the classification, scope, retention, deletion, and provider -policy of their source memory. Archiving or deleting a memory must remove or -invalidate derived vectors under the same rules as the memory content. +Vectors inherit the scope, retention, deletion, and provider policy of their +source memory. Archiving or deleting a memory must remove or invalidate derived +vectors under the same rules as the memory content. ### Vector Storage @@ -200,9 +227,10 @@ records the resolved provider and model used for each vector. Memory creation follows this order: -1. Validate content, scope, sensitivity, source, expiration, and metadata. +1. Validate content, scope, source, expiration, and metadata. 2. Run model-assisted policy adjudication when needed. -3. Run deterministic policy validation and centralized secret rejection. +3. Run deterministic public-content policy validation and centralized secret + rejection. 4. Insert the memory record transactionally. 5. After the transaction commits, batch-generate embeddings for inserted records when an embedding provider is configured. diff --git a/specs/memory-plugin/tools.md b/specs/memory-plugin/tools.md index 00a256cf1..4b3e2719b 100644 --- a/specs/memory-plugin/tools.md +++ b/specs/memory-plugin/tools.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-06-13 -- Last Edited: 2026-06-13 +- Last Edited: 2026-06-20 ## Purpose @@ -32,9 +32,7 @@ destination, conversation, and tenant/workspace authority from runtime context. `createMemory` may accept: - content -- optional scope enum: `personal` or `conversation` - optional expiration duration/date -- optional sensitivity hint `createMemory` must not accept: @@ -45,12 +43,31 @@ destination, conversation, and tenant/workspace authority from runtime context. - Slack thread timestamp - arbitrary conversation id - arbitrary owner id +- arbitrary scope enum +- arbitrary subject id +- arbitrary subject display name +- arbitrary subject enum - raw source metadata -The tool derives source, requester, destination, and scope from runtime context. -It runs the same validation, secret rejection, duplicate checks, and embedding -write path as passive extraction. Explicit tool requests are still subject to -install-level memory policy. +The tool derives source, requester, destination, and candidate scope from +runtime context plus the accepted memory intent. It runs the same validation, +secret rejection, idempotency checks, and embedding write path as passive +extraction. Explicit tool requests are still subject to install-level memory +policy. + +For `personal` scope, `createMemory` may store only public/shareable +first-person facts authored by the current requester. It must reject attempts +to create another person's personal memory, such as storing `David is xyz` as a +personal memory when the current requester is not David. Third-person facts may +be stored only as conversation-scoped operational knowledge when policy allows +them. + +The plugin derives the stored scope and subject from the accepted content, +runtime context, and classifier/extractor decision. In the first explicit +storage slice, personal memories default to `user` subject and conversation +memories default to `conversation` subject. The model cannot provide arbitrary +scope enums, subject ids, Slack user ids, display names, aliases, or subject +classes. `createMemory` must run the same deterministic policy filter as passive extraction before writing. The fact that a user explicitly asked Junior to @@ -60,7 +77,7 @@ bypass: - secret rejection - source and scope rules - workplace-sensitive category rejection -- sensitivity restrictions +- public-content restrictions - provider and embedding policy - retention and lifecycle policy diff --git a/specs/memory-plugin/verification.md b/specs/memory-plugin/verification.md index 032eba256..d0e4f0a77 100644 --- a/specs/memory-plugin/verification.md +++ b/specs/memory-plugin/verification.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-06-13 -- Last Edited: 2026-06-19 +- Last Edited: 2026-06-20 ## Purpose @@ -31,8 +31,7 @@ requirements. 10. Task retry bound exceeded or observation payload expired: mark or drop the task with safe metadata; do not fail the completed user turn. 11. Duplicate post-turn observation or duplicate task delivery: task - idempotency, source idempotency, and duplicate detection suppress duplicate - stored memories. + idempotency and source idempotency suppress duplicate stored memories. 12. Secret detection match: reject the write with a model-visible tool input error for explicit tools or drop the passive fact with safe logging. 13. Visibility mismatch: fail closed and omit the memory. @@ -48,7 +47,8 @@ Logs and spans may include: - memory operation name - memory id or bounded id prefix - scope type -- type and sensitivity enum +- subject type +- memory type - embedding provider/model/dimensions - extracted candidate fact count - accepted/rejected fact counts @@ -77,11 +77,15 @@ Use integration tests for: - memory plugin packaged storage migrations are discovered and applied through `junior upgrade` -- storage migrations provide the broad memory-record and derived-vector storage - mechanisms required by `storage.md` +- storage migrations provide the authoritative memory-record mechanism required + by the first storage slice in `storage.md` - explicit memory creation stores a personal memory under the current requester - explicit conversation memory stores under the current conversation without accepting model-supplied Slack ids +- explicit personal user-subject memory can be created only for the current + requester/author +- explicit personal memory rejects third-party user profile facts such as + storing `David is xyz` on David's behalf - explicit memory creation is rejected when it violates install policy or workplace-sensitive category rules - install policy can disable passive extraction without disabling explicit @@ -127,7 +131,7 @@ When the future admin CLI is implemented, use integration tests for: Use unit tests for: -- memory type, scope, and sensitivity parsers +- memory type, scope, and subject parsers - install policy parser and policy evaluation predicates - secret detection - storable-fact validation @@ -135,7 +139,7 @@ Use unit tests for: - policy adjudication output parsing - TTL calculation - visibility predicates -- duplicate detection +- semantic duplicate detection - prompt contribution formatting bounds - tool schema rejection of actor, destination, team, channel, and conversation fields