Files
PaperClipAI/server/src/services/memory.ts
T
2026-04-19 20:42:01 -05:00

3078 lines
109 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { and, asc, desc, eq, gte, ilike, inArray, isNull, isNotNull, lte, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
documents,
heartbeatRuns,
issueComments,
issueDocuments,
issues,
memoryBindings,
memoryBindingTargets,
memoryExtractionJobs,
memoryLocalRecords,
memoryOperations,
projects,
} from "@paperclipai/db";
import type {
MemoryBinding,
MemoryBindingTarget,
MemoryBindingTargetType,
MemoryCapture,
MemoryCaptureResult,
MemoryCitation,
MemoryCorrect,
MemoryCorrectResult,
MemoryHookKind,
MemoryHookPolicy,
MemoryHookPolicyMap,
MemoryExtractionJob,
MemoryForget,
MemoryForgetResult,
MemoryGovernedScope,
MemoryListExtractionJobsQuery,
MemoryListOperationsQuery,
MemoryListRecordsQuery,
MemoryOperation,
MemoryPrincipalRef,
MemoryProviderDescriptor,
MemoryQuery,
MemoryQueryResult,
MemoryRecord,
MemoryRefreshJob,
MemoryRefreshJobResult,
MemoryRefreshJobSourceCounts,
MemoryRetentionSweep,
MemoryRetentionSweepResult,
MemoryRevoke,
MemoryRevokeResult,
MemoryReview,
MemoryReviewResult,
MemoryResolvedBinding,
MemoryScope,
MemoryScopeType,
MemorySensitivityLabel,
MemorySourceRef,
MemoryUsage,
} from "@paperclipai/shared";
import { createMemoryBindingSchema, memoryCorrectSchema, memoryHookPoliciesSchema, memoryRefreshJobSchema, memoryRetentionSweepSchema, memoryReviewSchema, memoryRevokeSchema, updateMemoryBindingSchema } from "@paperclipai/shared";
import { z } from "zod";
import { conflict, notFound, unprocessable } from "../errors.js";
import { costService } from "./costs.js";
import { backgroundJobService } from "./background-jobs.js";
import { validateInstanceConfig } from "./plugin-config-validator.js";
import {
getDefaultPluginMemoryProviderDispatcher,
type PluginMemoryProviderDispatcher,
} from "./plugin-memory-provider-dispatcher.js";
import {
buildPostRunCaptureTrace,
buildPreRunHydrateTrace,
buildSkippedMemoryHookTrace,
type MemoryHookTrace,
} from "./memory-hook-trace.js";
type ActorInfo = {
actorType: "agent" | "user" | "system";
actorId: string;
agentId: string | null;
userId: string | null;
runId: string | null;
};
type BindingRow = typeof memoryBindings.$inferSelect;
type TargetRow = typeof memoryBindingTargets.$inferSelect;
type OperationRow = typeof memoryOperations.$inferSelect;
type RecordRow = typeof memoryLocalRecords.$inferSelect;
type ExtractionJobRow = typeof memoryExtractionJobs.$inferSelect;
type RefreshSource =
| {
kind: "issue";
id: string;
issueId: string;
projectId: string | null;
agentId: string | null;
title: string;
content: string;
createdAt: Date;
}
| {
kind: "issue_comment";
id: string;
issueId: string;
projectId: string | null;
agentId: string | null;
title: string;
content: string;
createdAt: Date;
}
| {
kind: "issue_document";
id: string;
issueId: string;
projectId: string | null;
agentId: string | null;
documentKey: string;
title: string;
content: string;
createdAt: Date;
}
| {
kind: "run";
id: string;
runId: string;
agentId: string | null;
projectId: string | null;
issueId: string | null;
title: string;
content: string;
createdAt: Date;
};
export interface MemoryPreRunHydrateResult {
preamble: string | null;
trace: MemoryHookTrace;
}
export interface MemoryPostRunCaptureResult {
trace: MemoryHookTrace;
}
const LOCAL_BASIC_PROVIDER_KEY = "local_basic";
const MEMORY_HOOK_POLICIES_CONFIG_KEY = "hookPolicies";
const localBasicConfigSchema = z
.object({
enablePreRunHydrate: z.boolean().optional().default(true),
enablePostRunCapture: z.boolean().optional().default(true),
enableIssueCommentCapture: z.boolean().optional().default(false),
enableIssueDocumentCapture: z.boolean().optional().default(true),
maxHydrateSnippets: z.number().int().positive().max(10).optional().default(5),
})
.strict();
type LocalBasicConfig = z.infer<typeof localBasicConfigSchema>;
const DEFAULT_CAPTURE_HOOK_POLICY: MemoryHookPolicy = {
enabled: true,
extractionMode: "raw_capture",
runMode: "sync",
harness: "server_worker",
sensitivityLabel: "internal",
reviewState: "accepted",
retentionPolicy: null,
modelProvider: null,
model: null,
config: null,
};
const LOCAL_BASIC_PROVIDER: MemoryProviderDescriptor = {
key: LOCAL_BASIC_PROVIDER_KEY,
displayName: "Local basic",
description: "Deterministic local memory backed by Postgres full-text search.",
kind: "builtin",
pluginId: null,
capabilities: {
browse: true,
correction: false,
asyncIngestion: false,
providerManagedExtraction: false,
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {
enablePreRunHydrate: { type: "boolean", default: true },
enablePostRunCapture: { type: "boolean", default: true },
enableIssueCommentCapture: { type: "boolean", default: false },
enableIssueDocumentCapture: { type: "boolean", default: true },
maxHydrateSnippets: { type: "integer", minimum: 1, maximum: 10, default: 5 },
hookPolicies: {
type: "object",
description: "Optional per-hook extraction policy overrides.",
},
},
},
configMetadata: {
suggestedConfig: {
enablePreRunHydrate: true,
enablePostRunCapture: true,
enableIssueCommentCapture: false,
enableIssueDocumentCapture: true,
maxHydrateSnippets: 5,
hookPolicies: {
issue_comment_capture: {
enabled: false,
extractionMode: "raw_capture",
runMode: "sync",
harness: "server_worker",
sensitivityLabel: "internal",
reviewState: "accepted",
},
},
},
fields: [
{
key: "enablePreRunHydrate",
label: "Pre-run hydrate",
description: "Read relevant memory before agent runs.",
input: "boolean",
defaultValue: true,
suggestedValue: true,
},
{
key: "enablePostRunCapture",
label: "Post-run capture",
description: "Capture run summaries after agent runs.",
input: "boolean",
defaultValue: true,
suggestedValue: true,
},
{
key: "enableIssueCommentCapture",
label: "Issue comment capture",
description: "Capture issue comments into memory.",
input: "boolean",
defaultValue: false,
suggestedValue: false,
},
{
key: "enableIssueDocumentCapture",
label: "Issue document capture",
description: "Capture issue documents into memory.",
input: "boolean",
defaultValue: true,
suggestedValue: true,
},
{
key: "maxHydrateSnippets",
label: "Hydration snippets",
description: "Maximum snippets to include when hydrating prompts.",
input: "number",
defaultValue: 5,
suggestedValue: 5,
min: 1,
max: 10,
},
],
healthChecks: [
{
key: "postgres",
label: "Postgres storage",
status: "ok",
message: "Local basic memory stores records in the Paperclip database.",
},
],
},
};
const SENSITIVITY_RANK: Record<MemorySensitivityLabel, number> = {
public: 0,
internal: 1,
confidential: 2,
restricted: 3,
};
const DEFAULT_AGENT_MAX_SENSITIVITY: MemorySensitivityLabel = "confidential";
function parseLocalBasicConfig(config: Record<string, unknown> | null | undefined): LocalBasicConfig {
return localBasicConfigSchema.parse(stripMemoryHookPolicies(config ?? {}));
}
function stripMemoryHookPolicies(config: Record<string, unknown>) {
const { [MEMORY_HOOK_POLICIES_CONFIG_KEY]: _policies, ...providerConfig } = config;
return providerConfig;
}
function parseMemoryHookPolicies(config: Record<string, unknown> | null | undefined): MemoryHookPolicyMap {
const raw = config?.[MEMORY_HOOK_POLICIES_CONFIG_KEY];
if (!raw) return {};
return memoryHookPoliciesSchema.parse(raw);
}
function mergeProviderConfigWithHookPolicies(
providerConfig: Record<string, unknown>,
hookPolicies: MemoryHookPolicyMap,
) {
if (Object.keys(hookPolicies).length === 0) return providerConfig;
return {
...providerConfig,
[MEMORY_HOOK_POLICIES_CONFIG_KEY]: hookPolicies,
};
}
function normalizeScopeType(scope: MemoryScope, fallback: MemoryScopeType = "org"): MemoryScopeType {
if (scope.scopeType) return scope.scopeType;
if (scope.runId) return "run";
if (scope.agentId) return "agent";
if (scope.workspaceId) return "workspace";
if (scope.projectId) return "project";
if (scope.teamId) return "team";
return fallback;
}
function normalizeScopeId(companyId: string, scopeType: MemoryScopeType, scope: MemoryScope) {
if (scope.scopeId) return scope.scopeId;
switch (scopeType) {
case "run":
return scope.runId ?? null;
case "agent":
return scope.agentId ?? null;
case "workspace":
return scope.workspaceId ?? null;
case "project":
return scope.projectId ?? null;
case "team":
return scope.teamId ?? null;
case "org":
return companyId;
}
}
function actorPrincipal(actor: ActorInfo): MemoryPrincipalRef {
if (actor.actorType === "agent" && actor.agentId) return { type: "agent", id: actor.agentId };
if (actor.actorType === "user" && actor.userId) return { type: "user", id: actor.userId };
return { type: "system", id: actor.actorId || "system" };
}
function normalizePrincipal(input: MemoryPrincipalRef | null | undefined, fallback: MemoryPrincipalRef): MemoryPrincipalRef {
if (input?.type && input.id) return input;
return fallback;
}
function normalizeCitation(input: MemoryCitation | null | undefined): Record<string, unknown> | null {
if (!input) return null;
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)) as Record<string, unknown>;
}
function citationFromRow(value: unknown): MemoryCitation | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as MemoryCitation;
}
function maxSensitivityForActor(actor: ActorInfo, scope: MemoryScope): MemorySensitivityLabel {
if (actor.actorType === "user" || actor.actorType === "system") return scope.maxSensitivityLabel ?? "restricted";
return scope.maxSensitivityLabel ?? DEFAULT_AGENT_MAX_SENSITIVITY;
}
function allowedSensitivityLabels(max: MemorySensitivityLabel): MemorySensitivityLabel[] {
const rank = SENSITIVITY_RANK[max];
return (Object.keys(SENSITIVITY_RANK) as MemorySensitivityLabel[]).filter(
(label) => SENSITIVITY_RANK[label] <= rank,
);
}
function scopeMatches(record: MemoryRecord, allowedScopes: MemoryGovernedScope[]) {
return allowedScopes.some((scope) => {
if (scope.type !== record.scopeType) return false;
if (!scope.id) return !record.scopeId;
return record.scopeId === scope.id;
});
}
function canReadRecord(companyId: string, record: MemoryRecord, actor: ActorInfo, scope: MemoryScope = {}) {
if (record.deletedAt || record.revokedAt || record.retentionState !== "active") return false;
if (record.supersededByRecordId) return false;
if (record.expiresAt && record.expiresAt <= new Date()) return false;
const maxSensitivity = maxSensitivityForActor(actor, scope);
if (SENSITIVITY_RANK[record.sensitivityLabel] > SENSITIVITY_RANK[maxSensitivity]) return false;
if (actor.actorType !== "agent") return true;
if (record.reviewState !== "accepted") return false;
return scopeMatches(record, deriveAllowedScopes(companyId, scope, actor));
}
function deriveAllowedScopes(companyId: string, scope: MemoryScope, actor: ActorInfo): MemoryGovernedScope[] {
const scopes: MemoryGovernedScope[] = [{ type: "org", id: companyId }];
if (scope.allowedScopes?.length) {
scopes.push(...scope.allowedScopes);
}
if (scope.agentId) scopes.push({ type: "agent", id: scope.agentId });
if (scope.runId) scopes.push({ type: "run", id: scope.runId });
if (scope.workspaceId) scopes.push({ type: "workspace", id: scope.workspaceId });
if (scope.projectId) scopes.push({ type: "project", id: scope.projectId });
if (scope.teamId) scopes.push({ type: "team", id: scope.teamId });
if (actor.actorType === "agent" && actor.agentId) scopes.push({ type: "agent", id: actor.agentId });
const seen = new Set<string>();
return scopes.filter((entry) => {
const key = `${entry.type}:${entry.id ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function buildMemoryPreamble(records: MemoryRecord[]) {
if (records.length === 0) return null;
return [
"Relevant memory:",
...records.map((record, index) => {
const sourceLabel = record.source?.kind ?? "memory";
const body = (record.summary ?? record.content).replace(/\s+/g, " ").slice(0, 240);
return `${index + 1}. [${sourceLabel}] ${body}`;
}),
].join("\n");
}
function scopeFromRow(row: {
scopeType?: MemoryScopeType | null;
scopeId?: string | null;
scopeAgentId: string | null;
scopeWorkspaceId?: string | null;
scopeProjectId: string | null;
scopeIssueId: string | null;
scopeRunId: string | null;
scopeTeamId?: string | null;
scopeSubjectId: string | null;
maxSensitivityLabel?: MemorySensitivityLabel | null;
}): MemoryScope {
return {
scopeType: row.scopeType ?? null,
scopeId: row.scopeId ?? null,
agentId: row.scopeAgentId,
workspaceId: row.scopeWorkspaceId ?? null,
projectId: row.scopeProjectId,
issueId: row.scopeIssueId,
runId: row.scopeRunId,
teamId: row.scopeTeamId ?? null,
subjectId: row.scopeSubjectId,
maxSensitivityLabel: row.maxSensitivityLabel ?? null,
};
}
function sourceFromRow(row: {
sourceKind: MemorySourceRef["kind"] | null;
sourceIssueId: string | null;
sourceCommentId: string | null;
sourceDocumentKey: string | null;
sourceRunId: string | null;
sourceActivityId: string | null;
sourceExternalRef: string | null;
}): MemorySourceRef | null {
if (!row.sourceKind) return null;
return {
kind: row.sourceKind,
issueId: row.sourceIssueId,
commentId: row.sourceCommentId,
documentKey: row.sourceDocumentKey,
runId: row.sourceRunId,
activityId: row.sourceActivityId,
externalRef: row.sourceExternalRef,
};
}
function normalizeUsage(input: unknown): MemoryUsage[] {
if (!Array.isArray(input)) return [];
return input
.filter((value): value is Record<string, unknown> => typeof value === "object" && value !== null)
.map((value) => ({
provider: typeof value.provider === "string" ? value.provider : "unknown",
model: typeof value.model === "string" ? value.model : null,
inputTokens: typeof value.inputTokens === "number" ? value.inputTokens : 0,
outputTokens: typeof value.outputTokens === "number" ? value.outputTokens : 0,
embeddingTokens: typeof value.embeddingTokens === "number" ? value.embeddingTokens : 0,
costCents: typeof value.costCents === "number" ? value.costCents : 0,
latencyMs: typeof value.latencyMs === "number" ? value.latencyMs : null,
details:
typeof value.details === "object" && value.details !== null && !Array.isArray(value.details)
? (value.details as Record<string, unknown>)
: null,
}));
}
function mapBinding(row: BindingRow): MemoryBinding {
return {
id: row.id,
companyId: row.companyId,
key: row.key,
name: row.name ?? null,
providerKey: row.providerKey,
config: row.config ?? {},
enabled: row.enabled,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function mapBindingForProvider(row: BindingRow): MemoryBinding {
return {
...mapBinding(row),
config: stripMemoryHookPolicies(row.config ?? {}),
};
}
function mapTarget(row: TargetRow): MemoryBindingTarget {
return {
id: row.id,
companyId: row.companyId,
bindingId: row.bindingId,
targetType: row.targetType,
targetId: row.targetId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function mapOperation(row: OperationRow): MemoryOperation {
return {
id: row.id,
companyId: row.companyId,
bindingId: row.bindingId,
providerKey: row.providerKey,
operationType: row.operationType,
triggerKind: row.triggerKind,
hookKind: row.hookKind ?? null,
status: row.status,
actorType: row.actorType as MemoryOperation["actorType"],
actorId: row.actorId,
agentId: row.agentId ?? null,
userId: row.userId ?? null,
scope: scopeFromRow(row),
source: sourceFromRow(row),
queryText: row.queryText ?? null,
recordCount: row.recordCount,
requestJson:
typeof row.requestJson === "object" && row.requestJson !== null && !Array.isArray(row.requestJson)
? (row.requestJson as Record<string, unknown>)
: null,
resultJson:
typeof row.resultJson === "object" && row.resultJson !== null && !Array.isArray(row.resultJson)
? (row.resultJson as Record<string, unknown>)
: null,
policyDecision:
typeof row.policyDecisionJson === "object" && row.policyDecisionJson !== null && !Array.isArray(row.policyDecisionJson)
? (row.policyDecisionJson as Record<string, unknown>)
: null,
revocationSelector:
typeof row.revocationSelectorJson === "object" && row.revocationSelectorJson !== null && !Array.isArray(row.revocationSelectorJson)
? (row.revocationSelectorJson as Record<string, unknown>)
: null,
retentionAction:
typeof row.retentionActionJson === "object" && row.retentionActionJson !== null && !Array.isArray(row.retentionActionJson)
? (row.retentionActionJson as Record<string, unknown>)
: null,
usage: normalizeUsage(row.usageJson),
error: row.error ?? null,
costEventId: row.costEventId ?? null,
financeEventId: row.financeEventId ?? null,
occurredAt: row.occurredAt,
createdAt: row.createdAt,
};
}
function mapRecord(row: RecordRow): MemoryRecord {
const scope = scopeFromRow(row);
const scopeType = row.scopeType ?? normalizeScopeType(scope);
return {
id: row.id,
companyId: row.companyId,
bindingId: row.bindingId,
providerKey: row.providerKey,
scope,
source: sourceFromRow(row),
scopeType,
scopeId: row.scopeId ?? normalizeScopeId(row.companyId, scopeType, scope),
owner: row.ownerType && row.ownerId ? { type: row.ownerType, id: row.ownerId } : null,
createdBy: row.createdByActorType && row.createdByActorId ? { type: row.createdByActorType, id: row.createdByActorId } : null,
sensitivityLabel: row.sensitivityLabel ?? "internal",
retentionPolicy:
typeof row.retentionPolicy === "object" && row.retentionPolicy !== null && !Array.isArray(row.retentionPolicy)
? (row.retentionPolicy as Record<string, unknown>)
: null,
expiresAt: row.expiresAt ?? null,
retentionState: row.retentionState ?? "active",
reviewState: row.reviewState ?? "pending",
reviewedAt: row.reviewedAt ?? null,
reviewedBy:
row.reviewedByActorType && row.reviewedByActorId ? { type: row.reviewedByActorType, id: row.reviewedByActorId } : null,
reviewNote: row.reviewNote ?? null,
citation: citationFromRow(row.citationJson),
supersedesRecordId: row.supersedesRecordId ?? null,
supersededByRecordId: row.supersededByRecordId ?? null,
revokedAt: row.revokedAt ?? null,
revokedBy:
row.revokedByActorType && row.revokedByActorId ? { type: row.revokedByActorType, id: row.revokedByActorId } : null,
revocationReason: row.revocationReason ?? null,
title: row.title ?? null,
content: row.content,
summary: row.summary ?? null,
metadata: row.metadata ?? {},
createdByOperationId: row.createdByOperationId ?? null,
deletedAt: row.deletedAt ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function mapExtractionJob(row: ExtractionJobRow): MemoryExtractionJob {
return {
id: row.id,
companyId: row.companyId,
bindingId: row.bindingId,
providerKey: row.providerKey,
operationId: row.operationId ?? null,
status: row.status,
providerJobId: row.providerJobId ?? null,
source: sourceFromRow(row),
resultJson:
typeof row.resultJson === "object" && row.resultJson !== null && !Array.isArray(row.resultJson)
? (row.resultJson as Record<string, unknown>)
: null,
error: row.error ?? null,
startedAt: row.startedAt ?? null,
completedAt: row.completedAt ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function buildRecordVisibilityConditions(
companyId: string,
bindingId: string,
scope: MemoryScope,
actor: ActorInfo,
options?: {
includeRevoked?: boolean;
includeExpired?: boolean;
includeSuperseded?: boolean;
includeDeleted?: boolean;
},
) {
const conditions = [
eq(memoryLocalRecords.companyId, companyId),
eq(memoryLocalRecords.bindingId, bindingId),
];
if (!options?.includeDeleted) conditions.push(isNull(memoryLocalRecords.deletedAt));
if (!options?.includeRevoked) {
conditions.push(isNull(memoryLocalRecords.revokedAt));
}
if (!options?.includeExpired) {
conditions.push(or(isNull(memoryLocalRecords.expiresAt), sql`${memoryLocalRecords.expiresAt} > now()`)!);
}
if (!options?.includeSuperseded) {
conditions.push(isNull(memoryLocalRecords.supersededByRecordId));
}
conditions.push(eq(memoryLocalRecords.reviewState, "accepted"));
const maxSensitivity = maxSensitivityForActor(actor, scope);
conditions.push(inArray(memoryLocalRecords.sensitivityLabel, allowedSensitivityLabels(maxSensitivity)));
const allowedScopes = deriveAllowedScopes(companyId, scope, actor);
if (actor.actorType === "agent" || scope.allowedScopes?.length) {
const scopeConditions = allowedScopes.map((allowedScope) =>
and(
eq(memoryLocalRecords.scopeType, allowedScope.type),
allowedScope.id ? eq(memoryLocalRecords.scopeId, allowedScope.id) : isNull(memoryLocalRecords.scopeId),
),
);
if (scopeConditions.length > 0) {
conditions.push(or(...scopeConditions)!);
}
}
return conditions;
}
async function createDirectCostEvent(
db: Db,
companyId: string,
actor: ActorInfo,
scope: MemoryScope,
usage: MemoryUsage[],
): Promise<string | null> {
const costCents = usage.reduce((sum, entry) => sum + entry.costCents, 0);
if (costCents <= 0) return null;
const agentId = actor.agentId ?? scope.agentId ?? null;
if (!agentId) return null;
const first = usage[0] ?? null;
const event = await costService(db).createEvent(companyId, {
agentId,
issueId: scope.issueId ?? null,
projectId: scope.projectId ?? null,
goalId: null,
heartbeatRunId: scope.runId ?? actor.runId ?? null,
billingCode: null,
provider: first?.provider ?? "memory",
biller: first?.provider ?? "memory",
billingType: "metered_api",
model: first?.model ?? "memory",
inputTokens: usage.reduce((sum, entry) => sum + entry.inputTokens + entry.embeddingTokens, 0),
outputTokens: usage.reduce((sum, entry) => sum + entry.outputTokens, 0),
cachedInputTokens: 0,
costCents,
occurredAt: new Date(),
});
return event.id;
}
export function memoryService(
db: Db,
opts?: {
pluginMemoryProviders?: PluginMemoryProviderDispatcher;
},
) {
const pluginMemoryProviders = opts?.pluginMemoryProviders ?? getDefaultPluginMemoryProviderDispatcher();
const backgroundJobsSvc = backgroundJobService(db);
async function getBindingOrThrow(bindingId: string) {
const binding = await db
.select()
.from(memoryBindings)
.where(eq(memoryBindings.id, bindingId))
.then((rows) => rows[0] ?? null);
if (!binding) throw notFound("Memory binding not found");
return binding;
}
async function validateProviderConfig(providerKey: string, config: Record<string, unknown>) {
const hookPolicies = parseMemoryHookPolicies(config);
const providerConfig = stripMemoryHookPolicies(config);
if (providerKey === LOCAL_BASIC_PROVIDER_KEY) {
return mergeProviderConfigWithHookPolicies(parseLocalBasicConfig(providerConfig), hookPolicies);
}
const pluginProvider = pluginMemoryProviders?.getProvider(providerKey);
if (!pluginProvider) {
throw unprocessable(`Unknown memory provider: ${providerKey}`);
}
const schema = pluginProvider.descriptor.configSchema;
if (!schema) return mergeProviderConfigWithHookPolicies(providerConfig, hookPolicies);
const validation = validateInstanceConfig(providerConfig, schema);
if (!validation.valid) {
const detail = validation.errors?.map((error) => `${error.field} ${error.message}`).join("; ") ?? "invalid config";
throw unprocessable(`Invalid memory provider config: ${detail}`);
}
return mergeProviderConfigWithHookPolicies(providerConfig, hookPolicies);
}
function resolutionSource(
targetType: MemoryBindingTargetType | null,
bindingKey?: string | null,
): MemoryResolvedBinding["source"] {
if (bindingKey) return "binding_key";
if (targetType === "agent") return "agent_override";
if (targetType === "project") return "project_override";
if (targetType === "company") return "company_default";
return "unconfigured";
}
async function findTargetBinding(
companyId: string,
targetType: MemoryBindingTargetType,
targetId: string,
) {
return db
.select({
target: memoryBindingTargets,
binding: memoryBindings,
})
.from(memoryBindingTargets)
.innerJoin(memoryBindings, eq(memoryBindingTargets.bindingId, memoryBindings.id))
.where(
and(
eq(memoryBindingTargets.companyId, companyId),
eq(memoryBindingTargets.targetType, targetType),
eq(memoryBindingTargets.targetId, targetId),
),
)
.then((rows) => rows[0] ?? null);
}
async function assertScopeTargetsBelongToCompany(companyId: string, scope: MemoryScope) {
if (scope.agentId) {
const agent = await db
.select({ companyId: agents.companyId })
.from(agents)
.where(eq(agents.id, scope.agentId))
.then((rows) => rows[0] ?? null);
if (!agent || agent.companyId !== companyId) {
throw unprocessable("Memory scope agent does not belong to company");
}
}
if (scope.projectId) {
const project = await db
.select({ companyId: projects.companyId })
.from(projects)
.where(eq(projects.id, scope.projectId))
.then((rows) => rows[0] ?? null);
if (!project || project.companyId !== companyId) {
throw unprocessable("Memory scope project does not belong to company");
}
}
}
async function resolveBindingInternal(companyId: string, scope: MemoryScope, bindingKey?: string | null) {
await assertScopeTargetsBelongToCompany(companyId, scope);
const checkedTargetTypes: MemoryBindingTargetType[] = [];
if (bindingKey) {
const binding = await db
.select()
.from(memoryBindings)
.where(and(eq(memoryBindings.companyId, companyId), eq(memoryBindings.key, bindingKey)))
.then((rows) => rows[0] ?? null);
if (!binding) throw notFound("Memory binding not found");
return {
targetType: null,
targetId: null,
binding,
source: resolutionSource(null, bindingKey),
checkedTargetTypes,
};
}
const agentId = scope.agentId ?? null;
if (agentId) {
checkedTargetTypes.push("agent");
const target = await findTargetBinding(companyId, "agent", agentId);
if (target) {
return {
targetType: target.target.targetType,
targetId: target.target.targetId,
binding: target.binding,
source: resolutionSource(target.target.targetType),
checkedTargetTypes,
};
}
}
const projectId = scope.projectId ?? null;
if (projectId) {
checkedTargetTypes.push("project");
const target = await findTargetBinding(companyId, "project", projectId);
if (target) {
return {
targetType: target.target.targetType,
targetId: target.target.targetId,
binding: target.binding,
source: resolutionSource(target.target.targetType),
checkedTargetTypes,
};
}
}
checkedTargetTypes.push("company");
const target = await findTargetBinding(companyId, "company", companyId);
return target
? {
targetType: target.target.targetType,
targetId: target.target.targetId,
binding: target.binding,
source: resolutionSource(target.target.targetType),
checkedTargetTypes,
}
: {
targetType: null,
targetId: null,
binding: null,
source: "unconfigured" as const,
checkedTargetTypes,
};
}
async function queryLocalBasic(
binding: BindingRow,
scope: MemoryScope,
query: string,
topK: number,
actor: ActorInfo,
) {
const rankExpr = sql<number>`
ts_rank_cd(
to_tsvector('english', coalesce(${memoryLocalRecords.title}, '') || ' ' || ${memoryLocalRecords.content}),
websearch_to_tsquery('english', ${query})
)
`;
const rows = await db
.select()
.from(memoryLocalRecords)
.where(
and(
...buildRecordVisibilityConditions(binding.companyId, binding.id, scope, actor),
sql`to_tsvector('english', coalesce(${memoryLocalRecords.title}, '') || ' ' || ${memoryLocalRecords.content}) @@ websearch_to_tsquery('english', ${query})`,
),
)
.orderBy(desc(rankExpr), desc(memoryLocalRecords.createdAt))
.limit(topK);
return rows.map((row) => mapRecord(row));
}
async function captureLocalBasic(
binding: BindingRow,
scope: MemoryScope,
source: MemorySourceRef,
input: {
actor: ActorInfo;
scopeType?: MemoryScopeType | null;
scopeId?: string | null;
owner?: MemoryPrincipalRef | null;
sensitivityLabel?: MemorySensitivityLabel;
retentionPolicy?: Record<string, unknown> | null;
expiresAt?: Date | string | null;
citation?: MemoryCitation | null;
title?: string | null;
content: string;
summary?: string | null;
metadata?: Record<string, unknown>;
reviewState?: MemoryRecord["reviewState"];
},
operationId: string,
) {
const scopeType = input.scopeType ?? normalizeScopeType(scope);
const scopeId = input.scopeId ?? normalizeScopeId(binding.companyId, scopeType, scope);
const createdBy = actorPrincipal(input.actor);
const owner = normalizePrincipal(input.owner, createdBy);
const [row] = await db
.insert(memoryLocalRecords)
.values({
id: randomUUID(),
companyId: binding.companyId,
bindingId: binding.id,
providerKey: binding.providerKey,
scopeAgentId: scope.agentId ?? null,
scopeProjectId: scope.projectId ?? null,
scopeIssueId: scope.issueId ?? null,
scopeRunId: scope.runId ?? null,
scopeSubjectId: scope.subjectId ?? null,
scopeType,
scopeId,
scopeWorkspaceId: scope.workspaceId ?? null,
scopeTeamId: scope.teamId ?? null,
sourceKind: source.kind,
sourceIssueId: source.issueId ?? null,
sourceCommentId: source.commentId ?? null,
sourceDocumentKey: source.documentKey ?? null,
sourceRunId: source.runId ?? null,
sourceActivityId: source.activityId ?? null,
sourceExternalRef: source.externalRef ?? null,
title: input.title ?? null,
content: input.content,
summary: input.summary ?? null,
metadata: input.metadata ?? {},
ownerType: owner.type,
ownerId: owner.id,
createdByActorType: createdBy.type,
createdByActorId: createdBy.id,
sensitivityLabel: input.sensitivityLabel ?? "internal",
retentionPolicy: input.retentionPolicy ?? null,
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
retentionState: "active",
reviewState: input.reviewState ?? "pending",
citationJson: normalizeCitation(input.citation),
createdByOperationId: operationId,
})
.returning();
return [mapRecord(row)];
}
async function persistCatalogRecords(
binding: BindingRow,
records: MemoryRecord[],
operationId: string | null,
) {
for (const record of records) {
await db
.insert(memoryLocalRecords)
.values({
id: record.id,
companyId: binding.companyId,
bindingId: binding.id,
providerKey: binding.providerKey,
scopeAgentId: record.scope.agentId ?? null,
scopeProjectId: record.scope.projectId ?? null,
scopeIssueId: record.scope.issueId ?? null,
scopeRunId: record.scope.runId ?? null,
scopeSubjectId: record.scope.subjectId ?? null,
scopeType: record.scopeType,
scopeId: record.scopeId,
scopeWorkspaceId: record.scope.workspaceId ?? null,
scopeTeamId: record.scope.teamId ?? null,
sourceKind: record.source?.kind ?? null,
sourceIssueId: record.source?.issueId ?? null,
sourceCommentId: record.source?.commentId ?? null,
sourceDocumentKey: record.source?.documentKey ?? null,
sourceRunId: record.source?.runId ?? null,
sourceActivityId: record.source?.activityId ?? null,
sourceExternalRef: record.source?.externalRef ?? null,
title: record.title ?? null,
content: record.content,
summary: record.summary ?? null,
metadata: record.metadata ?? {},
ownerType: record.owner?.type ?? null,
ownerId: record.owner?.id ?? null,
createdByActorType: record.createdBy?.type ?? null,
createdByActorId: record.createdBy?.id ?? null,
sensitivityLabel: record.sensitivityLabel,
retentionPolicy: record.retentionPolicy,
expiresAt: record.expiresAt,
retentionState: record.retentionState,
reviewState: record.reviewState ?? "pending",
reviewedAt: record.reviewedAt ?? null,
reviewedByActorType: record.reviewedBy?.type ?? null,
reviewedByActorId: record.reviewedBy?.id ?? null,
reviewNote: record.reviewNote ?? null,
citationJson: normalizeCitation(record.citation),
supersedesRecordId: record.supersedesRecordId ?? null,
supersededByRecordId: record.supersededByRecordId ?? null,
revokedAt: record.revokedAt ?? null,
revokedByActorType: record.revokedBy?.type ?? null,
revokedByActorId: record.revokedBy?.id ?? null,
revocationReason: record.revocationReason ?? null,
createdByOperationId: operationId,
deletedAt: record.deletedAt ?? null,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
})
.onConflictDoUpdate({
target: memoryLocalRecords.id,
set: {
bindingId: binding.id,
providerKey: binding.providerKey,
scopeAgentId: record.scope.agentId ?? null,
scopeProjectId: record.scope.projectId ?? null,
scopeIssueId: record.scope.issueId ?? null,
scopeRunId: record.scope.runId ?? null,
scopeSubjectId: record.scope.subjectId ?? null,
scopeType: record.scopeType,
scopeId: record.scopeId,
scopeWorkspaceId: record.scope.workspaceId ?? null,
scopeTeamId: record.scope.teamId ?? null,
sourceKind: record.source?.kind ?? null,
sourceIssueId: record.source?.issueId ?? null,
sourceCommentId: record.source?.commentId ?? null,
sourceDocumentKey: record.source?.documentKey ?? null,
sourceRunId: record.source?.runId ?? null,
sourceActivityId: record.source?.activityId ?? null,
sourceExternalRef: record.source?.externalRef ?? null,
title: record.title ?? null,
content: record.content,
summary: record.summary ?? null,
metadata: record.metadata ?? {},
ownerType: record.owner?.type ?? null,
ownerId: record.owner?.id ?? null,
createdByActorType: record.createdBy?.type ?? null,
createdByActorId: record.createdBy?.id ?? null,
sensitivityLabel: record.sensitivityLabel,
retentionPolicy: record.retentionPolicy,
expiresAt: record.expiresAt,
retentionState: record.retentionState,
reviewState: record.reviewState ?? "pending",
reviewedAt: record.reviewedAt ?? null,
reviewedByActorType: record.reviewedBy?.type ?? null,
reviewedByActorId: record.reviewedBy?.id ?? null,
reviewNote: record.reviewNote ?? null,
citationJson: normalizeCitation(record.citation),
supersedesRecordId: record.supersedesRecordId ?? null,
supersededByRecordId: record.supersededByRecordId ?? null,
revokedAt: record.revokedAt ?? null,
revokedByActorType: record.revokedBy?.type ?? null,
revokedByActorId: record.revokedBy?.id ?? null,
revocationReason: record.revocationReason ?? null,
createdByOperationId: operationId,
deletedAt: record.deletedAt ?? null,
updatedAt: record.updatedAt,
},
});
}
}
async function overlayCatalogRecordState(
companyId: string,
bindingId: string,
records: MemoryRecord[],
) {
if (records.length === 0) return records;
const rows = await db
.select()
.from(memoryLocalRecords)
.where(
and(
eq(memoryLocalRecords.companyId, companyId),
eq(memoryLocalRecords.bindingId, bindingId),
inArray(memoryLocalRecords.id, records.map((record) => record.id)),
),
);
const catalogById = new Map(rows.map((row) => [row.id, mapRecord(row)]));
return records.map((record) => {
const catalog = catalogById.get(record.id);
if (!catalog) return record;
return {
...record,
reviewState: catalog.reviewState,
reviewedAt: catalog.reviewedAt,
reviewedBy: catalog.reviewedBy,
reviewNote: catalog.reviewNote,
retentionState: catalog.retentionState,
revokedAt: catalog.revokedAt,
revokedBy: catalog.revokedBy,
revocationReason: catalog.revocationReason,
supersededByRecordId: catalog.supersededByRecordId,
deletedAt: catalog.deletedAt,
updatedAt: catalog.updatedAt,
};
});
}
async function listLocalBasic(companyId: string, filters: MemoryListRecordsQuery, actor: ActorInfo) {
const conditions = [eq(memoryLocalRecords.companyId, companyId)];
if (filters.bindingId) conditions.push(eq(memoryLocalRecords.bindingId, filters.bindingId));
if (filters.providerKey) conditions.push(eq(memoryLocalRecords.providerKey, filters.providerKey));
if (filters.scopeType) conditions.push(eq(memoryLocalRecords.scopeType, filters.scopeType));
if (filters.scopeId) conditions.push(eq(memoryLocalRecords.scopeId, filters.scopeId));
if (filters.ownerType) conditions.push(eq(memoryLocalRecords.ownerType, filters.ownerType));
if (filters.ownerId) conditions.push(eq(memoryLocalRecords.ownerId, filters.ownerId));
if (filters.sensitivityLabel) conditions.push(eq(memoryLocalRecords.sensitivityLabel, filters.sensitivityLabel));
if (filters.retentionState) conditions.push(eq(memoryLocalRecords.retentionState, filters.retentionState));
if (filters.reviewState) conditions.push(eq(memoryLocalRecords.reviewState, filters.reviewState));
if (filters.expiresBefore) conditions.push(lte(memoryLocalRecords.expiresAt, filters.expiresBefore));
if (filters.agentId) conditions.push(eq(memoryLocalRecords.scopeAgentId, filters.agentId));
if (filters.workspaceId) conditions.push(eq(memoryLocalRecords.scopeWorkspaceId, filters.workspaceId));
if (filters.issueId) {
conditions.push(or(eq(memoryLocalRecords.scopeIssueId, filters.issueId), eq(memoryLocalRecords.sourceIssueId, filters.issueId))!);
}
if (filters.projectId) conditions.push(eq(memoryLocalRecords.scopeProjectId, filters.projectId));
if (filters.teamId) conditions.push(eq(memoryLocalRecords.scopeTeamId, filters.teamId));
if (filters.runId) {
conditions.push(or(eq(memoryLocalRecords.scopeRunId, filters.runId), eq(memoryLocalRecords.sourceRunId, filters.runId))!);
}
if (filters.sourceKind) conditions.push(eq(memoryLocalRecords.sourceKind, filters.sourceKind));
if (filters.q) {
const pattern = `%${filters.q}%`;
conditions.push(
or(
ilike(memoryLocalRecords.title, pattern),
ilike(memoryLocalRecords.content, pattern),
ilike(memoryLocalRecords.summary, pattern),
)!,
);
}
if (!filters.includeDeleted) conditions.push(isNull(memoryLocalRecords.deletedAt));
if (!filters.includeRevoked) conditions.push(isNull(memoryLocalRecords.revokedAt));
if (!filters.includeExpired) {
conditions.push(or(isNull(memoryLocalRecords.expiresAt), sql`${memoryLocalRecords.expiresAt} > now()`)!);
conditions.push(or(isNull(memoryLocalRecords.retentionState), eq(memoryLocalRecords.retentionState, "active"))!);
}
if (!filters.includeSuperseded) conditions.push(isNull(memoryLocalRecords.supersededByRecordId));
if (actor.actorType === "agent") {
const scope: MemoryScope = {
agentId: filters.agentId ?? actor.agentId,
workspaceId: filters.workspaceId ?? null,
projectId: filters.projectId ?? null,
issueId: filters.issueId ?? null,
runId: filters.runId ?? null,
teamId: filters.teamId ?? null,
};
conditions.push(
...buildRecordVisibilityConditions(companyId, filters.bindingId ?? "", scope, actor, {
includeDeleted: filters.includeDeleted,
includeExpired: filters.includeExpired,
includeRevoked: filters.includeRevoked,
includeSuperseded: filters.includeSuperseded,
}).filter((condition, index) => index > 1),
);
}
const rows = await db
.select()
.from(memoryLocalRecords)
.where(and(...conditions))
.orderBy(desc(memoryLocalRecords.createdAt))
.limit(filters.limit);
return rows.map((row) => mapRecord(row));
}
async function logOperation(input: {
id?: string;
companyId: string;
binding: BindingRow;
actor: ActorInfo;
operationType: MemoryOperation["operationType"];
triggerKind: MemoryOperation["triggerKind"];
hookKind?: MemoryOperation["hookKind"];
scope: MemoryScope;
source?: MemorySourceRef | null;
queryText?: string | null;
requestJson?: Record<string, unknown> | null;
resultJson?: Record<string, unknown> | null;
policyDecision?: Record<string, unknown> | null;
revocationSelector?: Record<string, unknown> | null;
retentionAction?: Record<string, unknown> | null;
recordCount?: number;
usage?: MemoryUsage[];
error?: string | null;
}) {
const costEventId = await createDirectCostEvent(
db,
input.companyId,
input.actor,
input.scope,
input.usage ?? [],
);
const [row] = await db
.insert(memoryOperations)
.values({
id: input.id ?? randomUUID(),
companyId: input.companyId,
bindingId: input.binding.id,
providerKey: input.binding.providerKey,
operationType: input.operationType,
triggerKind: input.triggerKind,
hookKind: input.hookKind ?? null,
status: input.error ? "failed" : "succeeded",
actorType: input.actor.actorType,
actorId: input.actor.actorId,
agentId: input.actor.agentId ?? null,
userId: input.actor.userId ?? null,
scopeAgentId: input.scope.agentId ?? null,
scopeProjectId: input.scope.projectId ?? null,
scopeIssueId: input.scope.issueId ?? null,
scopeRunId: input.scope.runId ?? input.actor.runId ?? null,
scopeSubjectId: input.scope.subjectId ?? null,
scopeType: input.scope.scopeType ?? normalizeScopeType(input.scope),
scopeId: input.scope.scopeId ?? normalizeScopeId(input.companyId, input.scope.scopeType ?? normalizeScopeType(input.scope), input.scope),
scopeWorkspaceId: input.scope.workspaceId ?? null,
scopeTeamId: input.scope.teamId ?? null,
maxSensitivityLabel: input.scope.maxSensitivityLabel ?? null,
sourceKind: input.source?.kind ?? null,
sourceIssueId: input.source?.issueId ?? null,
sourceCommentId: input.source?.commentId ?? null,
sourceDocumentKey: input.source?.documentKey ?? null,
sourceRunId: input.source?.runId ?? null,
sourceActivityId: input.source?.activityId ?? null,
sourceExternalRef: input.source?.externalRef ?? null,
queryText: input.queryText ?? null,
recordCount: input.recordCount ?? 0,
requestJson: input.requestJson ?? null,
resultJson: input.resultJson ?? null,
policyDecisionJson: input.policyDecision ?? null,
revocationSelectorJson: input.revocationSelector ?? null,
retentionActionJson: input.retentionAction ?? null,
usageJson: (input.usage ?? []) as unknown as Array<Record<string, unknown>>,
error: input.error ?? null,
costEventId,
financeEventId: null,
})
.returning();
return mapOperation(row);
}
async function createExtractionJob(input: {
companyId: string;
binding: BindingRow;
hookKind: MemoryHookKind;
source: MemorySourceRef;
policy: MemoryHookPolicy;
}) {
const now = new Date();
const [row] = await db
.insert(memoryExtractionJobs)
.values({
id: randomUUID(),
companyId: input.companyId,
bindingId: input.binding.id,
providerKey: input.binding.providerKey,
status: "running",
sourceKind: input.source.kind,
sourceIssueId: input.source.issueId ?? null,
sourceCommentId: input.source.commentId ?? null,
sourceDocumentKey: input.source.documentKey ?? null,
sourceRunId: input.source.runId ?? null,
sourceActivityId: input.source.activityId ?? null,
sourceExternalRef: input.source.externalRef ?? null,
resultJson: {
hookKind: input.hookKind,
policy: extractionPolicyJson(input.policy),
},
startedAt: now,
updatedAt: now,
})
.returning();
return mapExtractionJob(row);
}
async function completeExtractionJob(input: {
jobId: string;
operationId?: string | null;
status: "succeeded" | "failed";
resultJson: Record<string, unknown>;
error?: string | null;
}) {
const now = new Date();
const [row] = await db
.update(memoryExtractionJobs)
.set({
operationId: input.operationId ?? null,
status: input.status,
resultJson: input.resultJson,
error: input.error ?? null,
completedAt: now,
updatedAt: now,
})
.where(eq(memoryExtractionJobs.id, input.jobId))
.returning();
return mapExtractionJob(row);
}
function extractionPolicyJson(policy: MemoryHookPolicy): Record<string, unknown> {
return {
enabled: policy.enabled,
extractionMode: policy.extractionMode,
runMode: policy.runMode,
harness: policy.harness,
sensitivityLabel: policy.sensitivityLabel,
reviewState: policy.reviewState,
retentionPolicy: policy.retentionPolicy ?? null,
modelProvider: policy.modelProvider ?? null,
model: policy.model ?? null,
config: policy.config ?? null,
};
}
function assertHookPolicySupported(binding: BindingRow, policy: MemoryHookPolicy) {
if (policy.extractionMode === "raw_capture") return;
const descriptor = isPluginProvider(binding.providerKey)
? pluginMemoryProviders?.getProvider(binding.providerKey)?.descriptor
: LOCAL_BASIC_PROVIDER;
if (policy.extractionMode === "provider_managed" && !descriptor?.capabilities.providerManagedExtraction) {
throw unprocessable(`Memory provider ${binding.providerKey} does not support provider-managed extraction`);
}
if (policy.extractionMode === "paperclip_managed" && policy.harness !== "server_worker") {
throw unprocessable("Only server_worker Paperclip-managed extraction is available");
}
}
function extractionMetadata(
metadata: Record<string, unknown> | undefined,
policy: MemoryHookPolicy,
jobId: string | null,
) {
return {
...(metadata ?? {}),
extraction: {
mode: policy.extractionMode,
runMode: policy.runMode,
harness: policy.harness,
modelProvider: policy.modelProvider ?? null,
model: policy.model ?? null,
jobId,
},
};
}
function ensureBindingEnabled(binding: BindingRow | null): BindingRow {
if (!binding) {
throw notFound("No memory binding is configured");
}
if (!binding.enabled) {
throw conflict("Resolved memory binding is disabled");
}
return binding;
}
function isPluginProvider(providerKey: string) {
return providerKey !== LOCAL_BASIC_PROVIDER_KEY;
}
function legacyHookEnabled(
binding: BindingRow,
hook: "preRunHydrate" | "postRunCapture" | "issueCommentCapture" | "issueDocumentCapture",
) {
if (binding.providerKey !== LOCAL_BASIC_PROVIDER_KEY) {
return true;
}
const config = parseLocalBasicConfig(binding.config);
switch (hook) {
case "preRunHydrate":
return config.enablePreRunHydrate;
case "postRunCapture":
return config.enablePostRunCapture;
case "issueCommentCapture":
return config.enableIssueCommentCapture;
case "issueDocumentCapture":
return config.enableIssueDocumentCapture;
}
}
function normalizeHookPolicy(binding: BindingRow, hookKind: MemoryHookKind): MemoryHookPolicy {
const configured = parseMemoryHookPolicies(binding.config)[hookKind] ?? {};
let enabled = DEFAULT_CAPTURE_HOOK_POLICY.enabled;
if (hookKind === "pre_run_hydrate") enabled = legacyHookEnabled(binding, "preRunHydrate");
if (hookKind === "post_run_capture") enabled = legacyHookEnabled(binding, "postRunCapture");
if (hookKind === "issue_comment_capture") enabled = legacyHookEnabled(binding, "issueCommentCapture");
if (hookKind === "issue_document_capture") enabled = legacyHookEnabled(binding, "issueDocumentCapture");
const providerDescriptor = isPluginProvider(binding.providerKey)
? pluginMemoryProviders?.getProvider(binding.providerKey)?.descriptor
: LOCAL_BASIC_PROVIDER;
const defaultExtractionMode = providerDescriptor?.capabilities.providerManagedExtraction
? "provider_managed"
: DEFAULT_CAPTURE_HOOK_POLICY.extractionMode;
const extractionMode = configured.extractionMode ?? defaultExtractionMode;
return {
...DEFAULT_CAPTURE_HOOK_POLICY,
...configured,
enabled: configured.enabled ?? enabled,
extractionMode,
harness:
configured.harness
?? (extractionMode === "provider_managed" && isPluginProvider(binding.providerKey)
? "plugin_worker"
: DEFAULT_CAPTURE_HOOK_POLICY.harness),
runMode: configured.runMode ?? DEFAULT_CAPTURE_HOOK_POLICY.runMode,
sensitivityLabel: configured.sensitivityLabel ?? DEFAULT_CAPTURE_HOOK_POLICY.sensitivityLabel,
reviewState: configured.reviewState ?? DEFAULT_CAPTURE_HOOK_POLICY.reviewState,
retentionPolicy: configured.retentionPolicy ?? DEFAULT_CAPTURE_HOOK_POLICY.retentionPolicy,
modelProvider: configured.modelProvider ?? DEFAULT_CAPTURE_HOOK_POLICY.modelProvider,
model: configured.model ?? DEFAULT_CAPTURE_HOOK_POLICY.model,
config: configured.config ?? DEFAULT_CAPTURE_HOOK_POLICY.config,
};
}
function isHookEnabled(binding: BindingRow, hookKind: MemoryHookKind) {
return normalizeHookPolicy(binding, hookKind).enabled;
}
function getHydrateTopK(binding: BindingRow) {
if (binding.providerKey === LOCAL_BASIC_PROVIDER_KEY) {
return parseLocalBasicConfig(binding.config).maxHydrateSnippets;
}
const raw = binding.config?.topK;
return typeof raw === "number" && Number.isFinite(raw) && raw > 0
? Math.min(Math.max(Math.floor(raw), 1), 25)
: 5;
}
async function captureWithHookPolicy(input: {
companyId: string;
binding: BindingRow;
hookKind: MemoryHookKind;
scope: MemoryScope;
scopeType: MemoryScopeType;
scopeId: string;
source: MemorySourceRef;
citation: MemoryCitation;
title: string;
content: string;
summary: string;
actor: ActorInfo;
metadata?: Record<string, unknown>;
}) {
const policy = normalizeHookPolicy(input.binding, input.hookKind);
if (!policy.enabled) return null;
assertHookPolicySupported(input.binding, policy);
if (policy.extractionMode === "raw_capture") {
return {
result: await service.capture(
input.companyId,
{
scope: input.scope,
scopeType: input.scopeType,
scopeId: input.scopeId,
source: input.source,
citation: input.citation,
sensitivityLabel: policy.sensitivityLabel,
retentionPolicy: policy.retentionPolicy ?? null,
title: input.title,
content: input.content,
summary: input.summary,
reviewState: policy.reviewState,
metadata: extractionMetadata(input.metadata, policy, null),
},
input.actor,
"hook",
input.hookKind,
),
job: null,
policy,
};
}
const job = await createExtractionJob({
companyId: input.companyId,
binding: input.binding,
hookKind: input.hookKind,
source: input.source,
policy,
});
try {
const result = await service.capture(
input.companyId,
{
scope: input.scope,
scopeType: input.scopeType,
scopeId: input.scopeId,
source: input.source,
citation: input.citation,
sensitivityLabel: policy.sensitivityLabel,
retentionPolicy: policy.retentionPolicy ?? null,
title: input.title,
content: input.content,
summary: input.summary,
reviewState: policy.reviewState,
metadata: extractionMetadata(input.metadata, policy, job.id),
},
input.actor,
"hook",
input.hookKind,
);
const completedJob = await completeExtractionJob({
jobId: job.id,
operationId: result.operation.id,
status: "succeeded",
resultJson: {
hookKind: input.hookKind,
policy: extractionPolicyJson(policy),
operationId: result.operation.id,
recordIds: result.records.map((record) => record.id),
},
});
return { result, job: completedJob, policy };
} catch (error) {
await completeExtractionJob({
jobId: job.id,
status: "failed",
resultJson: {
hookKind: input.hookKind,
policy: extractionPolicyJson(policy),
},
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
function refreshSourceCounts(sources: RefreshSource[]): MemoryRefreshJobSourceCounts {
return sources.reduce<MemoryRefreshJobSourceCounts>(
(counts, source) => {
counts[source.kind] += 1;
return counts;
},
{ issue: 0, issue_comment: 0, issue_document: 0, run: 0 },
);
}
function sourceSummary(content: string) {
return content.replace(/\s+/g, " ").slice(0, 240);
}
function truncateRefreshContent(content: string) {
return content.trim().slice(0, 20_000);
}
function buildRunSourceContent(row: {
status: string;
error: string | null;
stdoutExcerpt: string | null;
stderrExcerpt: string | null;
resultJson: unknown;
contextSnapshot: unknown;
}) {
const parts = [
`Status: ${row.status}`,
row.error ? `Error: ${row.error}` : null,
row.stdoutExcerpt ? `Stdout: ${row.stdoutExcerpt}` : null,
row.stderrExcerpt ? `Stderr: ${row.stderrExcerpt}` : null,
row.resultJson ? `Result: ${JSON.stringify(row.resultJson)}` : null,
row.contextSnapshot ? `Context: ${JSON.stringify(row.contextSnapshot)}` : null,
].filter((part): part is string => Boolean(part));
return truncateRefreshContent(parts.join("\n\n"));
}
function sourceScope(companyId: string, source: RefreshSource, baseScope: MemoryScope): {
scope: MemoryScope;
scopeType: MemoryScopeType;
scopeId: string;
} {
const projectId = source.projectId ?? baseScope.projectId ?? null;
const agentId = source.agentId ?? baseScope.agentId ?? null;
const issueId = "issueId" in source ? source.issueId : null;
const runId = source.kind === "run" ? source.runId : null;
const scopeType: MemoryScopeType = runId ? "run" : projectId ? "project" : agentId ? "agent" : "org";
const scopeId = runId ?? projectId ?? agentId ?? companyId;
return {
scope: {
...baseScope,
scopeType,
scopeId,
agentId,
projectId,
issueId,
runId,
},
scopeType,
scopeId,
};
}
function sourceRef(source: RefreshSource): MemorySourceRef {
if (source.kind === "run") {
return {
kind: "run",
issueId: source.issueId,
runId: source.runId,
};
}
if (source.kind === "issue_document") {
return {
kind: "issue_document",
issueId: source.issueId,
documentKey: source.documentKey,
};
}
if (source.kind === "issue_comment") {
return {
kind: "issue_comment",
issueId: source.issueId,
commentId: source.id,
};
}
return {
kind: "issue",
issueId: source.issueId,
};
}
async function collectRefreshSources(companyId: string, request: MemoryRefreshJob): Promise<RefreshSource[]> {
const sources: RefreshSource[] = [];
const sourceKinds = new Set(request.sourceKinds);
const issueIdFilter = request.issueIds?.length ? request.issueIds : null;
const runIdFilter = request.runIds?.length ? request.runIds : null;
const since = request.since ?? null;
const until = request.until ?? null;
if (sourceKinds.has("issue")) {
const conditions = [eq(issues.companyId, companyId)];
if (issueIdFilter) conditions.push(inArray(issues.id, issueIdFilter));
if (request.projectId) conditions.push(eq(issues.projectId, request.projectId));
if (request.agentId) conditions.push(eq(issues.assigneeAgentId, request.agentId));
if (since) conditions.push(gte(issues.createdAt, since));
if (until) conditions.push(lte(issues.createdAt, until));
const rows = await db
.select({
id: issues.id,
projectId: issues.projectId,
agentId: issues.assigneeAgentId,
title: issues.title,
description: issues.description,
createdAt: issues.createdAt,
})
.from(issues)
.where(and(...conditions))
.orderBy(asc(issues.createdAt))
.limit(request.limit);
for (const row of rows) {
const content = truncateRefreshContent([row.title, row.description].filter(Boolean).join("\n\n"));
if (!content) continue;
sources.push({
kind: "issue",
id: row.id,
issueId: row.id,
projectId: row.projectId ?? null,
agentId: row.agentId ?? null,
title: row.title,
content,
createdAt: row.createdAt,
});
}
}
if (sources.length < request.limit && sourceKinds.has("issue_comment")) {
const conditions = [eq(issueComments.companyId, companyId)];
if (issueIdFilter) conditions.push(inArray(issueComments.issueId, issueIdFilter));
if (request.projectId) conditions.push(eq(issues.projectId, request.projectId));
if (request.agentId) conditions.push(eq(issues.assigneeAgentId, request.agentId));
if (since) conditions.push(gte(issueComments.createdAt, since));
if (until) conditions.push(lte(issueComments.createdAt, until));
const rows = await db
.select({
id: issueComments.id,
issueId: issueComments.issueId,
projectId: issues.projectId,
agentId: issueComments.authorAgentId,
fallbackAgentId: issues.assigneeAgentId,
body: issueComments.body,
createdAt: issueComments.createdAt,
})
.from(issueComments)
.innerJoin(issues, eq(issueComments.issueId, issues.id))
.where(and(...conditions))
.orderBy(asc(issueComments.createdAt))
.limit(request.limit - sources.length);
for (const row of rows) {
const content = truncateRefreshContent(row.body);
if (!content) continue;
sources.push({
kind: "issue_comment",
id: row.id,
issueId: row.issueId,
projectId: row.projectId ?? null,
agentId: row.agentId ?? row.fallbackAgentId ?? null,
title: "Issue comment",
content,
createdAt: row.createdAt,
});
}
}
if (sources.length < request.limit && sourceKinds.has("issue_document")) {
const conditions = [eq(issueDocuments.companyId, companyId)];
if (issueIdFilter) conditions.push(inArray(issueDocuments.issueId, issueIdFilter));
if (request.projectId) conditions.push(eq(issues.projectId, request.projectId));
if (request.agentId) conditions.push(eq(issues.assigneeAgentId, request.agentId));
if (since) conditions.push(gte(documents.updatedAt, since));
if (until) conditions.push(lte(documents.updatedAt, until));
const rows = await db
.select({
id: issueDocuments.id,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
projectId: issues.projectId,
agentId: issues.assigneeAgentId,
title: documents.title,
body: documents.latestBody,
updatedAt: documents.updatedAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.innerJoin(issues, eq(issueDocuments.issueId, issues.id))
.where(and(...conditions))
.orderBy(asc(documents.updatedAt))
.limit(request.limit - sources.length);
for (const row of rows) {
const content = truncateRefreshContent(row.body);
if (!content) continue;
sources.push({
kind: "issue_document",
id: row.id,
issueId: row.issueId,
projectId: row.projectId ?? null,
agentId: row.agentId ?? null,
documentKey: row.key,
title: row.title ?? row.key,
content,
createdAt: row.updatedAt,
});
}
}
if (sources.length < request.limit && sourceKinds.has("run")) {
const conditions = [eq(heartbeatRuns.companyId, companyId)];
if (runIdFilter) conditions.push(inArray(heartbeatRuns.id, runIdFilter));
if (request.agentId) conditions.push(eq(heartbeatRuns.agentId, request.agentId));
if (since) conditions.push(gte(heartbeatRuns.createdAt, since));
if (until) conditions.push(lte(heartbeatRuns.createdAt, until));
const rows = await db
.select({
id: heartbeatRuns.id,
agentId: heartbeatRuns.agentId,
status: heartbeatRuns.status,
error: heartbeatRuns.error,
stdoutExcerpt: heartbeatRuns.stdoutExcerpt,
stderrExcerpt: heartbeatRuns.stderrExcerpt,
resultJson: heartbeatRuns.resultJson,
contextSnapshot: heartbeatRuns.contextSnapshot,
createdAt: heartbeatRuns.createdAt,
})
.from(heartbeatRuns)
.where(and(...conditions))
.orderBy(asc(heartbeatRuns.createdAt))
.limit(request.limit - sources.length);
for (const row of rows) {
const content = buildRunSourceContent(row);
if (!content) continue;
sources.push({
kind: "run",
id: row.id,
runId: row.id,
agentId: row.agentId ?? null,
projectId: request.projectId ?? null,
issueId: null,
title: `Heartbeat run ${row.id}`,
content,
createdAt: row.createdAt,
});
}
}
return sources;
}
async function executeRefreshJob(input: {
companyId: string;
runId: string;
request: MemoryRefreshJob;
sources: RefreshSource[];
actor: ActorInfo;
}): Promise<{ run: Awaited<ReturnType<typeof backgroundJobsSvc.getRun>>; recordCount: number }> {
let recordCount = 0;
await backgroundJobsSvc.startRun(input.runId);
if (input.request.dryRun) {
const run = await backgroundJobsSvc.completeRun(input.runId, {
status: "succeeded",
result: {
dryRun: true,
sourceCounts: refreshSourceCounts(input.sources),
recordCount,
},
});
return { run, recordCount };
}
let processed = 0;
let succeeded = 0;
let failed = 0;
let skipped = 0;
for (const source of input.sources) {
if (await backgroundJobsSvc.isCancellationRequested(input.runId)) {
const run = await backgroundJobsSvc.completeRun(input.runId, {
status: "cancelled",
result: {
dryRun: false,
sourceCounts: refreshSourceCounts(input.sources),
recordCount,
processed,
succeeded,
failed,
skipped,
},
});
return { run, recordCount };
}
processed += 1;
await backgroundJobsSvc.updateRunProgress(input.runId, {
totalItems: input.sources.length,
processedItems: processed - 1,
succeededItems: succeeded,
failedItems: failed,
skippedItems: skipped,
currentItem: `${source.kind}:${source.id}`,
});
try {
const scoped = sourceScope(input.companyId, source, input.request.scope ?? {});
const result = await service.capture(
input.companyId,
{
bindingKey: input.request.bindingKey,
scope: scoped.scope,
scopeType: scoped.scopeType,
scopeId: scoped.scopeId,
source: sourceRef(source),
citation: {
label: source.kind.replace(/_/g, " "),
sourceTitle: source.title,
},
sensitivityLabel: "internal",
title: source.title,
content: source.content,
summary: sourceSummary(source.content),
reviewState: "pending",
metadata: {
backgroundJobRunId: input.runId,
refreshSourceKind: source.kind,
refreshSourceId: source.id,
},
},
input.actor,
"manual",
undefined,
);
recordCount += result.records.length;
succeeded += 1;
await backgroundJobsSvc.appendEvent(input.runId, {
eventType: "checkpoint",
level: "info",
message: `Captured ${result.records.length} memory record(s) from ${source.kind}`,
currentItem: `${source.kind}:${source.id}`,
details: {
sourceKind: source.kind,
sourceId: source.id,
operationId: result.operation.id,
recordIds: result.records.map((record) => record.id),
},
});
} catch (error) {
failed += 1;
await backgroundJobsSvc.appendEvent(input.runId, {
eventType: "failed",
level: "error",
message: error instanceof Error ? error.message : String(error),
currentItem: `${source.kind}:${source.id}`,
details: {
sourceKind: source.kind,
sourceId: source.id,
},
});
}
await backgroundJobsSvc.updateRunProgress(input.runId, {
totalItems: input.sources.length,
processedItems: processed,
succeededItems: succeeded,
failedItems: failed,
skippedItems: skipped,
currentItem: null,
});
}
const run = await backgroundJobsSvc.completeRun(input.runId, {
status: failed > 0 ? "failed" : "succeeded",
error: failed > 0 ? `${failed} source(s) failed during memory refresh` : null,
result: {
dryRun: false,
sourceCounts: refreshSourceCounts(input.sources),
recordCount,
processed,
succeeded,
failed,
skipped,
},
});
return { run, recordCount };
}
const service = {
providers: async () => {
const pluginProviders = pluginMemoryProviders?.listProviders() ?? [];
return [LOCAL_BASIC_PROVIDER, ...pluginProviders];
},
getBindingById: async (bindingId: string) => {
const row = await db
.select()
.from(memoryBindings)
.where(eq(memoryBindings.id, bindingId))
.then((rows) => rows[0] ?? null);
return row ? mapBinding(row) : null;
},
listBindings: async (companyId: string) => {
const rows = await db
.select()
.from(memoryBindings)
.where(eq(memoryBindings.companyId, companyId))
.orderBy(memoryBindings.key);
return rows.map((row) => mapBinding(row));
},
listTargets: async (companyId: string) => {
const rows = await db
.select()
.from(memoryBindingTargets)
.where(eq(memoryBindingTargets.companyId, companyId))
.orderBy(memoryBindingTargets.targetType, memoryBindingTargets.targetId);
return rows.map((row) => mapTarget(row));
},
createBinding: async (companyId: string, data: unknown) => {
const parsed = createMemoryBindingSchema.parse(data);
const normalizedConfig = await validateProviderConfig(parsed.providerKey, parsed.config);
const existing = await db
.select({ id: memoryBindings.id })
.from(memoryBindings)
.where(and(eq(memoryBindings.companyId, companyId), eq(memoryBindings.key, parsed.key)))
.then((rows) => rows[0] ?? null);
if (existing) throw conflict("Memory binding key already exists");
const [row] = await db
.insert(memoryBindings)
.values({
companyId,
key: parsed.key,
name: parsed.name ?? null,
providerKey: parsed.providerKey,
config: normalizedConfig,
enabled: parsed.enabled,
})
.returning();
return mapBinding(row);
},
updateBinding: async (bindingId: string, data: unknown) => {
const parsed = updateMemoryBindingSchema.parse(data);
const current = await getBindingOrThrow(bindingId);
const normalizedConfig = parsed.config
? await validateProviderConfig(current.providerKey, parsed.config)
: current.config;
const [row] = await db
.update(memoryBindings)
.set({
name: parsed.name === undefined ? current.name : parsed.name ?? null,
config: normalizedConfig,
enabled: parsed.enabled ?? current.enabled,
updatedAt: new Date(),
})
.where(eq(memoryBindings.id, bindingId))
.returning();
return mapBinding(row);
},
setCompanyDefault: async (companyId: string, bindingId: string) => {
const binding = await getBindingOrThrow(bindingId);
if (binding.companyId !== companyId) throw unprocessable("Binding does not belong to company");
await db
.delete(memoryBindingTargets)
.where(
and(
eq(memoryBindingTargets.companyId, companyId),
eq(memoryBindingTargets.targetType, "company"),
eq(memoryBindingTargets.targetId, companyId),
),
);
const [row] = await db
.insert(memoryBindingTargets)
.values({
companyId,
bindingId,
targetType: "company",
targetId: companyId,
})
.returning();
return mapTarget(row);
},
setAgentOverride: async (agentId: string, bindingId: string | null) => {
const agent = await db
.select()
.from(agents)
.where(eq(agents.id, agentId))
.then((rows) => rows[0] ?? null);
if (!agent) throw notFound("Agent not found");
await db
.delete(memoryBindingTargets)
.where(
and(
eq(memoryBindingTargets.companyId, agent.companyId),
eq(memoryBindingTargets.targetType, "agent"),
eq(memoryBindingTargets.targetId, agent.id),
),
);
if (!bindingId) return null;
const binding = await getBindingOrThrow(bindingId);
if (binding.companyId !== agent.companyId) throw unprocessable("Binding does not belong to agent company");
const [row] = await db
.insert(memoryBindingTargets)
.values({
companyId: agent.companyId,
bindingId,
targetType: "agent",
targetId: agent.id,
})
.returning();
return mapTarget(row);
},
setProjectOverride: async (projectId: string, bindingId: string | null) => {
const project = await db
.select()
.from(projects)
.where(eq(projects.id, projectId))
.then((rows) => rows[0] ?? null);
if (!project) throw notFound("Project not found");
await db
.delete(memoryBindingTargets)
.where(
and(
eq(memoryBindingTargets.companyId, project.companyId),
eq(memoryBindingTargets.targetType, "project"),
eq(memoryBindingTargets.targetId, project.id),
),
);
if (!bindingId) return null;
const binding = await getBindingOrThrow(bindingId);
if (binding.companyId !== project.companyId) throw unprocessable("Binding does not belong to project company");
const [row] = await db
.insert(memoryBindingTargets)
.values({
companyId: project.companyId,
bindingId,
targetType: "project",
targetId: project.id,
})
.returning();
return mapTarget(row);
},
resolveBinding: async (companyId: string, scope: MemoryScope): Promise<MemoryResolvedBinding> => {
const resolved = await resolveBindingInternal(companyId, scope, null);
return {
companyId,
targetType: resolved.targetType,
targetId: resolved.targetId,
binding: resolved.binding ? mapBinding(resolved.binding) : null,
source: resolved.source,
checkedTargetTypes: resolved.checkedTargetTypes,
};
},
query: async (companyId: string, data: MemoryQuery, actor: ActorInfo, triggerKind: MemoryOperation["triggerKind"] = "manual", hookKind?: MemoryOperation["hookKind"]) => {
const resolved = await resolveBindingInternal(companyId, data.scope ?? {}, data.bindingKey);
const binding = ensureBindingEnabled(resolved.binding);
let records: MemoryRecord[];
let preamble: string | null = null;
let usage: MemoryUsage[] = [];
let providerResultJson: Record<string, unknown> | null = null;
if (binding.providerKey === LOCAL_BASIC_PROVIDER_KEY) {
const config = parseLocalBasicConfig(binding.config);
records = await queryLocalBasic(
binding,
data.scope ?? {},
data.query,
Math.min(data.topK ?? config.maxHydrateSnippets, 25),
actor,
);
} else {
if (!pluginMemoryProviders) {
throw unprocessable(`Unknown memory provider: ${binding.providerKey}`);
}
const providerResult = await pluginMemoryProviders.query(binding.providerKey, {
binding: mapBindingForProvider(binding),
scope: data.scope ?? {},
query: data.query,
topK: Math.min(data.topK ?? getHydrateTopK(binding), 25),
intent: data.intent,
metadataFilter: data.metadataFilter,
});
records = await overlayCatalogRecordState(companyId, binding.id, providerResult.records);
preamble = providerResult.preamble ?? null;
usage = providerResult.usage ?? [];
providerResultJson = providerResult.resultJson ?? null;
}
const allowedScopes = deriveAllowedScopes(companyId, data.scope ?? {}, actor);
const maxSensitivityLabel = maxSensitivityForActor(actor, data.scope ?? {});
if (actor.actorType === "agent" || data.scope?.allowedScopes?.length) {
records = records.filter((record) => scopeMatches(record, allowedScopes));
}
records = records.filter(
(record) =>
!record.deletedAt
&& !record.revokedAt
&& record.retentionState === "active"
&& record.reviewState === "accepted"
&& !record.supersededByRecordId
&& (!record.expiresAt || record.expiresAt > new Date())
&& SENSITIVITY_RANK[record.sensitivityLabel] <= SENSITIVITY_RANK[maxSensitivityLabel],
);
if (data.intent === "agent_preamble" && !preamble) {
preamble = buildMemoryPreamble(records);
}
const operation = await logOperation({
companyId,
binding,
actor,
operationType: "query",
triggerKind,
hookKind,
scope: data.scope ?? {},
queryText: data.query,
requestJson: {
topK: data.topK ?? null,
intent: data.intent,
metadataFilter: data.metadataFilter ?? null,
},
resultJson: {
preamble,
recordIds: records.map((record) => record.id),
providerResult: providerResultJson,
},
policyDecision: {
allowedScopes,
maxSensitivityLabel,
filteredRecordCount: records.length,
},
recordCount: records.length,
usage,
});
return { operation, records, preamble } satisfies MemoryQueryResult;
},
capture: async (companyId: string, data: MemoryCapture, actor: ActorInfo, triggerKind: MemoryOperation["triggerKind"] = "manual", hookKind?: MemoryOperation["hookKind"]) => {
const resolved = await resolveBindingInternal(companyId, data.scope ?? {}, data.bindingKey);
const binding = ensureBindingEnabled(resolved.binding);
const operationId = randomUUID();
let records: MemoryRecord[];
let usage: MemoryUsage[] = [];
let providerResultJson: Record<string, unknown> | null = null;
if (binding.providerKey === LOCAL_BASIC_PROVIDER_KEY) {
records = await captureLocalBasic(
binding,
data.scope ?? {},
data.source,
{
actor,
scopeType: data.scopeType ?? data.scope?.scopeType ?? null,
scopeId: data.scopeId ?? data.scope?.scopeId ?? null,
owner: data.owner ?? null,
sensitivityLabel: data.sensitivityLabel,
retentionPolicy: data.retentionPolicy ?? null,
expiresAt: data.expiresAt ?? null,
citation: data.citation ?? null,
title: data.title ?? null,
content: data.content,
summary: data.summary ?? null,
metadata: data.metadata ?? {},
reviewState: data.reviewState,
},
operationId,
);
} else {
if (!pluginMemoryProviders) {
throw unprocessable(`Unknown memory provider: ${binding.providerKey}`);
}
const providerResult = await pluginMemoryProviders.capture(binding.providerKey, {
binding: mapBindingForProvider(binding),
scope: data.scope ?? {},
source: data.source,
scopeType: data.scopeType ?? data.scope?.scopeType ?? null,
scopeId: data.scopeId ?? data.scope?.scopeId ?? null,
owner: data.owner ?? actorPrincipal(actor),
createdBy: actorPrincipal(actor),
sensitivityLabel: data.sensitivityLabel,
retentionPolicy: data.retentionPolicy ?? null,
expiresAt: data.expiresAt ?? null,
citation: data.citation ?? null,
title: data.title ?? null,
content: data.content,
summary: data.summary ?? null,
metadata: data.metadata ?? {},
reviewState: data.reviewState,
});
records = providerResult.records;
usage = providerResult.usage ?? [];
providerResultJson = providerResult.resultJson ?? null;
await persistCatalogRecords(binding, records, operationId);
}
const operation = await logOperation({
id: operationId,
companyId,
binding,
actor,
operationType: data.title || data.summary ? "upsert" : "capture",
triggerKind,
hookKind,
scope: data.scope ?? {},
source: data.source,
requestJson: {
title: data.title ?? null,
scopeType: data.scopeType ?? data.scope?.scopeType ?? null,
scopeId: data.scopeId ?? data.scope?.scopeId ?? null,
owner: data.owner ?? null,
sensitivityLabel: data.sensitivityLabel,
reviewState: data.reviewState,
expiresAt: data.expiresAt ? new Date(data.expiresAt).toISOString() : null,
metadata: data.metadata ?? {},
},
policyDecision: {
captureAllowed: true,
sensitivityLabel: data.sensitivityLabel,
reviewState: data.reviewState,
},
resultJson: {
recordIds: records.map((record) => record.id),
providerResult: providerResultJson,
},
recordCount: records.length,
usage,
});
return { operation, records } satisfies MemoryCaptureResult;
},
forget: async (companyId: string, data: MemoryForget, actor: ActorInfo, triggerKind: MemoryOperation["triggerKind"] = "manual") => {
const rows = await db
.select()
.from(memoryLocalRecords)
.where(and(eq(memoryLocalRecords.companyId, companyId), inArray(memoryLocalRecords.id, data.recordIds)));
const recordIds = rows.map((row) => row.id);
if (recordIds.length === 0) {
throw notFound("Memory records not found");
}
const bindingIds = new Set(rows.map((row) => row.bindingId));
if (bindingIds.size > 1) {
throw unprocessable("Memory records must belong to the same binding");
}
const binding = await getBindingOrThrow(rows[0].bindingId);
let usage: MemoryUsage[] = [];
let providerResultJson: Record<string, unknown> | null = null;
if (isPluginProvider(binding.providerKey)) {
if (!pluginMemoryProviders) {
throw unprocessable(`Unknown memory provider: ${binding.providerKey}`);
}
const providerResult = await pluginMemoryProviders.forget(binding.providerKey, {
binding: mapBindingForProvider(binding),
scope: data.scope ?? {},
recordIds,
});
usage = providerResult.usage ?? [];
providerResultJson = providerResult.resultJson ?? null;
}
await db
.update(memoryLocalRecords)
.set({
deletedAt: new Date(),
revokedAt: new Date(),
revokedByActorType: actorPrincipal(actor).type,
revokedByActorId: actorPrincipal(actor).id,
revocationReason: data.reason ?? "Record forgotten",
retentionState: "revoked",
updatedAt: new Date(),
})
.where(and(eq(memoryLocalRecords.companyId, companyId), inArray(memoryLocalRecords.id, recordIds)));
const operation = await logOperation({
companyId,
binding,
actor,
operationType: "forget",
triggerKind,
scope: data.scope ?? {},
requestJson: { recordIds, reason: data.reason ?? null },
revocationSelector: { recordIds },
resultJson: { forgottenRecordIds: recordIds, providerResult: providerResultJson },
recordCount: recordIds.length,
usage,
});
return { operation, forgottenRecordIds: recordIds } satisfies MemoryForgetResult;
},
revoke: async (companyId: string, data: MemoryRevoke, actor: ActorInfo): Promise<MemoryRevokeResult> => {
const parsed = memoryRevokeSchema.parse(data);
const conditions = [eq(memoryLocalRecords.companyId, companyId), isNull(memoryLocalRecords.revokedAt)];
const selector = parsed.selector;
if (selector.recordIds?.length) conditions.push(inArray(memoryLocalRecords.id, selector.recordIds));
if (selector.scopeType) conditions.push(eq(memoryLocalRecords.scopeType, selector.scopeType));
if (selector.scopeId !== undefined) {
conditions.push(selector.scopeId === null ? isNull(memoryLocalRecords.scopeId) : eq(memoryLocalRecords.scopeId, selector.scopeId));
}
if (selector.agentId) conditions.push(eq(memoryLocalRecords.scopeAgentId, selector.agentId));
if (selector.workspaceId) conditions.push(eq(memoryLocalRecords.scopeWorkspaceId, selector.workspaceId));
if (selector.projectId) conditions.push(eq(memoryLocalRecords.scopeProjectId, selector.projectId));
if (selector.teamId) conditions.push(eq(memoryLocalRecords.scopeTeamId, selector.teamId));
if (selector.issueId) {
conditions.push(or(eq(memoryLocalRecords.scopeIssueId, selector.issueId), eq(memoryLocalRecords.sourceIssueId, selector.issueId))!);
}
if (selector.runId) {
conditions.push(or(eq(memoryLocalRecords.scopeRunId, selector.runId), eq(memoryLocalRecords.sourceRunId, selector.runId))!);
}
if (selector.source) {
conditions.push(eq(memoryLocalRecords.sourceKind, selector.source.kind));
if (selector.source.issueId) conditions.push(eq(memoryLocalRecords.sourceIssueId, selector.source.issueId));
if (selector.source.commentId) conditions.push(eq(memoryLocalRecords.sourceCommentId, selector.source.commentId));
if (selector.source.documentKey) conditions.push(eq(memoryLocalRecords.sourceDocumentKey, selector.source.documentKey));
if (selector.source.runId) conditions.push(eq(memoryLocalRecords.sourceRunId, selector.source.runId));
if (selector.source.activityId) conditions.push(eq(memoryLocalRecords.sourceActivityId, selector.source.activityId));
if (selector.source.externalRef) conditions.push(eq(memoryLocalRecords.sourceExternalRef, selector.source.externalRef));
}
const rows = await db.select().from(memoryLocalRecords).where(and(...conditions));
if (rows.length === 0) throw notFound("No memory records matched revocation selector");
const revokedAt = new Date();
const principal = actorPrincipal(actor);
const revokedRecordIds = rows.map((row) => row.id);
const operations: MemoryOperation[] = [];
const rowsByBinding = new Map<string, RecordRow[]>();
for (const row of rows) {
rowsByBinding.set(row.bindingId, [...(rowsByBinding.get(row.bindingId) ?? []), row]);
}
for (const [bindingId, bindingRows] of rowsByBinding) {
const binding = await getBindingOrThrow(bindingId);
let usage: MemoryUsage[] = [];
let providerResultJson: Record<string, unknown> | null = null;
const bindingRecordIds = bindingRows.map((row) => row.id);
if (isPluginProvider(binding.providerKey)) {
if (!pluginMemoryProviders) {
throw unprocessable(`Unknown memory provider: ${binding.providerKey}`);
}
const providerResult = await pluginMemoryProviders.forget(binding.providerKey, {
binding: mapBindingForProvider(binding),
scope: {},
recordIds: bindingRecordIds,
});
usage = providerResult.usage ?? [];
providerResultJson = providerResult.resultJson ?? null;
}
await db
.update(memoryLocalRecords)
.set({
revokedAt,
revokedByActorType: principal.type,
revokedByActorId: principal.id,
revocationReason: parsed.reason,
retentionState: "revoked",
updatedAt: revokedAt,
})
.where(and(eq(memoryLocalRecords.companyId, companyId), inArray(memoryLocalRecords.id, bindingRecordIds)));
operations.push(
await logOperation({
companyId,
binding,
actor,
operationType: "revoke",
triggerKind: "manual",
scope: {},
requestJson: { reason: parsed.reason },
revocationSelector: selector,
resultJson: { revokedRecordIds: bindingRecordIds, providerResult: providerResultJson },
recordCount: bindingRecordIds.length,
usage,
}),
);
}
return { operations, revokedRecordIds } satisfies MemoryRevokeResult;
},
correct: async (
companyId: string,
recordId: string,
data: MemoryCorrect,
actor: ActorInfo,
): Promise<MemoryCorrectResult> => {
const parsed = memoryCorrectSchema.parse(data);
const originalRow = await db
.select()
.from(memoryLocalRecords)
.where(and(eq(memoryLocalRecords.companyId, companyId), eq(memoryLocalRecords.id, recordId)))
.then((rows) => rows[0] ?? null);
if (!originalRow) throw notFound("Memory record not found");
if (originalRow.revokedAt || originalRow.retentionState === "revoked") {
throw conflict("Revoked memory records cannot be corrected");
}
if (originalRow.supersededByRecordId) {
throw conflict("Memory record has already been corrected");
}
const binding = await getBindingOrThrow(originalRow.bindingId);
const operationId = randomUUID();
const principal = actorPrincipal(actor);
const correctedRecordId = randomUUID();
const correctedAt = new Date();
const [correctedRow] = await db
.insert(memoryLocalRecords)
.values({
id: correctedRecordId,
companyId,
bindingId: originalRow.bindingId,
providerKey: originalRow.providerKey,
scopeAgentId: originalRow.scopeAgentId,
scopeProjectId: originalRow.scopeProjectId,
scopeIssueId: originalRow.scopeIssueId,
scopeRunId: originalRow.scopeRunId,
scopeSubjectId: originalRow.scopeSubjectId,
scopeType: originalRow.scopeType,
scopeId: originalRow.scopeId,
scopeWorkspaceId: originalRow.scopeWorkspaceId,
scopeTeamId: originalRow.scopeTeamId,
sourceKind: originalRow.sourceKind,
sourceIssueId: originalRow.sourceIssueId,
sourceCommentId: originalRow.sourceCommentId,
sourceDocumentKey: originalRow.sourceDocumentKey,
sourceRunId: originalRow.sourceRunId,
sourceActivityId: originalRow.sourceActivityId,
sourceExternalRef: originalRow.sourceExternalRef,
title: parsed.title === undefined ? originalRow.title : parsed.title ?? null,
content: parsed.content,
summary: parsed.summary === undefined ? originalRow.summary : parsed.summary ?? null,
metadata: {
...(originalRow.metadata ?? {}),
correctionReason: parsed.reason,
correctedFromRecordId: originalRow.id,
},
ownerType: originalRow.ownerType,
ownerId: originalRow.ownerId,
createdByActorType: principal.type,
createdByActorId: principal.id,
sensitivityLabel: parsed.sensitivityLabel ?? originalRow.sensitivityLabel,
retentionPolicy: parsed.retentionPolicy === undefined ? originalRow.retentionPolicy : parsed.retentionPolicy,
expiresAt: parsed.expiresAt === undefined ? originalRow.expiresAt : parsed.expiresAt,
retentionState: "active",
reviewState: "accepted",
reviewedAt: correctedAt,
reviewedByActorType: principal.type,
reviewedByActorId: principal.id,
reviewNote: parsed.reason,
citationJson: parsed.citation === undefined ? originalRow.citationJson : normalizeCitation(parsed.citation),
supersedesRecordId: originalRow.id,
createdByOperationId: operationId,
createdAt: correctedAt,
updatedAt: correctedAt,
})
.returning();
await db
.update(memoryLocalRecords)
.set({ supersededByRecordId: correctedRecordId, updatedAt: correctedAt })
.where(and(eq(memoryLocalRecords.companyId, companyId), eq(memoryLocalRecords.id, originalRow.id)));
const operation = await logOperation({
id: operationId,
companyId,
binding,
actor,
operationType: "correct",
triggerKind: "manual",
scope: scopeFromRow(originalRow),
source: sourceFromRow(originalRow),
requestJson: {
recordId,
reason: parsed.reason,
sensitivityLabel: parsed.sensitivityLabel ?? null,
expiresAt: parsed.expiresAt ? parsed.expiresAt.toISOString() : null,
},
resultJson: {
originalRecordId: originalRow.id,
correctedRecordId,
},
recordCount: 1,
});
return {
operation,
originalRecord: mapRecord({ ...originalRow, supersededByRecordId: correctedRecordId, updatedAt: correctedAt }),
correctedRecord: mapRecord(correctedRow),
} satisfies MemoryCorrectResult;
},
review: async (
companyId: string,
recordId: string,
data: MemoryReview,
actor: ActorInfo,
): Promise<MemoryReviewResult> => {
const parsed = memoryReviewSchema.parse(data);
const row = await db
.select()
.from(memoryLocalRecords)
.where(and(eq(memoryLocalRecords.companyId, companyId), eq(memoryLocalRecords.id, recordId)))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Memory record not found");
if (row.deletedAt || row.revokedAt || row.retentionState === "revoked") {
throw conflict("Revoked memory records cannot be reviewed");
}
const binding = await getBindingOrThrow(row.bindingId);
const reviewedAt = new Date();
const principal = actorPrincipal(actor);
const [updatedRow] = await db
.update(memoryLocalRecords)
.set({
reviewState: parsed.reviewState,
reviewedAt,
reviewedByActorType: principal.type,
reviewedByActorId: principal.id,
reviewNote: parsed.note ?? null,
updatedAt: reviewedAt,
})
.where(and(eq(memoryLocalRecords.companyId, companyId), eq(memoryLocalRecords.id, recordId)))
.returning();
const operation = await logOperation({
companyId,
binding,
actor,
operationType: "review",
triggerKind: "manual",
scope: scopeFromRow(row),
source: sourceFromRow(row),
requestJson: {
recordId,
reviewState: parsed.reviewState,
note: parsed.note ?? null,
},
resultJson: {
recordId,
reviewState: parsed.reviewState,
},
recordCount: 1,
});
return { operation, record: mapRecord(updatedRow) } satisfies MemoryReviewResult;
},
sweepRetention: async (
companyId: string,
data: MemoryRetentionSweep,
actor: ActorInfo,
): Promise<MemoryRetentionSweepResult> => {
const parsed = memoryRetentionSweepSchema.parse(data);
const now = parsed.now ?? new Date();
const conditions = [
eq(memoryLocalRecords.companyId, companyId),
eq(memoryLocalRecords.retentionState, "active"),
isNull(memoryLocalRecords.revokedAt),
isNotNull(memoryLocalRecords.expiresAt),
lte(memoryLocalRecords.expiresAt, now),
];
if (parsed.bindingId) conditions.push(eq(memoryLocalRecords.bindingId, parsed.bindingId));
const rows = await db
.select()
.from(memoryLocalRecords)
.where(and(...conditions))
.orderBy(memoryLocalRecords.expiresAt)
.limit(parsed.limit);
if (rows.length === 0) return { operations: [], expiredRecordIds: [] };
const expiredRecordIds = rows.map((row) => row.id);
await db
.update(memoryLocalRecords)
.set({ retentionState: "expired", updatedAt: now })
.where(and(eq(memoryLocalRecords.companyId, companyId), inArray(memoryLocalRecords.id, expiredRecordIds)));
const operations: MemoryOperation[] = [];
const rowsByBinding = new Map<string, RecordRow[]>();
for (const row of rows) {
rowsByBinding.set(row.bindingId, [...(rowsByBinding.get(row.bindingId) ?? []), row]);
}
for (const [bindingId, bindingRows] of rowsByBinding) {
const binding = await getBindingOrThrow(bindingId);
operations.push(
await logOperation({
companyId,
binding,
actor,
operationType: "retention_sweep",
triggerKind: "manual",
scope: {},
retentionAction: {
now: now.toISOString(),
expiredRecordIds: bindingRows.map((row) => row.id),
},
resultJson: {
expiredRecordIds: bindingRows.map((row) => row.id),
},
recordCount: bindingRows.length,
}),
);
}
return { operations, expiredRecordIds } satisfies MemoryRetentionSweepResult;
},
listRecords: async (companyId: string, filters: MemoryListRecordsQuery, actor: ActorInfo) =>
listLocalBasic(companyId, filters, actor),
getRecord: async (companyId: string, recordId: string, actor?: ActorInfo) => {
const row = await db
.select()
.from(memoryLocalRecords)
.where(and(eq(memoryLocalRecords.companyId, companyId), eq(memoryLocalRecords.id, recordId)))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const record = mapRecord(row);
if (actor && !canReadRecord(companyId, record, actor, record.scope)) return null;
return record;
},
listOperations: async (companyId: string, filters: MemoryListOperationsQuery) => {
const conditions = [eq(memoryOperations.companyId, companyId)];
if (filters.bindingId) conditions.push(eq(memoryOperations.bindingId, filters.bindingId));
if (filters.operationType) conditions.push(eq(memoryOperations.operationType, filters.operationType));
if (filters.status) conditions.push(eq(memoryOperations.status, filters.status));
if (filters.hookKind) conditions.push(eq(memoryOperations.hookKind, filters.hookKind));
if (filters.agentId) conditions.push(eq(memoryOperations.scopeAgentId, filters.agentId));
if (filters.issueId) conditions.push(eq(memoryOperations.scopeIssueId, filters.issueId));
if (filters.runId) conditions.push(eq(memoryOperations.scopeRunId, filters.runId));
const rows = await db
.select()
.from(memoryOperations)
.where(and(...conditions))
.orderBy(desc(memoryOperations.occurredAt), desc(memoryOperations.createdAt))
.limit(filters.limit);
return rows.map((row) => mapOperation(row));
},
listExtractionJobs: async (companyId: string, filters: MemoryListExtractionJobsQuery) => {
const conditions = [eq(memoryExtractionJobs.companyId, companyId)];
if (filters.bindingId) conditions.push(eq(memoryExtractionJobs.bindingId, filters.bindingId));
const rows = await db
.select()
.from(memoryExtractionJobs)
.where(and(...conditions))
.orderBy(desc(memoryExtractionJobs.createdAt))
.limit(filters.limit);
return rows.map((row) => mapExtractionJob(row));
},
startRefreshJob: async (
companyId: string,
data: MemoryRefreshJob,
actor: ActorInfo,
options?: { runInline?: boolean },
): Promise<MemoryRefreshJobResult> => {
const parsed = memoryRefreshJobSchema.parse(data);
const sources = await collectRefreshSources(companyId, parsed);
const sourceCounts = refreshSourceCounts(sources);
const job = await backgroundJobsSvc.createOrUpdateJob(
companyId,
{
key: "memory.refresh",
jobType: "memory_refresh",
displayName: "Memory refresh",
description: "Refresh historical Paperclip sources into the configured memory provider.",
backendKind: "server_worker",
status: "active",
config: {
sourceKinds: parsed.sourceKinds,
},
sourceIssueId: parsed.issueIds?.length === 1 ? parsed.issueIds[0] : null,
sourceProjectId: parsed.projectId ?? null,
},
{
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
userId: actor.userId,
},
);
const run = await backgroundJobsSvc.createRun(
companyId,
{
jobId: job.id,
trigger: "manual",
sourceIssueId: parsed.issueIds?.length === 1 ? parsed.issueIds[0] : null,
sourceProjectId: parsed.projectId ?? null,
sourceAgentId: parsed.agentId ?? null,
heartbeatRunId: actor.runId ?? null,
totalItems: sources.length,
config: {
bindingKey: parsed.bindingKey ?? null,
sourceKinds: parsed.sourceKinds,
dryRun: parsed.dryRun,
},
},
{
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
userId: actor.userId,
},
);
if (options?.runInline || parsed.dryRun) {
const completed = await executeRefreshJob({
companyId,
runId: run.id,
request: parsed,
sources,
actor,
});
return {
job,
run: completed.run,
dryRun: parsed.dryRun,
sourceCounts,
recordCount: completed.recordCount,
};
}
void executeRefreshJob({
companyId,
runId: run.id,
request: parsed,
sources,
actor,
}).catch(async (error) => {
await backgroundJobsSvc.completeRun(run.id, {
status: "failed",
error: error instanceof Error ? error.message : String(error),
result: {
dryRun: parsed.dryRun,
sourceCounts,
},
}).catch(() => undefined);
});
return {
job,
run,
dryRun: parsed.dryRun,
sourceCounts,
recordCount: 0,
};
},
preRunHydrate: async (input: {
companyId: string;
agentId: string;
projectId?: string | null;
issueId?: string | null;
runId: string;
query: string;
}): Promise<MemoryPreRunHydrateResult> => {
const resolved = await resolveBindingInternal(
input.companyId,
{
agentId: input.agentId,
projectId: input.projectId ?? null,
issueId: input.issueId ?? null,
runId: input.runId,
},
null,
);
if (!resolved.binding || !resolved.binding.enabled) {
return {
preamble: null,
trace: buildSkippedMemoryHookTrace({
hookKind: "pre_run_hydrate",
reason: resolved.binding ? "binding_disabled" : "no_binding",
binding: resolved.binding ? mapBinding(resolved.binding) : null,
}),
};
}
if (!isHookEnabled(resolved.binding, "pre_run_hydrate")) {
return {
preamble: null,
trace: buildSkippedMemoryHookTrace({
hookKind: "pre_run_hydrate",
reason: "hook_disabled",
binding: mapBinding(resolved.binding),
}),
};
}
const result = await service.query(
input.companyId,
{
scope: {
scopeType: "run",
scopeId: input.runId,
agentId: input.agentId,
projectId: input.projectId ?? null,
issueId: input.issueId ?? null,
runId: input.runId,
},
query: input.query,
topK: getHydrateTopK(resolved.binding),
intent: "agent_preamble",
},
{
actorType: "agent",
actorId: input.agentId,
agentId: input.agentId,
userId: null,
runId: input.runId,
},
"hook",
"pre_run_hydrate",
);
return {
preamble: result.preamble,
trace: buildPreRunHydrateTrace(mapBinding(resolved.binding), result),
};
},
captureRunSummary: async (input: {
companyId: string;
agentId: string;
projectId?: string | null;
issueId?: string | null;
runId: string;
title?: string | null;
summary: string;
}): Promise<MemoryPostRunCaptureResult> => {
const resolved = await resolveBindingInternal(
input.companyId,
{
agentId: input.agentId,
projectId: input.projectId ?? null,
issueId: input.issueId ?? null,
runId: input.runId,
},
null,
);
if (!resolved.binding || !resolved.binding.enabled) {
return {
trace: buildSkippedMemoryHookTrace({
hookKind: "post_run_capture",
reason: resolved.binding ? "binding_disabled" : "no_binding",
binding: resolved.binding ? mapBinding(resolved.binding) : null,
}),
};
}
if (!isHookEnabled(resolved.binding, "post_run_capture")) {
return {
trace: buildSkippedMemoryHookTrace({
hookKind: "post_run_capture",
reason: "hook_disabled",
binding: mapBinding(resolved.binding),
}),
};
}
const captured = await captureWithHookPolicy({
companyId: input.companyId,
binding: resolved.binding,
hookKind: "post_run_capture",
scope: {
scopeType: "run",
scopeId: input.runId,
agentId: input.agentId,
projectId: input.projectId ?? null,
issueId: input.issueId ?? null,
runId: input.runId,
},
scopeType: "run",
scopeId: input.runId,
source: {
kind: "run",
issueId: input.issueId ?? null,
runId: input.runId,
},
citation: {
label: "Run summary",
sourceTitle: input.title ?? "Run summary",
},
title: input.title ?? "Run summary",
content: input.summary,
summary: input.summary,
actor: {
actorType: "agent",
actorId: input.agentId,
agentId: input.agentId,
userId: null,
runId: input.runId,
},
});
if (!captured) {
return {
trace: buildSkippedMemoryHookTrace({
hookKind: "post_run_capture",
reason: "hook_disabled",
binding: mapBinding(resolved.binding),
}),
};
}
return {
trace: buildPostRunCaptureTrace(mapBinding(resolved.binding), captured.result),
};
},
captureIssueComment: async (input: {
companyId: string;
issueId: string;
commentId: string;
agentId?: string | null;
projectId?: string | null;
body: string;
actor: ActorInfo;
}) => {
const resolved = await resolveBindingInternal(
input.companyId,
{
agentId: input.agentId ?? null,
projectId: input.projectId ?? null,
issueId: input.issueId,
},
null,
);
if (!resolved.binding || !resolved.binding.enabled) return null;
if (!isHookEnabled(resolved.binding, "issue_comment_capture")) return null;
const scopeType = input.projectId ? "project" : input.agentId ? "agent" : "org";
const scopeId = input.projectId ?? input.agentId ?? input.companyId;
return captureWithHookPolicy({
companyId: input.companyId,
binding: resolved.binding,
hookKind: "issue_comment_capture",
scope: {
scopeType,
scopeId,
agentId: input.agentId ?? null,
projectId: input.projectId ?? null,
issueId: input.issueId,
},
scopeType,
scopeId,
source: {
kind: "issue_comment",
issueId: input.issueId,
commentId: input.commentId,
},
citation: {
label: "Issue comment",
sourceTitle: `Issue comment ${input.commentId}`,
},
title: "Issue comment",
content: input.body,
summary: input.body.replace(/\s+/g, " ").slice(0, 240),
actor: input.actor,
}).then((capture) => capture?.result ?? null);
},
captureIssueDocument: async (input: {
companyId: string;
issueId: string;
agentId?: string | null;
projectId?: string | null;
key: string;
title?: string | null;
body: string;
actor: ActorInfo;
}) => {
const resolved = await resolveBindingInternal(
input.companyId,
{
agentId: input.agentId ?? null,
projectId: input.projectId ?? null,
issueId: input.issueId,
},
null,
);
if (!resolved.binding || !resolved.binding.enabled) return null;
if (!isHookEnabled(resolved.binding, "issue_document_capture")) return null;
const scopeType = input.projectId ? "project" : input.agentId ? "agent" : "org";
const scopeId = input.projectId ?? input.agentId ?? input.companyId;
return captureWithHookPolicy({
companyId: input.companyId,
binding: resolved.binding,
hookKind: "issue_document_capture",
scope: {
scopeType,
scopeId,
agentId: input.agentId ?? null,
projectId: input.projectId ?? null,
issueId: input.issueId,
},
scopeType,
scopeId,
source: {
kind: "issue_document",
issueId: input.issueId,
documentKey: input.key,
},
citation: {
label: "Issue document",
sourceTitle: input.title ?? input.key,
},
title: input.title ?? `Issue document: ${input.key}`,
content: input.body,
summary: input.body.replace(/\s+/g, " ").slice(0, 240),
actor: input.actor,
}).then((capture) => capture?.result ?? null);
},
};
return service;
}