Add governed memory controls

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-16 17:18:13 -05:00
1 parent d579a2c413
commit d4d336e20b
18 files changed
+16390 -32

No files matched your search

@@ -0,0 +1,60 @@
ALTER TABLE "memory_local_records" ADD COLUMN "scope_type" text DEFAULT 'org' NOT NULL;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "scope_id" text;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "scope_workspace_id" text;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "scope_team_id" text;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "owner_type" text;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "owner_id" text;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "created_by_actor_type" text;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "created_by_actor_id" text;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "sensitivity_label" text DEFAULT 'internal' NOT NULL;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "retention_policy" jsonb;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "expires_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "retention_state" text DEFAULT 'active' NOT NULL;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "citation_json" jsonb;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "supersedes_record_id" uuid;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "superseded_by_record_id" uuid;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "revoked_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "revoked_by_actor_type" text;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "revoked_by_actor_id" text;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "revocation_reason" text;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD COLUMN "scope_type" text;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD COLUMN "scope_id" text;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD COLUMN "scope_workspace_id" text;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD COLUMN "scope_team_id" text;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD COLUMN "max_sensitivity_label" text;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD COLUMN "policy_decision_json" jsonb;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD COLUMN "revocation_selector_json" jsonb;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD COLUMN "retention_action_json" jsonb;--> statement-breakpoint
UPDATE "memory_local_records"
SET
"scope_type" = CASE
WHEN "scope_run_id" IS NOT NULL THEN 'run'
WHEN "scope_agent_id" IS NOT NULL THEN 'agent'
WHEN "scope_project_id" IS NOT NULL THEN 'project'
ELSE 'org'
END,
"scope_id" = COALESCE("scope_run_id"::text, "scope_agent_id"::text, "scope_project_id"::text, "company_id"::text),
"retention_state" = CASE WHEN "deleted_at" IS NOT NULL THEN 'revoked' ELSE 'active' END,
"revoked_at" = CASE WHEN "deleted_at" IS NOT NULL THEN "deleted_at" ELSE NULL END,
"revocation_reason" = CASE WHEN "deleted_at" IS NOT NULL THEN 'Record was forgotten before governed memory migration' ELSE NULL END;--> statement-breakpoint
UPDATE "memory_local_records" AS "record"
SET
"owner_type" = "operation"."actor_type",
"owner_id" = "operation"."actor_id",
"created_by_actor_type" = "operation"."actor_type",
"created_by_actor_id" = "operation"."actor_id"
FROM "memory_operations" AS "operation"
WHERE "record"."created_by_operation_id" = "operation"."id";--> statement-breakpoint
UPDATE "memory_operations"
SET
"scope_type" = CASE
WHEN "scope_run_id" IS NOT NULL THEN 'run'
WHEN "scope_agent_id" IS NOT NULL THEN 'agent'
WHEN "scope_project_id" IS NOT NULL THEN 'project'
ELSE 'org'
END,
"scope_id" = COALESCE("scope_run_id"::text, "scope_agent_id"::text, "scope_project_id"::text, "company_id"::text);--> statement-breakpoint
CREATE INDEX "memory_local_records_company_scope_created_idx" ON "memory_local_records" USING btree ("company_id","scope_type","scope_id","created_at");--> statement-breakpoint
CREATE INDEX "memory_local_records_company_sensitivity_created_idx" ON "memory_local_records" USING btree ("company_id","sensitivity_label","created_at");--> statement-breakpoint
CREATE INDEX "memory_local_records_company_retention_created_idx" ON "memory_local_records" USING btree ("company_id","retention_state","expires_at","created_at");--> statement-breakpoint
CREATE INDEX "memory_operations_company_scope_occurred_idx" ON "memory_operations" USING btree ("company_id","scope_type","scope_id","occurred_at");
File diff suppressed because it is too large. Load diff
@@ -400,6 +400,13 @@
"when": 1775925298474,
"tag": "0056_oval_gauntlet",
"breakpoints": true
},
{
"idx": 57,
"version": "7",
"when": 1776377673907,
"tag": "0057_misty_riptide",
"breakpoints": true
}
]
}
+43 -1
View File
@@ -7,7 +7,13 @@ import { issues } from "./issues.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { memoryBindings } from "./memory_bindings.js";
import { memoryOperations } from "./memory_operations.js";
import type { MemorySourceKind } from "@paperclipai/shared";
import type {
MemoryPrincipalType,
MemoryRetentionState,
MemoryScopeType,
MemorySensitivityLabel,
MemorySourceKind,
} from "@paperclipai/shared";
export const memoryLocalRecords = pgTable(
"memory_local_records",
@@ -21,6 +27,10 @@ export const memoryLocalRecords = pgTable(
scopeIssueId: uuid("scope_issue_id").references(() => issues.id),
scopeRunId: uuid("scope_run_id").references(() => heartbeatRuns.id),
scopeSubjectId: text("scope_subject_id"),
scopeType: text("scope_type").$type<MemoryScopeType>().notNull().default("org"),
scopeId: text("scope_id"),
scopeWorkspaceId: text("scope_workspace_id"),
scopeTeamId: text("scope_team_id"),
sourceKind: text("source_kind").$type<MemorySourceKind>(),
sourceIssueId: uuid("source_issue_id").references(() => issues.id),
sourceCommentId: uuid("source_comment_id"),
@@ -32,6 +42,21 @@ export const memoryLocalRecords = pgTable(
content: text("content").notNull(),
summary: text("summary"),
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
ownerType: text("owner_type").$type<MemoryPrincipalType>(),
ownerId: text("owner_id"),
createdByActorType: text("created_by_actor_type").$type<MemoryPrincipalType>(),
createdByActorId: text("created_by_actor_id"),
sensitivityLabel: text("sensitivity_label").$type<MemorySensitivityLabel>().notNull().default("internal"),
retentionPolicy: jsonb("retention_policy").$type<Record<string, unknown> | null>(),
expiresAt: timestamp("expires_at", { withTimezone: true }),
retentionState: text("retention_state").$type<MemoryRetentionState>().notNull().default("active"),
citationJson: jsonb("citation_json").$type<Record<string, unknown> | null>(),
supersedesRecordId: uuid("supersedes_record_id"),
supersededByRecordId: uuid("superseded_by_record_id"),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
revokedByActorType: text("revoked_by_actor_type").$type<MemoryPrincipalType>(),
revokedByActorId: text("revoked_by_actor_id"),
revocationReason: text("revocation_reason"),
createdByOperationId: uuid("created_by_operation_id").references(() => memoryOperations.id, { onDelete: "set null" }),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
@@ -53,6 +78,23 @@ export const memoryLocalRecords = pgTable(
table.scopeIssueId,
table.createdAt,
),
companyScopeCreatedIdx: index("memory_local_records_company_scope_created_idx").on(
table.companyId,
table.scopeType,
table.scopeId,
table.createdAt,
),
companySensitivityCreatedIdx: index("memory_local_records_company_sensitivity_created_idx").on(
table.companyId,
table.sensitivityLabel,
table.createdAt,
),
companyRetentionCreatedIdx: index("memory_local_records_company_retention_created_idx").on(
table.companyId,
table.retentionState,
table.expiresAt,
table.createdAt,
),
contentSearchIdx: index("memory_local_records_fts_idx").using(
"gin",
sql`to_tsvector('english', coalesce(${table.title}, '') || ' ' || ${table.content})`,
@@ -13,6 +13,8 @@ import type {
MemoryHookKind,
MemoryOperationStatus,
MemoryOperationType,
MemoryScopeType,
MemorySensitivityLabel,
MemorySourceKind,
MemoryTriggerKind,
} from "@paperclipai/shared";
@@ -37,6 +39,11 @@ export const memoryOperations = pgTable(
scopeIssueId: uuid("scope_issue_id").references(() => issues.id),
scopeRunId: uuid("scope_run_id").references(() => heartbeatRuns.id),
scopeSubjectId: text("scope_subject_id"),
scopeType: text("scope_type").$type<MemoryScopeType>(),
scopeId: text("scope_id"),
scopeWorkspaceId: text("scope_workspace_id"),
scopeTeamId: text("scope_team_id"),
maxSensitivityLabel: text("max_sensitivity_label").$type<MemorySensitivityLabel>(),
sourceKind: text("source_kind").$type<MemorySourceKind>(),
sourceIssueId: uuid("source_issue_id").references(() => issues.id),
sourceCommentId: uuid("source_comment_id").references(() => issueComments.id),
@@ -48,6 +55,9 @@ export const memoryOperations = pgTable(
recordCount: integer("record_count").notNull().default(0),
requestJson: jsonb("request_json").$type<Record<string, unknown> | null>(),
resultJson: jsonb("result_json").$type<Record<string, unknown> | null>(),
policyDecisionJson: jsonb("policy_decision_json").$type<Record<string, unknown> | null>(),
revocationSelectorJson: jsonb("revocation_selector_json").$type<Record<string, unknown> | null>(),
retentionActionJson: jsonb("retention_action_json").$type<Record<string, unknown> | null>(),
usageJson: jsonb("usage_json").$type<Array<Record<string, unknown>>>().notNull().default([]),
error: text("error"),
costEventId: uuid("cost_event_id").references(() => costEvents.id),
@@ -72,5 +82,11 @@ export const memoryOperations = pgTable(
table.scopeRunId,
table.occurredAt,
),
companyScopeOccurredIdx: index("memory_operations_company_scope_occurred_idx").on(
table.companyId,
table.scopeType,
table.scopeId,
table.occurredAt,
),
}),
);
+23 -1
View File
@@ -393,7 +393,17 @@ export type MemoryBindingTargetType = (typeof MEMORY_BINDING_TARGET_TYPES)[numbe
export const MEMORY_PROVIDER_KINDS = ["builtin", "plugin"] as const;
export type MemoryProviderKind = (typeof MEMORY_PROVIDER_KINDS)[number];
export const MEMORY_OPERATION_TYPES = ["query", "capture", "upsert", "list", "get", "forget"] as const;
export const MEMORY_OPERATION_TYPES = [
"query",
"capture",
"upsert",
"list",
"get",
"forget",
"revoke",
"correct",
"retention_sweep",
] as const;
export type MemoryOperationType = (typeof MEMORY_OPERATION_TYPES)[number];
export const MEMORY_OPERATION_STATUSES = ["succeeded", "failed"] as const;
@@ -430,6 +440,18 @@ export const MEMORY_EXTRACTION_JOB_STATUSES = [
] as const;
export type MemoryExtractionJobStatus = (typeof MEMORY_EXTRACTION_JOB_STATUSES)[number];
export const MEMORY_SCOPE_TYPES = ["run", "agent", "workspace", "project", "team", "org"] as const;
export type MemoryScopeType = (typeof MEMORY_SCOPE_TYPES)[number];
export const MEMORY_PRINCIPAL_TYPES = ["agent", "user", "system", "team", "org"] as const;
export type MemoryPrincipalType = (typeof MEMORY_PRINCIPAL_TYPES)[number];
export const MEMORY_SENSITIVITY_LABELS = ["public", "internal", "confidential", "restricted"] as const;
export type MemorySensitivityLabel = (typeof MEMORY_SENSITIVITY_LABELS)[number];
export const MEMORY_RETENTION_STATES = ["active", "expired", "revoked"] as const;
export type MemoryRetentionState = (typeof MEMORY_RETENTION_STATES)[number];
// ---------------------------------------------------------------------------
// Plugin System — see doc/plugins/PLUGIN_SPEC.md for the full specification
// ---------------------------------------------------------------------------
+27
View File
@@ -68,6 +68,10 @@ export {
MEMORY_HOOK_KINDS,
MEMORY_SOURCE_KINDS,
MEMORY_EXTRACTION_JOB_STATUSES,
MEMORY_SCOPE_TYPES,
MEMORY_PRINCIPAL_TYPES,
MEMORY_SENSITIVITY_LABELS,
MEMORY_RETENTION_STATES,
PLUGIN_API_VERSION,
PLUGIN_STATUSES,
PLUGIN_CATEGORIES,
@@ -149,6 +153,10 @@ export {
type MemoryHookKind,
type MemorySourceKind,
type MemoryExtractionJobStatus,
type MemoryScopeType,
type MemoryPrincipalType,
type MemorySensitivityLabel,
type MemoryRetentionState,
type PluginStatus,
type PluginCategory,
type PluginCapability,
@@ -290,6 +298,9 @@ export type {
MemoryProviderCapabilities,
MemoryProviderDescriptor,
MemoryUsage,
MemoryGovernedScope,
MemoryPrincipalRef,
MemoryCitation,
MemoryScope,
MemorySourceRef,
MemoryBinding,
@@ -302,6 +313,10 @@ export type {
MemoryQueryResult,
MemoryCaptureResult,
MemoryForgetResult,
MemoryRevokeSelector,
MemoryRevokeResult,
MemoryCorrectResult,
MemoryRetentionSweepResult,
MemoryProviderQueryInput,
MemoryProviderQueryOutput,
MemoryProviderCaptureInput,
@@ -579,6 +594,9 @@ export {
createCostEventSchema,
createFinanceEventSchema,
updateBudgetSchema,
memoryGovernedScopeSchema,
memoryPrincipalRefSchema,
memoryCitationSchema,
memoryScopeSchema,
memorySourceRefSchema,
memoryProviderCapabilitiesSchema,
@@ -589,6 +607,9 @@ export {
memoryQuerySchema,
memoryCaptureSchema,
memoryForgetSchema,
memoryRevokeSchema,
memoryCorrectSchema,
memoryRetentionSweepSchema,
memoryListRecordsQuerySchema,
memoryListOperationsQuerySchema,
memoryListExtractionJobsQuerySchema,
@@ -608,6 +629,9 @@ export {
type CreateFinanceEvent,
type UpdateBudget,
type MemoryScopeInput,
type MemoryGovernedScopeInput,
type MemoryPrincipalRefInput,
type MemoryCitationInput,
type MemorySourceRefInput,
type CreateMemoryBinding,
type UpdateMemoryBinding,
@@ -616,6 +640,9 @@ export {
type MemoryQuery,
type MemoryCapture,
type MemoryForget,
type MemoryRevoke,
type MemoryCorrect,
type MemoryRetentionSweep,
type MemoryListRecordsQuery,
type MemoryListOperationsQuery,
type MemoryListExtractionJobsQuery,
+7
View File
@@ -123,6 +123,9 @@ export type {
MemoryProviderCapabilities,
MemoryProviderDescriptor,
MemoryUsage,
MemoryGovernedScope,
MemoryPrincipalRef,
MemoryCitation,
MemoryScope,
MemorySourceRef,
MemoryBinding,
@@ -135,6 +138,10 @@ export type {
MemoryQueryResult,
MemoryCaptureResult,
MemoryForgetResult,
MemoryRevokeSelector,
MemoryRevokeResult,
MemoryCorrectResult,
MemoryRetentionSweepResult,
MemoryProviderQueryInput,
MemoryProviderQueryOutput,
MemoryProviderCaptureInput,
+83
View File
@@ -4,7 +4,11 @@ import type {
MemoryHookKind,
MemoryOperationStatus,
MemoryOperationType,
MemoryPrincipalType,
MemoryProviderKind,
MemoryRetentionState,
MemoryScopeType,
MemorySensitivityLabel,
MemorySourceKind,
MemoryTriggerKind,
} from "../constants.js";
@@ -37,12 +41,37 @@ export interface MemoryUsage {
details: Record<string, unknown> | null;
}
export interface MemoryGovernedScope {
type: MemoryScopeType;
id?: string | null;
}
export interface MemoryPrincipalRef {
type: MemoryPrincipalType;
id: string;
}
export interface MemoryCitation {
label?: string | null;
url?: string | null;
excerpt?: string | null;
sourceTitle?: string | null;
sourcePath?: string | null;
metadata?: Record<string, unknown> | null;
}
export interface MemoryScope {
scopeType?: MemoryScopeType | null;
scopeId?: string | null;
agentId?: string | null;
workspaceId?: string | null;
projectId?: string | null;
issueId?: string | null;
runId?: string | null;
teamId?: string | null;
subjectId?: string | null;
allowedScopes?: MemoryGovernedScope[] | null;
maxSensitivityLabel?: MemorySensitivityLabel | null;
}
export interface MemorySourceRef {
@@ -96,6 +125,20 @@ export interface MemoryRecord {
providerKey: string;
scope: MemoryScope;
source: MemorySourceRef | null;
scopeType: MemoryScopeType;
scopeId: string | null;
owner: MemoryPrincipalRef | null;
createdBy: MemoryPrincipalRef | null;
sensitivityLabel: MemorySensitivityLabel;
retentionPolicy: Record<string, unknown> | null;
expiresAt: Date | null;
retentionState: MemoryRetentionState;
citation: MemoryCitation | null;
supersedesRecordId: string | null;
supersededByRecordId: string | null;
revokedAt: Date | null;
revokedBy: MemoryPrincipalRef | null;
revocationReason: string | null;
title: string | null;
content: string;
summary: string | null;
@@ -125,6 +168,9 @@ export interface MemoryOperation {
recordCount: number;
requestJson: Record<string, unknown> | null;
resultJson: Record<string, unknown> | null;
policyDecision: Record<string, unknown> | null;
revocationSelector: Record<string, unknown> | null;
retentionAction: Record<string, unknown> | null;
usage: MemoryUsage[];
error: string | null;
costEventId: string | null;
@@ -186,6 +232,14 @@ export interface MemoryProviderCaptureInput {
binding: MemoryBinding;
scope: MemoryScope;
source: MemorySourceRef;
scopeType?: MemoryScopeType | null;
scopeId?: string | null;
owner?: MemoryPrincipalRef | null;
createdBy?: MemoryPrincipalRef | null;
sensitivityLabel?: MemorySensitivityLabel;
retentionPolicy?: Record<string, unknown> | null;
expiresAt?: string | Date | null;
citation?: MemoryCitation | null;
title?: string | null;
content: string;
summary?: string | null;
@@ -209,3 +263,32 @@ export interface MemoryProviderForgetOutput {
usage?: MemoryUsage[];
resultJson?: Record<string, unknown> | null;
}
export interface MemoryRevokeSelector {
recordIds?: string[];
source?: MemorySourceRef;
runId?: string;
issueId?: string;
agentId?: string;
workspaceId?: string;
projectId?: string;
teamId?: string;
scopeType?: MemoryScopeType;
scopeId?: string | null;
}
export interface MemoryRevokeResult {
operations: MemoryOperation[];
revokedRecordIds: string[];
}
export interface MemoryCorrectResult {
operation: MemoryOperation;
originalRecord: MemoryRecord;
correctedRecord: MemoryRecord;
}
export interface MemoryRetentionSweepResult {
operations: MemoryOperation[];
expiredRecordIds: string[];
}
+12
View File
@@ -235,6 +235,9 @@ export {
} from "./cost.js";
export {
memoryGovernedScopeSchema,
memoryPrincipalRefSchema,
memoryCitationSchema,
memoryScopeSchema,
memorySourceRefSchema,
memoryProviderCapabilitiesSchema,
@@ -245,10 +248,16 @@ export {
memoryQuerySchema,
memoryCaptureSchema,
memoryForgetSchema,
memoryRevokeSchema,
memoryCorrectSchema,
memoryRetentionSweepSchema,
memoryListRecordsQuerySchema,
memoryListOperationsQuerySchema,
memoryListExtractionJobsQuerySchema,
memoryBindingTargetTypeSchema,
type MemoryGovernedScopeInput,
type MemoryPrincipalRefInput,
type MemoryCitationInput,
type MemoryScopeInput,
type MemorySourceRefInput,
type CreateMemoryBinding,
@@ -258,6 +267,9 @@ export {
type MemoryQuery,
type MemoryCapture,
type MemoryForget,
type MemoryRevoke,
type MemoryCorrect,
type MemoryRetentionSweep,
type MemoryListRecordsQuery,
type MemoryListOperationsQuery,
type MemoryListExtractionJobsQuery,
+104
View File
@@ -4,16 +4,51 @@ import {
MEMORY_HOOK_KINDS,
MEMORY_OPERATION_STATUSES,
MEMORY_OPERATION_TYPES,
MEMORY_PRINCIPAL_TYPES,
MEMORY_RETENTION_STATES,
MEMORY_SCOPE_TYPES,
MEMORY_SENSITIVITY_LABELS,
MEMORY_SOURCE_KINDS,
} from "../constants.js";
export const memoryGovernedScopeSchema = z
.object({
type: z.enum(MEMORY_SCOPE_TYPES),
id: z.string().trim().min(1).max(200).nullable().optional(),
})
.strict();
export const memoryPrincipalRefSchema = z
.object({
type: z.enum(MEMORY_PRINCIPAL_TYPES),
id: z.string().trim().min(1).max(200),
})
.strict();
export const memoryCitationSchema = z
.object({
label: z.string().trim().max(200).nullable().optional(),
url: z.string().trim().max(1000).nullable().optional(),
excerpt: z.string().trim().max(2000).nullable().optional(),
sourceTitle: z.string().trim().max(300).nullable().optional(),
sourcePath: z.string().trim().max(1000).nullable().optional(),
metadata: z.record(z.unknown()).nullable().optional(),
})
.strict();
export const memoryScopeSchema = z
.object({
scopeType: z.enum(MEMORY_SCOPE_TYPES).nullable().optional(),
scopeId: z.string().trim().min(1).max(200).nullable().optional(),
agentId: z.string().uuid().nullable().optional(),
workspaceId: z.string().trim().min(1).max(200).nullable().optional(),
projectId: z.string().uuid().nullable().optional(),
issueId: z.string().uuid().nullable().optional(),
runId: z.string().uuid().nullable().optional(),
teamId: z.string().trim().min(1).max(200).nullable().optional(),
subjectId: z.string().trim().max(200).nullable().optional(),
allowedScopes: z.array(memoryGovernedScopeSchema).max(20).nullable().optional(),
maxSensitivityLabel: z.enum(MEMORY_SENSITIVITY_LABELS).nullable().optional(),
})
.strict();
@@ -85,6 +120,13 @@ export const memoryCaptureSchema = z
bindingKey: z.string().trim().min(1).max(64).optional(),
scope: memoryScopeSchema.optional().default({}),
source: memorySourceRefSchema,
scopeType: z.enum(MEMORY_SCOPE_TYPES).nullable().optional(),
scopeId: z.string().trim().min(1).max(200).nullable().optional(),
owner: memoryPrincipalRefSchema.nullable().optional(),
sensitivityLabel: z.enum(MEMORY_SENSITIVITY_LABELS).optional().default("internal"),
retentionPolicy: z.record(z.unknown()).nullable().optional(),
expiresAt: z.coerce.date().nullable().optional(),
citation: memoryCitationSchema.nullable().optional(),
title: z.string().trim().max(200).nullable().optional(),
content: z.string().trim().min(1).max(20000),
summary: z.string().trim().max(2000).nullable().optional(),
@@ -96,17 +138,73 @@ export const memoryForgetSchema = z
.object({
recordIds: z.array(z.string().uuid()).min(1).max(100),
scope: memoryScopeSchema.optional().default({}),
reason: z.string().trim().min(1).max(1000).optional(),
})
.strict();
export const memoryRevokeSchema = z
.object({
selector: z
.object({
recordIds: z.array(z.string().uuid()).min(1).max(500).optional(),
source: memorySourceRefSchema.optional(),
runId: z.string().uuid().optional(),
issueId: z.string().uuid().optional(),
agentId: z.string().uuid().optional(),
workspaceId: z.string().trim().min(1).max(200).optional(),
projectId: z.string().uuid().optional(),
teamId: z.string().trim().min(1).max(200).optional(),
scopeType: z.enum(MEMORY_SCOPE_TYPES).optional(),
scopeId: z.string().trim().min(1).max(200).nullable().optional(),
})
.strict(),
reason: z.string().trim().min(1).max(1000),
})
.strict()
.refine((value) => Object.keys(value.selector).length > 0, "At least one revocation selector is required");
export const memoryCorrectSchema = z
.object({
content: z.string().trim().min(1).max(20000),
summary: z.string().trim().max(2000).nullable().optional(),
title: z.string().trim().max(200).nullable().optional(),
reason: z.string().trim().min(1).max(1000),
sensitivityLabel: z.enum(MEMORY_SENSITIVITY_LABELS).optional(),
retentionPolicy: z.record(z.unknown()).nullable().optional(),
expiresAt: z.coerce.date().nullable().optional(),
citation: memoryCitationSchema.nullable().optional(),
})
.strict();
export const memoryRetentionSweepSchema = z
.object({
now: z.coerce.date().optional(),
bindingId: z.string().uuid().optional(),
limit: z.number().int().positive().max(1000).optional().default(500),
})
.strict();
export const memoryListRecordsQuerySchema = z
.object({
bindingId: z.string().uuid().optional(),
scopeType: z.enum(MEMORY_SCOPE_TYPES).optional(),
scopeId: z.string().trim().min(1).max(200).optional(),
ownerType: z.enum(MEMORY_PRINCIPAL_TYPES).optional(),
ownerId: z.string().trim().min(1).max(200).optional(),
sensitivityLabel: z.enum(MEMORY_SENSITIVITY_LABELS).optional(),
retentionState: z.enum(MEMORY_RETENTION_STATES).optional(),
expiresBefore: z.coerce.date().optional(),
agentId: z.string().uuid().optional(),
workspaceId: z.string().trim().min(1).max(200).optional(),
issueId: z.string().uuid().optional(),
projectId: z.string().uuid().optional(),
teamId: z.string().trim().min(1).max(200).optional(),
runId: z.string().uuid().optional(),
sourceKind: z.enum(MEMORY_SOURCE_KINDS).optional(),
includeDeleted: z.coerce.boolean().optional().default(false),
includeRevoked: z.coerce.boolean().optional().default(false),
includeExpired: z.coerce.boolean().optional().default(false),
includeSuperseded: z.coerce.boolean().optional().default(false),
limit: z.coerce.number().int().positive().max(200).optional().default(50),
})
.strict();
@@ -134,6 +232,9 @@ export const memoryListExtractionJobsQuerySchema = z
export const memoryBindingTargetTypeSchema = z.enum(MEMORY_BINDING_TARGET_TYPES);
export type MemoryScopeInput = z.infer<typeof memoryScopeSchema>;
export type MemoryGovernedScopeInput = z.infer<typeof memoryGovernedScopeSchema>;
export type MemoryPrincipalRefInput = z.infer<typeof memoryPrincipalRefSchema>;
export type MemoryCitationInput = z.infer<typeof memoryCitationSchema>;
export type MemorySourceRefInput = z.infer<typeof memorySourceRefSchema>;
export type CreateMemoryBinding = z.infer<typeof createMemoryBindingSchema>;
export type UpdateMemoryBinding = z.infer<typeof updateMemoryBindingSchema>;
@@ -142,6 +243,9 @@ export type SetAgentMemoryBinding = z.infer<typeof setAgentMemoryBindingSchema>;
export type MemoryQuery = z.infer<typeof memoryQuerySchema>;
export type MemoryCapture = z.infer<typeof memoryCaptureSchema>;
export type MemoryForget = z.infer<typeof memoryForgetSchema>;
export type MemoryRevoke = z.infer<typeof memoryRevokeSchema>;
export type MemoryCorrect = z.infer<typeof memoryCorrectSchema>;
export type MemoryRetentionSweep = z.infer<typeof memoryRetentionSweepSchema>;
export type MemoryListRecordsQuery = z.infer<typeof memoryListRecordsQuerySchema>;
export type MemoryListOperationsQuery = z.infer<typeof memoryListOperationsQuerySchema>;
export type MemoryListExtractionJobsQuery = z.infer<typeof memoryListExtractionJobsQuerySchema>;
@@ -21,6 +21,9 @@ const mockMemoryService = vi.hoisted(() => ({
query: vi.fn(),
capture: vi.fn(),
forget: vi.fn(),
revoke: vi.fn(),
correct: vi.fn(),
sweepRetention: vi.fn(),
listRecords: vi.fn(),
getRecord: vi.fn(),
listOperations: vi.fn(),
@@ -118,4 +121,85 @@ describe("memory routes", () => {
expect(mockMemoryService.updateBinding).toHaveBeenCalledWith(bindingId, { enabled: false });
expect(mockLogActivity).toHaveBeenCalledOnce();
});
it("blocks scoped revocation for agent callers", async () => {
const app = createApp({
type: "agent",
agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: companyA,
});
const res = await request(app)
.post(`/api/companies/${companyA}/memory/revoke`)
.send({
selector: { recordIds: ["44444444-4444-4444-8444-444444444444"] },
reason: "Stale memory",
});
expect(res.status).toBe(403);
expect(mockMemoryService.revoke).not.toHaveBeenCalled();
});
it("routes board scoped revocation through memory service and activity log", async () => {
mockMemoryService.revoke.mockResolvedValue({
operations: [],
revokedRecordIds: ["44444444-4444-4444-8444-444444444444"],
});
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
companyIds: [companyA],
isInstanceAdmin: false,
});
const res = await request(app)
.post(`/api/companies/${companyA}/memory/revoke`)
.set("Origin", "http://localhost:3100")
.send({
selector: { issueId: "55555555-5555-4555-8555-555555555555" },
reason: "Issue memory should be revoked",
});
expect(res.status).toBe(200);
expect(mockMemoryService.revoke).toHaveBeenCalledWith(
companyA,
{
selector: { issueId: "55555555-5555-4555-8555-555555555555" },
reason: "Issue memory should be revoked",
},
expect.objectContaining({ actorType: "user", userId: "board-user" }),
);
expect(mockLogActivity).toHaveBeenCalledOnce();
});
it("routes board correction through memory service", async () => {
const recordId = "44444444-4444-4444-8444-444444444444";
mockMemoryService.correct.mockResolvedValue({
operation: { id: "op-1" },
originalRecord: { id: recordId },
correctedRecord: { id: "66666666-6666-4666-8666-666666666666" },
});
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
companyIds: [companyA],
isInstanceAdmin: false,
});
const res = await request(app)
.post(`/api/companies/${companyA}/memory/records/${recordId}/correct`)
.set("Origin", "http://localhost:3100")
.send({ content: "Corrected memory", reason: "User corrected stale fact" });
expect(res.status).toBe(201);
expect(mockMemoryService.correct).toHaveBeenCalledWith(
companyA,
recordId,
{ content: "Corrected memory", reason: "User corrected stale fact" },
expect.objectContaining({ actorType: "user", userId: "board-user" }),
);
expect(mockLogActivity).toHaveBeenCalledOnce();
});
});
+72 -2
View File
@@ -2,11 +2,14 @@ import { Router } from "express";
import type { Db } from "@paperclipai/db";
import {
memoryCaptureSchema,
memoryCorrectSchema,
memoryForgetSchema,
memoryListExtractionJobsQuerySchema,
memoryListOperationsQuerySchema,
memoryListRecordsQuerySchema,
memoryQuerySchema,
memoryRetentionSweepSchema,
memoryRevokeSchema,
setAgentMemoryBindingSchema,
setCompanyMemoryBindingSchema,
updateMemoryBindingSchema,
@@ -230,17 +233,84 @@ export function memoryRoutes(
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const parsed = memoryListRecordsQuerySchema.parse(req.query);
res.json(await memory.listRecords(companyId, parsed));
res.json(await memory.listRecords(companyId, parsed, actorInfoFromReq(req)));
});
router.get("/companies/:companyId/memory/records/:recordId", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const record = await memory.getRecord(companyId, req.params.recordId as string);
const record = await memory.getRecord(companyId, req.params.recordId as string, actorInfoFromReq(req));
if (!record) throw notFound("Memory record not found");
res.json(record);
});
router.post("/companies/:companyId/memory/revoke", validate(memoryRevokeSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const result = await memory.revoke(companyId, req.body, actorInfoFromReq(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "memory.revoked",
entityType: "memory_record",
entityId: result.revokedRecordIds[0] ?? "none",
details: {
revokedRecordIds: result.revokedRecordIds,
selector: req.body.selector,
reason: req.body.reason,
},
});
res.json(result);
});
router.post("/companies/:companyId/memory/records/:recordId/correct", validate(memoryCorrectSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const result = await memory.correct(companyId, req.params.recordId as string, req.body, actorInfoFromReq(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "memory.corrected",
entityType: "memory_record",
entityId: result.correctedRecord.id,
details: {
originalRecordId: result.originalRecord.id,
correctedRecordId: result.correctedRecord.id,
reason: req.body.reason,
},
});
res.status(201).json(result);
});
router.post("/companies/:companyId/memory/retention/sweep", validate(memoryRetentionSweepSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const result = await memory.sweepRetention(companyId, req.body, actorInfoFromReq(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "memory.retention_swept",
entityType: "memory_record",
entityId: result.expiredRecordIds[0] ?? "none",
details: {
expiredRecordIds: result.expiredRecordIds,
},
});
res.json(result);
});
router.get("/companies/:companyId/memory/operations", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
+657 -23
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, desc, eq, inArray, isNull, isNotNull, lte, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
@@ -14,23 +14,34 @@ import type {
MemoryBindingTarget,
MemoryCapture,
MemoryCaptureResult,
MemoryCitation,
MemoryCorrect,
MemoryCorrectResult,
MemoryExtractionJob,
MemoryForget,
MemoryForgetResult,
MemoryGovernedScope,
MemoryListExtractionJobsQuery,
MemoryListOperationsQuery,
MemoryListRecordsQuery,
MemoryOperation,
MemoryPrincipalRef,
MemoryProviderDescriptor,
MemoryQuery,
MemoryQueryResult,
MemoryRecord,
MemoryRetentionSweep,
MemoryRetentionSweepResult,
MemoryRevoke,
MemoryRevokeResult,
MemoryResolvedBinding,
MemoryScope,
MemoryScopeType,
MemorySensitivityLabel,
MemorySourceRef,
MemoryUsage,
} from "@paperclipai/shared";
import { createMemoryBindingSchema, updateMemoryBindingSchema } from "@paperclipai/shared";
import { createMemoryBindingSchema, memoryCorrectSchema, memoryRetentionSweepSchema, memoryRevokeSchema, updateMemoryBindingSchema } from "@paperclipai/shared";
import { z } from "zod";
import { conflict, notFound, unprocessable } from "../errors.js";
import { costService } from "./costs.js";
@@ -93,10 +104,119 @@ const LOCAL_BASIC_PROVIDER: MemoryProviderDescriptor = {
},
};
const SENSITIVITY_RANK: Record<MemorySensitivityLabel, number> = {
public: 0,
internal: 1,
confidential: 2,
restricted: 3,
};
const DEFAULT_AGENT_MAX_SENSITIVITY: MemorySensitivityLabel = "confidential";
function parseLocalBasicConfig(config: Record<string, unknown> | null | undefined): LocalBasicConfig {
return localBasicConfigSchema.parse(config ?? {});
}
function normalizeScopeType(scope: MemoryScope, fallback: MemoryScopeType = "org"): MemoryScopeType {
if (scope.scopeType) return scope.scopeType;
if (scope.runId) return "run";
if (scope.agentId) return "agent";
if (scope.workspaceId) return "workspace";
if (scope.projectId) return "project";
if (scope.teamId) return "team";
return fallback;
}
function normalizeScopeId(companyId: string, scopeType: MemoryScopeType, scope: MemoryScope) {
if (scope.scopeId) return scope.scopeId;
switch (scopeType) {
case "run":
return scope.runId ?? null;
case "agent":
return scope.agentId ?? null;
case "workspace":
return scope.workspaceId ?? null;
case "project":
return scope.projectId ?? null;
case "team":
return scope.teamId ?? null;
case "org":
return companyId;
}
}
function actorPrincipal(actor: ActorInfo): MemoryPrincipalRef {
if (actor.actorType === "agent" && actor.agentId) return { type: "agent", id: actor.agentId };
if (actor.actorType === "user" && actor.userId) return { type: "user", id: actor.userId };
return { type: "system", id: actor.actorId || "system" };
}
function normalizePrincipal(input: MemoryPrincipalRef | null | undefined, fallback: MemoryPrincipalRef): MemoryPrincipalRef {
if (input?.type && input.id) return input;
return fallback;
}
function normalizeCitation(input: MemoryCitation | null | undefined): Record<string, unknown> | null {
if (!input) return null;
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)) as Record<string, unknown>;
}
function citationFromRow(value: unknown): MemoryCitation | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as MemoryCitation;
}
function maxSensitivityForActor(actor: ActorInfo, scope: MemoryScope): MemorySensitivityLabel {
if (actor.actorType === "user" || actor.actorType === "system") return scope.maxSensitivityLabel ?? "restricted";
return scope.maxSensitivityLabel ?? DEFAULT_AGENT_MAX_SENSITIVITY;
}
function allowedSensitivityLabels(max: MemorySensitivityLabel): MemorySensitivityLabel[] {
const rank = SENSITIVITY_RANK[max];
return (Object.keys(SENSITIVITY_RANK) as MemorySensitivityLabel[]).filter(
(label) => SENSITIVITY_RANK[label] <= rank,
);
}
function scopeMatches(record: MemoryRecord, allowedScopes: MemoryGovernedScope[]) {
return allowedScopes.some((scope) => {
if (scope.type !== record.scopeType) return false;
if (!scope.id) return !record.scopeId;
return record.scopeId === scope.id;
});
}
function canReadRecord(companyId: string, record: MemoryRecord, actor: ActorInfo, scope: MemoryScope = {}) {
if (record.deletedAt || record.revokedAt || record.retentionState !== "active") return false;
if (record.supersededByRecordId) return false;
if (record.expiresAt && record.expiresAt <= new Date()) return false;
const maxSensitivity = maxSensitivityForActor(actor, scope);
if (SENSITIVITY_RANK[record.sensitivityLabel] > SENSITIVITY_RANK[maxSensitivity]) return false;
if (actor.actorType !== "agent") return true;
return scopeMatches(record, deriveAllowedScopes(companyId, scope, actor));
}
function deriveAllowedScopes(companyId: string, scope: MemoryScope, actor: ActorInfo): MemoryGovernedScope[] {
const scopes: MemoryGovernedScope[] = [{ type: "org", id: companyId }];
if (scope.allowedScopes?.length) {
scopes.push(...scope.allowedScopes);
}
if (scope.agentId) scopes.push({ type: "agent", id: scope.agentId });
if (scope.runId) scopes.push({ type: "run", id: scope.runId });
if (scope.workspaceId) scopes.push({ type: "workspace", id: scope.workspaceId });
if (scope.projectId) scopes.push({ type: "project", id: scope.projectId });
if (scope.teamId) scopes.push({ type: "team", id: scope.teamId });
if (actor.actorType === "agent" && actor.agentId) scopes.push({ type: "agent", id: actor.agentId });
const seen = new Set<string>();
return scopes.filter((entry) => {
const key = `${entry.type}:${entry.id ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function buildMemoryPreamble(records: MemoryRecord[]) {
if (records.length === 0) return null;
return [
@@ -110,18 +230,28 @@ function buildMemoryPreamble(records: MemoryRecord[]) {
}
function scopeFromRow(row: {
scopeType?: MemoryScopeType | null;
scopeId?: string | null;
scopeAgentId: string | null;
scopeWorkspaceId?: string | null;
scopeProjectId: string | null;
scopeIssueId: string | null;
scopeRunId: string | null;
scopeTeamId?: string | null;
scopeSubjectId: string | null;
maxSensitivityLabel?: MemorySensitivityLabel | null;
}): MemoryScope {
return {
scopeType: row.scopeType ?? null,
scopeId: row.scopeId ?? null,
agentId: row.scopeAgentId,
workspaceId: row.scopeWorkspaceId ?? null,
projectId: row.scopeProjectId,
issueId: row.scopeIssueId,
runId: row.scopeRunId,
teamId: row.scopeTeamId ?? null,
subjectId: row.scopeSubjectId,
maxSensitivityLabel: row.maxSensitivityLabel ?? null,
};
}
@@ -217,6 +347,18 @@ function mapOperation(row: OperationRow): MemoryOperation {
typeof row.resultJson === "object" && row.resultJson !== null && !Array.isArray(row.resultJson)
? (row.resultJson as Record<string, unknown>)
: null,
policyDecision:
typeof row.policyDecisionJson === "object" && row.policyDecisionJson !== null && !Array.isArray(row.policyDecisionJson)
? (row.policyDecisionJson as Record<string, unknown>)
: null,
revocationSelector:
typeof row.revocationSelectorJson === "object" && row.revocationSelectorJson !== null && !Array.isArray(row.revocationSelectorJson)
? (row.revocationSelectorJson as Record<string, unknown>)
: null,
retentionAction:
typeof row.retentionActionJson === "object" && row.retentionActionJson !== null && !Array.isArray(row.retentionActionJson)
? (row.retentionActionJson as Record<string, unknown>)
: null,
usage: normalizeUsage(row.usageJson),
error: row.error ?? null,
costEventId: row.costEventId ?? null,
@@ -227,13 +369,33 @@ function mapOperation(row: OperationRow): MemoryOperation {
}
function mapRecord(row: RecordRow): MemoryRecord {
const scope = scopeFromRow(row);
const scopeType = row.scopeType ?? normalizeScopeType(scope);
return {
id: row.id,
companyId: row.companyId,
bindingId: row.bindingId,
providerKey: row.providerKey,
scope: scopeFromRow(row),
scope,
source: sourceFromRow(row),
scopeType,
scopeId: row.scopeId ?? normalizeScopeId(row.companyId, scopeType, scope),
owner: row.ownerType && row.ownerId ? { type: row.ownerType, id: row.ownerId } : null,
createdBy: row.createdByActorType && row.createdByActorId ? { type: row.createdByActorType, id: row.createdByActorId } : null,
sensitivityLabel: row.sensitivityLabel ?? "internal",
retentionPolicy:
typeof row.retentionPolicy === "object" && row.retentionPolicy !== null && !Array.isArray(row.retentionPolicy)
? (row.retentionPolicy as Record<string, unknown>)
: null,
expiresAt: row.expiresAt ?? null,
retentionState: row.retentionState ?? "active",
citation: citationFromRow(row.citationJson),
supersedesRecordId: row.supersedesRecordId ?? null,
supersededByRecordId: row.supersededByRecordId ?? null,
revokedAt: row.revokedAt ?? null,
revokedBy:
row.revokedByActorType && row.revokedByActorId ? { type: row.revokedByActorType, id: row.revokedByActorId } : null,
revocationReason: row.revocationReason ?? null,
title: row.title ?? null,
content: row.content,
summary: row.summary ?? null,
@@ -267,27 +429,49 @@ function mapExtractionJob(row: ExtractionJobRow): MemoryExtractionJob {
};
}
function buildScopeConditions(companyId: string, bindingId: string, scope: MemoryScope) {
function buildRecordVisibilityConditions(
companyId: string,
bindingId: string,
scope: MemoryScope,
actor: ActorInfo,
options?: {
includeRevoked?: boolean;
includeExpired?: boolean;
includeSuperseded?: boolean;
includeDeleted?: boolean;
},
) {
const conditions = [
eq(memoryLocalRecords.companyId, companyId),
eq(memoryLocalRecords.bindingId, bindingId),
isNull(memoryLocalRecords.deletedAt),
];
if (scope.agentId) {
conditions.push(or(isNull(memoryLocalRecords.scopeAgentId), eq(memoryLocalRecords.scopeAgentId, scope.agentId))!);
if (!options?.includeDeleted) conditions.push(isNull(memoryLocalRecords.deletedAt));
if (!options?.includeRevoked) {
conditions.push(isNull(memoryLocalRecords.revokedAt));
}
if (scope.projectId) {
conditions.push(or(isNull(memoryLocalRecords.scopeProjectId), eq(memoryLocalRecords.scopeProjectId, scope.projectId))!);
if (!options?.includeExpired) {
conditions.push(or(isNull(memoryLocalRecords.expiresAt), sql`${memoryLocalRecords.expiresAt} > now()`)!);
}
if (scope.issueId) {
conditions.push(or(isNull(memoryLocalRecords.scopeIssueId), eq(memoryLocalRecords.scopeIssueId, scope.issueId))!);
if (!options?.includeSuperseded) {
conditions.push(isNull(memoryLocalRecords.supersededByRecordId));
}
if (scope.runId) {
conditions.push(or(isNull(memoryLocalRecords.scopeRunId), eq(memoryLocalRecords.scopeRunId, scope.runId))!);
}
if (scope.subjectId) {
conditions.push(or(isNull(memoryLocalRecords.scopeSubjectId), eq(memoryLocalRecords.scopeSubjectId, scope.subjectId))!);
const maxSensitivity = maxSensitivityForActor(actor, scope);
conditions.push(inArray(memoryLocalRecords.sensitivityLabel, allowedSensitivityLabels(maxSensitivity)));
const allowedScopes = deriveAllowedScopes(companyId, scope, actor);
if (actor.actorType === "agent" || scope.allowedScopes?.length) {
const scopeConditions = allowedScopes.map((allowedScope) =>
and(
eq(memoryLocalRecords.scopeType, allowedScope.type),
allowedScope.id ? eq(memoryLocalRecords.scopeId, allowedScope.id) : isNull(memoryLocalRecords.scopeId),
),
);
if (scopeConditions.length > 0) {
conditions.push(or(...scopeConditions)!);
}
}
return conditions;
}
@@ -437,6 +621,7 @@ export function memoryService(
scope: MemoryScope,
query: string,
topK: number,
actor: ActorInfo,
) {
const rankExpr = sql<number>`
ts_rank_cd(
@@ -449,7 +634,7 @@ export function memoryService(
.from(memoryLocalRecords)
.where(
and(
...buildScopeConditions(binding.companyId, binding.id, scope),
...buildRecordVisibilityConditions(binding.companyId, binding.id, scope, actor),
sql`to_tsvector('english', coalesce(${memoryLocalRecords.title}, '') || ' ' || ${memoryLocalRecords.content}) @@ websearch_to_tsquery('english', ${query})`,
),
)
@@ -464,6 +649,14 @@ export function memoryService(
scope: MemoryScope,
source: MemorySourceRef,
input: {
actor: ActorInfo;
scopeType?: MemoryScopeType | null;
scopeId?: string | null;
owner?: MemoryPrincipalRef | null;
sensitivityLabel?: MemorySensitivityLabel;
retentionPolicy?: Record<string, unknown> | null;
expiresAt?: Date | string | null;
citation?: MemoryCitation | null;
title?: string | null;
content: string;
summary?: string | null;
@@ -471,6 +664,10 @@ export function memoryService(
},
operationId: string,
) {
const scopeType = input.scopeType ?? normalizeScopeType(scope);
const scopeId = input.scopeId ?? normalizeScopeId(binding.companyId, scopeType, scope);
const createdBy = actorPrincipal(input.actor);
const owner = normalizePrincipal(input.owner, createdBy);
const [row] = await db
.insert(memoryLocalRecords)
.values({
@@ -483,6 +680,10 @@ export function memoryService(
scopeIssueId: scope.issueId ?? null,
scopeRunId: scope.runId ?? null,
scopeSubjectId: scope.subjectId ?? null,
scopeType,
scopeId,
scopeWorkspaceId: scope.workspaceId ?? null,
scopeTeamId: scope.teamId ?? null,
sourceKind: source.kind,
sourceIssueId: source.issueId ?? null,
sourceCommentId: source.commentId ?? null,
@@ -494,6 +695,15 @@ export function memoryService(
content: input.content,
summary: input.summary ?? null,
metadata: input.metadata ?? {},
ownerType: owner.type,
ownerId: owner.id,
createdByActorType: createdBy.type,
createdByActorId: createdBy.id,
sensitivityLabel: input.sensitivityLabel ?? "internal",
retentionPolicy: input.retentionPolicy ?? null,
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
retentionState: "active",
citationJson: normalizeCitation(input.citation),
createdByOperationId: operationId,
})
.returning();
@@ -519,6 +729,10 @@ export function memoryService(
scopeIssueId: record.scope.issueId ?? null,
scopeRunId: record.scope.runId ?? null,
scopeSubjectId: record.scope.subjectId ?? null,
scopeType: record.scopeType,
scopeId: record.scopeId,
scopeWorkspaceId: record.scope.workspaceId ?? null,
scopeTeamId: record.scope.teamId ?? null,
sourceKind: record.source?.kind ?? null,
sourceIssueId: record.source?.issueId ?? null,
sourceCommentId: record.source?.commentId ?? null,
@@ -530,6 +744,21 @@ export function memoryService(
content: record.content,
summary: record.summary ?? null,
metadata: record.metadata ?? {},
ownerType: record.owner?.type ?? null,
ownerId: record.owner?.id ?? null,
createdByActorType: record.createdBy?.type ?? null,
createdByActorId: record.createdBy?.id ?? null,
sensitivityLabel: record.sensitivityLabel,
retentionPolicy: record.retentionPolicy,
expiresAt: record.expiresAt,
retentionState: record.retentionState,
citationJson: normalizeCitation(record.citation),
supersedesRecordId: record.supersedesRecordId ?? null,
supersededByRecordId: record.supersededByRecordId ?? null,
revokedAt: record.revokedAt ?? null,
revokedByActorType: record.revokedBy?.type ?? null,
revokedByActorId: record.revokedBy?.id ?? null,
revocationReason: record.revocationReason ?? null,
createdByOperationId: operationId,
deletedAt: record.deletedAt ?? null,
createdAt: record.createdAt,
@@ -545,6 +774,10 @@ export function memoryService(
scopeIssueId: record.scope.issueId ?? null,
scopeRunId: record.scope.runId ?? null,
scopeSubjectId: record.scope.subjectId ?? null,
scopeType: record.scopeType,
scopeId: record.scopeId,
scopeWorkspaceId: record.scope.workspaceId ?? null,
scopeTeamId: record.scope.teamId ?? null,
sourceKind: record.source?.kind ?? null,
sourceIssueId: record.source?.issueId ?? null,
sourceCommentId: record.source?.commentId ?? null,
@@ -556,6 +789,21 @@ export function memoryService(
content: record.content,
summary: record.summary ?? null,
metadata: record.metadata ?? {},
ownerType: record.owner?.type ?? null,
ownerId: record.owner?.id ?? null,
createdByActorType: record.createdBy?.type ?? null,
createdByActorId: record.createdBy?.id ?? null,
sensitivityLabel: record.sensitivityLabel,
retentionPolicy: record.retentionPolicy,
expiresAt: record.expiresAt,
retentionState: record.retentionState,
citationJson: normalizeCitation(record.citation),
supersedesRecordId: record.supersedesRecordId ?? null,
supersededByRecordId: record.supersededByRecordId ?? null,
revokedAt: record.revokedAt ?? null,
revokedByActorType: record.revokedBy?.type ?? null,
revokedByActorId: record.revokedBy?.id ?? null,
revocationReason: record.revocationReason ?? null,
createdByOperationId: operationId,
deletedAt: record.deletedAt ?? null,
updatedAt: record.updatedAt,
@@ -564,14 +812,49 @@ export function memoryService(
}
}
async function listLocalBasic(companyId: string, filters: MemoryListRecordsQuery) {
async function listLocalBasic(companyId: string, filters: MemoryListRecordsQuery, actor: ActorInfo) {
const conditions = [eq(memoryLocalRecords.companyId, companyId)];
if (filters.bindingId) conditions.push(eq(memoryLocalRecords.bindingId, filters.bindingId));
if (filters.scopeType) conditions.push(eq(memoryLocalRecords.scopeType, filters.scopeType));
if (filters.scopeId) conditions.push(eq(memoryLocalRecords.scopeId, filters.scopeId));
if (filters.ownerType) conditions.push(eq(memoryLocalRecords.ownerType, filters.ownerType));
if (filters.ownerId) conditions.push(eq(memoryLocalRecords.ownerId, filters.ownerId));
if (filters.sensitivityLabel) conditions.push(eq(memoryLocalRecords.sensitivityLabel, filters.sensitivityLabel));
if (filters.retentionState) conditions.push(eq(memoryLocalRecords.retentionState, filters.retentionState));
if (filters.expiresBefore) conditions.push(lte(memoryLocalRecords.expiresAt, filters.expiresBefore));
if (filters.agentId) conditions.push(eq(memoryLocalRecords.scopeAgentId, filters.agentId));
if (filters.workspaceId) conditions.push(eq(memoryLocalRecords.scopeWorkspaceId, filters.workspaceId));
if (filters.issueId) conditions.push(eq(memoryLocalRecords.scopeIssueId, filters.issueId));
if (filters.projectId) conditions.push(eq(memoryLocalRecords.scopeProjectId, filters.projectId));
if (filters.teamId) conditions.push(eq(memoryLocalRecords.scopeTeamId, filters.teamId));
if (filters.runId) conditions.push(eq(memoryLocalRecords.scopeRunId, filters.runId));
if (filters.sourceKind) conditions.push(eq(memoryLocalRecords.sourceKind, filters.sourceKind));
if (!filters.includeDeleted) conditions.push(isNull(memoryLocalRecords.deletedAt));
if (!filters.includeRevoked) conditions.push(isNull(memoryLocalRecords.revokedAt));
if (!filters.includeExpired) {
conditions.push(or(isNull(memoryLocalRecords.expiresAt), sql`${memoryLocalRecords.expiresAt} > now()`)!);
conditions.push(or(isNull(memoryLocalRecords.retentionState), eq(memoryLocalRecords.retentionState, "active"))!);
}
if (!filters.includeSuperseded) conditions.push(isNull(memoryLocalRecords.supersededByRecordId));
if (actor.actorType === "agent") {
const scope: MemoryScope = {
agentId: filters.agentId ?? actor.agentId,
workspaceId: filters.workspaceId ?? null,
projectId: filters.projectId ?? null,
issueId: filters.issueId ?? null,
runId: filters.runId ?? null,
teamId: filters.teamId ?? null,
};
conditions.push(
...buildRecordVisibilityConditions(companyId, filters.bindingId ?? "", scope, actor, {
includeDeleted: filters.includeDeleted,
includeExpired: filters.includeExpired,
includeRevoked: filters.includeRevoked,
includeSuperseded: filters.includeSuperseded,
}).filter((condition, index) => index > 1),
);
}
const rows = await db
.select()
@@ -595,6 +878,9 @@ export function memoryService(
queryText?: string | null;
requestJson?: Record<string, unknown> | null;
resultJson?: Record<string, unknown> | null;
policyDecision?: Record<string, unknown> | null;
revocationSelector?: Record<string, unknown> | null;
retentionAction?: Record<string, unknown> | null;
recordCount?: number;
usage?: MemoryUsage[];
error?: string | null;
@@ -626,6 +912,11 @@ export function memoryService(
scopeIssueId: input.scope.issueId ?? null,
scopeRunId: input.scope.runId ?? input.actor.runId ?? null,
scopeSubjectId: input.scope.subjectId ?? null,
scopeType: input.scope.scopeType ?? normalizeScopeType(input.scope),
scopeId: input.scope.scopeId ?? normalizeScopeId(input.companyId, input.scope.scopeType ?? normalizeScopeType(input.scope), input.scope),
scopeWorkspaceId: input.scope.workspaceId ?? null,
scopeTeamId: input.scope.teamId ?? null,
maxSensitivityLabel: input.scope.maxSensitivityLabel ?? null,
sourceKind: input.source?.kind ?? null,
sourceIssueId: input.source?.issueId ?? null,
sourceCommentId: input.source?.commentId ?? null,
@@ -637,6 +928,9 @@ export function memoryService(
recordCount: input.recordCount ?? 0,
requestJson: input.requestJson ?? null,
resultJson: input.resultJson ?? null,
policyDecisionJson: input.policyDecision ?? null,
revocationSelectorJson: input.revocationSelector ?? null,
retentionActionJson: input.retentionAction ?? null,
usageJson: (input.usage ?? []) as unknown as Array<Record<string, unknown>>,
error: input.error ?? null,
costEventId,
@@ -851,6 +1145,7 @@ export function memoryService(
data.scope ?? {},
data.query,
Math.min(data.topK ?? config.maxHydrateSnippets, 25),
actor,
);
} else {
if (!pluginMemoryProviders) {
@@ -869,6 +1164,20 @@ export function memoryService(
usage = providerResult.usage ?? [];
providerResultJson = providerResult.resultJson ?? null;
}
const allowedScopes = deriveAllowedScopes(companyId, data.scope ?? {}, actor);
const maxSensitivityLabel = maxSensitivityForActor(actor, data.scope ?? {});
if (actor.actorType === "agent" || data.scope?.allowedScopes?.length) {
records = records.filter((record) => scopeMatches(record, allowedScopes));
}
records = records.filter(
(record) =>
!record.deletedAt
&& !record.revokedAt
&& record.retentionState === "active"
&& !record.supersededByRecordId
&& (!record.expiresAt || record.expiresAt > new Date())
&& SENSITIVITY_RANK[record.sensitivityLabel] <= SENSITIVITY_RANK[maxSensitivityLabel],
);
if (data.intent === "agent_preamble" && !preamble) {
preamble = buildMemoryPreamble(records);
@@ -892,6 +1201,11 @@ export function memoryService(
recordIds: records.map((record) => record.id),
providerResult: providerResultJson,
},
policyDecision: {
allowedScopes,
maxSensitivityLabel,
filteredRecordCount: records.length,
},
recordCount: records.length,
usage,
});
@@ -912,6 +1226,14 @@ export function memoryService(
data.scope ?? {},
data.source,
{
actor,
scopeType: data.scopeType ?? data.scope?.scopeType ?? null,
scopeId: data.scopeId ?? data.scope?.scopeId ?? null,
owner: data.owner ?? null,
sensitivityLabel: data.sensitivityLabel,
retentionPolicy: data.retentionPolicy ?? null,
expiresAt: data.expiresAt ?? null,
citation: data.citation ?? null,
title: data.title ?? null,
content: data.content,
summary: data.summary ?? null,
@@ -927,6 +1249,14 @@ export function memoryService(
binding: mapBinding(binding),
scope: data.scope ?? {},
source: data.source,
scopeType: data.scopeType ?? data.scope?.scopeType ?? null,
scopeId: data.scopeId ?? data.scope?.scopeId ?? null,
owner: data.owner ?? actorPrincipal(actor),
createdBy: actorPrincipal(actor),
sensitivityLabel: data.sensitivityLabel,
retentionPolicy: data.retentionPolicy ?? null,
expiresAt: data.expiresAt ?? null,
citation: data.citation ?? null,
title: data.title ?? null,
content: data.content,
summary: data.summary ?? null,
@@ -949,8 +1279,17 @@ export function memoryService(
source: data.source,
requestJson: {
title: data.title ?? null,
scopeType: data.scopeType ?? data.scope?.scopeType ?? null,
scopeId: data.scopeId ?? data.scope?.scopeId ?? null,
owner: data.owner ?? null,
sensitivityLabel: data.sensitivityLabel,
expiresAt: data.expiresAt ? new Date(data.expiresAt).toISOString() : null,
metadata: data.metadata ?? {},
},
policyDecision: {
captureAllowed: true,
sensitivityLabel: data.sensitivityLabel,
},
resultJson: {
recordIds: records.map((record) => record.id),
providerResult: providerResultJson,
@@ -991,7 +1330,15 @@ export function memoryService(
}
await db
.update(memoryLocalRecords)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.set({
deletedAt: new Date(),
revokedAt: new Date(),
revokedByActorType: actorPrincipal(actor).type,
revokedByActorId: actorPrincipal(actor).id,
revocationReason: data.reason ?? "Record forgotten",
retentionState: "revoked",
updatedAt: new Date(),
})
.where(and(eq(memoryLocalRecords.companyId, companyId), inArray(memoryLocalRecords.id, recordIds)));
const operation = await logOperation({
companyId,
@@ -1000,7 +1347,8 @@ export function memoryService(
operationType: "forget",
triggerKind,
scope: data.scope ?? {},
requestJson: { recordIds },
requestJson: { recordIds, reason: data.reason ?? null },
revocationSelector: { recordIds },
resultJson: { forgottenRecordIds: recordIds, providerResult: providerResultJson },
recordCount: recordIds.length,
usage,
@@ -1008,15 +1356,272 @@ export function memoryService(
return { operation, forgottenRecordIds: recordIds } satisfies MemoryForgetResult;
},
listRecords: async (companyId: string, filters: MemoryListRecordsQuery) => listLocalBasic(companyId, filters),
revoke: async (companyId: string, data: MemoryRevoke, actor: ActorInfo): Promise<MemoryRevokeResult> => {
const parsed = memoryRevokeSchema.parse(data);
const conditions = [eq(memoryLocalRecords.companyId, companyId), isNull(memoryLocalRecords.revokedAt)];
const selector = parsed.selector;
if (selector.recordIds?.length) conditions.push(inArray(memoryLocalRecords.id, selector.recordIds));
if (selector.scopeType) conditions.push(eq(memoryLocalRecords.scopeType, selector.scopeType));
if (selector.scopeId !== undefined) {
conditions.push(selector.scopeId === null ? isNull(memoryLocalRecords.scopeId) : eq(memoryLocalRecords.scopeId, selector.scopeId));
}
if (selector.agentId) conditions.push(eq(memoryLocalRecords.scopeAgentId, selector.agentId));
if (selector.workspaceId) conditions.push(eq(memoryLocalRecords.scopeWorkspaceId, selector.workspaceId));
if (selector.projectId) conditions.push(eq(memoryLocalRecords.scopeProjectId, selector.projectId));
if (selector.teamId) conditions.push(eq(memoryLocalRecords.scopeTeamId, selector.teamId));
if (selector.issueId) {
conditions.push(or(eq(memoryLocalRecords.scopeIssueId, selector.issueId), eq(memoryLocalRecords.sourceIssueId, selector.issueId))!);
}
if (selector.runId) {
conditions.push(or(eq(memoryLocalRecords.scopeRunId, selector.runId), eq(memoryLocalRecords.sourceRunId, selector.runId))!);
}
if (selector.source) {
conditions.push(eq(memoryLocalRecords.sourceKind, selector.source.kind));
if (selector.source.issueId) conditions.push(eq(memoryLocalRecords.sourceIssueId, selector.source.issueId));
if (selector.source.commentId) conditions.push(eq(memoryLocalRecords.sourceCommentId, selector.source.commentId));
if (selector.source.documentKey) conditions.push(eq(memoryLocalRecords.sourceDocumentKey, selector.source.documentKey));
if (selector.source.runId) conditions.push(eq(memoryLocalRecords.sourceRunId, selector.source.runId));
if (selector.source.activityId) conditions.push(eq(memoryLocalRecords.sourceActivityId, selector.source.activityId));
if (selector.source.externalRef) conditions.push(eq(memoryLocalRecords.sourceExternalRef, selector.source.externalRef));
}
getRecord: async (companyId: string, recordId: string) => {
const rows = await db.select().from(memoryLocalRecords).where(and(...conditions));
if (rows.length === 0) throw notFound("No memory records matched revocation selector");
const revokedAt = new Date();
const principal = actorPrincipal(actor);
const revokedRecordIds = rows.map((row) => row.id);
const operations: MemoryOperation[] = [];
const rowsByBinding = new Map<string, RecordRow[]>();
for (const row of rows) {
rowsByBinding.set(row.bindingId, [...(rowsByBinding.get(row.bindingId) ?? []), row]);
}
for (const [bindingId, bindingRows] of rowsByBinding) {
const binding = await getBindingOrThrow(bindingId);
let usage: MemoryUsage[] = [];
let providerResultJson: Record<string, unknown> | null = null;
const bindingRecordIds = bindingRows.map((row) => row.id);
if (isPluginProvider(binding.providerKey)) {
if (!pluginMemoryProviders) {
throw unprocessable(`Unknown memory provider: ${binding.providerKey}`);
}
const providerResult = await pluginMemoryProviders.forget(binding.providerKey, {
binding: mapBinding(binding),
scope: {},
recordIds: bindingRecordIds,
});
usage = providerResult.usage ?? [];
providerResultJson = providerResult.resultJson ?? null;
}
await db
.update(memoryLocalRecords)
.set({
revokedAt,
revokedByActorType: principal.type,
revokedByActorId: principal.id,
revocationReason: parsed.reason,
retentionState: "revoked",
updatedAt: revokedAt,
})
.where(and(eq(memoryLocalRecords.companyId, companyId), inArray(memoryLocalRecords.id, bindingRecordIds)));
operations.push(
await logOperation({
companyId,
binding,
actor,
operationType: "revoke",
triggerKind: "manual",
scope: {},
requestJson: { reason: parsed.reason },
revocationSelector: selector,
resultJson: { revokedRecordIds: bindingRecordIds, providerResult: providerResultJson },
recordCount: bindingRecordIds.length,
usage,
}),
);
}
return { operations, revokedRecordIds } satisfies MemoryRevokeResult;
},
correct: async (
companyId: string,
recordId: string,
data: MemoryCorrect,
actor: ActorInfo,
): Promise<MemoryCorrectResult> => {
const parsed = memoryCorrectSchema.parse(data);
const originalRow = await db
.select()
.from(memoryLocalRecords)
.where(and(eq(memoryLocalRecords.companyId, companyId), eq(memoryLocalRecords.id, recordId)))
.then((rows) => rows[0] ?? null);
if (!originalRow) throw notFound("Memory record not found");
if (originalRow.revokedAt || originalRow.retentionState === "revoked") {
throw conflict("Revoked memory records cannot be corrected");
}
if (originalRow.supersededByRecordId) {
throw conflict("Memory record has already been corrected");
}
const binding = await getBindingOrThrow(originalRow.bindingId);
const operationId = randomUUID();
const principal = actorPrincipal(actor);
const correctedRecordId = randomUUID();
const correctedAt = new Date();
const [correctedRow] = await db
.insert(memoryLocalRecords)
.values({
id: correctedRecordId,
companyId,
bindingId: originalRow.bindingId,
providerKey: originalRow.providerKey,
scopeAgentId: originalRow.scopeAgentId,
scopeProjectId: originalRow.scopeProjectId,
scopeIssueId: originalRow.scopeIssueId,
scopeRunId: originalRow.scopeRunId,
scopeSubjectId: originalRow.scopeSubjectId,
scopeType: originalRow.scopeType,
scopeId: originalRow.scopeId,
scopeWorkspaceId: originalRow.scopeWorkspaceId,
scopeTeamId: originalRow.scopeTeamId,
sourceKind: originalRow.sourceKind,
sourceIssueId: originalRow.sourceIssueId,
sourceCommentId: originalRow.sourceCommentId,
sourceDocumentKey: originalRow.sourceDocumentKey,
sourceRunId: originalRow.sourceRunId,
sourceActivityId: originalRow.sourceActivityId,
sourceExternalRef: originalRow.sourceExternalRef,
title: parsed.title === undefined ? originalRow.title : parsed.title ?? null,
content: parsed.content,
summary: parsed.summary === undefined ? originalRow.summary : parsed.summary ?? null,
metadata: {
...(originalRow.metadata ?? {}),
correctionReason: parsed.reason,
correctedFromRecordId: originalRow.id,
},
ownerType: originalRow.ownerType,
ownerId: originalRow.ownerId,
createdByActorType: principal.type,
createdByActorId: principal.id,
sensitivityLabel: parsed.sensitivityLabel ?? originalRow.sensitivityLabel,
retentionPolicy: parsed.retentionPolicy === undefined ? originalRow.retentionPolicy : parsed.retentionPolicy,
expiresAt: parsed.expiresAt === undefined ? originalRow.expiresAt : parsed.expiresAt,
retentionState: "active",
citationJson: parsed.citation === undefined ? originalRow.citationJson : normalizeCitation(parsed.citation),
supersedesRecordId: originalRow.id,
createdByOperationId: operationId,
createdAt: correctedAt,
updatedAt: correctedAt,
})
.returning();
await db
.update(memoryLocalRecords)
.set({ supersededByRecordId: correctedRecordId, updatedAt: correctedAt })
.where(and(eq(memoryLocalRecords.companyId, companyId), eq(memoryLocalRecords.id, originalRow.id)));
const operation = await logOperation({
id: operationId,
companyId,
binding,
actor,
operationType: "correct",
triggerKind: "manual",
scope: scopeFromRow(originalRow),
source: sourceFromRow(originalRow),
requestJson: {
recordId,
reason: parsed.reason,
sensitivityLabel: parsed.sensitivityLabel ?? null,
expiresAt: parsed.expiresAt ? parsed.expiresAt.toISOString() : null,
},
resultJson: {
originalRecordId: originalRow.id,
correctedRecordId,
},
recordCount: 1,
});
return {
operation,
originalRecord: mapRecord({ ...originalRow, supersededByRecordId: correctedRecordId, updatedAt: correctedAt }),
correctedRecord: mapRecord(correctedRow),
} satisfies MemoryCorrectResult;
},
sweepRetention: async (
companyId: string,
data: MemoryRetentionSweep,
actor: ActorInfo,
): Promise<MemoryRetentionSweepResult> => {
const parsed = memoryRetentionSweepSchema.parse(data);
const now = parsed.now ?? new Date();
const conditions = [
eq(memoryLocalRecords.companyId, companyId),
eq(memoryLocalRecords.retentionState, "active"),
isNull(memoryLocalRecords.revokedAt),
isNotNull(memoryLocalRecords.expiresAt),
lte(memoryLocalRecords.expiresAt, now),
];
if (parsed.bindingId) conditions.push(eq(memoryLocalRecords.bindingId, parsed.bindingId));
const rows = await db
.select()
.from(memoryLocalRecords)
.where(and(...conditions))
.orderBy(memoryLocalRecords.expiresAt)
.limit(parsed.limit);
if (rows.length === 0) return { operations: [], expiredRecordIds: [] };
const expiredRecordIds = rows.map((row) => row.id);
await db
.update(memoryLocalRecords)
.set({ retentionState: "expired", updatedAt: now })
.where(and(eq(memoryLocalRecords.companyId, companyId), inArray(memoryLocalRecords.id, expiredRecordIds)));
const operations: MemoryOperation[] = [];
const rowsByBinding = new Map<string, RecordRow[]>();
for (const row of rows) {
rowsByBinding.set(row.bindingId, [...(rowsByBinding.get(row.bindingId) ?? []), row]);
}
for (const [bindingId, bindingRows] of rowsByBinding) {
const binding = await getBindingOrThrow(bindingId);
operations.push(
await logOperation({
companyId,
binding,
actor,
operationType: "retention_sweep",
triggerKind: "manual",
scope: {},
retentionAction: {
now: now.toISOString(),
expiredRecordIds: bindingRows.map((row) => row.id),
},
resultJson: {
expiredRecordIds: bindingRows.map((row) => row.id),
},
recordCount: bindingRows.length,
}),
);
}
return { operations, expiredRecordIds } satisfies MemoryRetentionSweepResult;
},
listRecords: async (companyId: string, filters: MemoryListRecordsQuery, actor: ActorInfo) =>
listLocalBasic(companyId, filters, actor),
getRecord: async (companyId: string, recordId: string, actor?: ActorInfo) => {
const row = await db
.select()
.from(memoryLocalRecords)
.where(and(eq(memoryLocalRecords.companyId, companyId), eq(memoryLocalRecords.id, recordId)))
.then((rows) => rows[0] ?? null);
return row ? mapRecord(row) : null;
if (!row) return null;
const record = mapRecord(row);
if (actor && !canReadRecord(companyId, record, actor, record.scope)) return null;
return record;
},
listOperations: async (companyId: string, filters: MemoryListOperationsQuery) => {
@@ -1077,6 +1682,8 @@ export function memoryService(
input.companyId,
{
scope: {
scopeType: "run",
scopeId: input.runId,
agentId: input.agentId,
projectId: input.projectId ?? null,
issueId: input.issueId ?? null,
@@ -1128,16 +1735,25 @@ export function memoryService(
input.companyId,
{
scope: {
scopeType: "run",
scopeId: input.runId,
agentId: input.agentId,
projectId: input.projectId ?? null,
issueId: input.issueId ?? null,
runId: input.runId,
},
scopeType: "run",
scopeId: input.runId,
source: {
kind: "run",
issueId: input.issueId ?? null,
runId: input.runId,
},
sensitivityLabel: "internal",
citation: {
label: "Run summary",
sourceTitle: input.title ?? "Run summary",
},
title: input.title ?? "Run summary",
content: input.summary,
summary: input.summary,
@@ -1178,15 +1794,24 @@ export function memoryService(
input.companyId,
{
scope: {
scopeType: input.projectId ? "project" : input.agentId ? "agent" : "org",
scopeId: input.projectId ?? input.agentId ?? input.companyId,
agentId: input.agentId ?? null,
projectId: input.projectId ?? null,
issueId: input.issueId,
},
scopeType: input.projectId ? "project" : input.agentId ? "agent" : "org",
scopeId: input.projectId ?? input.agentId ?? input.companyId,
source: {
kind: "issue_comment",
issueId: input.issueId,
commentId: input.commentId,
},
citation: {
label: "Issue comment",
sourceTitle: `Issue comment ${input.commentId}`,
},
sensitivityLabel: "internal",
title: "Issue comment",
content: input.body,
summary: input.body.replace(/\s+/g, " ").slice(0, 240),
@@ -1222,15 +1847,24 @@ export function memoryService(
input.companyId,
{
scope: {
scopeType: input.projectId ? "project" : input.agentId ? "agent" : "org",
scopeId: input.projectId ?? input.agentId ?? input.companyId,
agentId: input.agentId ?? null,
projectId: input.projectId ?? null,
issueId: input.issueId,
},
scopeType: input.projectId ? "project" : input.agentId ? "agent" : "org",
scopeId: input.projectId ?? input.agentId ?? input.companyId,
source: {
kind: "issue_document",
issueId: input.issueId,
documentKey: input.key,
},
citation: {
label: "Issue document",
sourceTitle: input.title ?? input.key,
},
sensitivityLabel: "internal",
title: input.title ?? `Issue document: ${input.key}`,
content: input.body,
summary: input.body.replace(/\s+/g, " ").slice(0, 240),
+21 -3
View File
@@ -2,11 +2,17 @@ import type {
CreateMemoryBinding,
MemoryBinding,
MemoryBindingTarget,
MemoryCorrect,
MemoryCorrectResult,
MemoryListOperationsQuery,
MemoryListRecordsQuery,
MemoryOperation,
MemoryProviderDescriptor,
MemoryRecord,
MemoryRetentionSweep,
MemoryRetentionSweepResult,
MemoryRevoke,
MemoryRevokeResult,
MemoryResolvedBinding,
SetAgentMemoryBinding,
SetCompanyMemoryBinding,
@@ -14,11 +20,11 @@ import type {
} from "@paperclipai/shared";
import { api } from "./client";
function buildQueryString(filters?: Record<string, string | number | boolean | undefined>) {
function buildQueryString(filters?: Record<string, string | number | boolean | Date | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters ?? {})) {
if (value === undefined) continue;
params.set(key, String(value));
params.set(key, value instanceof Date ? value.toISOString() : String(value));
}
const qs = params.toString();
return qs ? `?${qs}` : "";
@@ -41,7 +47,7 @@ export const memoryApi = {
api.get<MemoryResolvedBinding>(`/agents/${encodeURIComponent(agentId)}/memory-binding`),
setAgentBinding: (agentId: string, bindingId: SetAgentMemoryBinding["bindingId"]) =>
api.put<MemoryBindingTarget | null>(`/agents/${encodeURIComponent(agentId)}/memory-binding`, { bindingId }),
listRecords: (companyId: string, filters?: MemoryListRecordsQuery) =>
listRecords: (companyId: string, filters?: Partial<MemoryListRecordsQuery>) =>
api.get<MemoryRecord[]>(
`/companies/${encodeURIComponent(companyId)}/memory/records${buildQueryString(filters)}`,
),
@@ -49,4 +55,16 @@ export const memoryApi = {
api.get<MemoryOperation[]>(
`/companies/${encodeURIComponent(companyId)}/memory/operations${buildQueryString(filters)}`,
),
revoke: (companyId: string, data: MemoryRevoke) =>
api.post<MemoryRevokeResult>(`/companies/${encodeURIComponent(companyId)}/memory/revoke`, data),
correctRecord: (companyId: string, recordId: string, data: MemoryCorrect) =>
api.post<MemoryCorrectResult>(
`/companies/${encodeURIComponent(companyId)}/memory/records/${encodeURIComponent(recordId)}/correct`,
data,
),
sweepRetention: (companyId: string, data: Partial<MemoryRetentionSweep> = {}) =>
api.post<MemoryRetentionSweepResult>(
`/companies/${encodeURIComponent(companyId)}/memory/retention/sweep`,
data,
),
};
+14 -1
View File
@@ -28,6 +28,11 @@ function summarizeRecord(record: MemoryRecord) {
return body.length > 280 ? `${body.slice(0, 277)}...` : body;
}
function governanceLabel(record: MemoryRecord) {
const scope = `${record.scopeType}${record.scopeId ? `:${record.scopeId.slice(0, 8)}` : ""}`;
return `${scope} • ${record.sensitivityLabel} • ${record.retentionState}`;
}
function operationCost(operation: MemoryOperation) {
const total = operation.usage.reduce((sum, item) => sum + item.costCents, 0);
return total > 0 ? formatCents(total) : "-";
@@ -134,6 +139,9 @@ export function AgentMemoryTab({
<div className="text-xs text-muted-foreground">
Automatic capture hooks write recent run summaries and issue context into the resolved binding.
</div>
<div className="text-xs text-muted-foreground">
Hydration can read org memory plus this agent, issue, project, workspace, and run scopes when present. Restricted records are withheld from agent prompts unless explicitly allowed.
</div>
</>
)}
</div>
@@ -185,8 +193,13 @@ export function AgentMemoryTab({
<div className="space-y-1">
<div className="text-sm font-medium">{record.title ?? describeRecordSource(record)}</div>
<div className="text-xs text-muted-foreground">
{describeRecordSource(record)} • {relativeTime(record.createdAt)}
{describeRecordSource(record)} • {governanceLabel(record)} • {relativeTime(record.createdAt)}
</div>
{record.citation?.label || record.citation?.sourceTitle ? (
<div className="text-xs text-muted-foreground">
Citation: {record.citation.label ?? record.citation.sourceTitle}
</div>
) : null}
</div>
<div className="text-xs text-muted-foreground">
{record.scope.issueId ? `Issue ${record.scope.issueId.slice(0, 8)}` : "No issue scope"}
+294
View File
@@ -2,8 +2,11 @@ import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
MemoryBinding,
MemoryListRecordsQuery,
MemoryProviderDescriptor,
MemoryRecord,
} from "@paperclipai/shared";
import { MEMORY_RETENTION_STATES, MEMORY_SCOPE_TYPES, MEMORY_SENSITIVITY_LABELS } from "@paperclipai/shared";
import { memoryApi } from "../api/memory";
import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
@@ -60,6 +63,15 @@ function providerDescription(provider: MemoryProviderDescriptor | undefined) {
return provider?.description ?? "Memory provider";
}
function summarizeRecord(record: MemoryRecord) {
const text = (record.summary ?? record.content).replace(/\s+/g, " ").trim();
return text.length > 220 ? `${text.slice(0, 217)}...` : text;
}
function recordScopeLabel(record: MemoryRecord) {
return `${record.scopeType}${record.scopeId ? `:${record.scopeId.slice(0, 8)}` : ""}`;
}
function MemoryBindingCard({
binding,
isDefault,
@@ -229,6 +241,15 @@ export function CompanyMemorySettings({ companyId }: { companyId: string }) {
const [enabled, setEnabled] = useState(true);
const [makeDefault, setMakeDefault] = useState(true);
const [createError, setCreateError] = useState<string | null>(null);
const [recordScopeType, setRecordScopeType] = useState("");
const [recordSensitivity, setRecordSensitivity] = useState("");
const [recordRetentionState, setRecordRetentionState] = useState("");
const [includeRevoked, setIncludeRevoked] = useState(false);
const [includeExpired, setIncludeExpired] = useState(false);
const [revokeReasonById, setRevokeReasonById] = useState<Record<string, string>>({});
const [correctionRecord, setCorrectionRecord] = useState<MemoryRecord | null>(null);
const [correctionContent, setCorrectionContent] = useState("");
const [correctionReason, setCorrectionReason] = useState("");
const providersQuery = useQuery({
queryKey: queryKeys.memory.providers(companyId),
@@ -245,6 +266,22 @@ export function CompanyMemorySettings({ companyId }: { companyId: string }) {
queryFn: () => memoryApi.listTargets(companyId),
});
const recordsFilters: Partial<MemoryListRecordsQuery> = {
includeDeleted: false,
scopeType: recordScopeType || undefined,
sensitivityLabel: recordSensitivity || undefined,
retentionState: recordRetentionState || undefined,
includeRevoked,
includeExpired,
includeSuperseded: false,
limit: 25,
} as Partial<MemoryListRecordsQuery>;
const recordsQuery = useQuery({
queryKey: queryKeys.memory.records(companyId, recordsFilters),
queryFn: () => memoryApi.listRecords(companyId, recordsFilters),
});
const providersByKey = useMemo(
() => new Map((providersQuery.data ?? []).map((provider) => [provider.key, provider])),
[providersQuery.data],
@@ -325,6 +362,73 @@ export function CompanyMemorySettings({ companyId }: { companyId: string }) {
},
});
const revokeRecord = useMutation({
mutationFn: ({ recordId, reason }: { recordId: string; reason: string }) =>
memoryApi.revoke(companyId, { selector: { recordIds: [recordId] }, reason }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all });
pushToast({
title: "Memory record revoked",
body: "The record is now hidden from browse and prompt hydration by default.",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to revoke memory record",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
const correctRecord = useMutation({
mutationFn: () => {
if (!correctionRecord) throw new Error("No memory record selected");
return memoryApi.correctRecord(companyId, correctionRecord.id, {
content: correctionContent,
reason: correctionReason,
});
},
onSuccess: async () => {
setCorrectionRecord(null);
setCorrectionContent("");
setCorrectionReason("");
await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all });
pushToast({
title: "Memory record corrected",
body: "The original record was superseded and the correction is now the active memory.",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to correct memory record",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
const sweepRetention = useMutation({
mutationFn: () => memoryApi.sweepRetention(companyId),
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all });
pushToast({
title: "Retention sweep completed",
body: `${result.expiredRecordIds.length} expired record${result.expiredRecordIds.length === 1 ? "" : "s"} marked.`,
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to sweep memory retention",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
const isLoading = providersQuery.isLoading || bindingsQuery.isLoading || targetsQuery.isLoading;
const error = providersQuery.error ?? bindingsQuery.error ?? targetsQuery.error ?? null;
@@ -450,6 +554,196 @@ export function CompanyMemorySettings({ companyId }: { companyId: string }) {
</>
)}
</div>
<div className="space-y-4 rounded-md border border-border px-4 py-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<h2 className="text-sm font-medium">Governed records</h2>
<p className="text-sm text-muted-foreground">
Inspect memory by scope, sensitivity, and retention state. Revoked, expired, and superseded records stay auditable but are excluded from hydration by default.
</p>
</div>
<Button
size="sm"
variant="outline"
disabled={sweepRetention.isPending}
onClick={() => sweepRetention.mutate()}
>
{sweepRetention.isPending ? "Sweeping..." : "Sweep retention"}
</Button>
</div>
<div className="grid gap-3 md:grid-cols-3">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Scope</label>
<select
value={recordScopeType}
onChange={(event) => setRecordScopeType(event.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none"
>
<option value="">Any scope</option>
{MEMORY_SCOPE_TYPES.map((scopeType) => (
<option key={scopeType} value={scopeType}>
{scopeType}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Sensitivity</label>
<select
value={recordSensitivity}
onChange={(event) => setRecordSensitivity(event.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none"
>
<option value="">Any label</option>
{MEMORY_SENSITIVITY_LABELS.map((label) => (
<option key={label} value={label}>
{label}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Retention</label>
<select
value={recordRetentionState}
onChange={(event) => setRecordRetentionState(event.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none"
>
<option value="">Active records</option>
{MEMORY_RETENTION_STATES.map((state) => (
<option key={state} value={state}>
{state}
</option>
))}
</select>
</div>
</div>
<div className="flex flex-wrap gap-4 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={includeRevoked}
onChange={(event) => setIncludeRevoked(event.target.checked)}
/>
Include revoked
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={includeExpired}
onChange={(event) => setIncludeExpired(event.target.checked)}
/>
Include expired
</label>
</div>
{recordsQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading governed records...</p>
) : recordsQuery.error ? (
<p className="text-sm text-destructive">{recordsQuery.error.message}</p>
) : (recordsQuery.data ?? []).length === 0 ? (
<div className="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
No memory records match the current filters.
</div>
) : (
<div className="space-y-3">
{(recordsQuery.data ?? []).map((record) => {
const revokeReason = revokeReasonById[record.id] ?? "";
const canMutate = record.retentionState === "active" && !record.revokedAt && !record.supersededByRecordId;
return (
<div key={record.id} className="rounded-md border border-border px-4 py-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-1">
<div className="text-sm font-medium">{record.title ?? record.source?.kind ?? "Memory record"}</div>
<div className="text-xs text-muted-foreground">
{recordScopeLabel(record)} • {record.sensitivityLabel} • {record.retentionState}
{record.expiresAt ? ` • expires ${new Date(record.expiresAt).toLocaleDateString()}` : ""}
</div>
{record.citation?.label || record.citation?.sourceTitle ? (
<div className="text-xs text-muted-foreground">
Citation: {record.citation.label ?? record.citation.sourceTitle}
</div>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
disabled={!canMutate}
onClick={() => {
setCorrectionRecord(record);
setCorrectionContent(record.content);
setCorrectionReason("");
}}
>
Correct
</Button>
</div>
</div>
<p className="mt-2 text-sm text-foreground/90">{summarizeRecord(record)}</p>
{canMutate ? (
<div className="mt-3 grid gap-2 md:grid-cols-[1fr_auto] md:items-center">
<Input
value={revokeReason}
onChange={(event) =>
setRevokeReasonById((current) => ({ ...current, [record.id]: event.target.value }))
}
placeholder="Reason required to revoke"
/>
<Button
size="sm"
variant="outline"
disabled={!revokeReason.trim() || revokeRecord.isPending}
onClick={() => revokeRecord.mutate({ recordId: record.id, reason: revokeReason.trim() })}
>
Revoke
</Button>
</div>
) : null}
</div>
);
})}
</div>
)}
{correctionRecord ? (
<div className="space-y-3 rounded-md border border-border bg-accent/20 px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-medium">Correct memory record</h3>
<p className="text-xs text-muted-foreground">
This creates a superseding record and keeps the original for audit history.
</p>
</div>
<Button size="sm" variant="ghost" onClick={() => setCorrectionRecord(null)}>
Cancel
</Button>
</div>
<Textarea
value={correctionContent}
onChange={(event) => setCorrectionContent(event.target.value)}
className="min-h-32"
/>
<Input
value={correctionReason}
onChange={(event) => setCorrectionReason(event.target.value)}
placeholder="Correction reason"
/>
<div className="flex justify-end">
<Button
size="sm"
disabled={!correctionContent.trim() || !correctionReason.trim() || correctRecord.isPending}
onClick={() => correctRecord.mutate()}
>
{correctRecord.isPending ? "Saving correction..." : "Save correction"}
</Button>
</div>
</div>
) : null}
</div>
</div>
);
}
+1 -1
View File
@@ -35,7 +35,7 @@ export const queryKeys = {
bindings: (companyId: string) => ["memory", companyId, "bindings"] as const,
targets: (companyId: string) => ["memory", companyId, "targets"] as const,
agentBinding: (agentId: string) => ["memory", "agent-binding", agentId] as const,
records: (companyId: string, filters?: Record<string, string | number | boolean | undefined>) =>
records: (companyId: string, filters?: Record<string, string | number | boolean | Date | undefined>) =>
["memory", companyId, "records", filters ?? {}] as const,
operations: (companyId: string, filters?: Record<string, string | number | boolean | undefined>) =>
["memory", companyId, "operations", filters ?? {}] as const,