mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Implement memory binding provider metadata
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -254,6 +254,13 @@ export type {
|
||||
MemoryProviderQueryOutput,
|
||||
MemoryOperation,
|
||||
MemoryProviderCapabilities,
|
||||
MemoryProviderConfigFieldInput,
|
||||
MemoryProviderConfigFieldOption,
|
||||
MemoryProviderConfigFieldMetadata,
|
||||
MemoryProviderHealthStatus,
|
||||
MemoryProviderHealthCheck,
|
||||
MemoryProviderConfigPathSuggestion,
|
||||
MemoryProviderConfigMetadata,
|
||||
MemoryProviderDescriptor,
|
||||
MemoryRecord,
|
||||
MemoryResolvedBinding,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user