feat: implement memory service end to end

This commit is contained in:
Dotta committed 2026-04-11 11:46:47 -05:00
1 parent b649bd454f
commit c3238242e8
48 files changed
+17769 -2

No files matched your search

@@ -410,10 +410,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
const memoryPreamble = asString(context.paperclipMemoryPreamble, "").trim();
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
renderedBootstrapPrompt,
wakePrompt,
memoryPreamble,
sessionHandoffNote,
renderedPrompt,
]);
@@ -421,6 +423,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
promptChars: prompt.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
memoryPreambleChars: memoryPreamble.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
@@ -467,11 +467,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
];
})();
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
const memoryPreamble = asString(context.paperclipMemoryPreamble, "").trim();
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
promptInstructionsPrefix,
renderedBootstrapPrompt,
wakePrompt,
memoryPreamble,
sessionHandoffNote,
renderedPrompt,
]);
@@ -480,6 +482,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
instructionsChars,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
memoryPreambleChars: memoryPreamble.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
@@ -365,12 +365,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
const memoryPreamble = asString(context.paperclipMemoryPreamble, "").trim();
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const paperclipEnvNote = renderPaperclipEnvNote(env);
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
wakePrompt,
memoryPreamble,
sessionHandoffNote,
paperclipEnvNote,
renderedPrompt,
@@ -380,6 +382,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
instructionsChars,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
memoryPreambleChars: memoryPreamble.length,
sessionHandoffChars: sessionHandoffNote.length,
runtimeNoteChars: paperclipEnvNote.length,
heartbeatPromptChars: renderedPrompt.length,
@@ -306,6 +306,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
const memoryPreamble = asString(context.paperclipMemoryPreamble, "").trim();
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const paperclipEnvNote = renderPaperclipEnvNote(env);
const apiAccessNote = renderApiAccessNote(env);
@@ -313,6 +314,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
instructionsPrefix,
renderedBootstrapPrompt,
wakePrompt,
memoryPreamble,
sessionHandoffNote,
paperclipEnvNote,
apiAccessNote,
@@ -323,6 +325,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
memoryPreambleChars: memoryPreamble.length,
sessionHandoffChars: sessionHandoffNote.length,
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
heartbeatPromptChars: renderedPrompt.length,
@@ -281,11 +281,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
const memoryPreamble = asString(context.paperclipMemoryPreamble, "").trim();
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
wakePrompt,
memoryPreamble,
sessionHandoffNote,
renderedPrompt,
]);
@@ -294,6 +296,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
memoryPreambleChars: memoryPreamble.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
@@ -309,10 +309,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canResumeSession });
const shouldUseResumeDeltaPrompt = canResumeSession && wakePrompt.length > 0;
const renderedHeartbeatPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
const memoryPreamble = asString(context.paperclipMemoryPreamble, "").trim();
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const userPrompt = joinPromptSections([
renderedBootstrapPrompt,
wakePrompt,
memoryPreamble,
sessionHandoffNote,
renderedHeartbeatPrompt,
]);
@@ -321,6 +323,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
promptChars: userPrompt.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
memoryPreambleChars: memoryPreamble.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedHeartbeatPrompt.length,
};
@@ -0,0 +1,150 @@
CREATE TABLE "memory_binding_targets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"binding_id" uuid NOT NULL,
"target_type" text NOT NULL,
"target_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "memory_bindings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"key" text NOT NULL,
"name" text,
"provider_key" text NOT NULL,
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "memory_extraction_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"binding_id" uuid NOT NULL,
"provider_key" text NOT NULL,
"operation_id" uuid,
"status" text DEFAULT 'queued' NOT NULL,
"provider_job_id" text,
"source_kind" text,
"source_issue_id" uuid,
"source_comment_id" uuid,
"source_document_key" text,
"source_run_id" uuid,
"source_activity_id" uuid,
"source_external_ref" text,
"result_json" jsonb,
"error" text,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "memory_local_records" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"binding_id" uuid NOT NULL,
"provider_key" text NOT NULL,
"scope_agent_id" uuid,
"scope_project_id" uuid,
"scope_issue_id" uuid,
"scope_run_id" uuid,
"scope_subject_id" text,
"source_kind" text,
"source_issue_id" uuid,
"source_comment_id" uuid,
"source_document_key" text,
"source_run_id" uuid,
"source_activity_id" uuid,
"source_external_ref" text,
"title" text,
"content" text NOT NULL,
"summary" text,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_by_operation_id" uuid,
"deleted_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "memory_operations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"binding_id" uuid NOT NULL,
"provider_key" text NOT NULL,
"operation_type" text NOT NULL,
"trigger_kind" text DEFAULT 'manual' NOT NULL,
"hook_kind" text,
"status" text DEFAULT 'succeeded' NOT NULL,
"actor_type" text NOT NULL,
"actor_id" text NOT NULL,
"agent_id" uuid,
"user_id" text,
"scope_agent_id" uuid,
"scope_project_id" uuid,
"scope_issue_id" uuid,
"scope_run_id" uuid,
"scope_subject_id" text,
"source_kind" text,
"source_issue_id" uuid,
"source_comment_id" uuid,
"source_document_key" text,
"source_run_id" uuid,
"source_activity_id" uuid,
"source_external_ref" text,
"query_text" text,
"record_count" integer DEFAULT 0 NOT NULL,
"request_json" jsonb,
"result_json" jsonb,
"usage_json" jsonb DEFAULT '[]'::jsonb NOT NULL,
"error" text,
"cost_event_id" uuid,
"finance_event_id" uuid,
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "memory_binding_targets" ADD CONSTRAINT "memory_binding_targets_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_binding_targets" ADD CONSTRAINT "memory_binding_targets_binding_id_memory_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."memory_bindings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_bindings" ADD CONSTRAINT "memory_bindings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_extraction_jobs" ADD CONSTRAINT "memory_extraction_jobs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_extraction_jobs" ADD CONSTRAINT "memory_extraction_jobs_binding_id_memory_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."memory_bindings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_extraction_jobs" ADD CONSTRAINT "memory_extraction_jobs_operation_id_memory_operations_id_fk" FOREIGN KEY ("operation_id") REFERENCES "public"."memory_operations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_binding_id_memory_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."memory_bindings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_scope_agent_id_agents_id_fk" FOREIGN KEY ("scope_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_scope_project_id_projects_id_fk" FOREIGN KEY ("scope_project_id") REFERENCES "public"."projects"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_scope_issue_id_issues_id_fk" FOREIGN KEY ("scope_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_scope_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("scope_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_source_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("source_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_created_by_operation_id_memory_operations_id_fk" FOREIGN KEY ("created_by_operation_id") REFERENCES "public"."memory_operations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_binding_id_memory_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."memory_bindings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_scope_agent_id_agents_id_fk" FOREIGN KEY ("scope_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_scope_project_id_projects_id_fk" FOREIGN KEY ("scope_project_id") REFERENCES "public"."projects"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_scope_issue_id_issues_id_fk" FOREIGN KEY ("scope_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_scope_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("scope_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_source_comment_id_issue_comments_id_fk" FOREIGN KEY ("source_comment_id") REFERENCES "public"."issue_comments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_source_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("source_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_source_activity_id_activity_log_id_fk" FOREIGN KEY ("source_activity_id") REFERENCES "public"."activity_log"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_cost_event_id_cost_events_id_fk" FOREIGN KEY ("cost_event_id") REFERENCES "public"."cost_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_finance_event_id_finance_events_id_fk" FOREIGN KEY ("finance_event_id") REFERENCES "public"."finance_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "memory_binding_targets_company_target_idx" ON "memory_binding_targets" USING btree ("company_id","target_type","target_id");--> statement-breakpoint
CREATE INDEX "memory_binding_targets_company_binding_idx" ON "memory_binding_targets" USING btree ("company_id","binding_id");--> statement-breakpoint
CREATE UNIQUE INDEX "memory_bindings_company_key_idx" ON "memory_bindings" USING btree ("company_id","key");--> statement-breakpoint
CREATE INDEX "memory_bindings_company_provider_idx" ON "memory_bindings" USING btree ("company_id","provider_key");--> statement-breakpoint
CREATE INDEX "memory_extraction_jobs_company_status_created_idx" ON "memory_extraction_jobs" USING btree ("company_id","status","created_at");--> statement-breakpoint
CREATE INDEX "memory_extraction_jobs_company_binding_created_idx" ON "memory_extraction_jobs" USING btree ("company_id","binding_id","created_at");--> statement-breakpoint
CREATE INDEX "memory_local_records_company_binding_created_idx" ON "memory_local_records" USING btree ("company_id","binding_id","created_at");--> statement-breakpoint
CREATE INDEX "memory_local_records_company_agent_created_idx" ON "memory_local_records" USING btree ("company_id","scope_agent_id","created_at");--> statement-breakpoint
CREATE INDEX "memory_local_records_company_issue_created_idx" ON "memory_local_records" USING btree ("company_id","scope_issue_id","created_at");--> statement-breakpoint
CREATE INDEX "memory_operations_company_occurred_idx" ON "memory_operations" USING btree ("company_id","occurred_at");--> statement-breakpoint
CREATE INDEX "memory_operations_company_binding_occurred_idx" ON "memory_operations" USING btree ("company_id","binding_id","occurred_at");--> statement-breakpoint
CREATE INDEX "memory_operations_company_issue_occurred_idx" ON "memory_operations" USING btree ("company_id","scope_issue_id","occurred_at");--> statement-breakpoint
CREATE INDEX "memory_operations_company_run_occurred_idx" ON "memory_operations" USING btree ("company_id","scope_run_id","occurred_at");
File diff suppressed because it is too large. Load diff
@@ -393,6 +393,13 @@
"when": 1775825256196,
"tag": "0055_kind_weapon_omega",
"breakpoints": true
},
{
"idx": 56,
"version": "7",
"when": 1775925298474,
"tag": "0056_oval_gauntlet",
"breakpoints": true
}
]
}
+5
View File
@@ -53,6 +53,11 @@ export { activityLog } from "./activity_log.js";
export { companySecrets } from "./company_secrets.js";
export { companySecretVersions } from "./company_secret_versions.js";
export { companySkills } from "./company_skills.js";
export { memoryBindings } from "./memory_bindings.js";
export { memoryBindingTargets } from "./memory_binding_targets.js";
export { memoryOperations } from "./memory_operations.js";
export { memoryExtractionJobs } from "./memory_extraction_jobs.js";
export { memoryLocalRecords } from "./memory_local_records.js";
export { plugins } from "./plugins.js";
export { pluginConfig } from "./plugin_config.js";
export { pluginCompanySettings } from "./plugin_company_settings.js";
@@ -0,0 +1,25 @@
import { pgTable, uuid, text, timestamp, uniqueIndex, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { memoryBindings } from "./memory_bindings.js";
import type { MemoryBindingTargetType } from "@paperclipai/shared";
export const memoryBindingTargets = pgTable(
"memory_binding_targets",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
bindingId: uuid("binding_id").notNull().references(() => memoryBindings.id, { onDelete: "cascade" }),
targetType: text("target_type").$type<MemoryBindingTargetType>().notNull(),
targetId: uuid("target_id").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyTargetUniqueIdx: uniqueIndex("memory_binding_targets_company_target_idx").on(
table.companyId,
table.targetType,
table.targetId,
),
companyBindingIdx: index("memory_binding_targets_company_binding_idx").on(table.companyId, table.bindingId),
}),
);
+21
View File
@@ -0,0 +1,21 @@
import { pgTable, uuid, text, timestamp, boolean, jsonb, uniqueIndex, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
export const memoryBindings = pgTable(
"memory_bindings",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
key: text("key").notNull(),
name: text("name"),
providerKey: text("provider_key").notNull(),
config: jsonb("config").$type<Record<string, unknown>>().notNull().default({}),
enabled: boolean("enabled").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyKeyUniqueIdx: uniqueIndex("memory_bindings_company_key_idx").on(table.companyId, table.key),
companyProviderIdx: index("memory_bindings_company_provider_idx").on(table.companyId, table.providerKey),
}),
);
@@ -0,0 +1,43 @@
import { pgTable, uuid, text, timestamp, jsonb, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { memoryBindings } from "./memory_bindings.js";
import { memoryOperations } from "./memory_operations.js";
import type { MemoryExtractionJobStatus, MemorySourceKind } from "@paperclipai/shared";
export const memoryExtractionJobs = pgTable(
"memory_extraction_jobs",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
bindingId: uuid("binding_id").notNull().references(() => memoryBindings.id, { onDelete: "cascade" }),
providerKey: text("provider_key").notNull(),
operationId: uuid("operation_id").references(() => memoryOperations.id, { onDelete: "set null" }),
status: text("status").$type<MemoryExtractionJobStatus>().notNull().default("queued"),
providerJobId: text("provider_job_id"),
sourceKind: text("source_kind").$type<MemorySourceKind>(),
sourceIssueId: uuid("source_issue_id"),
sourceCommentId: uuid("source_comment_id"),
sourceDocumentKey: text("source_document_key"),
sourceRunId: uuid("source_run_id"),
sourceActivityId: uuid("source_activity_id"),
sourceExternalRef: text("source_external_ref"),
resultJson: jsonb("result_json").$type<Record<string, unknown> | null>(),
error: text("error"),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyStatusCreatedIdx: index("memory_extraction_jobs_company_status_created_idx").on(
table.companyId,
table.status,
table.createdAt,
),
companyBindingCreatedIdx: index("memory_extraction_jobs_company_binding_created_idx").on(
table.companyId,
table.bindingId,
table.createdAt,
),
}),
);
@@ -0,0 +1,56 @@
import { pgTable, uuid, text, timestamp, jsonb, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
import { projects } from "./projects.js";
import { issues } from "./issues.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { memoryBindings } from "./memory_bindings.js";
import { memoryOperations } from "./memory_operations.js";
import type { MemorySourceKind } from "@paperclipai/shared";
export const memoryLocalRecords = pgTable(
"memory_local_records",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
bindingId: uuid("binding_id").notNull().references(() => memoryBindings.id, { onDelete: "cascade" }),
providerKey: text("provider_key").notNull(),
scopeAgentId: uuid("scope_agent_id").references(() => agents.id),
scopeProjectId: uuid("scope_project_id").references(() => projects.id),
scopeIssueId: uuid("scope_issue_id").references(() => issues.id),
scopeRunId: uuid("scope_run_id").references(() => heartbeatRuns.id),
scopeSubjectId: text("scope_subject_id"),
sourceKind: text("source_kind").$type<MemorySourceKind>(),
sourceIssueId: uuid("source_issue_id").references(() => issues.id),
sourceCommentId: uuid("source_comment_id"),
sourceDocumentKey: text("source_document_key"),
sourceRunId: uuid("source_run_id").references(() => heartbeatRuns.id),
sourceActivityId: uuid("source_activity_id"),
sourceExternalRef: text("source_external_ref"),
title: text("title"),
content: text("content").notNull(),
summary: text("summary"),
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
createdByOperationId: uuid("created_by_operation_id").references(() => memoryOperations.id, { onDelete: "set null" }),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyBindingCreatedIdx: index("memory_local_records_company_binding_created_idx").on(
table.companyId,
table.bindingId,
table.createdAt,
),
companyAgentCreatedIdx: index("memory_local_records_company_agent_created_idx").on(
table.companyId,
table.scopeAgentId,
table.createdAt,
),
companyIssueCreatedIdx: index("memory_local_records_company_issue_created_idx").on(
table.companyId,
table.scopeIssueId,
table.createdAt,
),
}),
);
@@ -0,0 +1,76 @@
import { pgTable, uuid, text, timestamp, integer, jsonb, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
import { projects } from "./projects.js";
import { issues } from "./issues.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issueComments } from "./issue_comments.js";
import { activityLog } from "./activity_log.js";
import { costEvents } from "./cost_events.js";
import { financeEvents } from "./finance_events.js";
import { memoryBindings } from "./memory_bindings.js";
import type {
MemoryHookKind,
MemoryOperationStatus,
MemoryOperationType,
MemorySourceKind,
MemoryTriggerKind,
} from "@paperclipai/shared";
export const memoryOperations = pgTable(
"memory_operations",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
bindingId: uuid("binding_id").notNull().references(() => memoryBindings.id, { onDelete: "cascade" }),
providerKey: text("provider_key").notNull(),
operationType: text("operation_type").$type<MemoryOperationType>().notNull(),
triggerKind: text("trigger_kind").$type<MemoryTriggerKind>().notNull().default("manual"),
hookKind: text("hook_kind").$type<MemoryHookKind>(),
status: text("status").$type<MemoryOperationStatus>().notNull().default("succeeded"),
actorType: text("actor_type").notNull(),
actorId: text("actor_id").notNull(),
agentId: uuid("agent_id").references(() => agents.id),
userId: text("user_id"),
scopeAgentId: uuid("scope_agent_id").references(() => agents.id),
scopeProjectId: uuid("scope_project_id").references(() => projects.id),
scopeIssueId: uuid("scope_issue_id").references(() => issues.id),
scopeRunId: uuid("scope_run_id").references(() => heartbeatRuns.id),
scopeSubjectId: text("scope_subject_id"),
sourceKind: text("source_kind").$type<MemorySourceKind>(),
sourceIssueId: uuid("source_issue_id").references(() => issues.id),
sourceCommentId: uuid("source_comment_id").references(() => issueComments.id),
sourceDocumentKey: text("source_document_key"),
sourceRunId: uuid("source_run_id").references(() => heartbeatRuns.id),
sourceActivityId: uuid("source_activity_id").references(() => activityLog.id),
sourceExternalRef: text("source_external_ref"),
queryText: text("query_text"),
recordCount: integer("record_count").notNull().default(0),
requestJson: jsonb("request_json").$type<Record<string, unknown> | null>(),
resultJson: jsonb("result_json").$type<Record<string, unknown> | null>(),
usageJson: jsonb("usage_json").$type<Array<Record<string, unknown>>>().notNull().default([]),
error: text("error"),
costEventId: uuid("cost_event_id").references(() => costEvents.id),
financeEventId: uuid("finance_event_id").references(() => financeEvents.id),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyOccurredIdx: index("memory_operations_company_occurred_idx").on(table.companyId, table.occurredAt),
companyBindingOccurredIdx: index("memory_operations_company_binding_occurred_idx").on(
table.companyId,
table.bindingId,
table.occurredAt,
),
companyIssueOccurredIdx: index("memory_operations_company_issue_occurred_idx").on(
table.companyId,
table.scopeIssueId,
table.occurredAt,
),
companyRunOccurredIdx: index("memory_operations_company_run_occurred_idx").on(
table.companyId,
table.scopeRunId,
table.occurredAt,
),
}),
);
+51
View File
@@ -14,8 +14,21 @@ import type {
PluginStateScopeKind,
PluginEventType,
PluginToolDeclaration,
MemoryBinding,
MemoryBindingTarget,
MemoryCaptureResult,
PluginLauncherDeclaration,
Company,
MemoryForgetResult,
MemoryOperation,
MemoryProviderCapabilities,
MemoryProviderDescriptor,
MemoryQueryResult,
MemoryRecord,
MemoryResolvedBinding,
MemoryScope,
MemorySourceRef,
MemoryUsage,
Project,
Issue,
IssueComment,
@@ -34,6 +47,7 @@ export type {
PluginJobDeclaration,
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginMemoryProviderDeclaration,
PluginUiSlotDeclaration,
PluginUiDeclaration,
PluginLauncherActionDeclaration,
@@ -60,6 +74,19 @@ export type {
PluginEventType,
PluginBridgeErrorCode,
Company,
MemoryBinding,
MemoryBindingTarget,
MemoryCaptureResult,
MemoryForgetResult,
MemoryOperation,
MemoryProviderCapabilities,
MemoryProviderDescriptor,
MemoryQueryResult,
MemoryRecord,
MemoryResolvedBinding,
MemoryScope,
MemorySourceRef,
MemoryUsage,
Project,
Issue,
IssueComment,
@@ -145,6 +172,30 @@ export interface PluginEvent<TPayload = unknown> {
payload: TPayload;
}
export interface PluginMemoryProvider {
key: string;
displayName: string;
description?: string;
capabilities?: Partial<MemoryProviderCapabilities>;
query?(input: {
scope: MemoryScope;
query: string;
topK?: number;
metadataFilter?: Record<string, unknown>;
}): Promise<MemoryQueryResult>;
capture?(input: {
scope: MemoryScope;
source: MemorySourceRef;
title?: string | null;
content: string;
summary?: string | null;
metadata?: Record<string, unknown>;
}): Promise<MemoryCaptureResult>;
list?(input: { scope: MemoryScope; limit?: number }): Promise<MemoryRecord[]>;
get?(input: { scope: MemoryScope; recordId: string }): Promise<MemoryRecord | null>;
forget?(input: { scope: MemoryScope; recordIds: string[] }): Promise<MemoryForgetResult>;
}
// ---------------------------------------------------------------------------
// Job context
// ---------------------------------------------------------------------------
+44
View File
@@ -387,6 +387,49 @@ export const PERMISSION_KEYS = [
] as const;
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
export const MEMORY_BINDING_TARGET_TYPES = ["company", "agent"] as const;
export type MemoryBindingTargetType = (typeof MEMORY_BINDING_TARGET_TYPES)[number];
export const MEMORY_PROVIDER_KINDS = ["builtin", "plugin"] as const;
export type MemoryProviderKind = (typeof MEMORY_PROVIDER_KINDS)[number];
export const MEMORY_OPERATION_TYPES = ["query", "capture", "upsert", "list", "get", "forget"] as const;
export type MemoryOperationType = (typeof MEMORY_OPERATION_TYPES)[number];
export const MEMORY_OPERATION_STATUSES = ["succeeded", "failed"] as const;
export type MemoryOperationStatus = (typeof MEMORY_OPERATION_STATUSES)[number];
export const MEMORY_TRIGGER_KINDS = ["manual", "hook"] as const;
export type MemoryTriggerKind = (typeof MEMORY_TRIGGER_KINDS)[number];
export const MEMORY_HOOK_KINDS = [
"pre_run_hydrate",
"post_run_capture",
"issue_comment_capture",
"issue_document_capture",
] as const;
export type MemoryHookKind = (typeof MEMORY_HOOK_KINDS)[number];
export const MEMORY_SOURCE_KINDS = [
"issue_comment",
"issue_document",
"issue",
"run",
"activity",
"manual_note",
"external_document",
] as const;
export type MemorySourceKind = (typeof MEMORY_SOURCE_KINDS)[number];
export const MEMORY_EXTRACTION_JOB_STATUSES = [
"queued",
"running",
"succeeded",
"failed",
"cancelled",
] as const;
export type MemoryExtractionJobStatus = (typeof MEMORY_EXTRACTION_JOB_STATUSES)[number];
// ---------------------------------------------------------------------------
// Plugin System — see doc/plugins/PLUGIN_SPEC.md for the full specification
// ---------------------------------------------------------------------------
@@ -474,6 +517,7 @@ export const PLUGIN_CAPABILITIES = [
"activity.log.write",
"metrics.write",
"telemetry.track",
"memory.providers.register",
// Plugin State
"plugin.state.read",
"plugin.state.write",
+60
View File
@@ -60,6 +60,14 @@ export {
JOIN_REQUEST_TYPES,
JOIN_REQUEST_STATUSES,
PERMISSION_KEYS,
MEMORY_BINDING_TARGET_TYPES,
MEMORY_PROVIDER_KINDS,
MEMORY_OPERATION_TYPES,
MEMORY_OPERATION_STATUSES,
MEMORY_TRIGGER_KINDS,
MEMORY_HOOK_KINDS,
MEMORY_SOURCE_KINDS,
MEMORY_EXTRACTION_JOB_STATUSES,
PLUGIN_API_VERSION,
PLUGIN_STATUSES,
PLUGIN_CATEGORIES,
@@ -133,6 +141,14 @@ export {
type JoinRequestType,
type JoinRequestStatus,
type PermissionKey,
type MemoryBindingTargetType,
type MemoryProviderKind,
type MemoryOperationType,
type MemoryOperationStatus,
type MemoryTriggerKind,
type MemoryHookKind,
type MemorySourceKind,
type MemoryExtractionJobStatus,
type PluginStatus,
type PluginCategory,
type PluginCapability,
@@ -271,6 +287,21 @@ export type {
Goal,
Approval,
ApprovalComment,
MemoryProviderCapabilities,
MemoryProviderDescriptor,
MemoryUsage,
MemoryScope,
MemorySourceRef,
MemoryBinding,
MemoryBindingTarget,
MemoryResolvedBinding,
MemoryRecordHandle,
MemoryRecord,
MemoryOperation,
MemoryExtractionJob,
MemoryQueryResult,
MemoryCaptureResult,
MemoryForgetResult,
BudgetPolicy,
BudgetPolicySummary,
BudgetIncident,
@@ -354,6 +385,7 @@ export type {
PluginJobDeclaration,
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginMemoryProviderDeclaration,
PluginUiSlotDeclaration,
PluginLauncherActionDeclaration,
PluginLauncherRenderDeclaration,
@@ -541,6 +573,20 @@ export {
createCostEventSchema,
createFinanceEventSchema,
updateBudgetSchema,
memoryScopeSchema,
memorySourceRefSchema,
memoryProviderCapabilitiesSchema,
createMemoryBindingSchema,
updateMemoryBindingSchema,
setCompanyMemoryBindingSchema,
setAgentMemoryBindingSchema,
memoryQuerySchema,
memoryCaptureSchema,
memoryForgetSchema,
memoryListRecordsQuerySchema,
memoryListOperationsQuerySchema,
memoryListExtractionJobsQuerySchema,
memoryBindingTargetTypeSchema,
createAssetImageMetadataSchema,
createCompanyInviteSchema,
createOpenClawInvitePromptSchema,
@@ -555,6 +601,18 @@ export {
type CreateCostEvent,
type CreateFinanceEvent,
type UpdateBudget,
type MemoryScopeInput,
type MemorySourceRefInput,
type CreateMemoryBinding,
type UpdateMemoryBinding,
type SetCompanyMemoryBinding,
type SetAgentMemoryBinding,
type MemoryQuery,
type MemoryCapture,
type MemoryForget,
type MemoryListRecordsQuery,
type MemoryListOperationsQuery,
type MemoryListExtractionJobsQuery,
type CreateAssetImageMetadata,
type CreateCompanyInvite,
type CreateOpenClawInvitePrompt,
@@ -604,6 +662,7 @@ export {
pluginJobDeclarationSchema,
pluginWebhookDeclarationSchema,
pluginToolDeclarationSchema,
pluginMemoryProviderDeclarationSchema,
pluginUiSlotDeclarationSchema,
pluginLauncherActionDeclarationSchema,
pluginLauncherRenderDeclarationSchema,
@@ -620,6 +679,7 @@ export {
type PluginJobDeclarationInput,
type PluginWebhookDeclarationInput,
type PluginToolDeclarationInput,
type PluginMemoryProviderDeclarationInput,
type PluginUiSlotDeclarationInput,
type PluginLauncherActionDeclarationInput,
type PluginLauncherRenderDeclarationInput,
+18
View File
@@ -119,6 +119,23 @@ export type {
} from "./issue.js";
export type { Goal } from "./goal.js";
export type { Approval, ApprovalComment } from "./approval.js";
export type {
MemoryProviderCapabilities,
MemoryProviderDescriptor,
MemoryUsage,
MemoryScope,
MemorySourceRef,
MemoryBinding,
MemoryBindingTarget,
MemoryResolvedBinding,
MemoryRecordHandle,
MemoryRecord,
MemoryOperation,
MemoryExtractionJob,
MemoryQueryResult,
MemoryCaptureResult,
MemoryForgetResult,
} from "./memory.js";
export type {
BudgetPolicy,
BudgetPolicySummary,
@@ -210,6 +227,7 @@ export type {
PluginJobDeclaration,
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginMemoryProviderDeclaration,
PluginUiSlotDeclaration,
PluginLauncherActionDeclaration,
PluginLauncherRenderDeclaration,
+167
View File
@@ -0,0 +1,167 @@
import type {
MemoryBindingTargetType,
MemoryExtractionJobStatus,
MemoryHookKind,
MemoryOperationStatus,
MemoryOperationType,
MemoryProviderKind,
MemorySourceKind,
MemoryTriggerKind,
} from "../constants.js";
export interface MemoryProviderCapabilities {
browse: boolean;
correction: boolean;
asyncIngestion: boolean;
providerManagedExtraction: boolean;
}
export interface MemoryProviderDescriptor {
key: string;
displayName: string;
description: string | null;
kind: MemoryProviderKind;
pluginId: string | null;
capabilities: MemoryProviderCapabilities;
configSchema: Record<string, unknown> | null;
}
export interface MemoryUsage {
provider: string;
model: string | null;
inputTokens: number;
outputTokens: number;
embeddingTokens: number;
costCents: number;
latencyMs: number | null;
details: Record<string, unknown> | null;
}
export interface MemoryScope {
agentId?: string | null;
projectId?: string | null;
issueId?: string | null;
runId?: string | null;
subjectId?: string | null;
}
export interface MemorySourceRef {
kind: MemorySourceKind;
issueId?: string | null;
commentId?: string | null;
documentKey?: string | null;
runId?: string | null;
activityId?: string | null;
externalRef?: string | null;
}
export interface MemoryBinding {
id: string;
companyId: string;
key: string;
name: string | null;
providerKey: string;
config: Record<string, unknown>;
enabled: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface MemoryBindingTarget {
id: string;
companyId: string;
bindingId: string;
targetType: MemoryBindingTargetType;
targetId: string;
createdAt: Date;
updatedAt: Date;
}
export interface MemoryResolvedBinding {
companyId: string;
targetType: MemoryBindingTargetType | null;
targetId: string | null;
binding: MemoryBinding | null;
}
export interface MemoryRecordHandle {
providerKey: string;
recordId: string;
}
export interface MemoryRecord {
id: string;
companyId: string;
bindingId: string;
providerKey: string;
scope: MemoryScope;
source: MemorySourceRef | null;
title: string | null;
content: string;
summary: string | null;
metadata: Record<string, unknown>;
createdByOperationId: string | null;
deletedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface MemoryOperation {
id: string;
companyId: string;
bindingId: string;
providerKey: string;
operationType: MemoryOperationType;
triggerKind: MemoryTriggerKind;
hookKind: MemoryHookKind | null;
status: MemoryOperationStatus;
actorType: "agent" | "user" | "system";
actorId: string;
agentId: string | null;
userId: string | null;
scope: MemoryScope;
source: MemorySourceRef | null;
queryText: string | null;
recordCount: number;
requestJson: Record<string, unknown> | null;
resultJson: Record<string, unknown> | null;
usage: MemoryUsage[];
error: string | null;
costEventId: string | null;
financeEventId: string | null;
occurredAt: Date;
createdAt: Date;
}
export interface MemoryExtractionJob {
id: string;
companyId: string;
bindingId: string;
providerKey: string;
operationId: string | null;
status: MemoryExtractionJobStatus;
providerJobId: string | null;
source: MemorySourceRef | null;
resultJson: Record<string, unknown> | null;
error: string | null;
startedAt: Date | null;
completedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface MemoryQueryResult {
operation: MemoryOperation;
records: MemoryRecord[];
preamble: string | null;
}
export interface MemoryCaptureResult {
operation: MemoryOperation;
records: MemoryRecord[];
}
export interface MemoryForgetResult {
operation: MemoryOperation;
forgottenRecordIds: string[];
}
+16
View File
@@ -10,6 +10,7 @@ import type {
PluginLauncherBounds,
PluginLauncherRenderEnvironment,
} from "../constants.js";
import type { MemoryProviderCapabilities } from "./memory.js";
// ---------------------------------------------------------------------------
// JSON Schema placeholder – plugins declare config schemas as JSON Schema
@@ -190,6 +191,19 @@ export interface PluginUiDeclaration {
launchers?: PluginLauncherDeclaration[];
}
export interface PluginMemoryProviderDeclaration {
/** Stable provider key unique within the plugin manifest. */
key: string;
/** Human-readable name shown in the operator UI. */
displayName: string;
/** Optional description of what the provider does. */
description?: string;
/** Capability flags surfaced in the host provider catalog. */
capabilities?: Partial<MemoryProviderCapabilities>;
/** Optional JSON Schema for provider-specific binding config. */
configSchema?: JsonSchema;
}
// ---------------------------------------------------------------------------
// Plugin Manifest V1
// ---------------------------------------------------------------------------
@@ -240,6 +254,8 @@ export interface PaperclipPluginManifestV1 {
webhooks?: PluginWebhookDeclaration[];
/** Agent tools this plugin contributes. Requires `agent.tools.register` capability. */
tools?: PluginToolDeclaration[];
/** Memory providers this plugin contributes. Requires `memory.providers.register` capability. */
memoryProviders?: PluginMemoryProviderDeclaration[];
/**
* Legacy top-level launcher declarations.
* Prefer `ui.launchers` for new manifests.
+31
View File
@@ -234,6 +234,35 @@ export {
type UpdateBudget,
} from "./cost.js";
export {
memoryScopeSchema,
memorySourceRefSchema,
memoryProviderCapabilitiesSchema,
createMemoryBindingSchema,
updateMemoryBindingSchema,
setCompanyMemoryBindingSchema,
setAgentMemoryBindingSchema,
memoryQuerySchema,
memoryCaptureSchema,
memoryForgetSchema,
memoryListRecordsQuerySchema,
memoryListOperationsQuerySchema,
memoryListExtractionJobsQuerySchema,
memoryBindingTargetTypeSchema,
type MemoryScopeInput,
type MemorySourceRefInput,
type CreateMemoryBinding,
type UpdateMemoryBinding,
type SetCompanyMemoryBinding,
type SetAgentMemoryBinding,
type MemoryQuery,
type MemoryCapture,
type MemoryForget,
type MemoryListRecordsQuery,
type MemoryListOperationsQuery,
type MemoryListExtractionJobsQuery,
} from "./memory.js";
export {
createFinanceEventSchema,
type CreateFinanceEvent,
@@ -272,6 +301,7 @@ export {
pluginJobDeclarationSchema,
pluginWebhookDeclarationSchema,
pluginToolDeclarationSchema,
pluginMemoryProviderDeclarationSchema,
pluginUiSlotDeclarationSchema,
pluginLauncherActionDeclarationSchema,
pluginLauncherRenderDeclarationSchema,
@@ -288,6 +318,7 @@ export {
type PluginJobDeclarationInput,
type PluginWebhookDeclarationInput,
type PluginToolDeclarationInput,
type PluginMemoryProviderDeclarationInput,
type PluginUiSlotDeclarationInput,
type PluginLauncherActionDeclarationInput,
type PluginLauncherRenderDeclarationInput,
+147
View File
@@ -0,0 +1,147 @@
import { z } from "zod";
import {
MEMORY_BINDING_TARGET_TYPES,
MEMORY_HOOK_KINDS,
MEMORY_OPERATION_STATUSES,
MEMORY_OPERATION_TYPES,
MEMORY_SOURCE_KINDS,
} from "../constants.js";
export const memoryScopeSchema = z
.object({
agentId: z.string().uuid().nullable().optional(),
projectId: z.string().uuid().nullable().optional(),
issueId: z.string().uuid().nullable().optional(),
runId: z.string().uuid().nullable().optional(),
subjectId: z.string().trim().max(200).nullable().optional(),
})
.strict();
export const memorySourceRefSchema = z
.object({
kind: z.enum(MEMORY_SOURCE_KINDS),
issueId: z.string().uuid().nullable().optional(),
commentId: z.string().uuid().nullable().optional(),
documentKey: z.string().trim().max(64).nullable().optional(),
runId: z.string().uuid().nullable().optional(),
activityId: z.string().uuid().nullable().optional(),
externalRef: z.string().trim().max(500).nullable().optional(),
})
.strict();
export const memoryProviderCapabilitiesSchema = z
.object({
browse: z.boolean().optional().default(false),
correction: z.boolean().optional().default(false),
asyncIngestion: z.boolean().optional().default(false),
providerManagedExtraction: z.boolean().optional().default(false),
})
.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 -"),
name: z.string().trim().max(200).nullable().optional(),
providerKey: z.string().trim().min(1).max(128),
config: z.record(z.unknown()).optional().default({}),
enabled: z.boolean().optional().default(true),
})
.strict();
export const updateMemoryBindingSchema = z
.object({
name: z.string().trim().max(200).nullable().optional(),
config: z.record(z.unknown()).optional(),
enabled: z.boolean().optional(),
})
.strict()
.refine((value) => Object.keys(value).length > 0, "At least one field must be updated");
export const setCompanyMemoryBindingSchema = z
.object({
bindingId: z.string().uuid(),
})
.strict();
export const setAgentMemoryBindingSchema = z
.object({
bindingId: z.string().uuid().nullable(),
})
.strict();
export const memoryQuerySchema = z
.object({
bindingKey: z.string().trim().min(1).max(64).optional(),
scope: memoryScopeSchema.optional().default({}),
query: z.string().trim().min(1).max(4000),
topK: z.number().int().positive().max(25).optional().default(5),
intent: z.enum(["agent_preamble", "answer", "browse"]).optional().default("answer"),
metadataFilter: z.record(z.unknown()).optional(),
})
.strict();
export const memoryCaptureSchema = z
.object({
bindingKey: z.string().trim().min(1).max(64).optional(),
scope: memoryScopeSchema.optional().default({}),
source: memorySourceRefSchema,
title: z.string().trim().max(200).nullable().optional(),
content: z.string().trim().min(1).max(20000),
summary: z.string().trim().max(2000).nullable().optional(),
metadata: z.record(z.unknown()).optional(),
})
.strict();
export const memoryForgetSchema = z
.object({
recordIds: z.array(z.string().uuid()).min(1).max(100),
scope: memoryScopeSchema.optional().default({}),
})
.strict();
export const memoryListRecordsQuerySchema = z
.object({
bindingId: z.string().uuid().optional(),
agentId: z.string().uuid().optional(),
issueId: z.string().uuid().optional(),
runId: z.string().uuid().optional(),
sourceKind: z.enum(MEMORY_SOURCE_KINDS).optional(),
includeDeleted: z.coerce.boolean().optional().default(false),
limit: z.coerce.number().int().positive().max(200).optional().default(50),
})
.strict();
export const memoryListOperationsQuerySchema = z
.object({
bindingId: z.string().uuid().optional(),
operationType: z.enum(MEMORY_OPERATION_TYPES).optional(),
status: z.enum(MEMORY_OPERATION_STATUSES).optional(),
hookKind: z.enum(MEMORY_HOOK_KINDS).optional(),
agentId: z.string().uuid().optional(),
issueId: z.string().uuid().optional(),
runId: z.string().uuid().optional(),
limit: z.coerce.number().int().positive().max(200).optional().default(50),
})
.strict();
export const memoryListExtractionJobsQuerySchema = z
.object({
bindingId: z.string().uuid().optional(),
limit: z.coerce.number().int().positive().max(200).optional().default(50),
})
.strict();
export const memoryBindingTargetTypeSchema = z.enum(MEMORY_BINDING_TARGET_TYPES);
export type MemoryScopeInput = z.infer<typeof memoryScopeSchema>;
export type MemorySourceRefInput = z.infer<typeof memorySourceRefSchema>;
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 MemoryQuery = z.infer<typeof memoryQuerySchema>;
export type MemoryCapture = z.infer<typeof memoryCaptureSchema>;
export type MemoryForget = z.infer<typeof memoryForgetSchema>;
export type MemoryListRecordsQuery = z.infer<typeof memoryListRecordsQuerySchema>;
export type MemoryListOperationsQuery = z.infer<typeof memoryListOperationsQuerySchema>;
export type MemoryListExtractionJobsQuery = z.infer<typeof memoryListExtractionJobsQuerySchema>;
+34
View File
@@ -12,6 +12,7 @@ import {
PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS,
PLUGIN_STATE_SCOPE_KINDS,
} from "../constants.js";
import { memoryProviderCapabilitiesSchema } from "./memory.js";
// ---------------------------------------------------------------------------
// JSON Schema placeholder – a permissive validator for JSON Schema objects
@@ -105,6 +106,16 @@ export const pluginToolDeclarationSchema = z.object({
export type PluginToolDeclarationInput = z.infer<typeof pluginToolDeclarationSchema>;
export const pluginMemoryProviderDeclarationSchema = z.object({
key: z.string().min(1),
displayName: z.string().min(1),
description: z.string().optional(),
capabilities: memoryProviderCapabilitiesSchema.partial().optional(),
configSchema: jsonSchemaSchema.optional(),
});
export type PluginMemoryProviderDeclarationInput = z.infer<typeof pluginMemoryProviderDeclarationSchema>;
/**
* Validates a {@link PluginUiSlotDeclaration} — a UI extension slot the plugin
* fills with a React component. Includes `superRefine` checks for slot-specific
@@ -405,6 +416,7 @@ export const pluginManifestV1Schema = z.object({
jobs: z.array(pluginJobDeclarationSchema).optional(),
webhooks: z.array(pluginWebhookDeclarationSchema).optional(),
tools: z.array(pluginToolDeclarationSchema).optional(),
memoryProviders: z.array(pluginMemoryProviderDeclarationSchema).optional(),
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
ui: z.object({
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
@@ -452,6 +464,16 @@ export const pluginManifestV1Schema = z.object({
}
}
if (manifest.memoryProviders && manifest.memoryProviders.length > 0) {
if (!manifest.capabilities.includes("memory.providers.register")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'memory.providers.register' is required when memoryProviders are declared",
path: ["capabilities"],
});
}
}
// jobs require jobs.schedule (PLUGIN_SPEC.md §17)
if (manifest.jobs && manifest.jobs.length > 0) {
if (!manifest.capabilities.includes("jobs.schedule")) {
@@ -517,6 +539,18 @@ export const pluginManifestV1Schema = z.object({
}
}
if (manifest.memoryProviders) {
const providerKeys = manifest.memoryProviders.map((provider) => provider.key);
const duplicates = providerKeys.filter((key, i) => providerKeys.indexOf(key) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate memory provider keys: ${[...new Set(duplicates)].join(", ")}`,
path: ["memoryProviders"],
});
}
}
// UI slot ids must be unique within the plugin (namespaced at runtime)
if (manifest.ui) {
if (manifest.ui.slots) {
@@ -53,6 +53,10 @@ vi.mock("../services/index.js", () => ({
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
memoryService: () => ({
captureIssueComment: vi.fn(async () => undefined),
captureIssueDocument: vi.fn(async () => undefined),
}),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
@@ -58,6 +58,10 @@ function registerRouteMocks() {
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
memoryService: () => ({
captureIssueComment: vi.fn(async () => undefined),
captureIssueDocument: vi.fn(async () => undefined),
}),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
@@ -76,6 +76,10 @@ function registerServiceMocks() {
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
memoryService: () => ({
captureIssueComment: vi.fn(async () => undefined),
captureIssueDocument: vi.fn(async () => undefined),
}),
projectService: () => mockProjectService,
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
@@ -77,6 +77,10 @@ vi.mock("../services/index.js", () => ({
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
memoryService: () => ({
captureIssueComment: vi.fn(async () => undefined),
captureIssueDocument: vi.fn(async () => undefined),
}),
projectService: () => ({}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
@@ -47,6 +47,10 @@ vi.mock("../services/index.js", () => ({
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
memoryService: () => ({
captureIssueComment: vi.fn(async () => undefined),
captureIssueDocument: vi.fn(async () => undefined),
}),
projectService: () => ({
getById: vi.fn(),
listByIds: vi.fn(async () => []),
@@ -45,6 +45,10 @@ vi.mock("../services/index.js", () => ({
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
memoryService: () => ({
captureIssueComment: vi.fn(async () => undefined),
captureIssueDocument: vi.fn(async () => undefined),
}),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
@@ -169,7 +173,7 @@ describe("issue document revision routes", () => {
it("rejects invalid document keys before attempting restore", async () => {
const res = await request(createApp())
.post(`/api/issues/${issueId}/documents/INVALID KEY/revisions/revision-1/restore`)
.post(`/api/issues/${issueId}/documents/INVALID.KEY/revisions/revision-1/restore`)
.send({});
expect(res.status).toBe(400);
@@ -53,6 +53,10 @@ vi.mock("../services/index.js", () => ({
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
memoryService: () => ({
captureIssueComment: vi.fn(async () => undefined),
captureIssueDocument: vi.fn(async () => undefined),
}),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
@@ -71,6 +71,10 @@ vi.mock("../services/index.js", () => ({
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
memoryService: () => ({
captureIssueComment: vi.fn(async () => undefined),
captureIssueDocument: vi.fn(async () => undefined),
}),
projectService: () => ({}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
@@ -45,6 +45,10 @@ vi.mock("../services/index.js", () => ({
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
memoryService: () => ({
captureIssueComment: vi.fn(async () => undefined),
captureIssueDocument: vi.fn(async () => undefined),
}),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
@@ -53,6 +53,10 @@ vi.mock("../services/index.js", () => ({
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
memoryService: () => ({
captureIssueComment: vi.fn(async () => undefined),
captureIssueDocument: vi.fn(async () => undefined),
}),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
@@ -60,6 +60,10 @@ vi.mock("../services/index.js", () => ({
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
memoryService: () => ({
captureIssueComment: vi.fn(async () => undefined),
captureIssueDocument: vi.fn(async () => undefined),
}),
projectService: () => mockProjectService,
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
+2
View File
@@ -26,6 +26,7 @@ import { dashboardRoutes } from "./routes/dashboard.js";
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
import { memoryRoutes } from "./routes/memory.js";
import { llmRoutes } from "./routes/llms.js";
import { assetRoutes } from "./routes/assets.js";
import { accessRoutes } from "./routes/access.js";
@@ -164,6 +165,7 @@ export async function createApp(
api.use(approvalRoutes(db));
api.use(secretRoutes(db));
api.use(costRoutes(db));
api.use(memoryRoutes(db));
api.use(activityRoutes(db));
api.use(dashboardRoutes(db));
api.use(sidebarBadgeRoutes(db));
+61
View File
@@ -39,6 +39,7 @@ import {
instanceSettingsService,
issueApprovalService,
issueService,
memoryService,
documentService,
logActivity,
projectService,
@@ -280,6 +281,7 @@ export function issueRoutes(
const svc = issueService(db);
const access = accessService(db);
const heartbeat = heartbeatService(db);
const memorySvc = memoryService(db);
const feedback = feedbackService(db);
const instanceSettings = instanceSettingsService(db);
const agentsSvc = agentService(db);
@@ -859,6 +861,25 @@ export function issueRoutes(
},
});
void memorySvc.captureIssueDocument({
companyId: issue.companyId,
issueId: issue.id,
agentId: issue.assigneeAgentId ?? null,
projectId: issue.projectId ?? null,
key: doc.key,
title: doc.title ?? null,
body: doc.body,
actor: {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId ?? null,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId ?? null,
},
}).catch((err) => {
logger.warn({ err, issueId: issue.id, key: doc.key }, "failed to capture issue document to memory");
});
res.status(result.created ? 201 : 200).json(doc);
});
@@ -926,6 +947,28 @@ export function issueRoutes(
},
});
void memorySvc.captureIssueDocument({
companyId: issue.companyId,
issueId: issue.id,
agentId: issue.assigneeAgentId ?? null,
projectId: issue.projectId ?? null,
key: result.document.key,
title: result.document.title ?? null,
body: result.document.body,
actor: {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId ?? null,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId ?? null,
},
}).catch((err) => {
logger.warn(
{ err, issueId: issue.id, key: result.document.key },
"failed to capture restored issue document to memory",
);
});
res.json(result.document);
},
);
@@ -2258,6 +2301,24 @@ export function issueRoutes(
},
});
void memorySvc.captureIssueComment({
companyId: currentIssue.companyId,
issueId: currentIssue.id,
commentId: comment.id,
agentId: currentIssue.assigneeAgentId ?? null,
projectId: currentIssue.projectId ?? null,
body: comment.body,
actor: {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId ?? null,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId ?? null,
},
}).catch((err) => {
logger.warn({ err, issueId: currentIssue.id, commentId: comment.id }, "failed to capture issue comment to memory");
});
// Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs.
void (async () => {
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
+251
View File
@@ -0,0 +1,251 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import {
memoryCaptureSchema,
memoryForgetSchema,
memoryListExtractionJobsQuerySchema,
memoryListOperationsQuerySchema,
memoryListRecordsQuerySchema,
memoryQuerySchema,
setAgentMemoryBindingSchema,
setCompanyMemoryBindingSchema,
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 { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
function actorInfoFromReq(req: any) {
if (req.actor.type === "agent") {
return {
actorType: "agent" as const,
actorId: req.actor.agentId ?? "unknown-agent",
agentId: req.actor.agentId ?? null,
userId: null,
runId: req.actor.runId ?? null,
};
}
return {
actorType: req.actor.type === "board" ? ("user" as const) : ("system" as const),
actorId: req.actor.type === "board" ? (req.actor.userId ?? "board") : "system",
agentId: null,
userId: req.actor.type === "board" ? (req.actor.userId ?? null) : null,
runId: req.actor.runId ?? null,
};
}
export function memoryRoutes(db: Db) {
const router = Router();
const memory = memoryService(db);
const agentsSvc = agentService(db);
router.get("/companies/:companyId/memory/providers", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await memory.providers());
});
router.get("/companies/:companyId/memory/bindings", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await memory.listBindings(companyId));
});
router.get("/companies/:companyId/memory/targets", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await memory.listTargets(companyId));
});
router.post("/companies/:companyId/memory/bindings", validate(createMemoryBindingSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const binding = await memory.createBinding(companyId, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "memory.binding_created",
entityType: "memory_binding",
entityId: binding.id,
details: {
key: binding.key,
providerKey: binding.providerKey,
enabled: binding.enabled,
configKeys: Object.keys(binding.config ?? {}).sort(),
},
});
res.status(201).json(binding);
});
router.patch("/memory/bindings/:bindingId", validate(updateMemoryBindingSchema), async (req, res) => {
assertBoard(req);
const bindingId = req.params.bindingId as string;
const binding = await memory.updateBinding(bindingId, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: binding.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "memory.binding_updated",
entityType: "memory_binding",
entityId: binding.id,
details: {
changedKeys: Object.keys(req.body as Record<string, unknown>).sort(),
enabled: binding.enabled,
configKeys: Object.keys(binding.config ?? {}).sort(),
},
});
res.json(binding);
});
router.put("/companies/:companyId/memory/default-binding", validate(setCompanyMemoryBindingSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const target = await memory.setCompanyDefault(companyId, req.body.bindingId);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "memory.default_set",
entityType: "memory_binding",
entityId: target.bindingId,
details: {
targetType: target.targetType,
targetId: target.targetId,
},
});
res.json(target);
});
router.get("/agents/:agentId/memory-binding", async (req, res) => {
const agentId = req.params.agentId as string;
const agent = await agentsSvc.getById(agentId);
if (!agent) throw notFound("Agent not found");
assertCompanyAccess(req, agent.companyId);
res.json(await memory.resolveBinding(agent.companyId, { agentId: agent.id }));
});
router.put("/agents/:agentId/memory-binding", validate(setAgentMemoryBindingSchema), async (req, res) => {
const agentId = req.params.agentId as string;
const agent = await agentsSvc.getById(agentId);
if (!agent) throw notFound("Agent not found");
assertCompanyAccess(req, agent.companyId);
assertBoard(req);
const target = await memory.setAgentOverride(agent.id, req.body.bindingId);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: agent.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: target ? "memory.agent_override_set" : "memory.agent_override_cleared",
entityType: "agent",
entityId: agent.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);
const payload = req.body;
if (req.actor.type === "agent" && payload.scope?.agentId && payload.scope.agentId !== req.actor.agentId) {
throw forbidden("Agent cannot query memory for another agent scope");
}
res.json(await memory.query(companyId, payload, actorInfoFromReq(req)));
});
router.post("/companies/:companyId/memory/capture", validate(memoryCaptureSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const payload = req.body;
if (req.actor.type === "agent" && payload.scope?.agentId && payload.scope.agentId !== req.actor.agentId) {
throw forbidden("Agent cannot capture memory for another agent scope");
}
const result = await memory.capture(companyId, payload, actorInfoFromReq(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "memory.captured",
entityType: "memory_operation",
entityId: result.operation.id,
details: {
bindingId: result.operation.bindingId,
recordIds: result.records.map((record) => record.id),
sourceKind: result.operation.source?.kind ?? null,
},
});
res.status(201).json(result);
});
router.post("/companies/:companyId/memory/forget", validate(memoryForgetSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const result = await memory.forget(companyId, req.body, actorInfoFromReq(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "memory.forgotten",
entityType: "memory_operation",
entityId: result.operation.id,
details: {
forgottenRecordIds: result.forgottenRecordIds,
},
});
res.json(result);
});
router.get("/companies/:companyId/memory/records", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const parsed = memoryListRecordsQuerySchema.parse(req.query);
res.json(await memory.listRecords(companyId, parsed));
});
router.get("/companies/:companyId/memory/records/:recordId", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const record = await memory.getRecord(companyId, req.params.recordId as string);
if (!record) throw notFound("Memory record not found");
res.json(record);
});
router.get("/companies/:companyId/memory/operations", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const parsed = memoryListOperationsQuerySchema.parse(req.query);
res.json(await memory.listOperations(companyId, parsed));
});
router.get("/companies/:companyId/memory/extraction-jobs", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const parsed = memoryListExtractionJobsQuerySchema.parse(req.query);
res.json(await memory.listExtractionJobs(companyId, parsed));
});
return router;
}
+52
View File
@@ -31,6 +31,7 @@ import { getTelemetryClient } from "../telemetry.js";
import { companySkillService } from "./company-skills.js";
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
import { secretService } from "./secrets.js";
import { memoryService } from "./memory.js";
import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js";
import {
buildHeartbeatRunIssueComment,
@@ -1190,6 +1191,7 @@ export function heartbeatService(db: Db) {
const runLogStore = getRunLogStore();
const secretsSvc = secretService(db);
const memorySvc = memoryService(db);
const companySkills = companySkillService(db);
const issuesSvc = issueService(db);
const executionWorkspacesSvc = executionWorkspaceService(db);
@@ -3213,6 +3215,35 @@ export function heartbeatService(db: Db) {
);
}
}
if (issueRef) {
try {
const memoryPreamble = await memorySvc.preRunHydrate({
companyId: agent.companyId,
agentId: agent.id,
projectId: issueRef.projectId ?? null,
issueId: issueRef.id,
runId: run.id,
query: [issueRef.identifier ?? null, issueRef.title]
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.join("\n"),
});
if (memoryPreamble) {
context.paperclipMemoryPreamble = memoryPreamble;
await db
.update(heartbeatRuns)
.set({
contextSnapshot: context,
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, run.id));
}
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to hydrate memory: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
if (meta.env && secretKeys.size > 0) {
for (const key of secretKeys) {
@@ -3443,6 +3474,27 @@ export function heartbeatService(db: Db) {
);
}
}
const memorySummary =
buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null)
?? (typeof adapterResult.summary === "string" ? adapterResult.summary.trim() || null : null);
if (memorySummary) {
try {
await memorySvc.captureRunSummary({
companyId: agent.companyId,
agentId: agent.id,
projectId: issueRef?.projectId ?? null,
issueId: issueRef?.id ?? null,
runId: finalizedRun.id,
title: issueRef?.title ? `Run summary: ${issueRef.title}` : "Run summary",
summary: memorySummary,
});
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to capture run summary to memory: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
await finalizeIssueCommentPolicy(finalizedRun, agent);
await releaseIssueExecutionAndPromote(finalizedRun);
}
+1
View File
@@ -20,6 +20,7 @@ export { heartbeatService } from "./heartbeat.js";
export { dashboardService } from "./dashboard.js";
export { sidebarBadgeService } from "./sidebar-badges.js";
export { inboxDismissalService } from "./inbox-dismissals.js";
export { memoryService } from "./memory.js";
export { accessService } from "./access.js";
export { boardAuthService } from "./board-auth.js";
export { instanceSettingsService } from "./instance-settings.js";
File diff suppressed because it is too large. Load diff
+1
View File
@@ -17,3 +17,4 @@ export { instanceSettingsApi } from "./instanceSettings";
export { sidebarBadgesApi } from "./sidebarBadges";
export { inboxDismissalsApi } from "./inboxDismissals";
export { companySkillsApi } from "./companySkills";
export { memoryApi } from "./memory";
+52
View File
@@ -0,0 +1,52 @@
import type {
CreateMemoryBinding,
MemoryBinding,
MemoryBindingTarget,
MemoryListOperationsQuery,
MemoryListRecordsQuery,
MemoryOperation,
MemoryProviderDescriptor,
MemoryRecord,
MemoryResolvedBinding,
SetAgentMemoryBinding,
SetCompanyMemoryBinding,
UpdateMemoryBinding,
} from "@paperclipai/shared";
import { api } from "./client";
function buildQueryString(filters?: Record<string, string | number | boolean | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters ?? {})) {
if (value === undefined) continue;
params.set(key, String(value));
}
const qs = params.toString();
return qs ? `?${qs}` : "";
}
export const memoryApi = {
providers: (companyId: string) =>
api.get<MemoryProviderDescriptor[]>(`/companies/${encodeURIComponent(companyId)}/memory/providers`),
listBindings: (companyId: string) =>
api.get<MemoryBinding[]>(`/companies/${encodeURIComponent(companyId)}/memory/bindings`),
listTargets: (companyId: string) =>
api.get<MemoryBindingTarget[]>(`/companies/${encodeURIComponent(companyId)}/memory/targets`),
createBinding: (companyId: string, data: CreateMemoryBinding) =>
api.post<MemoryBinding>(`/companies/${encodeURIComponent(companyId)}/memory/bindings`, data),
updateBinding: (bindingId: string, data: UpdateMemoryBinding) =>
api.patch<MemoryBinding>(`/memory/bindings/${encodeURIComponent(bindingId)}`, data),
setCompanyDefault: (companyId: string, bindingId: SetCompanyMemoryBinding["bindingId"]) =>
api.put<MemoryBindingTarget>(`/companies/${encodeURIComponent(companyId)}/memory/default-binding`, { bindingId }),
getAgentBinding: (agentId: string) =>
api.get<MemoryResolvedBinding>(`/agents/${encodeURIComponent(agentId)}/memory-binding`),
setAgentBinding: (agentId: string, bindingId: SetAgentMemoryBinding["bindingId"]) =>
api.put<MemoryBindingTarget | null>(`/agents/${encodeURIComponent(agentId)}/memory-binding`, { bindingId }),
listRecords: (companyId: string, filters?: MemoryListRecordsQuery) =>
api.get<MemoryRecord[]>(
`/companies/${encodeURIComponent(companyId)}/memory/records${buildQueryString(filters)}`,
),
listOperations: (companyId: string, filters?: MemoryListOperationsQuery) =>
api.get<MemoryOperation[]>(
`/companies/${encodeURIComponent(companyId)}/memory/operations${buildQueryString(filters)}`,
),
};
+252
View File
@@ -0,0 +1,252 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { MemoryBinding, MemoryOperation, MemoryRecord } from "@paperclipai/shared";
import { memoryApi } from "../api/memory";
import { useToast } from "../context/ToastContext";
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";
return "Unconfigured";
}
function describeRecordSource(record: MemoryRecord) {
if (!record.source?.kind) return "memory";
return record.source.kind.replace(/_/g, " ");
}
function describeBinding(binding: MemoryBinding | null) {
if (!binding) return "No memory binding resolves for this agent yet.";
return `${binding.name ?? binding.key} (${binding.providerKey})`;
}
function summarizeRecord(record: MemoryRecord) {
const body = (record.summary ?? record.content).replace(/\s+/g, " ").trim();
return body.length > 280 ? `${body.slice(0, 277)}...` : body;
}
function operationCost(operation: MemoryOperation) {
const total = operation.usage.reduce((sum, item) => sum + item.costCents, 0);
return total > 0 ? formatCents(total) : "-";
}
export function AgentMemoryTab({
companyId,
agentId,
}: {
companyId: string;
agentId: 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.agentBinding(agentId),
queryFn: () => memoryApi.getAgentBinding(agentId),
});
const recordsQuery = useQuery({
queryKey: queryKeys.memory.records(companyId, { agentId, includeDeleted: false, limit: 20 }),
queryFn: () => memoryApi.listRecords(companyId, { agentId, includeDeleted: false, limit: 20 }),
});
const operationsQuery = useQuery({
queryKey: queryKeys.memory.operations(companyId, { agentId, limit: 20 }),
queryFn: () => memoryApi.listOperations(companyId, { agentId, limit: 20 }),
});
useEffect(() => {
const resolved = resolvedBindingQuery.data;
if (!resolved) return;
if (resolved.targetType === "agent" && resolved.binding) {
setSelectedBindingId(resolved.binding.id);
return;
}
setSelectedBindingId("__inherit__");
}, [resolvedBindingQuery.data]);
const saveOverride = useMutation({
mutationFn: () =>
memoryApi.setAgentBinding(agentId, selectedBindingId === "__inherit__" ? null : selectedBindingId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all });
pushToast({
title: "Agent memory binding updated",
body: selectedBindingId === "__inherit__"
? "This agent now inherits the company default binding."
: "The agent override is active for subsequent memory operations.",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to update agent memory binding",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
const currentSelection = useMemo(() => {
const resolved = resolvedBindingQuery.data;
if (!resolved) return "__inherit__";
if (resolved.targetType === "agent" && resolved.binding) return resolved.binding.id;
return "__inherit__";
}, [resolvedBindingQuery.data]);
const loading =
bindingsQuery.isLoading
|| resolvedBindingQuery.isLoading
|| recordsQuery.isLoading
|| operationsQuery.isLoading;
const error =
bindingsQuery.error
?? resolvedBindingQuery.error
?? recordsQuery.error
?? operationsQuery.error
?? null;
return (
<div className="space-y-6">
<div className="rounded-lg border border-border px-4 py-4">
<div className="grid gap-4 lg:grid-cols-[1.4fr_1fr]">
<div className="space-y-2">
<div className="text-sm font-medium">Resolved memory</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading memory state...</p>
) : error ? (
<p className="text-sm text-destructive">{error.message}</p>
) : (
<>
<div className="text-sm">{describeBinding(resolvedBindingQuery.data?.binding ?? null)}</div>
<div className="text-xs text-muted-foreground">
Source: {describeResolvedScope(resolvedBindingQuery.data?.targetType ?? null)}
</div>
<div className="text-xs text-muted-foreground">
Automatic capture hooks write recent run summaries and issue context into the resolved binding.
</div>
</>
)}
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Agent override</div>
<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 className="flex items-center justify-end gap-2">
<Button
size="sm"
disabled={loading || Boolean(error) || saveOverride.isPending || selectedBindingId === currentSelection}
onClick={() => saveOverride.mutate()}
>
{saveOverride.isPending ? "Saving..." : "Save override"}
</Button>
</div>
</div>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Recent memory records</h3>
<div className="text-xs text-muted-foreground">Filtered to this agent scope</div>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading records...</p>
) : (recordsQuery.data ?? []).length === 0 ? (
<div className="rounded-lg border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
No memory has been captured for this agent yet.
</div>
) : (
<div className="space-y-3">
{(recordsQuery.data ?? []).map((record) => (
<div key={record.id} className="rounded-lg border border-border px-4 py-3">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">{record.title ?? describeRecordSource(record)}</div>
<div className="text-xs text-muted-foreground">
{describeRecordSource(record)} • {relativeTime(record.createdAt)}
</div>
</div>
<div className="text-xs text-muted-foreground">
{record.scope.issueId ? `Issue ${record.scope.issueId.slice(0, 8)}` : "No issue scope"}
{record.scope.runId ? ` • Run ${record.scope.runId.slice(0, 8)}` : ""}
</div>
</div>
<p className="mt-2 text-sm text-foreground/90">{summarizeRecord(record)}</p>
</div>
))}
</div>
)}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Recent memory operations</h3>
<div className="text-xs text-muted-foreground">Queries, captures, and forgets for this agent</div>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading operations...</p>
) : (operationsQuery.data ?? []).length === 0 ? (
<div className="rounded-lg border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
No memory operations logged yet.
</div>
) : (
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-accent/20 text-left text-xs text-muted-foreground">
<th className="px-3 py-2 font-medium">Operation</th>
<th className="px-3 py-2 font-medium">Hook</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium text-right">Records</th>
<th className="px-3 py-2 font-medium text-right">Cost</th>
<th className="px-3 py-2 font-medium text-right">When</th>
</tr>
</thead>
<tbody>
{(operationsQuery.data ?? []).map((operation) => (
<tr key={operation.id} className="border-b border-border last:border-b-0">
<td className="px-3 py-2">
<div className="font-medium">{operation.operationType}</div>
<div className="text-xs text-muted-foreground">{operation.providerKey}</div>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{operation.hookKind ?? operation.triggerKind}
</td>
<td className="px-3 py-2 text-xs">{operation.status}</td>
<td className="px-3 py-2 text-right tabular-nums">{operation.recordCount}</td>
<td className="px-3 py-2 text-right tabular-nums">{operationCost(operation)}</td>
<td className="px-3 py-2 text-right text-xs text-muted-foreground">
{relativeTime(operation.occurredAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+455
View File
@@ -0,0 +1,455 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
MemoryBinding,
MemoryProviderDescriptor,
} from "@paperclipai/shared";
import { memoryApi } from "../api/memory";
import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
const DEFAULT_LOCAL_BASIC_CONFIG = {
enablePreRunHydrate: true,
enablePostRunCapture: true,
enableIssueCommentCapture: false,
enableIssueDocumentCapture: true,
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 ?? {});
return keys.length > 0 ? `${keys.length} config field${keys.length === 1 ? "" : "s"}` : "Default config";
}
const config = { ...DEFAULT_LOCAL_BASIC_CONFIG, ...binding.config };
return [
config.enablePreRunHydrate ? "pre-run hydrate on" : "pre-run hydrate off",
config.enablePostRunCapture ? "post-run capture on" : "post-run capture off",
config.enableIssueDocumentCapture ? "issue docs on" : "issue docs off",
config.enableIssueCommentCapture ? "comments on" : "comments off",
`top ${String(config.maxHydrateSnippets)} snippets`,
].join(" • ");
}
function providerLabel(provider: MemoryProviderDescriptor | undefined, binding: MemoryBinding) {
return provider?.displayName ?? binding.providerKey;
}
function providerDescription(provider: MemoryProviderDescriptor | undefined) {
return provider?.description ?? "Memory provider";
}
function MemoryBindingCard({
binding,
isDefault,
overrideCount,
provider,
onSetDefault,
}: {
binding: MemoryBinding;
isDefault: boolean;
overrideCount: number;
provider?: MemoryProviderDescriptor;
onSetDefault: (bindingId: string) => void;
}) {
const { pushToast } = useToast();
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);
useEffect(() => {
setName(binding.name ?? "");
setEnabled(binding.enabled);
setConfigText(prettyJson(binding.config ?? {}));
setConfigError(null);
}, [binding]);
const dirty =
name !== (binding.name ?? "")
|| enabled !== binding.enabled
|| configText !== prettyJson(binding.config ?? {});
const updateBinding = useMutation({
mutationFn: async () => {
const config = parseConfigText(configText);
return memoryApi.updateBinding(binding.id, {
name: name.trim() || null,
enabled,
config,
});
},
onSuccess: async () => {
setConfigError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all });
pushToast({
title: "Memory binding updated",
body: `${binding.key} saved successfully.`,
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to update memory binding",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
return (
<div className="rounded-md border border-border px-4 py-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-medium">{binding.name ?? binding.key}</h3>
<span className="rounded-full bg-accent px-2 py-0.5 text-[11px] text-muted-foreground">
{providerLabel(provider, binding)}
</span>
<span
className={`rounded-full px-2 py-0.5 text-[11px] ${
binding.enabled
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-amber-500/10 text-amber-700 dark:text-amber-300"
}`}
>
{binding.enabled ? "Enabled" : "Disabled"}
</span>
{isDefault && (
<span className="rounded-full bg-blue-500/10 px-2 py-0.5 text-[11px] text-blue-700 dark:text-blue-300">
Company default
</span>
)}
</div>
<div className="text-xs text-muted-foreground">Key: {binding.key}</div>
<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"}
</div>
</div>
<Button
variant="outline"
size="sm"
disabled={isDefault}
onClick={() => onSetDefault(binding.id)}
>
{isDefault ? "Default" : "Set as default"}
</Button>
</div>
<div className="mt-4 grid gap-3">
<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">Display name</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Optional label"
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
/>
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"
/>
{configError && <p className="text-xs text-destructive">{configError}</p>}
{updateBinding.isError && !configError && (
<p className="text-xs text-destructive">
{updateBinding.error instanceof Error ? updateBinding.error.message : "Failed to update binding"}
</p>
)}
</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");
}
}}
>
{updateBinding.isPending ? "Saving..." : "Save binding"}
</Button>
</div>
</div>
</div>
);
}
export function CompanyMemorySettings({ companyId }: { companyId: string }) {
const { pushToast } = useToast();
const queryClient = useQueryClient();
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 [enabled, setEnabled] = useState(true);
const [makeDefault, setMakeDefault] = useState(true);
const [createError, setCreateError] = useState<string | null>(null);
const providersQuery = useQuery({
queryKey: queryKeys.memory.providers(companyId),
queryFn: () => memoryApi.providers(companyId),
});
const bindingsQuery = useQuery({
queryKey: queryKeys.memory.bindings(companyId),
queryFn: () => memoryApi.listBindings(companyId),
});
const targetsQuery = useQuery({
queryKey: queryKeys.memory.targets(companyId),
queryFn: () => memoryApi.listTargets(companyId),
});
const providersByKey = useMemo(
() => new Map((providersQuery.data ?? []).map((provider) => [provider.key, provider])),
[providersQuery.data],
);
const defaultBindingId =
targetsQuery.data?.find((target) => target.targetType === "company" && target.targetId === companyId)?.bindingId ?? null;
const overrideCountByBindingId = useMemo(() => {
const result = new Map<string, number>();
for (const target of targetsQuery.data ?? []) {
if (target.targetType !== "agent") continue;
result.set(target.bindingId, (result.get(target.bindingId) ?? 0) + 1);
}
return result;
}, [targetsQuery.data]);
useEffect(() => {
if (!providersQuery.data?.length) return;
if (providersQuery.data.some((provider) => provider.key === providerKey)) return;
setProviderKey(providersQuery.data[0]!.key);
}, [providerKey, providersQuery.data]);
const createBinding = useMutation({
mutationFn: async () => {
const config = parseConfigText(configText);
const created = await memoryApi.createBinding(companyId, {
key: key.trim(),
name: name.trim() || null,
providerKey,
config,
enabled,
});
if (makeDefault) {
await memoryApi.setCompanyDefault(companyId, created.id);
}
return created;
},
onSuccess: async () => {
setCreateError(null);
setKey("default-memory");
setName("Default memory");
setConfigText(prettyJson(DEFAULT_LOCAL_BASIC_CONFIG));
setEnabled(true);
setMakeDefault(true);
await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all });
pushToast({
title: "Memory binding created",
body: "The new binding is ready for company and agent scopes.",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to create memory binding",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
const setCompanyDefault = useMutation({
mutationFn: (bindingId: string) => memoryApi.setCompanyDefault(companyId, bindingId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.memory.all });
pushToast({
title: "Company default updated",
body: "New runs will resolve memory through the selected binding.",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to update company memory default",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
const isLoading = providersQuery.isLoading || bindingsQuery.isLoading || targetsQuery.isLoading;
const error = providersQuery.error ?? bindingsQuery.error ?? targetsQuery.error ?? null;
return (
<div className="space-y-4">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Memory
</div>
<div className="space-y-4 rounded-md border border-border px-4 py-4">
<div className="space-y-1">
<h2 className="text-sm font-medium">Company memory bindings</h2>
<p className="text-sm text-muted-foreground">
Bindings determine where agent memory is hydrated from and where run summaries, issue documents, and other captured context are written.
</p>
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading memory settings...</p>
) : error ? (
<p className="text-sm text-destructive">{error.message}</p>
) : (
<>
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Binding key</label>
<Input value={key} onChange={(event) => setKey(event.target.value)} placeholder="default-memory" />
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Display name</label>
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="Default memory" />
</div>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_auto_auto] md:items-end">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Provider</label>
<select
value={providerKey}
onChange={(event) => setProviderKey(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"
>
{(providersQuery.data ?? []).map((provider) => (
<option key={provider.key} value={provider.key}>
{provider.displayName}
</option>
))}
</select>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
/>
Enabled
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={makeDefault}
onChange={(event) => setMakeDefault(event.target.checked)}
/>
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);
setCreateError(null);
}}
className="min-h-44 font-mono text-xs"
/>
{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}
onClick={() => {
try {
parseConfigText(configText);
setCreateError(null);
createBinding.mutate();
} catch (error) {
setCreateError(error instanceof Error ? error.message : "Config must be valid JSON");
}
}}
>
{createBinding.isPending ? "Creating..." : "Create binding"}
</Button>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Existing bindings</h3>
<div className="text-xs text-muted-foreground">
{defaultBindingId ? "A company default is configured." : "No company default yet."}
</div>
</div>
{(bindingsQuery.data ?? []).length === 0 ? (
<div className="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
Create a binding to enable memory hydration and capture for this company.
</div>
) : (
<div className="space-y-3">
{(bindingsQuery.data ?? []).map((binding) => (
<MemoryBindingCard
key={binding.id}
binding={binding}
isDefault={binding.id === defaultBindingId}
overrideCount={overrideCountByBindingId.get(binding.id) ?? 0}
provider={providersByKey.get(binding.providerKey)}
onSetDefault={(bindingId) => setCompanyDefault.mutate(bindingId)}
/>
))}
</div>
)}
</div>
</>
)}
</div>
</div>
);
}
+12
View File
@@ -28,6 +28,18 @@ export const queryKeys = {
detectModel: (companyId: string, adapterType: string) =>
["agents", companyId, "detect-model", adapterType] as const,
},
memory: {
all: ["memory"] as const,
company: (companyId: string) => ["memory", companyId] as const,
providers: (companyId: string) => ["memory", companyId, "providers"] as const,
bindings: (companyId: string) => ["memory", companyId, "bindings"] as const,
targets: (companyId: string) => ["memory", companyId, "targets"] as const,
agentBinding: (agentId: string) => ["memory", "agent-binding", agentId] as const,
records: (companyId: string, filters?: Record<string, string | number | boolean | undefined>) =>
["memory", companyId, "records", filters ?? {}] as const,
operations: (companyId: string, filters?: Record<string, string | number | boolean | undefined>) =>
["memory", companyId, "operations", filters ?? {}] as const,
},
issues: {
list: (companyId: string) => ["issues", companyId] as const,
search: (companyId: string, q: string, projectId?: string, limit?: number) =>
+15 -1
View File
@@ -23,6 +23,7 @@ import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { AgentConfigForm } from "../components/AgentConfigForm";
import { AgentMemoryTab } from "../components/AgentMemoryTab";
import { PageTabBar } from "../components/PageTabBar";
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
@@ -223,13 +224,14 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
container.scrollTo({ top: container.scrollHeight, behavior });
}
type AgentDetailView = "dashboard" | "instructions" | "configuration" | "skills" | "runs" | "budget";
type AgentDetailView = "dashboard" | "instructions" | "configuration" | "skills" | "runs" | "budget" | "memory";
function parseAgentDetailView(value: string | null): AgentDetailView {
if (value === "instructions" || value === "prompts") return "instructions";
if (value === "configure" || value === "configuration") return "configuration";
if (value === "skills") return "skills";
if (value === "budget") return "budget";
if (value === "memory") return "memory";
if (value === "runs") return value;
return "dashboard";
}
@@ -748,6 +750,8 @@ export function AgentDetail() {
? "runs"
: activeView === "budget"
? "budget"
: activeView === "memory"
? "memory"
: "dashboard";
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
@@ -865,6 +869,8 @@ export function AgentDetail() {
crumbs.push({ label: "Instructions" });
} else if (activeView === "configuration") {
crumbs.push({ label: "Configuration" });
} else if (activeView === "memory") {
crumbs.push({ label: "Memory" });
// } else if (activeView === "skills") { // TODO: bring back later
// crumbs.push({ label: "Skills" });
} else if (activeView === "runs") {
@@ -1009,6 +1015,7 @@ export function AgentDetail() {
{ value: "instructions", label: "Instructions" },
{ value: "skills", label: "Skills" },
{ value: "configuration", label: "Configuration" },
{ value: "memory", label: "Memory" },
{ value: "runs", label: "Runs" },
{ value: "budget", label: "Budget" },
]}
@@ -1136,6 +1143,13 @@ export function AgentDetail() {
/>
)}
{activeView === "memory" && resolvedCompanyId ? (
<AgentMemoryTab
companyId={resolvedCompanyId}
agentId={agent.id}
/>
) : null}
{activeView === "budget" && resolvedCompanyId ? (
<div className="max-w-3xl">
<BudgetPolicyCard
+3
View File
@@ -17,6 +17,7 @@ import {
ToggleField,
HintIcon
} from "../components/agent-config-primitives";
import { CompanyMemorySettings } from "../components/CompanyMemorySettings";
type AgentSnippetInput = {
onboardingTextUrl: string;
@@ -417,6 +418,8 @@ export function CompanySettings() {
</div>
</div>
<CompanyMemorySettings companyId={selectedCompanyId!} />
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Feedback Sharing