Implement memory binding provider metadata

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-19 20:22:11 -05:00
1 parent 74918dfb76
commit f4ce06638f
37 files changed
+17746 -122

No files matched your search

@@ -0,0 +1,6 @@
ALTER TABLE "memory_local_records" ADD COLUMN "review_state" text DEFAULT 'pending' NOT NULL;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "reviewed_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "reviewed_by_actor_type" text;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "reviewed_by_actor_id" text;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD COLUMN "review_note" text;--> statement-breakpoint
CREATE INDEX "memory_local_records_company_review_created_idx" ON "memory_local_records" USING btree ("company_id","review_state","created_at");
File diff suppressed because it is too large. Load diff
@@ -407,6 +407,13 @@
"when": 1776377673907,
"tag": "0057_misty_riptide",
"breakpoints": true
},
{
"idx": 58,
"version": "7",
"when": 1776647797670,
"tag": "0058_lovely_eternals",
"breakpoints": true
}
]
}
@@ -10,6 +10,7 @@ import { memoryOperations } from "./memory_operations.js";
import type {
MemoryPrincipalType,
MemoryRetentionState,
MemoryReviewState,
MemoryScopeType,
MemorySensitivityLabel,
MemorySourceKind,
@@ -50,6 +51,11 @@ export const memoryLocalRecords = pgTable(
retentionPolicy: jsonb("retention_policy").$type<Record<string, unknown> | null>(),
expiresAt: timestamp("expires_at", { withTimezone: true }),
retentionState: text("retention_state").$type<MemoryRetentionState>().notNull().default("active"),
reviewState: text("review_state").$type<MemoryReviewState>().notNull().default("pending"),
reviewedAt: timestamp("reviewed_at", { withTimezone: true }),
reviewedByActorType: text("reviewed_by_actor_type").$type<MemoryPrincipalType>(),
reviewedByActorId: text("reviewed_by_actor_id"),
reviewNote: text("review_note"),
citationJson: jsonb("citation_json").$type<Record<string, unknown> | null>(),
supersedesRecordId: uuid("supersedes_record_id"),
supersededByRecordId: uuid("superseded_by_record_id"),
@@ -95,6 +101,11 @@ export const memoryLocalRecords = pgTable(
table.expiresAt,
table.createdAt,
),
companyReviewCreatedIdx: index("memory_local_records_company_review_created_idx").on(
table.companyId,
table.reviewState,
table.createdAt,
),
contentSearchIdx: index("memory_local_records_fts_idx").using(
"gin",
sql`to_tsvector('english', coalesce(${table.title}, '') || ' ' || ${table.content})`,
@@ -1,7 +1,11 @@
import { randomUUID } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import { access, mkdir } from "node:fs/promises";
import path from "node:path";
import type {
MemoryBinding,
MemoryProviderConfigMetadata,
MemoryProviderHealthCheck,
MemoryProviderCaptureInput,
MemoryProviderForgetInput,
MemoryProviderQueryInput,
@@ -35,6 +39,39 @@ function normalizeNullableString(value: unknown) {
return typeof value === "string" ? value : null;
}
function normalizeScopeType(scope: MemoryScope, fallback: MemoryRecord["scopeType"] = "org"): MemoryRecord["scopeType"] {
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: MemoryRecord["scopeType"], 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 normalizeDate(value: string | Date | null | undefined) {
if (!value) return null;
return value instanceof Date ? value : new Date(value);
}
function clampInt(value: unknown, fallback: number, min: number, max: number) {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return Math.min(Math.max(Math.floor(value), min), max);
@@ -81,6 +118,8 @@ function buildRecord(
recordId: string,
now: Date,
): MemoryRecord {
const scopeType = input.scopeType ?? input.scope.scopeType ?? normalizeScopeType(input.scope);
const scopeId = input.scopeId ?? input.scope.scopeId ?? normalizeScopeId(input.binding.companyId, scopeType, input.scope);
return {
id: recordId,
companyId: input.binding.companyId,
@@ -88,6 +127,24 @@ function buildRecord(
providerKey: input.binding.providerKey,
scope: input.scope,
source: input.source,
scopeType,
scopeId,
owner: input.owner ?? input.createdBy ?? null,
createdBy: input.createdBy ?? null,
sensitivityLabel: input.sensitivityLabel ?? "internal",
retentionPolicy: input.retentionPolicy ?? null,
expiresAt: normalizeDate(input.expiresAt),
retentionState: "active",
reviewState: "pending",
reviewedAt: null,
reviewedBy: null,
reviewNote: null,
citation: input.citation ?? null,
supersedesRecordId: null,
supersededByRecordId: null,
revokedAt: null,
revokedBy: null,
revocationReason: null,
title: input.title ?? null,
content: input.content,
summary: input.summary ?? null,
@@ -107,6 +164,136 @@ export function resolveQmdMemoryDataDir(dataDir?: string) {
return dataDir ?? process.env[QMD_PLUGIN_DATA_DIR_ENV] ?? buildFallbackDataDir();
}
export function buildQmdMemoryConfigMetadata(dataDir = resolveQmdMemoryDataDir()): MemoryProviderConfigMetadata {
return {
suggestedConfig: {
searchMode: "query",
topK: 5,
autoIndexOnWrite: true,
qmdBinaryPath: null,
},
pathSuggestions: [
{
key: "dataDir",
label: "QMD storage root",
path: dataDir,
description: "Default plugin data directory used for markdown memory records and qmd indexes.",
},
],
healthChecks: [
{
key: "dataDir",
label: "Storage directory",
status: "unknown",
message: "Checked by the QMD plugin health endpoint.",
},
{
key: "qmdBinary",
label: "qmd binary",
status: "unknown",
message: "Checked by the QMD plugin health endpoint.",
},
],
fields: [
{
key: "searchMode",
label: "Search mode",
description: "qmd command used for retrieval.",
input: "select",
defaultValue: "query",
suggestedValue: "query",
options: [
{ value: "query", label: "Query" },
{ value: "search", label: "Search" },
{ value: "vsearch", label: "Vector search" },
],
},
{
key: "topK",
label: "Result limit",
description: "Maximum qmd hits requested before Paperclip policy filtering.",
input: "number",
defaultValue: 5,
suggestedValue: 5,
min: 1,
max: 25,
},
{
key: "autoIndexOnWrite",
label: "Auto-index on write",
description: "Refresh the qmd index after captures and forgets.",
input: "boolean",
defaultValue: true,
suggestedValue: true,
},
{
key: "qmdBinaryPath",
label: "qmd binary path",
description: "Optional absolute path to qmd. Leave empty to use qmd from PATH.",
input: "path",
defaultValue: null,
suggestedValue: null,
placeholder: "qmd",
},
],
};
}
export async function checkQmdMemoryHealth(options: {
dataDir?: string;
qmdClient?: QmdClient;
qmdBinaryPath?: string | null;
} = {}) {
const dataDir = resolveQmdMemoryDataDir(options.dataDir);
const qmdClient = options.qmdClient ?? createQmdClient();
const checks: MemoryProviderHealthCheck[] = [];
try {
await mkdir(dataDir, { recursive: true });
await access(dataDir, fsConstants.W_OK);
checks.push({
key: "dataDir",
label: "Storage directory",
status: "ok",
message: "QMD memory storage directory is writable.",
details: { dataDir },
});
} catch (error) {
checks.push({
key: "dataDir",
label: "Storage directory",
status: "error",
message: error instanceof Error ? error.message : "QMD memory storage directory is not accessible.",
details: { dataDir },
});
}
if (qmdClient.checkHealth) {
const binary = await qmdClient.checkHealth({ binaryPath: options.qmdBinaryPath });
checks.push({
key: "qmdBinary",
label: "qmd binary",
status: binary.available ? "ok" : "warning",
message: binary.message,
details: {
binaryPath: binary.binaryPath,
},
});
} else {
checks.push({
key: "qmdBinary",
label: "qmd binary",
status: "unknown",
message: "qmd binary health check is unavailable for this client.",
});
}
return {
dataDir,
checks,
};
}
async function maybeRefreshIndex(
qmdClient: QmdClient,
binding: MemoryBinding,
@@ -24,6 +24,13 @@ export interface QmdQueryHit {
}
export interface QmdClient {
checkHealth?(input: {
binaryPath?: string | null;
}): Promise<{
available: boolean;
binaryPath: string;
message: string;
}>;
refreshIndex(input: {
bindingDir: string;
binaryPath?: string | null;
@@ -98,6 +105,24 @@ export async function defaultQmdExecutor(
export function createQmdClient(executor: QmdExecutor = defaultQmdExecutor): QmdClient {
return {
async checkHealth(input) {
const binaryPath = input.binaryPath ?? "qmd";
try {
await executor(binaryPath, ["--version"], { cwd: process.cwd(), timeoutMs: 5_000 });
return {
available: true,
binaryPath,
message: "qmd binary is available",
};
} catch (error) {
return {
available: false,
binaryPath,
message: error instanceof Error ? error.message : "qmd binary check failed",
};
}
},
async refreshIndex(input) {
await executor(
input.binaryPath ?? "qmd",
@@ -25,14 +25,92 @@ function parseDate(value: unknown) {
function normalizeScope(value: unknown): MemoryScope {
if (!isPlainRecord(value)) return {};
return {
scopeType: asString(value.scopeType) as MemoryScope["scopeType"],
scopeId: asString(value.scopeId),
agentId: asString(value.agentId),
workspaceId: asString(value.workspaceId),
projectId: asString(value.projectId),
issueId: asString(value.issueId),
runId: asString(value.runId),
teamId: asString(value.teamId),
subjectId: asString(value.subjectId),
allowedScopes: Array.isArray(value.allowedScopes)
? value.allowedScopes
.filter(isPlainRecord)
.map((scope) => ({
type: asString(scope.type) as MemoryRecord["scopeType"],
id: asString(scope.id),
}))
.filter((scope) => Boolean(scope.type))
: null,
maxSensitivityLabel: asString(value.maxSensitivityLabel) as MemoryScope["maxSensitivityLabel"],
};
}
function normalizeScopeType(scope: MemoryScope, value: unknown): MemoryRecord["scopeType"] {
const raw = asString(value);
if (raw === "run" || raw === "agent" || raw === "workspace" || raw === "project" || raw === "team" || raw === "org") {
return raw;
}
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 "org";
}
function normalizeScopeId(companyId: string, scopeType: MemoryRecord["scopeType"], scope: MemoryScope, value: unknown) {
const raw = asString(value);
if (raw) return raw;
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 normalizePrincipal(value: unknown): MemoryRecord["owner"] {
if (!isPlainRecord(value)) return null;
const type = asString(value.type);
const id = asString(value.id);
if (!type || !id) return null;
return { type: type as NonNullable<MemoryRecord["owner"]>["type"], id };
}
function normalizeSensitivityLabel(value: unknown): MemoryRecord["sensitivityLabel"] {
const raw = asString(value);
if (raw === "public" || raw === "internal" || raw === "confidential" || raw === "restricted") return raw;
return "internal";
}
function normalizeRetentionState(value: unknown): MemoryRecord["retentionState"] {
const raw = asString(value);
if (raw === "active" || raw === "expired" || raw === "revoked") return raw;
return "active";
}
function normalizeReviewState(value: unknown): MemoryRecord["reviewState"] {
const raw = asString(value);
if (raw === "pending" || raw === "accepted" || raw === "rejected") return raw;
return "pending";
}
function normalizeCitation(value: unknown): MemoryRecord["citation"] {
return isPlainRecord(value) ? (value as MemoryRecord["citation"]) : null;
}
function normalizeSource(value: unknown): MemorySourceRef | null {
if (!isPlainRecord(value)) return null;
const kind = asString(value.kind);
@@ -98,6 +176,24 @@ export async function writeStoredRecord(dataDir: string, binding: MemoryBinding,
providerKey: input.record.providerKey,
scope: input.record.scope,
source: input.record.source,
scopeType: input.record.scopeType,
scopeId: input.record.scopeId,
owner: input.record.owner,
createdBy: input.record.createdBy,
sensitivityLabel: input.record.sensitivityLabel,
retentionPolicy: input.record.retentionPolicy,
expiresAt: input.record.expiresAt?.toISOString() ?? null,
retentionState: input.record.retentionState,
reviewState: input.record.reviewState,
reviewedAt: input.record.reviewedAt?.toISOString() ?? null,
reviewedBy: input.record.reviewedBy,
reviewNote: input.record.reviewNote,
citation: input.record.citation,
supersedesRecordId: input.record.supersedesRecordId,
supersededByRecordId: input.record.supersededByRecordId,
revokedAt: input.record.revokedAt?.toISOString() ?? null,
revokedBy: input.record.revokedBy,
revocationReason: input.record.revocationReason,
title: input.record.title,
summary: input.record.summary,
metadata: input.record.metadata,
@@ -125,14 +221,35 @@ export async function readStoredRecord(filePath: string): Promise<MemoryRecord |
if (!id || !companyId || !bindingId || !providerKey || !createdAt || !updatedAt) {
return null;
}
const scope = normalizeScope(frontmatter.scope);
const scopeType = normalizeScopeType(scope, frontmatter.scopeType);
const scopeId = normalizeScopeId(companyId, scopeType, scope, frontmatter.scopeId);
return {
id,
companyId,
bindingId,
providerKey,
scope: normalizeScope(frontmatter.scope),
scope,
source: normalizeSource(frontmatter.source),
scopeType,
scopeId,
owner: normalizePrincipal(frontmatter.owner),
createdBy: normalizePrincipal(frontmatter.createdBy),
sensitivityLabel: normalizeSensitivityLabel(frontmatter.sensitivityLabel),
retentionPolicy: isPlainRecord(frontmatter.retentionPolicy) ? frontmatter.retentionPolicy : null,
expiresAt: parseDate(frontmatter.expiresAt),
retentionState: normalizeRetentionState(frontmatter.retentionState),
reviewState: normalizeReviewState(frontmatter.reviewState),
reviewedAt: parseDate(frontmatter.reviewedAt),
reviewedBy: normalizePrincipal(frontmatter.reviewedBy),
reviewNote: asString(frontmatter.reviewNote),
citation: normalizeCitation(frontmatter.citation),
supersedesRecordId: asString(frontmatter.supersedesRecordId),
supersededByRecordId: asString(frontmatter.supersededByRecordId),
revokedAt: parseDate(frontmatter.revokedAt),
revokedBy: normalizePrincipal(frontmatter.revokedBy),
revocationReason: asString(frontmatter.revocationReason),
title: asString(frontmatter.title),
content: parsed.body,
summary: asString(frontmatter.summary),
@@ -1,5 +1,6 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
import { QMD_MEMORY_PROVIDER_KEY, QMD_PLUGIN_ID } from "./constants.js";
import { buildQmdMemoryConfigMetadata } from "./lib/provider.js";
const manifest: PaperclipPluginManifestV1 = {
id: QMD_PLUGIN_ID,
@@ -30,25 +31,34 @@ const manifest: PaperclipPluginManifestV1 = {
properties: {
searchMode: {
type: "string",
title: "Search mode",
description: "qmd command used for retrieval.",
enum: ["query", "search", "vsearch"],
default: "query",
},
topK: {
type: "integer",
title: "Result limit",
description: "Maximum qmd hits requested before Paperclip policy filtering.",
minimum: 1,
maximum: 25,
default: 5,
},
autoIndexOnWrite: {
type: "boolean",
title: "Auto-index on write",
description: "Refresh the qmd index after captures and forgets.",
default: true,
},
qmdBinaryPath: {
type: ["string", "null"],
title: "qmd binary path",
description: "Optional absolute path to qmd. Leave empty to use qmd from PATH.",
default: null,
},
},
},
configMetadata: buildQmdMemoryConfigMetadata(),
},
],
};
@@ -1,6 +1,6 @@
import { definePlugin, runWorker, type PluginMemoryProvider } from "@paperclipai/plugin-sdk";
import { QMD_PLUGIN_DATA_DIR_ENV, QMD_MEMORY_PROVIDER_KEY } from "./constants.js";
import { createQmdMemoryProvider, resolveQmdMemoryDataDir } from "./lib/provider.js";
import { checkQmdMemoryHealth, createQmdMemoryProvider, resolveQmdMemoryDataDir } from "./lib/provider.js";
export function createQmdMemoryPlugin(opts?: {
createProvider?: () => PluginMemoryProvider;
@@ -19,14 +19,20 @@ export function createQmdMemoryPlugin(opts?: {
async onHealth() {
const configuredDataDir = process.env[QMD_PLUGIN_DATA_DIR_ENV] ?? null;
const health = await checkQmdMemoryHealth();
const hasError = health.checks.some((check) => check.status === "error");
const hasWarning = health.checks.some((check) => check.status === "warning");
return {
status: "ok",
message: configuredDataDir
? "QMD memory provider is ready"
: "QMD memory provider is ready with fallback local data dir",
status: hasError ? "error" : hasWarning ? "degraded" : "ok",
message: hasError
? "QMD memory provider has health check failures"
: configuredDataDir
? "QMD memory provider is ready"
: "QMD memory provider is ready with fallback local data dir",
details: {
dataDir: resolveQmdMemoryDataDir(),
dataDir: health.dataDir,
usingFallbackDataDir: configuredDataDir === null,
checks: health.checks,
},
};
},
@@ -2,8 +2,8 @@ import { mkdtemp, readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { MemoryBinding } from "@paperclipai/plugin-sdk";
import { createQmdMemoryProvider } from "../src/lib/provider.js";
import type { MemoryBinding, MemoryProviderConfigFieldMetadata } from "@paperclipai/plugin-sdk";
import { buildQmdMemoryConfigMetadata, checkQmdMemoryHealth, createQmdMemoryProvider } from "../src/lib/provider.js";
import { resolveRecordPath } from "../src/lib/storage.js";
const fixedNow = new Date("2026-04-13T14:00:00.000Z");
@@ -181,3 +181,58 @@ describe("createQmdMemoryProvider", () => {
});
});
});
describe("QMD memory provider config metadata", () => {
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.map(async (dir) => {
await import("node:fs/promises").then((fs) => fs.rm(dir, { recursive: true, force: true }));
}));
tempDirs.length = 0;
});
it("suggests a storage root and field-level config metadata", () => {
const metadata = buildQmdMemoryConfigMetadata("/tmp/paperclip-qmd");
expect(metadata.pathSuggestions?.[0]).toMatchObject({
key: "dataDir",
path: "/tmp/paperclip-qmd",
});
expect(metadata.suggestedConfig).toMatchObject({
searchMode: "query",
topK: 5,
autoIndexOnWrite: true,
qmdBinaryPath: null,
});
expect(metadata.fields.map((field: MemoryProviderConfigFieldMetadata) => field.key)).toEqual([
"searchMode",
"topK",
"autoIndexOnWrite",
"qmdBinaryPath",
]);
});
it("reports storage and qmd binary health details", async () => {
const dataDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-qmd-memory-health-"));
tempDirs.push(dataDir);
const health = await checkQmdMemoryHealth({
dataDir,
qmdClient: {
refreshIndex: vi.fn(),
query: vi.fn(),
checkHealth: vi.fn().mockResolvedValue({
available: false,
binaryPath: "qmd",
message: "qmd binary not found: qmd",
}),
},
});
expect(health.dataDir).toBe(dataDir);
expect(health.checks).toEqual([
expect.objectContaining({ key: "dataDir", status: "ok" }),
expect.objectContaining({ key: "qmdBinary", status: "warning" }),
]);
});
});
+7
View File
@@ -254,6 +254,13 @@ export type {
MemoryProviderQueryOutput,
MemoryOperation,
MemoryProviderCapabilities,
MemoryProviderConfigFieldInput,
MemoryProviderConfigFieldOption,
MemoryProviderConfigFieldMetadata,
MemoryProviderHealthStatus,
MemoryProviderHealthCheck,
MemoryProviderConfigPathSuggestion,
MemoryProviderConfigMetadata,
MemoryProviderDescriptor,
MemoryRecord,
MemoryResolvedBinding,
+14
View File
@@ -26,6 +26,13 @@ import type {
Company,
MemoryOperation,
MemoryProviderCapabilities,
MemoryProviderConfigFieldInput,
MemoryProviderConfigFieldOption,
MemoryProviderConfigFieldMetadata,
MemoryProviderHealthStatus,
MemoryProviderHealthCheck,
MemoryProviderConfigPathSuggestion,
MemoryProviderConfigMetadata,
MemoryProviderDescriptor,
MemoryResolvedBinding,
MemoryUsage,
@@ -86,6 +93,13 @@ export type {
MemoryForgetResult,
MemoryOperation,
MemoryProviderCapabilities,
MemoryProviderConfigFieldInput,
MemoryProviderConfigFieldOption,
MemoryProviderConfigFieldMetadata,
MemoryProviderHealthStatus,
MemoryProviderHealthCheck,
MemoryProviderConfigPathSuggestion,
MemoryProviderConfigMetadata,
MemoryProviderDescriptor,
MemoryQueryResult,
MemoryRecord,
+6 -1
View File
@@ -387,7 +387,7 @@ export const PERMISSION_KEYS = [
] as const;
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
export const MEMORY_BINDING_TARGET_TYPES = ["company", "agent"] as const;
export const MEMORY_BINDING_TARGET_TYPES = ["company", "project", "agent"] as const;
export type MemoryBindingTargetType = (typeof MEMORY_BINDING_TARGET_TYPES)[number];
export const MEMORY_PROVIDER_KINDS = ["builtin", "plugin"] as const;
@@ -402,6 +402,7 @@ export const MEMORY_OPERATION_TYPES = [
"forget",
"revoke",
"correct",
"review",
"retention_sweep",
] as const;
export type MemoryOperationType = (typeof MEMORY_OPERATION_TYPES)[number];
@@ -452,6 +453,9 @@ 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];
export const MEMORY_REVIEW_STATES = ["pending", "accepted", "rejected"] as const;
export type MemoryReviewState = (typeof MEMORY_REVIEW_STATES)[number];
// ---------------------------------------------------------------------------
// Plugin System — see doc/plugins/PLUGIN_SPEC.md for the full specification
// ---------------------------------------------------------------------------
@@ -602,6 +606,7 @@ export const PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS = [
"agents",
"projects",
"issues",
"memories",
"goals",
"approvals",
"costs",
+19
View File
@@ -72,6 +72,7 @@ export {
MEMORY_PRINCIPAL_TYPES,
MEMORY_SENSITIVITY_LABELS,
MEMORY_RETENTION_STATES,
MEMORY_REVIEW_STATES,
PLUGIN_API_VERSION,
PLUGIN_STATUSES,
PLUGIN_CATEGORIES,
@@ -157,6 +158,7 @@ export {
type MemoryPrincipalType,
type MemorySensitivityLabel,
type MemoryRetentionState,
type MemoryReviewState,
type PluginStatus,
type PluginCategory,
type PluginCapability,
@@ -296,6 +298,13 @@ export type {
Approval,
ApprovalComment,
MemoryProviderCapabilities,
MemoryProviderConfigFieldInput,
MemoryProviderConfigFieldOption,
MemoryProviderConfigFieldMetadata,
MemoryProviderHealthStatus,
MemoryProviderHealthCheck,
MemoryProviderConfigPathSuggestion,
MemoryProviderConfigMetadata,
MemoryProviderDescriptor,
MemoryUsage,
MemoryGovernedScope,
@@ -316,6 +325,7 @@ export type {
MemoryRevokeSelector,
MemoryRevokeResult,
MemoryCorrectResult,
MemoryReviewResult,
MemoryRetentionSweepResult,
MemoryProviderQueryInput,
MemoryProviderQueryOutput,
@@ -600,15 +610,22 @@ export {
memoryScopeSchema,
memorySourceRefSchema,
memoryProviderCapabilitiesSchema,
memoryProviderConfigFieldOptionSchema,
memoryProviderConfigFieldMetadataSchema,
memoryProviderHealthCheckSchema,
memoryProviderConfigPathSuggestionSchema,
memoryProviderConfigMetadataSchema,
createMemoryBindingSchema,
updateMemoryBindingSchema,
setCompanyMemoryBindingSchema,
setAgentMemoryBindingSchema,
setProjectMemoryBindingSchema,
memoryQuerySchema,
memoryCaptureSchema,
memoryForgetSchema,
memoryRevokeSchema,
memoryCorrectSchema,
memoryReviewSchema,
memoryRetentionSweepSchema,
memoryListRecordsQuerySchema,
memoryListOperationsQuerySchema,
@@ -637,11 +654,13 @@ export {
type UpdateMemoryBinding,
type SetCompanyMemoryBinding,
type SetAgentMemoryBinding,
type SetProjectMemoryBinding,
type MemoryQuery,
type MemoryCapture,
type MemoryForget,
type MemoryRevoke,
type MemoryCorrect,
type MemoryReview,
type MemoryRetentionSweep,
type MemoryListRecordsQuery,
type MemoryListOperationsQuery,
+9
View File
@@ -121,6 +121,13 @@ export type { Goal } from "./goal.js";
export type { Approval, ApprovalComment } from "./approval.js";
export type {
MemoryProviderCapabilities,
MemoryProviderConfigFieldInput,
MemoryProviderConfigFieldOption,
MemoryProviderConfigFieldMetadata,
MemoryProviderHealthStatus,
MemoryProviderHealthCheck,
MemoryProviderConfigPathSuggestion,
MemoryProviderConfigMetadata,
MemoryProviderDescriptor,
MemoryUsage,
MemoryGovernedScope,
@@ -140,6 +147,8 @@ export type {
MemoryForgetResult,
MemoryRevokeSelector,
MemoryRevokeResult,
MemoryReview,
MemoryReviewResult,
MemoryCorrectResult,
MemoryRetentionSweepResult,
MemoryProviderQueryInput,
+76
View File
@@ -7,6 +7,7 @@ import type {
MemoryPrincipalType,
MemoryProviderKind,
MemoryRetentionState,
MemoryReviewState,
MemoryScopeType,
MemorySensitivityLabel,
MemorySourceKind,
@@ -20,6 +21,59 @@ export interface MemoryProviderCapabilities {
providerManagedExtraction: boolean;
}
export type MemoryProviderConfigFieldInput =
| "text"
| "number"
| "boolean"
| "select"
| "path"
| "secret";
export interface MemoryProviderConfigFieldOption {
value: string | number | boolean | null;
label: string;
description?: string | null;
}
export interface MemoryProviderConfigFieldMetadata {
key: string;
label: string;
description?: string | null;
input: MemoryProviderConfigFieldInput;
required?: boolean;
secret?: boolean;
defaultValue?: unknown;
suggestedValue?: unknown;
placeholder?: string | null;
min?: number | null;
max?: number | null;
options?: MemoryProviderConfigFieldOption[];
}
export type MemoryProviderHealthStatus = "ok" | "warning" | "error" | "unknown";
export interface MemoryProviderHealthCheck {
key: string;
label: string;
status: MemoryProviderHealthStatus;
message?: string | null;
details?: Record<string, unknown> | null;
}
export interface MemoryProviderConfigPathSuggestion {
key: string;
label: string;
path: string;
description?: string | null;
}
export interface MemoryProviderConfigMetadata {
fields: MemoryProviderConfigFieldMetadata[];
suggestedConfig: Record<string, unknown>;
pathSuggestions?: MemoryProviderConfigPathSuggestion[];
healthChecks?: MemoryProviderHealthCheck[];
}
export interface MemoryProviderDescriptor {
key: string;
displayName: string;
@@ -28,6 +82,7 @@ export interface MemoryProviderDescriptor {
pluginId: string | null;
capabilities: MemoryProviderCapabilities;
configSchema: Record<string, unknown> | null;
configMetadata: MemoryProviderConfigMetadata | null;
}
export interface MemoryUsage {
@@ -111,6 +166,13 @@ export interface MemoryResolvedBinding {
targetType: MemoryBindingTargetType | null;
targetId: string | null;
binding: MemoryBinding | null;
source:
| "binding_key"
| "agent_override"
| "project_override"
| "company_default"
| "unconfigured";
checkedTargetTypes: MemoryBindingTargetType[];
}
export interface MemoryRecordHandle {
@@ -133,6 +195,10 @@ export interface MemoryRecord {
retentionPolicy: Record<string, unknown> | null;
expiresAt: Date | null;
retentionState: MemoryRetentionState;
reviewState: MemoryReviewState;
reviewedAt: Date | null;
reviewedBy: MemoryPrincipalRef | null;
reviewNote: string | null;
citation: MemoryCitation | null;
supersedesRecordId: string | null;
supersededByRecordId: string | null;
@@ -288,6 +354,16 @@ export interface MemoryCorrectResult {
correctedRecord: MemoryRecord;
}
export interface MemoryReview {
reviewState: MemoryReviewState;
note?: string | null;
}
export interface MemoryReviewResult {
operation: MemoryOperation;
record: MemoryRecord;
}
export interface MemoryRetentionSweepResult {
operations: MemoryOperation[];
expiredRecordIds: string[];
+3 -1
View File
@@ -10,7 +10,7 @@ import type {
PluginLauncherBounds,
PluginLauncherRenderEnvironment,
} from "../constants.js";
import type { MemoryProviderCapabilities } from "./memory.js";
import type { MemoryProviderCapabilities, MemoryProviderConfigMetadata } from "./memory.js";
// ---------------------------------------------------------------------------
// JSON Schema placeholder – plugins declare config schemas as JSON Schema
@@ -202,6 +202,8 @@ export interface PluginMemoryProviderDeclaration {
capabilities?: Partial<MemoryProviderCapabilities>;
/** Optional JSON Schema for provider-specific binding config. */
configSchema?: JsonSchema;
/** Optional UI metadata for rendering provider-specific binding config. */
configMetadata?: MemoryProviderConfigMetadata;
}
// ---------------------------------------------------------------------------
+9
View File
@@ -241,15 +241,22 @@ export {
memoryScopeSchema,
memorySourceRefSchema,
memoryProviderCapabilitiesSchema,
memoryProviderConfigFieldOptionSchema,
memoryProviderConfigFieldMetadataSchema,
memoryProviderHealthCheckSchema,
memoryProviderConfigPathSuggestionSchema,
memoryProviderConfigMetadataSchema,
createMemoryBindingSchema,
updateMemoryBindingSchema,
setCompanyMemoryBindingSchema,
setAgentMemoryBindingSchema,
setProjectMemoryBindingSchema,
memoryQuerySchema,
memoryCaptureSchema,
memoryForgetSchema,
memoryRevokeSchema,
memoryCorrectSchema,
memoryReviewSchema,
memoryRetentionSweepSchema,
memoryListRecordsQuerySchema,
memoryListOperationsQuerySchema,
@@ -264,11 +271,13 @@ export {
type UpdateMemoryBinding,
type SetCompanyMemoryBinding,
type SetAgentMemoryBinding,
type SetProjectMemoryBinding,
type MemoryQuery,
type MemoryCapture,
type MemoryForget,
type MemoryRevoke,
type MemoryCorrect,
type MemoryReview,
type MemoryRetentionSweep,
type MemoryListRecordsQuery,
type MemoryListOperationsQuery,
+73
View File
@@ -6,6 +6,7 @@ import {
MEMORY_OPERATION_TYPES,
MEMORY_PRINCIPAL_TYPES,
MEMORY_RETENTION_STATES,
MEMORY_REVIEW_STATES,
MEMORY_SCOPE_TYPES,
MEMORY_SENSITIVITY_LABELS,
MEMORY_SOURCE_KINDS,
@@ -73,6 +74,59 @@ export const memoryProviderCapabilitiesSchema = z
})
.strict();
export const memoryProviderConfigFieldOptionSchema = z
.object({
value: z.union([z.string(), z.number(), z.boolean(), z.null()]),
label: z.string().trim().min(1).max(120),
description: z.string().trim().max(500).nullable().optional(),
})
.strict();
export const memoryProviderConfigFieldMetadataSchema = z
.object({
key: z.string().trim().min(1).max(128),
label: z.string().trim().min(1).max(120),
description: z.string().trim().max(1000).nullable().optional(),
input: z.enum(["text", "number", "boolean", "select", "path", "secret"]),
required: z.boolean().optional(),
secret: z.boolean().optional(),
defaultValue: z.unknown().optional(),
suggestedValue: z.unknown().optional(),
placeholder: z.string().trim().max(500).nullable().optional(),
min: z.number().nullable().optional(),
max: z.number().nullable().optional(),
options: z.array(memoryProviderConfigFieldOptionSchema).max(50).optional(),
})
.strict();
export const memoryProviderHealthCheckSchema = z
.object({
key: z.string().trim().min(1).max(128),
label: z.string().trim().min(1).max(120),
status: z.enum(["ok", "warning", "error", "unknown"]),
message: z.string().trim().max(1000).nullable().optional(),
details: z.record(z.unknown()).nullable().optional(),
})
.strict();
export const memoryProviderConfigPathSuggestionSchema = z
.object({
key: z.string().trim().min(1).max(128),
label: z.string().trim().min(1).max(120),
path: z.string().trim().min(1).max(2000),
description: z.string().trim().max(1000).nullable().optional(),
})
.strict();
export const memoryProviderConfigMetadataSchema = z
.object({
fields: z.array(memoryProviderConfigFieldMetadataSchema).max(100),
suggestedConfig: z.record(z.unknown()),
pathSuggestions: z.array(memoryProviderConfigPathSuggestionSchema).max(20).optional(),
healthChecks: z.array(memoryProviderHealthCheckSchema).max(50).optional(),
})
.strict();
export const createMemoryBindingSchema = z
.object({
key: z.string().trim().min(1).max(64).regex(/^[a-z0-9][a-z0-9_-]*$/, "Binding key must be lowercase letters, numbers, _ or -"),
@@ -104,6 +158,12 @@ export const setAgentMemoryBindingSchema = z
})
.strict();
export const setProjectMemoryBindingSchema = z
.object({
bindingId: z.string().uuid().nullable(),
})
.strict();
export const memoryQuerySchema = z
.object({
bindingKey: z.string().trim().min(1).max(64).optional(),
@@ -131,6 +191,7 @@ export const memoryCaptureSchema = z
content: z.string().trim().min(1).max(20000),
summary: z.string().trim().max(2000).nullable().optional(),
metadata: z.record(z.unknown()).optional(),
reviewState: z.enum(MEMORY_REVIEW_STATES).optional().default("pending"),
})
.strict();
@@ -176,6 +237,13 @@ export const memoryCorrectSchema = z
})
.strict();
export const memoryReviewSchema = z
.object({
reviewState: z.enum(MEMORY_REVIEW_STATES),
note: z.string().trim().max(1000).nullable().optional(),
})
.strict();
export const memoryRetentionSweepSchema = z
.object({
now: z.coerce.date().optional(),
@@ -187,12 +255,14 @@ export const memoryRetentionSweepSchema = z
export const memoryListRecordsQuerySchema = z
.object({
bindingId: z.string().uuid().optional(),
providerKey: z.string().trim().min(1).max(128).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(),
reviewState: z.enum(MEMORY_REVIEW_STATES).optional(),
expiresBefore: z.coerce.date().optional(),
agentId: z.string().uuid().optional(),
workspaceId: z.string().trim().min(1).max(200).optional(),
@@ -201,6 +271,7 @@ export const memoryListRecordsQuerySchema = z
teamId: z.string().trim().min(1).max(200).optional(),
runId: z.string().uuid().optional(),
sourceKind: z.enum(MEMORY_SOURCE_KINDS).optional(),
q: z.string().trim().min(1).max(500).optional(),
includeDeleted: z.coerce.boolean().optional().default(false),
includeRevoked: z.coerce.boolean().optional().default(false),
includeExpired: z.coerce.boolean().optional().default(false),
@@ -240,11 +311,13 @@ export type CreateMemoryBinding = z.infer<typeof createMemoryBindingSchema>;
export type UpdateMemoryBinding = z.infer<typeof updateMemoryBindingSchema>;
export type SetCompanyMemoryBinding = z.infer<typeof setCompanyMemoryBindingSchema>;
export type SetAgentMemoryBinding = z.infer<typeof setAgentMemoryBindingSchema>;
export type SetProjectMemoryBinding = z.infer<typeof setProjectMemoryBindingSchema>;
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 MemoryReview = z.infer<typeof memoryReviewSchema>;
export type MemoryRetentionSweep = z.infer<typeof memoryRetentionSweepSchema>;
export type MemoryListRecordsQuery = z.infer<typeof memoryListRecordsQuerySchema>;
export type MemoryListOperationsQuery = z.infer<typeof memoryListOperationsQuerySchema>;
+2 -1
View File
@@ -12,7 +12,7 @@ import {
PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS,
PLUGIN_STATE_SCOPE_KINDS,
} from "../constants.js";
import { memoryProviderCapabilitiesSchema } from "./memory.js";
import { memoryProviderCapabilitiesSchema, memoryProviderConfigMetadataSchema } from "./memory.js";
// ---------------------------------------------------------------------------
// JSON Schema placeholder – a permissive validator for JSON Schema objects
@@ -112,6 +112,7 @@ export const pluginMemoryProviderDeclarationSchema = z.object({
description: z.string().optional(),
capabilities: memoryProviderCapabilitiesSchema.partial().optional(),
configSchema: jsonSchemaSchema.optional(),
configMetadata: memoryProviderConfigMetadataSchema.optional(),
});
export type PluginMemoryProviderDeclarationInput = z.infer<typeof pluginMemoryProviderDeclarationSchema>;
@@ -18,11 +18,13 @@ const mockMemoryService = vi.hoisted(() => ({
setCompanyDefault: vi.fn(),
resolveBinding: vi.fn(),
setAgentOverride: vi.fn(),
setProjectOverride: vi.fn(),
query: vi.fn(),
capture: vi.fn(),
forget: vi.fn(),
revoke: vi.fn(),
correct: vi.fn(),
review: vi.fn(),
sweepRetention: vi.fn(),
listRecords: vi.fn(),
getRecord: vi.fn(),
@@ -34,12 +36,17 @@ const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockProjectService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
logActivity: mockLogActivity,
memoryService: () => mockMemoryService,
projectService: () => mockProjectService,
}));
function createApp(actor: Record<string, unknown>) {
@@ -79,6 +86,11 @@ describe("memory routes", () => {
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-02T00:00:00.000Z"),
});
mockProjectService.getById.mockResolvedValue({
id: "77777777-7777-4777-8777-777777777777",
companyId: companyA,
name: "Project A",
});
mockLogActivity.mockResolvedValue(undefined);
});
@@ -202,4 +214,90 @@ describe("memory routes", () => {
);
expect(mockLogActivity).toHaveBeenCalledOnce();
});
it("routes board review decisions through memory service", async () => {
const recordId = "44444444-4444-4444-8444-444444444444";
mockMemoryService.review.mockResolvedValue({
operation: { id: "op-1" },
record: { id: recordId, reviewState: "accepted" },
});
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
companyIds: [companyA],
isInstanceAdmin: false,
});
const res = await request(app)
.patch(`/api/companies/${companyA}/memory/records/${recordId}/review`)
.set("Origin", "http://localhost:3100")
.send({ reviewState: "accepted", note: "Looks correct" });
expect(res.status).toBe(200);
expect(mockMemoryService.review).toHaveBeenCalledWith(
companyA,
recordId,
{ reviewState: "accepted", note: "Looks correct" },
expect.objectContaining({ actorType: "user", userId: "board-user" }),
);
expect(mockLogActivity).toHaveBeenCalledOnce();
});
it("sets project memory overrides through the owning project company", async () => {
const projectId = "77777777-7777-4777-8777-777777777777";
mockMemoryService.setProjectOverride.mockResolvedValue({
id: "88888888-8888-4888-8888-888888888888",
companyId: companyA,
bindingId,
targetType: "project",
targetId: projectId,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
});
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
companyIds: [companyA],
isInstanceAdmin: false,
});
const res = await request(app)
.put(`/api/projects/${projectId}/memory-binding`)
.set("Origin", "http://localhost:3100")
.send({ bindingId });
expect(res.status).toBe(200);
expect(mockProjectService.getById).toHaveBeenCalledWith(projectId);
expect(mockMemoryService.setProjectOverride).toHaveBeenCalledWith(projectId, bindingId);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: companyA,
action: "memory.project_override_set",
entityType: "project",
entityId: projectId,
}),
);
});
it("blocks project memory overrides outside the board user's companies", async () => {
const projectId = "77777777-7777-4777-8777-777777777777";
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
companyIds: [companyB],
isInstanceAdmin: false,
});
const res = await request(app)
.put(`/api/projects/${projectId}/memory-binding`)
.set("Origin", "http://localhost:3100")
.send({ bindingId });
expect(res.status).toBe(403);
expect(mockMemoryService.setProjectOverride).not.toHaveBeenCalled();
});
});
+135
View File
@@ -1,6 +1,43 @@
import { describe, expect, it, vi } from "vitest";
import { memoryService } from "../services/memory.js";
function createQueuedSelectDb(selectResults: unknown[][]) {
const calls: Array<{ kind: string }> = [];
const makeThenable = () => ({
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
then: vi.fn((resolve: (rows: unknown[]) => unknown) => {
calls.push({ kind: "select" });
return Promise.resolve(resolve(selectResults.shift() ?? []));
}),
});
return {
calls,
db: {
select: vi.fn(() => makeThenable()),
} as any,
};
}
function makeBinding(id: string, key: string, providerKey = "local_basic") {
const now = new Date("2026-04-01T00:00:00.000Z");
return {
id,
companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
key,
name: key,
providerKey,
config: {},
enabled: true,
createdAt: now,
updatedAt: now,
};
}
describe("memoryService.forget", () => {
it("rejects record sets that span multiple bindings", async () => {
const rows = [
@@ -45,3 +82,101 @@ describe("memoryService.forget", () => {
expect(db.update).not.toHaveBeenCalled();
});
});
describe("memoryService providers", () => {
it("exposes config metadata with field-level defaults for built-in providers", async () => {
const providers = await memoryService({} as any).providers();
const local = providers.find((provider) => provider.key === "local_basic");
expect(local?.configMetadata?.suggestedConfig).toMatchObject({
enablePreRunHydrate: true,
enablePostRunCapture: true,
maxHydrateSnippets: 5,
});
expect(local?.configMetadata?.fields.map((field) => field.key)).toContain("maxHydrateSnippets");
expect(local?.configMetadata?.healthChecks?.[0]).toMatchObject({
key: "postgres",
status: "ok",
});
});
});
describe("memoryService.resolveBinding", () => {
it("prefers an agent override before project and company bindings", async () => {
const agentBinding = makeBinding("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "agent");
const { db } = createQueuedSelectDb([
[{ companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }],
[{ companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }],
[
{
target: {
id: "target-agent",
companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
bindingId: agentBinding.id,
targetType: "agent",
targetId: "11111111-1111-4111-8111-111111111111",
createdAt: agentBinding.createdAt,
updatedAt: agentBinding.updatedAt,
},
binding: agentBinding,
},
],
]);
const resolved = await memoryService(db).resolveBinding(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
{
agentId: "11111111-1111-4111-8111-111111111111",
projectId: "22222222-2222-4222-8222-222222222222",
},
);
expect(resolved.source).toBe("agent_override");
expect(resolved.checkedTargetTypes).toEqual(["agent"]);
expect(resolved.binding?.id).toBe(agentBinding.id);
});
it("falls back from project override to company default", async () => {
const companyBinding = makeBinding("cccccccc-cccc-4ccc-8ccc-cccccccccccc", "company");
const { db } = createQueuedSelectDb([
[{ companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }],
[],
[
{
target: {
id: "target-company",
companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
bindingId: companyBinding.id,
targetType: "company",
targetId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
createdAt: companyBinding.createdAt,
updatedAt: companyBinding.updatedAt,
},
binding: companyBinding,
},
],
]);
const resolved = await memoryService(db).resolveBinding(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
{ projectId: "22222222-2222-4222-8222-222222222222" },
);
expect(resolved.source).toBe("company_default");
expect(resolved.checkedTargetTypes).toEqual(["project", "company"]);
expect(resolved.binding?.id).toBe(companyBinding.id);
});
it("rejects project scopes outside the company before resolving defaults", async () => {
const { db } = createQueuedSelectDb([
[{ companyId: "99999999-9999-4999-8999-999999999999" }],
]);
await expect(
memoryService(db).resolveBinding(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
{ projectId: "22222222-2222-4222-8222-222222222222" },
),
).rejects.toThrow("Memory scope project does not belong to company");
});
});
+57 -1
View File
@@ -9,15 +9,17 @@ import {
memoryListRecordsQuerySchema,
memoryQuerySchema,
memoryRetentionSweepSchema,
memoryReviewSchema,
memoryRevokeSchema,
setAgentMemoryBindingSchema,
setCompanyMemoryBindingSchema,
setProjectMemoryBindingSchema,
updateMemoryBindingSchema,
createMemoryBindingSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { forbidden, notFound } from "../errors.js";
import { agentService, logActivity, memoryService } from "../services/index.js";
import { agentService, logActivity, memoryService, projectService } from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
function actorInfoFromReq(req: any) {
@@ -51,6 +53,7 @@ export function memoryRoutes(
pluginMemoryProviders: opts?.pluginMemoryProviders,
});
const agentsSvc = agentService(db);
const projectsSvc = projectService(db);
router.get("/companies/:companyId/memory/providers", async (req, res) => {
const companyId = req.params.companyId as string;
@@ -172,6 +175,37 @@ export function memoryRoutes(
res.json(target);
});
router.get("/projects/:projectId/memory-binding", async (req, res) => {
const projectId = req.params.projectId as string;
const project = await projectsSvc.getById(projectId);
if (!project) throw notFound("Project not found");
assertCompanyAccess(req, project.companyId);
res.json(await memory.resolveBinding(project.companyId, { projectId: project.id }));
});
router.put("/projects/:projectId/memory-binding", validate(setProjectMemoryBindingSchema), async (req, res) => {
const projectId = req.params.projectId as string;
const project = await projectsSvc.getById(projectId);
if (!project) throw notFound("Project not found");
assertCompanyAccess(req, project.companyId);
assertBoard(req);
const target = await memory.setProjectOverride(project.id, req.body.bindingId);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: project.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: target ? "memory.project_override_set" : "memory.project_override_cleared",
entityType: "project",
entityId: project.id,
details: {
bindingId: target?.bindingId ?? null,
},
});
res.json(target);
});
router.post("/companies/:companyId/memory/query", validate(memoryQuerySchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -290,6 +324,28 @@ export function memoryRoutes(
res.status(201).json(result);
});
router.patch("/companies/:companyId/memory/records/:recordId/review", validate(memoryReviewSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const result = await memory.review(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.reviewed",
entityType: "memory_record",
entityId: result.record.id,
details: {
recordId: result.record.id,
reviewState: result.record.reviewState,
},
});
res.json(result);
});
router.post("/companies/:companyId/memory/retention/sweep", validate(memoryRetentionSweepSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
+332 -35
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { and, desc, eq, inArray, isNull, isNotNull, lte, or, sql } from "drizzle-orm";
import { and, desc, eq, ilike, inArray, isNull, isNotNull, lte, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
@@ -8,10 +8,12 @@ import {
memoryExtractionJobs,
memoryLocalRecords,
memoryOperations,
projects,
} from "@paperclipai/db";
import type {
MemoryBinding,
MemoryBindingTarget,
MemoryBindingTargetType,
MemoryCapture,
MemoryCaptureResult,
MemoryCitation,
@@ -34,6 +36,8 @@ import type {
MemoryRetentionSweepResult,
MemoryRevoke,
MemoryRevokeResult,
MemoryReview,
MemoryReviewResult,
MemoryResolvedBinding,
MemoryScope,
MemoryScopeType,
@@ -41,7 +45,7 @@ import type {
MemorySourceRef,
MemoryUsage,
} from "@paperclipai/shared";
import { createMemoryBindingSchema, memoryCorrectSchema, memoryRetentionSweepSchema, memoryRevokeSchema, updateMemoryBindingSchema } from "@paperclipai/shared";
import { createMemoryBindingSchema, memoryCorrectSchema, memoryRetentionSweepSchema, memoryReviewSchema, memoryRevokeSchema, updateMemoryBindingSchema } from "@paperclipai/shared";
import { z } from "zod";
import { conflict, notFound, unprocessable } from "../errors.js";
import { costService } from "./costs.js";
@@ -117,6 +121,67 @@ const LOCAL_BASIC_PROVIDER: MemoryProviderDescriptor = {
maxHydrateSnippets: { type: "integer", minimum: 1, maximum: 10, default: 5 },
},
},
configMetadata: {
suggestedConfig: {
enablePreRunHydrate: true,
enablePostRunCapture: true,
enableIssueCommentCapture: false,
enableIssueDocumentCapture: true,
maxHydrateSnippets: 5,
},
fields: [
{
key: "enablePreRunHydrate",
label: "Pre-run hydrate",
description: "Read relevant memory before agent runs.",
input: "boolean",
defaultValue: true,
suggestedValue: true,
},
{
key: "enablePostRunCapture",
label: "Post-run capture",
description: "Capture run summaries after agent runs.",
input: "boolean",
defaultValue: true,
suggestedValue: true,
},
{
key: "enableIssueCommentCapture",
label: "Issue comment capture",
description: "Capture issue comments into memory.",
input: "boolean",
defaultValue: false,
suggestedValue: false,
},
{
key: "enableIssueDocumentCapture",
label: "Issue document capture",
description: "Capture issue documents into memory.",
input: "boolean",
defaultValue: true,
suggestedValue: true,
},
{
key: "maxHydrateSnippets",
label: "Hydration snippets",
description: "Maximum snippets to include when hydrating prompts.",
input: "number",
defaultValue: 5,
suggestedValue: 5,
min: 1,
max: 10,
},
],
healthChecks: [
{
key: "postgres",
label: "Postgres storage",
status: "ok",
message: "Local basic memory stores records in the Paperclip database.",
},
],
},
};
const SENSITIVITY_RANK: Record<MemorySensitivityLabel, number> = {
@@ -208,6 +273,7 @@ function canReadRecord(companyId: string, record: MemoryRecord, actor: ActorInfo
const maxSensitivity = maxSensitivityForActor(actor, scope);
if (SENSITIVITY_RANK[record.sensitivityLabel] > SENSITIVITY_RANK[maxSensitivity]) return false;
if (actor.actorType !== "agent") return true;
if (record.reviewState !== "accepted") return false;
return scopeMatches(record, deriveAllowedScopes(companyId, scope, actor));
}
@@ -404,6 +470,11 @@ function mapRecord(row: RecordRow): MemoryRecord {
: null,
expiresAt: row.expiresAt ?? null,
retentionState: row.retentionState ?? "active",
reviewState: row.reviewState ?? "pending",
reviewedAt: row.reviewedAt ?? null,
reviewedBy:
row.reviewedByActorType && row.reviewedByActorId ? { type: row.reviewedByActorType, id: row.reviewedByActorId } : null,
reviewNote: row.reviewNote ?? null,
citation: citationFromRow(row.citationJson),
supersedesRecordId: row.supersedesRecordId ?? null,
supersededByRecordId: row.supersededByRecordId ?? null,
@@ -470,6 +541,7 @@ function buildRecordVisibilityConditions(
if (!options?.includeSuperseded) {
conditions.push(isNull(memoryLocalRecords.supersededByRecordId));
}
conditions.push(eq(memoryLocalRecords.reviewState, "accepted"));
const maxSensitivity = maxSensitivityForActor(actor, scope);
conditions.push(inArray(memoryLocalRecords.sensitivityLabel, allowedSensitivityLabels(maxSensitivity)));
@@ -561,7 +633,66 @@ export function memoryService(
return config;
}
function resolutionSource(
targetType: MemoryBindingTargetType | null,
bindingKey?: string | null,
): MemoryResolvedBinding["source"] {
if (bindingKey) return "binding_key";
if (targetType === "agent") return "agent_override";
if (targetType === "project") return "project_override";
if (targetType === "company") return "company_default";
return "unconfigured";
}
async function findTargetBinding(
companyId: string,
targetType: MemoryBindingTargetType,
targetId: string,
) {
return db
.select({
target: memoryBindingTargets,
binding: memoryBindings,
})
.from(memoryBindingTargets)
.innerJoin(memoryBindings, eq(memoryBindingTargets.bindingId, memoryBindings.id))
.where(
and(
eq(memoryBindingTargets.companyId, companyId),
eq(memoryBindingTargets.targetType, targetType),
eq(memoryBindingTargets.targetId, targetId),
),
)
.then((rows) => rows[0] ?? null);
}
async function assertScopeTargetsBelongToCompany(companyId: string, scope: MemoryScope) {
if (scope.agentId) {
const agent = await db
.select({ companyId: agents.companyId })
.from(agents)
.where(eq(agents.id, scope.agentId))
.then((rows) => rows[0] ?? null);
if (!agent || agent.companyId !== companyId) {
throw unprocessable("Memory scope agent does not belong to company");
}
}
if (scope.projectId) {
const project = await db
.select({ companyId: projects.companyId })
.from(projects)
.where(eq(projects.id, scope.projectId))
.then((rows) => rows[0] ?? null);
if (!project || project.companyId !== companyId) {
throw unprocessable("Memory scope project does not belong to company");
}
}
}
async function resolveBindingInternal(companyId: string, scope: MemoryScope, bindingKey?: string | null) {
await assertScopeTargetsBelongToCompany(companyId, scope);
const checkedTargetTypes: MemoryBindingTargetType[] = [];
if (bindingKey) {
const binding = await db
.select()
@@ -573,61 +704,58 @@ export function memoryService(
targetType: null,
targetId: null,
binding,
source: resolutionSource(null, bindingKey),
checkedTargetTypes,
};
}
const agentId = scope.agentId ?? null;
if (agentId) {
const target = await db
.select({
target: memoryBindingTargets,
binding: memoryBindings,
})
.from(memoryBindingTargets)
.innerJoin(memoryBindings, eq(memoryBindingTargets.bindingId, memoryBindings.id))
.where(
and(
eq(memoryBindingTargets.companyId, companyId),
eq(memoryBindingTargets.targetType, "agent"),
eq(memoryBindingTargets.targetId, agentId),
),
)
.then((rows) => rows[0] ?? null);
checkedTargetTypes.push("agent");
const target = await findTargetBinding(companyId, "agent", agentId);
if (target) {
return {
targetType: target.target.targetType,
targetId: target.target.targetId,
binding: target.binding,
source: resolutionSource(target.target.targetType),
checkedTargetTypes,
};
}
}
const target = await db
.select({
target: memoryBindingTargets,
binding: memoryBindings,
})
.from(memoryBindingTargets)
.innerJoin(memoryBindings, eq(memoryBindingTargets.bindingId, memoryBindings.id))
.where(
and(
eq(memoryBindingTargets.companyId, companyId),
eq(memoryBindingTargets.targetType, "company"),
eq(memoryBindingTargets.targetId, companyId),
),
)
.then((rows) => rows[0] ?? null);
const projectId = scope.projectId ?? null;
if (projectId) {
checkedTargetTypes.push("project");
const target = await findTargetBinding(companyId, "project", projectId);
if (target) {
return {
targetType: target.target.targetType,
targetId: target.target.targetId,
binding: target.binding,
source: resolutionSource(target.target.targetType),
checkedTargetTypes,
};
}
}
checkedTargetTypes.push("company");
const target = await findTargetBinding(companyId, "company", companyId);
return target
? {
targetType: target.target.targetType,
targetId: target.target.targetId,
binding: target.binding,
source: resolutionSource(target.target.targetType),
checkedTargetTypes,
}
: {
targetType: null,
targetId: null,
binding: null,
source: "unconfigured" as const,
checkedTargetTypes,
};
}
@@ -676,6 +804,7 @@ export function memoryService(
content: string;
summary?: string | null;
metadata?: Record<string, unknown>;
reviewState?: MemoryRecord["reviewState"];
},
operationId: string,
) {
@@ -718,6 +847,7 @@ export function memoryService(
retentionPolicy: input.retentionPolicy ?? null,
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
retentionState: "active",
reviewState: input.reviewState ?? "pending",
citationJson: normalizeCitation(input.citation),
createdByOperationId: operationId,
})
@@ -767,6 +897,11 @@ export function memoryService(
retentionPolicy: record.retentionPolicy,
expiresAt: record.expiresAt,
retentionState: record.retentionState,
reviewState: record.reviewState ?? "pending",
reviewedAt: record.reviewedAt ?? null,
reviewedByActorType: record.reviewedBy?.type ?? null,
reviewedByActorId: record.reviewedBy?.id ?? null,
reviewNote: record.reviewNote ?? null,
citationJson: normalizeCitation(record.citation),
supersedesRecordId: record.supersedesRecordId ?? null,
supersededByRecordId: record.supersededByRecordId ?? null,
@@ -812,6 +947,11 @@ export function memoryService(
retentionPolicy: record.retentionPolicy,
expiresAt: record.expiresAt,
retentionState: record.retentionState,
reviewState: record.reviewState ?? "pending",
reviewedAt: record.reviewedAt ?? null,
reviewedByActorType: record.reviewedBy?.type ?? null,
reviewedByActorId: record.reviewedBy?.id ?? null,
reviewNote: record.reviewNote ?? null,
citationJson: normalizeCitation(record.citation),
supersedesRecordId: record.supersedesRecordId ?? null,
supersededByRecordId: record.supersededByRecordId ?? null,
@@ -827,23 +967,76 @@ export function memoryService(
}
}
async function overlayCatalogRecordState(
companyId: string,
bindingId: string,
records: MemoryRecord[],
) {
if (records.length === 0) return records;
const rows = await db
.select()
.from(memoryLocalRecords)
.where(
and(
eq(memoryLocalRecords.companyId, companyId),
eq(memoryLocalRecords.bindingId, bindingId),
inArray(memoryLocalRecords.id, records.map((record) => record.id)),
),
);
const catalogById = new Map(rows.map((row) => [row.id, mapRecord(row)]));
return records.map((record) => {
const catalog = catalogById.get(record.id);
if (!catalog) return record;
return {
...record,
reviewState: catalog.reviewState,
reviewedAt: catalog.reviewedAt,
reviewedBy: catalog.reviewedBy,
reviewNote: catalog.reviewNote,
retentionState: catalog.retentionState,
revokedAt: catalog.revokedAt,
revokedBy: catalog.revokedBy,
revocationReason: catalog.revocationReason,
supersededByRecordId: catalog.supersededByRecordId,
deletedAt: catalog.deletedAt,
updatedAt: catalog.updatedAt,
};
});
}
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.providerKey) conditions.push(eq(memoryLocalRecords.providerKey, filters.providerKey));
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.reviewState) conditions.push(eq(memoryLocalRecords.reviewState, filters.reviewState));
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.issueId) {
conditions.push(or(eq(memoryLocalRecords.scopeIssueId, filters.issueId), eq(memoryLocalRecords.sourceIssueId, 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.runId) {
conditions.push(or(eq(memoryLocalRecords.scopeRunId, filters.runId), eq(memoryLocalRecords.sourceRunId, filters.runId))!);
}
if (filters.sourceKind) conditions.push(eq(memoryLocalRecords.sourceKind, filters.sourceKind));
if (filters.q) {
const pattern = `%${filters.q}%`;
conditions.push(
or(
ilike(memoryLocalRecords.title, pattern),
ilike(memoryLocalRecords.content, pattern),
ilike(memoryLocalRecords.summary, pattern),
)!,
);
}
if (!filters.includeDeleted) conditions.push(isNull(memoryLocalRecords.deletedAt));
if (!filters.includeRevoked) conditions.push(isNull(memoryLocalRecords.revokedAt));
if (!filters.includeExpired) {
@@ -1135,6 +1328,40 @@ export function memoryService(
return mapTarget(row);
},
setProjectOverride: async (projectId: string, bindingId: string | null) => {
const project = await db
.select()
.from(projects)
.where(eq(projects.id, projectId))
.then((rows) => rows[0] ?? null);
if (!project) throw notFound("Project not found");
await db
.delete(memoryBindingTargets)
.where(
and(
eq(memoryBindingTargets.companyId, project.companyId),
eq(memoryBindingTargets.targetType, "project"),
eq(memoryBindingTargets.targetId, project.id),
),
);
if (!bindingId) return null;
const binding = await getBindingOrThrow(bindingId);
if (binding.companyId !== project.companyId) throw unprocessable("Binding does not belong to project company");
const [row] = await db
.insert(memoryBindingTargets)
.values({
companyId: project.companyId,
bindingId,
targetType: "project",
targetId: project.id,
})
.returning();
return mapTarget(row);
},
resolveBinding: async (companyId: string, scope: MemoryScope): Promise<MemoryResolvedBinding> => {
const resolved = await resolveBindingInternal(companyId, scope, null);
return {
@@ -1142,6 +1369,8 @@ export function memoryService(
targetType: resolved.targetType,
targetId: resolved.targetId,
binding: resolved.binding ? mapBinding(resolved.binding) : null,
source: resolved.source,
checkedTargetTypes: resolved.checkedTargetTypes,
};
},
@@ -1174,7 +1403,7 @@ export function memoryService(
intent: data.intent,
metadataFilter: data.metadataFilter,
});
records = providerResult.records;
records = await overlayCatalogRecordState(companyId, binding.id, providerResult.records);
preamble = providerResult.preamble ?? null;
usage = providerResult.usage ?? [];
providerResultJson = providerResult.resultJson ?? null;
@@ -1189,6 +1418,7 @@ export function memoryService(
!record.deletedAt
&& !record.revokedAt
&& record.retentionState === "active"
&& record.reviewState === "accepted"
&& !record.supersededByRecordId
&& (!record.expiresAt || record.expiresAt > new Date())
&& SENSITIVITY_RANK[record.sensitivityLabel] <= SENSITIVITY_RANK[maxSensitivityLabel],
@@ -1253,6 +1483,7 @@ export function memoryService(
content: data.content,
summary: data.summary ?? null,
metadata: data.metadata ?? {},
reviewState: data.reviewState,
},
operationId,
);
@@ -1298,12 +1529,14 @@ export function memoryService(
scopeId: data.scopeId ?? data.scope?.scopeId ?? null,
owner: data.owner ?? null,
sensitivityLabel: data.sensitivityLabel,
reviewState: data.reviewState,
expiresAt: data.expiresAt ? new Date(data.expiresAt).toISOString() : null,
metadata: data.metadata ?? {},
},
policyDecision: {
captureAllowed: true,
sensitivityLabel: data.sensitivityLabel,
reviewState: data.reviewState,
},
resultJson: {
recordIds: records.map((record) => record.id),
@@ -1524,6 +1757,11 @@ export function memoryService(
retentionPolicy: parsed.retentionPolicy === undefined ? originalRow.retentionPolicy : parsed.retentionPolicy,
expiresAt: parsed.expiresAt === undefined ? originalRow.expiresAt : parsed.expiresAt,
retentionState: "active",
reviewState: "accepted",
reviewedAt: correctedAt,
reviewedByActorType: principal.type,
reviewedByActorId: principal.id,
reviewNote: parsed.reason,
citationJson: parsed.citation === undefined ? originalRow.citationJson : normalizeCitation(parsed.citation),
supersedesRecordId: originalRow.id,
createdByOperationId: operationId,
@@ -1565,6 +1803,62 @@ export function memoryService(
} satisfies MemoryCorrectResult;
},
review: async (
companyId: string,
recordId: string,
data: MemoryReview,
actor: ActorInfo,
): Promise<MemoryReviewResult> => {
const parsed = memoryReviewSchema.parse(data);
const row = await db
.select()
.from(memoryLocalRecords)
.where(and(eq(memoryLocalRecords.companyId, companyId), eq(memoryLocalRecords.id, recordId)))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Memory record not found");
if (row.deletedAt || row.revokedAt || row.retentionState === "revoked") {
throw conflict("Revoked memory records cannot be reviewed");
}
const binding = await getBindingOrThrow(row.bindingId);
const reviewedAt = new Date();
const principal = actorPrincipal(actor);
const [updatedRow] = await db
.update(memoryLocalRecords)
.set({
reviewState: parsed.reviewState,
reviewedAt,
reviewedByActorType: principal.type,
reviewedByActorId: principal.id,
reviewNote: parsed.note ?? null,
updatedAt: reviewedAt,
})
.where(and(eq(memoryLocalRecords.companyId, companyId), eq(memoryLocalRecords.id, recordId)))
.returning();
const operation = await logOperation({
companyId,
binding,
actor,
operationType: "review",
triggerKind: "manual",
scope: scopeFromRow(row),
source: sourceFromRow(row),
requestJson: {
recordId,
reviewState: parsed.reviewState,
note: parsed.note ?? null,
},
resultJson: {
recordId,
reviewState: parsed.reviewState,
},
recordCount: 1,
});
return { operation, record: mapRecord(updatedRow) } satisfies MemoryReviewResult;
},
sweepRetention: async (
companyId: string,
data: MemoryRetentionSweep,
@@ -1801,6 +2095,7 @@ export function memoryService(
title: input.title ?? "Run summary",
content: input.summary,
summary: input.summary,
reviewState: "accepted",
},
{
actorType: "agent",
@@ -1862,6 +2157,7 @@ export function memoryService(
title: "Issue comment",
content: input.body,
summary: input.body.replace(/\s+/g, " ").slice(0, 240),
reviewState: "accepted",
},
input.actor,
"hook",
@@ -1915,6 +2211,7 @@ export function memoryService(
title: input.title ?? `Issue document: ${input.key}`,
content: input.body,
summary: input.body.replace(/\s+/g, " ").slice(0, 240),
reviewState: "accepted",
},
input.actor,
"hook",
@@ -86,6 +86,7 @@ export function createPluginMemoryProviderDispatcher(
providerManagedExtraction: provider.capabilities?.providerManagedExtraction ?? false,
},
configSchema: provider.configSchema ?? null,
configMetadata: provider.configMetadata ?? null,
},
});
}
+5
View File
@@ -14,6 +14,7 @@ import { ProjectDetail } from "./pages/ProjectDetail";
import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail";
import { Issues } from "./pages/Issues";
import { IssueDetail } from "./pages/IssueDetail";
import { WorkMemories } from "./pages/WorkMemories";
import { Routines } from "./pages/Routines";
import { RoutineDetail } from "./pages/RoutineDetail";
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
@@ -150,6 +151,7 @@ function boardRoutes() {
<Route path="projects/:projectId/workspaces/:workspaceId" element={<ProjectWorkspaceDetail />} />
<Route path="projects/:projectId/workspaces" element={<ProjectDetail />} />
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
<Route path="projects/:projectId/memory" element={<ProjectDetail />} />
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
<Route path="issues" element={<Issues />} />
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
@@ -158,6 +160,7 @@ function boardRoutes() {
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="memories" element={<WorkMemories />} />
<Route path="routines" element={<Routines />} />
<Route path="routines/:routineId" element={<RoutineDetail />} />
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
@@ -350,6 +353,8 @@ export function App() {
<Route path="projects/:projectId/workspaces" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/memory" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/budget" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
+12
View File
@@ -11,11 +11,14 @@ import type {
MemoryRecord,
MemoryRetentionSweep,
MemoryRetentionSweepResult,
MemoryReview,
MemoryReviewResult,
MemoryRevoke,
MemoryRevokeResult,
MemoryResolvedBinding,
SetAgentMemoryBinding,
SetCompanyMemoryBinding,
SetProjectMemoryBinding,
UpdateMemoryBinding,
} from "@paperclipai/shared";
import { api } from "./client";
@@ -47,6 +50,10 @@ 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 }),
getProjectBinding: (projectId: string) =>
api.get<MemoryResolvedBinding>(`/projects/${encodeURIComponent(projectId)}/memory-binding`),
setProjectBinding: (projectId: string, bindingId: SetProjectMemoryBinding["bindingId"]) =>
api.put<MemoryBindingTarget | null>(`/projects/${encodeURIComponent(projectId)}/memory-binding`, { bindingId }),
listRecords: (companyId: string, filters?: Partial<MemoryListRecordsQuery>) =>
api.get<MemoryRecord[]>(
`/companies/${encodeURIComponent(companyId)}/memory/records${buildQueryString(filters)}`,
@@ -62,6 +69,11 @@ export const memoryApi = {
`/companies/${encodeURIComponent(companyId)}/memory/records/${encodeURIComponent(recordId)}/correct`,
data,
),
reviewRecord: (companyId: string, recordId: string, data: MemoryReview) =>
api.patch<MemoryReviewResult>(
`/companies/${encodeURIComponent(companyId)}/memory/records/${encodeURIComponent(recordId)}/review`,
data,
),
sweepRetention: (companyId: string, data: Partial<MemoryRetentionSweep> = {}) =>
api.post<MemoryRetentionSweepResult>(
`/companies/${encodeURIComponent(companyId)}/memory/retention/sweep`,
+7 -5
View File
@@ -7,9 +7,11 @@ import { queryKeys } from "../lib/queryKeys";
import { formatCents, relativeTime } from "../lib/utils";
import { Button } from "@/components/ui/button";
function describeResolvedScope(targetType: "company" | "agent" | null) {
if (targetType === "agent") return "Agent override";
if (targetType === "company") return "Company default";
function describeResolvedSource(source: string | null | undefined) {
if (source === "agent_override") return "Agent override";
if (source === "project_override") return "Project override";
if (source === "company_default") return "Company default";
if (source === "binding_key") return "Direct binding key";
return "Unconfigured";
}
@@ -134,7 +136,7 @@ export function AgentMemoryTab({
<>
<div className="text-sm">{describeBinding(resolvedBindingQuery.data?.binding ?? null)}</div>
<div className="text-xs text-muted-foreground">
Source: {describeResolvedScope(resolvedBindingQuery.data?.targetType ?? null)}
Source: {describeResolvedSource(resolvedBindingQuery.data?.source ?? null)}
</div>
<div className="text-xs text-muted-foreground">
Automatic capture hooks write recent run summaries and issue context into the resolved binding.
@@ -154,7 +156,7 @@ export function AgentMemoryTab({
disabled={loading || Boolean(error)}
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="__inherit__">Inherit company default</option>
<option value="__inherit__">Inherit project/company default</option>
{(bindingsQuery.data ?? []).map((binding) => (
<option key={binding.id} value={binding.id}>
{binding.name ?? binding.key}
+52 -68
View File
@@ -10,9 +10,11 @@ import { MEMORY_RETENTION_STATES, MEMORY_SCOPE_TYPES, MEMORY_SENSITIVITY_LABELS
import { memoryApi } from "../api/memory";
import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { getSuggestedMemoryConfig, prettyMemoryConfig, validateMemoryProviderConfig } from "../lib/memory-config-schema";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { MemoryProviderConfigForm } from "./MemoryProviderConfigForm";
const DEFAULT_LOCAL_BASIC_CONFIG = {
enablePreRunHydrate: true,
@@ -22,23 +24,6 @@ const DEFAULT_LOCAL_BASIC_CONFIG = {
maxHydrateSnippets: 5,
};
function prettyJson(value: Record<string, unknown>) {
return JSON.stringify(value, null, 2);
}
function parseConfigText(configText: string) {
let parsed: unknown;
try {
parsed = JSON.parse(configText);
} catch (error) {
throw new Error(error instanceof Error ? error.message : "Config must be valid JSON");
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("Config must be a JSON object");
}
return parsed as Record<string, unknown>;
}
function describeBindingConfig(binding: MemoryBinding) {
if (binding.providerKey !== "local_basic") {
const keys = Object.keys(binding.config ?? {});
@@ -89,24 +74,25 @@ function MemoryBindingCard({
const queryClient = useQueryClient();
const [name, setName] = useState(binding.name ?? "");
const [enabled, setEnabled] = useState(binding.enabled);
const [configText, setConfigText] = useState(prettyJson(binding.config ?? {}));
const [configError, setConfigError] = useState<string | null>(null);
const [config, setConfig] = useState<Record<string, unknown>>(binding.config ?? {});
const [configValid, setConfigValid] = useState(true);
useEffect(() => {
setName(binding.name ?? "");
setEnabled(binding.enabled);
setConfigText(prettyJson(binding.config ?? {}));
setConfigError(null);
setConfig(binding.config ?? {});
setConfigValid(true);
}, [binding]);
const dirty =
name !== (binding.name ?? "")
|| enabled !== binding.enabled
|| configText !== prettyJson(binding.config ?? {});
|| prettyMemoryConfig(config) !== prettyMemoryConfig(binding.config ?? {});
const updateBinding = useMutation({
mutationFn: async () => {
const config = parseConfigText(configText);
const validation = validateMemoryProviderConfig(provider, config);
if (!validation.valid) throw new Error("Provider config has invalid fields");
return memoryApi.updateBinding(binding.id, {
name: name.trim() || null,
enabled,
@@ -114,7 +100,6 @@ function MemoryBindingCard({
});
},
onSuccess: async () => {
setConfigError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all });
pushToast({
title: "Memory binding updated",
@@ -159,7 +144,7 @@ function MemoryBindingCard({
<div className="text-xs text-muted-foreground">{providerDescription(provider)}</div>
<div className="text-xs text-muted-foreground">{describeBindingConfig(binding)}</div>
<div className="text-xs text-muted-foreground">
{overrideCount > 0 ? `${overrideCount} agent override${overrideCount === 1 ? "" : "s"}` : "No agent overrides"}
{overrideCount > 0 ? `${overrideCount} override${overrideCount === 1 ? "" : "s"}` : "No project or agent overrides"}
</div>
</div>
<Button
@@ -191,37 +176,26 @@ function MemoryBindingCard({
Enabled
</label>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Provider config JSON</label>
<Textarea
value={configText}
onChange={(event) => {
setConfigText(event.target.value);
setConfigError(null);
}}
className="min-h-44 font-mono text-xs"
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Provider config</div>
<MemoryProviderConfigForm
provider={provider}
value={config}
onChange={setConfig}
onValidationChange={setConfigValid}
/>
{configError && <p className="text-xs text-destructive">{configError}</p>}
{updateBinding.isError && !configError && (
{updateBinding.isError ? (
<p className="text-xs text-destructive">
{updateBinding.error instanceof Error ? updateBinding.error.message : "Failed to update binding"}
</p>
)}
) : null}
</div>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
disabled={!dirty || updateBinding.isPending}
onClick={() => {
try {
parseConfigText(configText);
setConfigError(null);
updateBinding.mutate();
} catch (error) {
setConfigError(error instanceof Error ? error.message : "Config must be valid JSON");
}
}}
disabled={!dirty || !configValid || updateBinding.isPending}
onClick={() => updateBinding.mutate()}
>
{updateBinding.isPending ? "Saving..." : "Save binding"}
</Button>
@@ -237,7 +211,8 @@ export function CompanyMemorySettings({ companyId }: { companyId: string }) {
const [key, setKey] = useState("default-memory");
const [name, setName] = useState("Default memory");
const [providerKey, setProviderKey] = useState("local_basic");
const [configText, setConfigText] = useState(prettyJson(DEFAULT_LOCAL_BASIC_CONFIG));
const [config, setConfig] = useState<Record<string, unknown>>(DEFAULT_LOCAL_BASIC_CONFIG);
const [configValid, setConfigValid] = useState(true);
const [enabled, setEnabled] = useState(true);
const [makeDefault, setMakeDefault] = useState(true);
const [createError, setCreateError] = useState<string | null>(null);
@@ -286,6 +261,7 @@ export function CompanyMemorySettings({ companyId }: { companyId: string }) {
() => new Map((providersQuery.data ?? []).map((provider) => [provider.key, provider])),
[providersQuery.data],
);
const selectedProvider = providersByKey.get(providerKey);
const defaultBindingId =
targetsQuery.data?.find((target) => target.targetType === "company" && target.targetId === companyId)?.bindingId ?? null;
@@ -293,7 +269,7 @@ export function CompanyMemorySettings({ companyId }: { companyId: string }) {
const overrideCountByBindingId = useMemo(() => {
const result = new Map<string, number>();
for (const target of targetsQuery.data ?? []) {
if (target.targetType !== "agent") continue;
if (target.targetType !== "agent" && target.targetType !== "project") continue;
result.set(target.bindingId, (result.get(target.bindingId) ?? 0) + 1);
}
return result;
@@ -302,12 +278,16 @@ export function CompanyMemorySettings({ companyId }: { companyId: string }) {
useEffect(() => {
if (!providersQuery.data?.length) return;
if (providersQuery.data.some((provider) => provider.key === providerKey)) return;
setProviderKey(providersQuery.data[0]!.key);
const nextProvider = providersQuery.data[0]!;
setProviderKey(nextProvider.key);
setConfig(getSuggestedMemoryConfig(nextProvider));
setConfigValid(true);
}, [providerKey, providersQuery.data]);
const createBinding = useMutation({
mutationFn: async () => {
const config = parseConfigText(configText);
const validation = validateMemoryProviderConfig(selectedProvider, config);
if (!validation.valid) throw new Error("Provider config has invalid fields");
const created = await memoryApi.createBinding(companyId, {
key: key.trim(),
name: name.trim() || null,
@@ -324,7 +304,8 @@ export function CompanyMemorySettings({ companyId }: { companyId: string }) {
setCreateError(null);
setKey("default-memory");
setName("Default memory");
setConfigText(prettyJson(DEFAULT_LOCAL_BASIC_CONFIG));
setConfig(getSuggestedMemoryConfig(providersByKey.get("local_basic")) ?? DEFAULT_LOCAL_BASIC_CONFIG);
setConfigValid(true);
setEnabled(true);
setMakeDefault(true);
await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all });
@@ -467,7 +448,14 @@ export function CompanyMemorySettings({ companyId }: { companyId: string }) {
<label className="text-xs font-medium text-muted-foreground">Provider</label>
<select
value={providerKey}
onChange={(event) => setProviderKey(event.target.value)}
onChange={(event) => {
const nextKey = event.target.value;
const nextProvider = providersByKey.get(nextKey);
setProviderKey(nextKey);
setConfig(getSuggestedMemoryConfig(nextProvider));
setConfigValid(true);
setCreateError(null);
}}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none"
>
{(providersQuery.data ?? []).map((provider) => (
@@ -494,30 +482,26 @@ export function CompanyMemorySettings({ companyId }: { companyId: string }) {
Set as company default
</label>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Config JSON</label>
<Textarea
value={configText}
onChange={(event) => {
setConfigText(event.target.value);
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Provider config</div>
<MemoryProviderConfigForm
provider={selectedProvider}
value={config}
onChange={(nextConfig) => {
setConfig(nextConfig);
setCreateError(null);
}}
className="min-h-44 font-mono text-xs"
onValidationChange={setConfigValid}
/>
{createError && <p className="text-xs text-destructive">{createError}</p>}
</div>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
disabled={!key.trim() || !providerKey || createBinding.isPending}
disabled={!key.trim() || !providerKey || !configValid || createBinding.isPending}
onClick={() => {
try {
parseConfigText(configText);
setCreateError(null);
createBinding.mutate();
} catch (error) {
setCreateError(error instanceof Error ? error.message : "Config must be valid JSON");
}
setCreateError(null);
createBinding.mutate();
}}
>
{createBinding.isPending ? "Creating..." : "Create binding"}
@@ -0,0 +1,227 @@
import { useEffect, useMemo, useState } from "react";
import type { MemoryProviderConfigFieldMetadata, MemoryProviderDescriptor } from "@paperclipai/shared";
import { ChevronDown, Database, FileSearch } from "lucide-react";
import {
getMemoryConfigFields,
parseMemoryConfigJson,
prettyMemoryConfig,
validateMemoryProviderConfig,
} from "../lib/memory-config-schema";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { cn } from "../lib/utils";
function fieldValue(value: unknown) {
if (value === null || value === undefined) return "";
return String(value);
}
function coerceNumber(value: string) {
if (value.trim() === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
}
function coerceSelectValue(field: MemoryProviderConfigFieldMetadata, value: string) {
const option = (field.options ?? []).find((entry) => String(entry.value) === value);
return option ? option.value : value;
}
function fieldGridClass(fields: MemoryProviderConfigFieldMetadata[]) {
return fields.some((field) => field.input === "boolean")
? "grid gap-3 lg:grid-cols-2"
: "grid gap-3 md:grid-cols-2";
}
export function MemoryProviderConfigForm({
provider,
value,
onChange,
onValidationChange,
}: {
provider?: MemoryProviderDescriptor;
value: Record<string, unknown>;
onChange: (value: Record<string, unknown>) => void;
onValidationChange?: (valid: boolean) => void;
}) {
const fields = useMemo(() => getMemoryConfigFields(provider), [provider]);
const validation = useMemo(() => validateMemoryProviderConfig(provider, value), [provider, value]);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [jsonText, setJsonText] = useState(() => prettyMemoryConfig(value));
const [jsonError, setJsonError] = useState<string | null>(null);
useEffect(() => {
setJsonText(prettyMemoryConfig(value));
setJsonError(null);
}, [value]);
useEffect(() => {
onValidationChange?.(validation.valid && !jsonError);
}, [jsonError, onValidationChange, validation.valid]);
function updateField(key: string, nextValue: unknown) {
onChange({ ...value, [key]: nextValue });
}
function renderField(field: MemoryProviderConfigFieldMetadata) {
const error = validation.fieldErrors[field.key];
const common = (
<>
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-medium text-muted-foreground">{field.label}</label>
{field.suggestedValue !== undefined && value[field.key] !== field.suggestedValue ? (
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 px-2 text-[11px]"
onClick={() => updateField(field.key, field.suggestedValue)}
>
Use suggested
</Button>
) : null}
</div>
{field.description ? <p className="text-xs text-muted-foreground">{field.description}</p> : null}
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</>
);
if (field.input === "boolean") {
return (
<div key={field.key} className="rounded-md border border-border px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">{common}</div>
<ToggleSwitch
checked={Boolean(value[field.key])}
onCheckedChange={(checked) => updateField(field.key, checked)}
/>
</div>
</div>
);
}
if (field.input === "select") {
return (
<div key={field.key} className="space-y-1">
{common}
<select
value={fieldValue(value[field.key])}
onChange={(event) => updateField(field.key, coerceSelectValue(field, 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"
>
{(field.options ?? []).map((option) => (
<option key={String(option.value)} value={fieldValue(option.value)}>
{option.label}
</option>
))}
</select>
</div>
);
}
return (
<div key={field.key} className="space-y-1">
{common}
<Input
type={field.input === "number" ? "number" : field.input === "secret" ? "password" : "text"}
min={field.min ?? undefined}
max={field.max ?? undefined}
value={fieldValue(value[field.key])}
placeholder={field.placeholder ?? undefined}
onChange={(event) => {
const raw = event.target.value;
if (field.input === "number") {
updateField(field.key, coerceNumber(raw));
} else {
updateField(field.key, raw.trim() === "" && field.defaultValue === null ? null : raw);
}
}}
/>
</div>
);
}
return (
<div className="space-y-3">
{fields.length > 0 ? (
<div className={fieldGridClass(fields)}>{fields.map((field) => renderField(field))}</div>
) : (
<div className="rounded-md border border-dashed border-border px-4 py-4 text-sm text-muted-foreground">
This provider does not publish schema fields yet. Use advanced JSON for its config.
</div>
)}
{provider?.configMetadata?.pathSuggestions?.length ? (
<div className="grid gap-2 md:grid-cols-2">
{provider.configMetadata.pathSuggestions.map((suggestion) => (
<div key={suggestion.key} className="rounded-md border border-border px-3 py-3 text-xs">
<div className="flex items-center gap-2 font-medium">
<FileSearch className="h-3.5 w-3.5 text-muted-foreground" />
{suggestion.label}
</div>
<div className="mt-1 font-mono text-muted-foreground">{suggestion.path}</div>
{suggestion.description ? <div className="mt-1 text-muted-foreground">{suggestion.description}</div> : null}
</div>
))}
</div>
) : null}
{provider?.configMetadata?.healthChecks?.length ? (
<div className="grid gap-2 md:grid-cols-2">
{provider.configMetadata.healthChecks.map((check) => (
<div key={check.key} className="rounded-md border border-border px-3 py-3 text-xs">
<div className="flex items-center gap-2 font-medium">
<Database className="h-3.5 w-3.5 text-muted-foreground" />
{check.label}
<span className={cn(
"rounded-full px-2 py-0.5 text-[10px] uppercase",
check.status === "ok"
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: check.status === "error"
? "bg-destructive/10 text-destructive"
: "bg-accent text-muted-foreground",
)}>
{check.status}
</span>
</div>
{check.message ? <div className="mt-1 text-muted-foreground">{check.message}</div> : null}
</div>
))}
</div>
) : null}
<div className="rounded-md border border-border">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-accent/30"
onClick={() => setAdvancedOpen((open) => !open)}
>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", !advancedOpen && "-rotate-90")} />
Advanced JSON
</button>
{advancedOpen ? (
<div className="space-y-2 border-t border-border px-3 py-3">
<Textarea
value={jsonText}
onChange={(event) => {
const nextText = event.target.value;
setJsonText(nextText);
try {
onChange(parseMemoryConfigJson(nextText));
setJsonError(null);
} catch (error) {
setJsonError(error instanceof Error ? error.message : "Config must be valid JSON");
}
}}
className="min-h-40 font-mono text-xs"
/>
{jsonError ? <p className="text-xs text-destructive">{jsonError}</p> : null}
</div>
) : null}
</div>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Database } from "lucide-react";
import { memoryApi } from "../api/memory";
import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
function sourceLabel(source: string | null | undefined) {
if (source === "project_override") return "Project override";
if (source === "company_default") return "Company default";
if (source === "agent_override") return "Agent override";
if (source === "binding_key") return "Direct binding key";
return "Unconfigured";
}
export function ProjectMemorySettings({
companyId,
projectId,
}: {
companyId: string;
projectId: string;
}) {
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [selectedBindingId, setSelectedBindingId] = useState("__inherit__");
const bindingsQuery = useQuery({
queryKey: queryKeys.memory.bindings(companyId),
queryFn: () => memoryApi.listBindings(companyId),
});
const resolvedBindingQuery = useQuery({
queryKey: queryKeys.memory.projectBinding(projectId),
queryFn: () => memoryApi.getProjectBinding(projectId),
});
useEffect(() => {
const resolved = resolvedBindingQuery.data;
if (!resolved) return;
if (resolved.targetType === "project" && resolved.binding) {
setSelectedBindingId(resolved.binding.id);
return;
}
setSelectedBindingId("__inherit__");
}, [resolvedBindingQuery.data]);
const currentSelection = useMemo(() => {
const resolved = resolvedBindingQuery.data;
if (!resolved) return "__inherit__";
if (resolved.targetType === "project" && resolved.binding) return resolved.binding.id;
return "__inherit__";
}, [resolvedBindingQuery.data]);
const saveOverride = useMutation({
mutationFn: () =>
memoryApi.setProjectBinding(projectId, selectedBindingId === "__inherit__" ? null : selectedBindingId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all });
pushToast({
title: "Project memory binding updated",
body: selectedBindingId === "__inherit__"
? "This project now inherits the company default binding."
: "The project override is active for subsequent memory operations.",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to update project memory binding",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
const loading = bindingsQuery.isLoading || resolvedBindingQuery.isLoading;
const error = bindingsQuery.error ?? resolvedBindingQuery.error ?? null;
const resolvedBinding = resolvedBindingQuery.data?.binding ?? null;
return (
<div className="max-w-3xl space-y-4">
<div className="rounded-md border border-border px-4 py-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-md bg-accent text-muted-foreground">
<Database className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-medium">Project memory</h2>
{loading ? (
<p className="text-sm text-muted-foreground">Loading memory state...</p>
) : error ? (
<p className="text-sm text-destructive">{error.message}</p>
) : (
<>
<p className="text-sm">
{resolvedBinding ? `${resolvedBinding.name ?? resolvedBinding.key} (${resolvedBinding.providerKey})` : "No memory binding resolves for this project yet."}
</p>
<p className="text-xs text-muted-foreground">
Source: {sourceLabel(resolvedBindingQuery.data?.source ?? null)}
</p>
</>
)}
</div>
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Project override</label>
<select
value={selectedBindingId}
onChange={(event) => setSelectedBindingId(event.target.value)}
disabled={loading || Boolean(error)}
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="__inherit__">Inherit company default</option>
{(bindingsQuery.data ?? []).map((binding) => (
<option key={binding.id} value={binding.id}>
{binding.name ?? binding.key}
</option>
))}
</select>
</div>
<Button
size="sm"
disabled={loading || Boolean(error) || saveOverride.isPending || selectedBindingId === currentSelection}
onClick={() => saveOverride.mutate()}
>
{saveOverride.isPending ? "Saving..." : "Save override"}
</Button>
</div>
{saveOverride.isError ? (
<p className="text-xs text-destructive">
{saveOverride.error instanceof Error ? saveOverride.error.message : "Failed to update project memory binding"}
</p>
) : null}
</div>
</div>
</div>
</div>
);
}
+2
View File
@@ -11,6 +11,7 @@ import {
Boxes,
Repeat,
Settings,
Database,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { SidebarSection } from "./SidebarSection";
@@ -99,6 +100,7 @@ export function Sidebar() {
<SidebarSection label="Work">
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
<SidebarNavItem to="/memories" label="Memories" icon={Database} />
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
</SidebarSection>
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import type { MemoryProviderDescriptor } from "@paperclipai/shared";
import {
getMemoryConfigFields,
getSuggestedMemoryConfig,
parseMemoryConfigJson,
validateMemoryProviderConfig,
} from "./memory-config-schema";
const provider: MemoryProviderDescriptor = {
key: "qmd_memory",
displayName: "QMD Memory",
description: "Markdown memory",
kind: "plugin",
pluginId: "paperclip.qmd-memory",
capabilities: {
browse: true,
correction: false,
asyncIngestion: false,
providerManagedExtraction: false,
},
configSchema: {
type: "object",
properties: {
searchMode: { type: "string", enum: ["query", "search"], default: "query" },
topK: { type: "integer", minimum: 1, maximum: 25, default: 5 },
autoIndexOnWrite: { type: "boolean", default: true },
},
},
configMetadata: {
suggestedConfig: {
searchMode: "query",
topK: 5,
autoIndexOnWrite: true,
},
fields: [
{
key: "searchMode",
label: "Search mode",
input: "select",
options: [
{ value: "query", label: "Query" },
{ value: "search", label: "Search" },
],
},
{
key: "topK",
label: "Hydration snippets",
input: "number",
min: 1,
max: 25,
},
{
key: "autoIndexOnWrite",
label: "Auto-index on write",
input: "boolean",
},
],
},
};
describe("memory config schema helpers", () => {
it("prefers provider metadata for form fields and suggested config", () => {
expect(getMemoryConfigFields(provider).map((field) => field.label)).toEqual([
"Search mode",
"Hydration snippets",
"Auto-index on write",
]);
expect(getSuggestedMemoryConfig(provider)).toEqual({
searchMode: "query",
topK: 5,
autoIndexOnWrite: true,
});
});
it("returns field-specific validation errors", () => {
const validation = validateMemoryProviderConfig(provider, {
searchMode: "unknown",
topK: 50,
autoIndexOnWrite: "yes",
});
expect(validation.valid).toBe(false);
expect(validation.fieldErrors).toEqual({
searchMode: "Search mode must be one of the listed options.",
topK: "Hydration snippets must be at most 25.",
autoIndexOnWrite: "Auto-index on write must be on or off.",
});
});
it("parses only JSON objects for advanced editing", () => {
expect(parseMemoryConfigJson("{\"topK\":5}")).toEqual({ topK: 5 });
expect(() => parseMemoryConfigJson("[]")).toThrow("Config must be a JSON object");
});
});
+171
View File
@@ -0,0 +1,171 @@
import type {
MemoryProviderConfigFieldMetadata,
MemoryProviderDescriptor,
} from "@paperclipai/shared";
export type MemoryConfigValidation = {
valid: boolean;
fieldErrors: Record<string, string>;
formError: string | null;
};
type JsonSchemaProperty = {
type?: string | string[];
enum?: unknown[];
default?: unknown;
minimum?: number;
maximum?: number;
description?: string;
};
type JsonSchemaObject = {
type?: string | string[];
properties?: Record<string, JsonSchemaProperty>;
required?: string[];
};
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function schemaTypeIncludes(type: JsonSchemaProperty["type"], expected: string) {
return Array.isArray(type) ? type.includes(expected) : type === expected;
}
function titleizeKey(key: string) {
return key
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function inferInput(key: string, property: JsonSchemaProperty): MemoryProviderConfigFieldMetadata["input"] {
const lowered = key.toLowerCase();
if (property.enum?.length) return "select";
if (schemaTypeIncludes(property.type, "boolean")) return "boolean";
if (schemaTypeIncludes(property.type, "number") || schemaTypeIncludes(property.type, "integer")) return "number";
if (lowered.includes("password") || lowered.includes("token") || lowered.includes("apikey") || lowered.includes("api_key")) {
return "secret";
}
if (lowered.includes("path") || lowered.includes("dir")) return "path";
return "text";
}
function schemaObject(provider: MemoryProviderDescriptor | undefined): JsonSchemaObject | null {
const schema = provider?.configSchema;
if (!isObject(schema)) return null;
return schema as JsonSchemaObject;
}
export function getMemoryConfigFields(provider: MemoryProviderDescriptor | undefined) {
const metadataFields = provider?.configMetadata?.fields;
if (metadataFields?.length) return metadataFields;
const schema = schemaObject(provider);
if (!schema?.properties) return [];
const required = new Set(schema.required ?? []);
return Object.entries(schema.properties).map(([key, property]) => ({
key,
label: titleizeKey(key),
description: property.description ?? null,
input: inferInput(key, property),
required: required.has(key),
defaultValue: property.default,
suggestedValue: property.default,
min: property.minimum ?? null,
max: property.maximum ?? null,
options: property.enum?.map((value) => ({
value: typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null
? value
: String(value),
label: value === null ? "None" : titleizeKey(String(value)),
})),
} satisfies MemoryProviderConfigFieldMetadata));
}
export function getSuggestedMemoryConfig(provider: MemoryProviderDescriptor | undefined): Record<string, unknown> {
if (provider?.configMetadata?.suggestedConfig) {
return { ...provider.configMetadata.suggestedConfig };
}
const result: Record<string, unknown> = {};
for (const field of getMemoryConfigFields(provider)) {
if (field.suggestedValue !== undefined) {
result[field.key] = field.suggestedValue;
} else if (field.defaultValue !== undefined) {
result[field.key] = field.defaultValue;
}
}
return result;
}
export function prettyMemoryConfig(value: Record<string, unknown>) {
return JSON.stringify(value, null, 2);
}
export function parseMemoryConfigJson(text: string) {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (error) {
throw new Error(error instanceof Error ? error.message : "Config must be valid JSON");
}
if (!isObject(parsed)) {
throw new Error("Config must be a JSON object");
}
return parsed;
}
function isBlank(value: unknown) {
return value === undefined || value === null || value === "";
}
function allowedOptionValue(field: MemoryProviderConfigFieldMetadata, value: unknown) {
return (field.options ?? []).some((option) => option.value === value);
}
export function validateMemoryProviderConfig(
provider: MemoryProviderDescriptor | undefined,
config: Record<string, unknown>,
): MemoryConfigValidation {
const fieldErrors: Record<string, string> = {};
for (const field of getMemoryConfigFields(provider)) {
const value = config[field.key];
if (field.required && isBlank(value)) {
fieldErrors[field.key] = `${field.label} is required.`;
continue;
}
if (isBlank(value)) continue;
if (field.input === "boolean" && typeof value !== "boolean") {
fieldErrors[field.key] = `${field.label} must be on or off.`;
continue;
}
if (field.input === "number") {
if (typeof value !== "number" || !Number.isFinite(value)) {
fieldErrors[field.key] = `${field.label} must be a number.`;
continue;
}
if (field.min !== null && field.min !== undefined && value < field.min) {
fieldErrors[field.key] = `${field.label} must be at least ${field.min}.`;
continue;
}
if (field.max !== null && field.max !== undefined && value > field.max) {
fieldErrors[field.key] = `${field.label} must be at most ${field.max}.`;
continue;
}
}
if (field.input === "select" && field.options?.length && !allowedOptionValue(field, value)) {
fieldErrors[field.key] = `${field.label} must be one of the listed options.`;
}
}
return {
valid: Object.keys(fieldErrors).length === 0,
fieldErrors,
formError: null,
};
}
+1
View File
@@ -35,6 +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,
projectBinding: (projectId: string) => ["memory", "project-binding", projectId] as const,
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>) =>
+17 -1
View File
@@ -16,6 +16,7 @@ import { useToast } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
import { ProjectMemorySettings } from "../components/ProjectMemorySettings";
import { CopyText } from "../components/CopyText";
import { InlineEditor } from "../components/InlineEditor";
import { StatusBadge } from "../components/StatusBadge";
@@ -36,7 +37,7 @@ import { IssuesQuicklook } from "../components/IssuesQuicklook";
/* ── Top-level tab types ── */
type ProjectBaseTab = "overview" | "list" | "workspaces" | "configuration" | "budget";
type ProjectBaseTab = "overview" | "list" | "workspaces" | "configuration" | "memory" | "budget";
type ProjectPluginTab = `plugin:${string}`;
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
@@ -51,6 +52,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu
const tab = segments[projectsIdx + 2];
if (tab === "overview") return "overview";
if (tab === "configuration") return "configuration";
if (tab === "memory") return "memory";
if (tab === "budget") return "budget";
if (tab === "issues") return "list";
if (tab === "workspaces") return "workspaces";
@@ -627,6 +629,10 @@ export function ProjectDetail() {
navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true });
return;
}
if (activeTab === "memory") {
navigate(`/projects/${canonicalProjectRef}/memory`, { replace: true });
return;
}
if (activeTab === "workspaces") {
navigate(`/projects/${canonicalProjectRef}/workspaces`, { replace: true });
return;
@@ -760,6 +766,9 @@ export function ProjectDetail() {
if (cachedTab === "budget") {
return <Navigate to={`/projects/${canonicalProjectRef}/budget`} replace />;
}
if (cachedTab === "memory") {
return <Navigate to={`/projects/${canonicalProjectRef}/memory`} replace />;
}
if (cachedTab === "workspaces" && workspaceTabDecisionLoaded && showWorkspacesTab) {
return <Navigate to={`/projects/${canonicalProjectRef}/workspaces`} replace />;
}
@@ -791,6 +800,8 @@ export function ProjectDetail() {
navigate(`/projects/${canonicalProjectRef}/workspaces`);
} else if (tab === "budget") {
navigate(`/projects/${canonicalProjectRef}/budget`);
} else if (tab === "memory") {
navigate(`/projects/${canonicalProjectRef}/memory`);
} else if (tab === "configuration") {
navigate(`/projects/${canonicalProjectRef}/configuration`);
} else {
@@ -861,6 +872,7 @@ export function ProjectDetail() {
{ value: "overview", label: "Overview" },
...(showWorkspacesTab ? [{ value: "workspaces", label: "Workspaces" }] : []),
{ value: "configuration", label: "Configuration" },
{ value: "memory", label: "Memory" },
{ value: "budget", label: "Budget" },
...pluginTabItems.map((item) => ({
value: item.value,
@@ -918,6 +930,10 @@ export function ProjectDetail() {
</div>
)}
{activeTab === "memory" && resolvedCompanyId ? (
<ProjectMemorySettings companyId={resolvedCompanyId} projectId={project.id} />
) : null}
{activeTab === "budget" && resolvedCompanyId ? (
<div className="max-w-3xl">
<BudgetPolicyCard
+815
View File
@@ -0,0 +1,815 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
MEMORY_RETENTION_STATES,
MEMORY_REVIEW_STATES,
MEMORY_SCOPE_TYPES,
MEMORY_SENSITIVITY_LABELS,
MEMORY_SOURCE_KINDS,
type Agent,
type Issue,
type MemoryListOperationsQuery,
type MemoryListRecordsQuery,
type MemoryRecord,
type MemoryRetentionState,
type MemoryReviewState,
type MemoryScopeType,
type MemorySensitivityLabel,
type MemorySourceKind,
type Project,
} from "@paperclipai/shared";
import { Check, Clipboard, Database, RotateCcw, SlidersHorizontal, Trash2, X } from "lucide-react";
import { agentsApi } from "../api/agents";
import { issuesApi } from "../api/issues";
import { memoryApi } from "../api/memory";
import { projectsApi } from "../api/projects";
import { EmptyState } from "../components/EmptyState";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { agentUrl, cn, formatDateTime, issueUrl, projectUrl, relativeTime } from "../lib/utils";
type MemoryFilters = {
q: string;
providerKey: string;
reviewState: MemoryReviewState | "";
retentionState: MemoryRetentionState | "";
sensitivityLabel: MemorySensitivityLabel | "";
sourceKind: MemorySourceKind | "";
scopeType: MemoryScopeType | "";
projectId: string;
agentId: string;
issueId: string;
runId: string;
includeRevoked: boolean;
includeExpired: boolean;
includeSuperseded: boolean;
};
const DEFAULT_FILTERS: MemoryFilters = {
q: "",
providerKey: "",
reviewState: "pending",
retentionState: "",
sensitivityLabel: "",
sourceKind: "",
scopeType: "",
projectId: "",
agentId: "",
issueId: "",
runId: "",
includeRevoked: false,
includeExpired: false,
includeSuperseded: false,
};
function labelize(value: string) {
return value.replace(/_/g, " ");
}
function summarizeRecord(record: MemoryRecord) {
const text = (record.summary ?? record.content).replace(/\s+/g, " ").trim();
return text.length > 360 ? `${text.slice(0, 357)}...` : text;
}
function recordTitle(record: MemoryRecord) {
return record.title ?? record.citation?.sourceTitle ?? labelize(record.source?.kind ?? "memory");
}
function badgeTone(record: MemoryRecord) {
if (record.revokedAt || record.retentionState === "revoked") return "bg-zinc-500/10 text-zinc-700 dark:text-zinc-300";
if (record.reviewState === "accepted") return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
if (record.reviewState === "rejected") return "bg-rose-500/10 text-rose-700 dark:text-rose-300";
return "bg-amber-500/10 text-amber-800 dark:text-amber-300";
}
function sourceLabel(record: MemoryRecord, issuesById: Map<string, Issue>) {
const issueId = record.source?.issueId ?? record.scope.issueId ?? null;
const issue = issueId ? issuesById.get(issueId) : null;
if (issue) return issue.identifier ?? issue.title;
if (record.source?.runId ?? record.scope.runId) return `run ${(record.source?.runId ?? record.scope.runId)!.slice(0, 8)}`;
if (record.source?.externalRef) return record.source.externalRef;
return labelize(record.source?.kind ?? record.scopeType);
}
function sourceHref(record: MemoryRecord, issuesById: Map<string, Issue>) {
const issueId = record.source?.issueId ?? record.scope.issueId ?? null;
const issue = issueId ? issuesById.get(issueId) : null;
if (issue) {
const base = issueUrl(issue);
if (record.source?.commentId) return `${base}#comment-${record.source.commentId}`;
if (record.source?.documentKey) return `${base}#document-${record.source.documentKey}`;
return base;
}
const runId = record.source?.runId ?? record.scope.runId ?? null;
const agentId = record.scope.agentId ?? null;
if (runId && agentId) return `/agents/${agentId}/runs/${runId}`;
return null;
}
function buildRecordFilters(filters: MemoryFilters): Partial<MemoryListRecordsQuery> {
return {
q: filters.q.trim() || undefined,
providerKey: filters.providerKey || undefined,
reviewState: filters.reviewState || undefined,
retentionState: filters.retentionState || undefined,
sensitivityLabel: filters.sensitivityLabel || undefined,
sourceKind: filters.sourceKind || undefined,
scopeType: filters.scopeType || undefined,
projectId: filters.projectId || undefined,
agentId: filters.agentId || undefined,
issueId: filters.issueId || undefined,
runId: filters.runId.trim() || undefined,
includeDeleted: false,
includeRevoked: filters.includeRevoked,
includeExpired: filters.includeExpired,
includeSuperseded: filters.includeSuperseded,
limit: 100,
};
}
function detailOperationFilters(record: MemoryRecord | null): MemoryListOperationsQuery | undefined {
if (!record) return undefined;
if (record.scope.runId ?? record.source?.runId) {
return { runId: record.scope.runId ?? record.source?.runId ?? undefined, limit: 20 };
}
if (record.scope.issueId ?? record.source?.issueId) {
return { issueId: record.scope.issueId ?? record.source?.issueId ?? undefined, limit: 20 };
}
if (record.scope.agentId) return { agentId: record.scope.agentId, limit: 20 };
return { limit: 20 };
}
function FilterSelect({
label,
value,
onChange,
options,
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: Array<{ value: string; label: string }>;
}) {
return (
<label className="space-y-1 text-xs font-medium text-muted-foreground">
<span>{label}</span>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm font-normal text-foreground shadow-xs outline-none"
>
{options.map((option) => (
<option key={option.value || "__all__"} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
);
}
function ReviewBadge({ record }: { record: MemoryRecord }) {
return (
<span className={cn("rounded-full px-2 py-0.5 text-[11px] font-medium", badgeTone(record))}>
{record.revokedAt ? "revoked" : record.reviewState}
</span>
);
}
function MemoryDetailSheet({
companyId,
record,
agentsById,
issuesById,
projectsById,
onClose,
}: {
companyId: string;
record: MemoryRecord | null;
agentsById: Map<string, Agent>;
issuesById: Map<string, Issue>;
projectsById: Map<string, Project>;
onClose: () => void;
}) {
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [reviewNote, setReviewNote] = useState("");
const [revokeReason, setRevokeReason] = useState("");
const [correctionContent, setCorrectionContent] = useState("");
const [correctionReason, setCorrectionReason] = useState("");
useEffect(() => {
setReviewNote(record?.reviewNote ?? "");
setRevokeReason("");
setCorrectionContent(record?.content ?? "");
setCorrectionReason("");
}, [record]);
const operationFilters = useMemo(() => detailOperationFilters(record), [record]);
const operationsQuery = useQuery({
queryKey: record && operationFilters ? queryKeys.memory.operations(companyId, operationFilters) : ["memory", companyId, "operations", "none"],
queryFn: () => memoryApi.listOperations(companyId, operationFilters),
enabled: Boolean(record && operationFilters),
});
const invalidateMemory = async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all });
};
const reviewRecord = useMutation({
mutationFn: (reviewState: "pending" | "accepted" | "rejected") => {
if (!record) throw new Error("No memory record selected");
return memoryApi.reviewRecord(companyId, record.id, {
reviewState,
note: reviewNote.trim() || null,
});
},
onSuccess: async () => {
await invalidateMemory();
pushToast({ title: "Memory review saved", tone: "success" });
},
onError: (error) => {
pushToast({
title: "Failed to review memory",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
const correctRecord = useMutation({
mutationFn: () => {
if (!record) throw new Error("No memory record selected");
return memoryApi.correctRecord(companyId, record.id, {
content: correctionContent,
reason: correctionReason,
});
},
onSuccess: async () => {
setCorrectionReason("");
await invalidateMemory();
pushToast({ title: "Memory corrected", tone: "success" });
},
onError: (error) => {
pushToast({
title: "Failed to correct memory",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
const revokeRecord = useMutation({
mutationFn: () => {
if (!record) throw new Error("No memory record selected");
return memoryApi.revoke(companyId, {
selector: { recordIds: [record.id] },
reason: revokeReason,
});
},
onSuccess: async () => {
setRevokeReason("");
await invalidateMemory();
pushToast({ title: "Memory revoked", tone: "success" });
},
onError: (error) => {
pushToast({
title: "Failed to revoke memory",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
if (!record) {
return (
<Sheet open={false} onOpenChange={onClose}>
<SheetContent />
</Sheet>
);
}
const sourceUrl = sourceHref(record, issuesById);
const agent = record.scope.agentId ? agentsById.get(record.scope.agentId) : null;
const project = record.scope.projectId ? projectsById.get(record.scope.projectId) : null;
const canMutate = record.retentionState === "active" && !record.revokedAt && !record.supersededByRecordId;
const sourceReference = [
record.source?.kind ?? "memory",
record.source?.issueId ? `issue:${record.source.issueId}` : null,
record.source?.commentId ? `comment:${record.source.commentId}` : null,
record.source?.documentKey ? `document:${record.source.documentKey}` : null,
record.source?.runId ? `run:${record.source.runId}` : null,
].filter(Boolean).join(" ");
return (
<Sheet open={Boolean(record)} onOpenChange={(next) => { if (!next) onClose(); }}>
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-2xl">
<SheetHeader>
<SheetTitle className="text-base">{recordTitle(record)}</SheetTitle>
<SheetDescription>
{record.providerKey} · {record.sensitivityLabel} · {record.retentionState}
</SheetDescription>
</SheetHeader>
<div className="mt-5 space-y-5">
<div className="flex flex-wrap items-center gap-2">
<ReviewBadge record={record} />
<span className="rounded-full bg-accent px-2 py-0.5 text-[11px] text-muted-foreground">
{record.scopeType}
</span>
<span className="rounded-full bg-accent px-2 py-0.5 text-[11px] text-muted-foreground">
{labelize(record.source?.kind ?? "memory")}
</span>
</div>
<div className="rounded-md border border-border px-4 py-3">
<div className="mb-2 text-xs font-medium uppercase text-muted-foreground">Content</div>
<p className="whitespace-pre-wrap text-sm leading-6">{record.content}</p>
{record.summary ? (
<div className="mt-4 border-t border-border pt-3 text-sm text-muted-foreground">
{record.summary}
</div>
) : null}
</div>
<div className="grid gap-3 text-sm sm:grid-cols-2">
<div className="rounded-md border border-border px-3 py-3">
<div className="mb-2 text-xs font-medium uppercase text-muted-foreground">Provenance</div>
<div className="space-y-1">
{sourceUrl ? (
<Link className="text-primary hover:underline" to={sourceUrl}>
{sourceLabel(record, issuesById)}
</Link>
) : (
<div>{sourceLabel(record, issuesById)}</div>
)}
{record.citation?.label || record.citation?.sourceTitle ? (
<div className="text-xs text-muted-foreground">
{record.citation.label ?? record.citation.sourceTitle}
</div>
) : null}
{record.citation?.url ? (
<a className="block break-all text-xs text-primary hover:underline" href={record.citation.url}>
{record.citation.url}
</a>
) : null}
</div>
</div>
<div className="rounded-md border border-border px-3 py-3">
<div className="mb-2 text-xs font-medium uppercase text-muted-foreground">Scope</div>
<div className="space-y-1 text-xs">
{project ? <Link className="block text-primary hover:underline" to={projectUrl(project)}>{project.name}</Link> : null}
{agent ? <Link className="block text-primary hover:underline" to={agentUrl(agent)}>{agent.name}</Link> : null}
<div className="break-all text-muted-foreground">{record.scopeId ?? record.scopeType}</div>
<div className="text-muted-foreground">Created {formatDateTime(record.createdAt)}</div>
</div>
</div>
</div>
<div className="rounded-md border border-border px-4 py-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Review</div>
<div className="text-xs text-muted-foreground">
{record.reviewedAt ? `Last reviewed ${relativeTime(record.reviewedAt)}` : "Not reviewed yet"}
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
void navigator.clipboard.writeText(sourceReference);
pushToast({ title: "Source reference copied", tone: "success" });
}}
>
<Clipboard className="mr-2 h-4 w-4" />
Copy
</Button>
</div>
<Textarea
value={reviewNote}
onChange={(event) => setReviewNote(event.target.value)}
placeholder="Optional review note"
className="min-h-20"
/>
<div className="mt-3 flex flex-wrap justify-end gap-2">
<Button
size="sm"
variant="outline"
disabled={!canMutate || reviewRecord.isPending}
onClick={() => reviewRecord.mutate("pending")}
>
<RotateCcw className="mr-2 h-4 w-4" />
Pending
</Button>
<Button
size="sm"
variant="outline"
disabled={!canMutate || reviewRecord.isPending}
onClick={() => reviewRecord.mutate("rejected")}
>
<X className="mr-2 h-4 w-4" />
Reject
</Button>
<Button
size="sm"
disabled={!canMutate || reviewRecord.isPending}
onClick={() => reviewRecord.mutate("accepted")}
>
<Check className="mr-2 h-4 w-4" />
Accept
</Button>
</div>
</div>
<div className="rounded-md border border-border px-4 py-4">
<div className="mb-3 text-sm font-medium">Correct</div>
<Textarea
value={correctionContent}
onChange={(event) => setCorrectionContent(event.target.value)}
className="min-h-32"
/>
<div className="mt-3 grid gap-2 sm:grid-cols-[1fr_auto]">
<Input
value={correctionReason}
onChange={(event) => setCorrectionReason(event.target.value)}
placeholder="Correction reason"
/>
<Button
size="sm"
variant="outline"
disabled={!canMutate || !correctionContent.trim() || !correctionReason.trim() || correctRecord.isPending}
onClick={() => correctRecord.mutate()}
>
Save correction
</Button>
</div>
</div>
<div className="rounded-md border border-border px-4 py-4">
<div className="mb-3 text-sm font-medium">Revoke</div>
<div className="grid gap-2 sm:grid-cols-[1fr_auto]">
<Input
value={revokeReason}
onChange={(event) => setRevokeReason(event.target.value)}
placeholder="Revocation reason"
/>
<Button
size="sm"
variant="outline"
disabled={!canMutate || !revokeReason.trim() || revokeRecord.isPending}
onClick={() => revokeRecord.mutate()}
>
<Trash2 className="mr-2 h-4 w-4" />
Revoke
</Button>
</div>
</div>
<div className="rounded-md border border-border px-4 py-4">
<div className="mb-3 text-sm font-medium">Operations</div>
{operationsQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Loading operations...</div>
) : (operationsQuery.data ?? []).length === 0 ? (
<div className="text-sm text-muted-foreground">No related operations found.</div>
) : (
<div className="divide-y divide-border">
{(operationsQuery.data ?? []).map((operation) => (
<div key={operation.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<div>
<div className="font-medium">{operation.operationType}</div>
<div className="text-xs text-muted-foreground">{operation.hookKind ?? operation.triggerKind}</div>
</div>
<div className="text-right text-xs text-muted-foreground">
<div>{operation.status}</div>
<div>{relativeTime(operation.occurredAt)}</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
);
}
export function WorkMemories() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [filters, setFilters] = useState<MemoryFilters>(DEFAULT_FILTERS);
const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([{ label: "Memories" }]);
}, [setBreadcrumbs]);
const providersQuery = useQuery({
queryKey: selectedCompanyId ? queryKeys.memory.providers(selectedCompanyId) : ["memory", "providers", "none"],
queryFn: () => memoryApi.providers(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const bindingsQuery = useQuery({
queryKey: selectedCompanyId ? queryKeys.memory.bindings(selectedCompanyId) : ["memory", "bindings", "none"],
queryFn: () => memoryApi.listBindings(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const agentsQuery = useQuery({
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none"],
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const projectsQuery = useQuery({
queryKey: selectedCompanyId ? queryKeys.projects.list(selectedCompanyId) : ["projects", "none"],
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const issuesQuery = useQuery({
queryKey: selectedCompanyId ? [...queryKeys.issues.list(selectedCompanyId), "memory-filter"] : ["issues", "none"],
queryFn: () => issuesApi.list(selectedCompanyId!, { includeRoutineExecutions: true, limit: 300 }),
enabled: !!selectedCompanyId,
});
const recordFilters = useMemo(() => buildRecordFilters(filters), [filters]);
const recordsQuery = useQuery({
queryKey: selectedCompanyId ? queryKeys.memory.records(selectedCompanyId, recordFilters) : ["memory", "records", "none"],
queryFn: () => memoryApi.listRecords(selectedCompanyId!, recordFilters),
enabled: !!selectedCompanyId,
});
const agentsById = useMemo(() => new Map((agentsQuery.data ?? []).map((agent) => [agent.id, agent])), [agentsQuery.data]);
const projectsById = useMemo(() => new Map((projectsQuery.data ?? []).map((project) => [project.id, project])), [projectsQuery.data]);
const issuesById = useMemo(() => new Map((issuesQuery.data ?? []).map((issue) => [issue.id, issue])), [issuesQuery.data]);
const selectedRecord = (recordsQuery.data ?? []).find((record) => record.id === selectedRecordId) ?? null;
const counts = useMemo(() => {
const records = recordsQuery.data ?? [];
return {
total: records.length,
pending: records.filter((record) => record.reviewState === "pending").length,
accepted: records.filter((record) => record.reviewState === "accepted").length,
rejected: records.filter((record) => record.reviewState === "rejected").length,
};
}, [recordsQuery.data]);
if (!selectedCompanyId) {
return <EmptyState icon={Database} message="Select a company to view memories." />;
}
const providerOptions = [
{ value: "", label: "Any provider" },
...(providersQuery.data ?? []).map((provider) => ({ value: provider.key, label: provider.displayName })),
];
const bindingById = new Map((bindingsQuery.data ?? []).map((binding) => [binding.id, binding]));
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-muted-foreground" />
<h1 className="text-xl font-semibold">Memories</h1>
</div>
<div className="mt-2 flex flex-wrap gap-2">
<span className="rounded-full bg-accent px-2.5 py-1 text-xs text-muted-foreground">{counts.total} visible</span>
<span className="rounded-full bg-amber-500/10 px-2.5 py-1 text-xs text-amber-800 dark:text-amber-300">{counts.pending} pending</span>
<span className="rounded-full bg-emerald-500/10 px-2.5 py-1 text-xs text-emerald-700 dark:text-emerald-300">{counts.accepted} accepted</span>
<span className="rounded-full bg-rose-500/10 px-2.5 py-1 text-xs text-rose-700 dark:text-rose-300">{counts.rejected} rejected</span>
</div>
</div>
<Button variant="outline" size="sm" onClick={() => setFilters(DEFAULT_FILTERS)}>
<RotateCcw className="mr-2 h-4 w-4" />
Reset
</Button>
</div>
<div className="rounded-md border border-border px-4 py-4">
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
<SlidersHorizontal className="h-4 w-4 text-muted-foreground" />
Filters
</div>
<div className="grid gap-3 lg:grid-cols-4">
<label className="space-y-1 text-xs font-medium text-muted-foreground lg:col-span-2">
<span>Search</span>
<Input
value={filters.q}
onChange={(event) => setFilters((current) => ({ ...current, q: event.target.value }))}
placeholder="Search title, summary, or content"
/>
</label>
<FilterSelect
label="Review"
value={filters.reviewState}
onChange={(reviewState) =>
setFilters((current) => ({ ...current, reviewState: reviewState as MemoryFilters["reviewState"] }))
}
options={[
{ value: "", label: "Any review state" },
...MEMORY_REVIEW_STATES.map((state) => ({ value: state, label: state })),
]}
/>
<FilterSelect
label="Provider"
value={filters.providerKey}
onChange={(providerKey) => setFilters((current) => ({ ...current, providerKey }))}
options={providerOptions}
/>
<FilterSelect
label="Project"
value={filters.projectId}
onChange={(projectId) => setFilters((current) => ({ ...current, projectId }))}
options={[
{ value: "", label: "Any project" },
...(projectsQuery.data ?? []).map((project) => ({ value: project.id, label: project.name })),
]}
/>
<FilterSelect
label="Agent"
value={filters.agentId}
onChange={(agentId) => setFilters((current) => ({ ...current, agentId }))}
options={[
{ value: "", label: "Any agent" },
...(agentsQuery.data ?? []).map((agent) => ({ value: agent.id, label: agent.name })),
]}
/>
<FilterSelect
label="Task"
value={filters.issueId}
onChange={(issueId) => setFilters((current) => ({ ...current, issueId }))}
options={[
{ value: "", label: "Any task" },
...(issuesQuery.data ?? []).map((issue) => ({
value: issue.id,
label: `${issue.identifier ?? issue.id.slice(0, 8)} ${issue.title}`,
})),
]}
/>
<label className="space-y-1 text-xs font-medium text-muted-foreground">
<span>Run ID</span>
<Input
value={filters.runId}
onChange={(event) => setFilters((current) => ({ ...current, runId: event.target.value }))}
placeholder="UUID"
/>
</label>
<FilterSelect
label="Source"
value={filters.sourceKind}
onChange={(sourceKind) =>
setFilters((current) => ({ ...current, sourceKind: sourceKind as MemoryFilters["sourceKind"] }))
}
options={[
{ value: "", label: "Any source" },
...MEMORY_SOURCE_KINDS.map((kind) => ({ value: kind, label: labelize(kind) })),
]}
/>
<FilterSelect
label="Sensitivity"
value={filters.sensitivityLabel}
onChange={(sensitivityLabel) =>
setFilters((current) => ({
...current,
sensitivityLabel: sensitivityLabel as MemoryFilters["sensitivityLabel"],
}))
}
options={[
{ value: "", label: "Any label" },
...MEMORY_SENSITIVITY_LABELS.map((label) => ({ value: label, label })),
]}
/>
<FilterSelect
label="Retention"
value={filters.retentionState}
onChange={(retentionState) =>
setFilters((current) => ({
...current,
retentionState: retentionState as MemoryFilters["retentionState"],
}))
}
options={[
{ value: "", label: "Active records" },
...MEMORY_RETENTION_STATES.map((state) => ({ value: state, label: state })),
]}
/>
<FilterSelect
label="Scope"
value={filters.scopeType}
onChange={(scopeType) =>
setFilters((current) => ({ ...current, scopeType: scopeType as MemoryFilters["scopeType"] }))
}
options={[
{ value: "", label: "Any scope" },
...MEMORY_SCOPE_TYPES.map((scope) => ({ value: scope, label: scope })),
]}
/>
</div>
<div className="mt-3 flex flex-wrap gap-4 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={filters.includeRevoked}
onChange={(event) => setFilters((current) => ({ ...current, includeRevoked: event.target.checked }))}
/>
Include revoked
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={filters.includeExpired}
onChange={(event) => setFilters((current) => ({ ...current, includeExpired: event.target.checked }))}
/>
Include expired
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={filters.includeSuperseded}
onChange={(event) => setFilters((current) => ({ ...current, includeSuperseded: event.target.checked }))}
/>
Include superseded
</label>
</div>
</div>
{recordsQuery.isLoading ? (
<div className="rounded-md border border-border px-4 py-8 text-sm text-muted-foreground">Loading memories...</div>
) : recordsQuery.error ? (
<div className="rounded-md border border-destructive/40 px-4 py-4 text-sm text-destructive">
{recordsQuery.error.message}
</div>
) : (recordsQuery.data ?? []).length === 0 ? (
<div className="rounded-md border border-dashed border-border px-4 py-10 text-sm text-muted-foreground">
No memory records match the current filters.
</div>
) : (
<div className="overflow-hidden rounded-md border border-border">
<div className="divide-y divide-border">
{(recordsQuery.data ?? []).map((record) => {
const sourceUrl = sourceHref(record, issuesById);
const agent = record.scope.agentId ? agentsById.get(record.scope.agentId) : null;
const project = record.scope.projectId ? projectsById.get(record.scope.projectId) : null;
const binding = bindingById.get(record.bindingId);
return (
<button
key={record.id}
type="button"
className="grid w-full gap-3 px-4 py-4 text-left transition-colors hover:bg-accent/30 lg:grid-cols-[1fr_220px_160px]"
onClick={() => setSelectedRecordId(record.id)}
>
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<ReviewBadge record={record} />
<span className="text-sm font-medium">{recordTitle(record)}</span>
<span className="rounded-full bg-accent px-2 py-0.5 text-[11px] text-muted-foreground">
{binding?.name ?? binding?.key ?? record.providerKey}
</span>
</div>
<p className="text-sm leading-6 text-foreground/85">{summarizeRecord(record)}</p>
</div>
<div className="min-w-0 text-xs text-muted-foreground">
<div className="font-medium text-foreground">{sourceLabel(record, issuesById)}</div>
<div>{labelize(record.source?.kind ?? record.scopeType)}</div>
{sourceUrl ? <div className="mt-1 text-primary">Open source</div> : null}
</div>
<div className="text-xs text-muted-foreground lg:text-right">
{project ? <div className="truncate">{project.name}</div> : null}
{agent ? <div className="truncate">{agent.name}</div> : null}
<div>{relativeTime(record.createdAt)}</div>
<div>{record.sensitivityLabel}</div>
</div>
</button>
);
})}
</div>
</div>
)}
<MemoryDetailSheet
companyId={selectedCompanyId}
record={selectedRecord}
agentsById={agentsById}
issuesById={issuesById}
projectsById={projectsById}
onClose={() => setSelectedRecordId(null)}
/>
</div>
);
}