mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Add governed memory controls
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
),
|
||||
};
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user