626 lines
21 KiB
TypeScript
626 lines
21 KiB
TypeScript
import { z } from "zod";
|
|
|
|
import { writeLearningAuditLog } from "../audit/learningAudit.js";
|
|
import { type SupportedModel } from "../chat/models.js";
|
|
import { type ChatRequestContext } from "../chat/sessionBridge.js";
|
|
import { config } from "../config.js";
|
|
import { type SessionTurnRecord, SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
|
import { logger } from "../logger.js";
|
|
import { SessionLearningStateStore } from "./sessionStateStore.js";
|
|
import { MemoryStore, type MemoryScope } from "../memory/store.js";
|
|
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
|
import { SkillStore } from "../skills/store.js";
|
|
import {
|
|
removeRuntimeSessionContext,
|
|
setRuntimeSessionContext,
|
|
} from "../runtime/sessionContext.js";
|
|
import {
|
|
sanitizePersistentDocument,
|
|
sanitizePersistentLine,
|
|
} from "../utils/persistencePolicy.js";
|
|
|
|
const gateResultSchema = z.object({
|
|
confidence: z.number().min(0).max(1).default(0),
|
|
focus: z.enum(["memory", "skill", "both", "none"]).default("none"),
|
|
reason: z.string().default(""),
|
|
should_review: z.boolean().default(false),
|
|
});
|
|
|
|
const reviewResultSchema = z.object({
|
|
memories: z
|
|
.array(
|
|
z.object({
|
|
action: z.enum(["add", "replace", "remove"]),
|
|
confidence: z.number().min(0).max(1),
|
|
content: z.string().optional(),
|
|
evidence: z.string().default(""),
|
|
scope: z.enum(["user", "workspace"]),
|
|
target_id: z.string().optional(),
|
|
}),
|
|
)
|
|
.default([]),
|
|
skills: z
|
|
.array(
|
|
z.object({
|
|
action: z.enum([
|
|
"append_pattern",
|
|
"remove_pattern",
|
|
"write_reference",
|
|
"write_skill",
|
|
"remove_skill",
|
|
]),
|
|
confidence: z.number().min(0).max(1),
|
|
content: z.string().optional(),
|
|
evidence: z.string().default(""),
|
|
file_path: z.string().optional(),
|
|
pattern: z.string().optional(),
|
|
skill_path: z.string(),
|
|
target_id: z.string().optional(),
|
|
}),
|
|
)
|
|
.default([]),
|
|
summary: z.string().default(""),
|
|
});
|
|
|
|
type GateResult = z.infer<typeof gateResultSchema>;
|
|
type ReviewResult = z.infer<typeof reviewResultSchema>;
|
|
|
|
type TurnReviewInput = {
|
|
assistantMessage: string;
|
|
model?: SupportedModel;
|
|
requestContext: ChatRequestContext;
|
|
sessionId: string;
|
|
toolCallCount: number;
|
|
userMessage: string;
|
|
};
|
|
|
|
export class LearningOrchestrator {
|
|
private readonly activeReviews = new Set<string>();
|
|
private readonly sessionLearningStateStore = new SessionLearningStateStore();
|
|
private readonly skillStore = new SkillStore();
|
|
|
|
constructor(
|
|
private readonly runtime: OpencodeRuntimeAdapter,
|
|
private readonly memoryStore: MemoryStore,
|
|
private readonly transcriptStore: SessionTranscriptStore,
|
|
) {}
|
|
|
|
async initialize() {
|
|
await this.sessionLearningStateStore.initialize();
|
|
}
|
|
|
|
async onTurnCompleted(input: TurnReviewInput) {
|
|
const transcript = await this.transcriptStore.appendTurn(
|
|
{
|
|
actorKey: input.requestContext.actorKey,
|
|
clientSessionId: input.requestContext.clientSessionId,
|
|
projectKey: input.requestContext.projectKey,
|
|
sessionId: input.sessionId,
|
|
},
|
|
{
|
|
assistantMessage: input.assistantMessage,
|
|
toolCallCount: input.toolCallCount,
|
|
userMessage: input.userMessage,
|
|
},
|
|
);
|
|
const turnCount = transcript.turns.length;
|
|
if (this.activeReviews.has(input.sessionId)) {
|
|
return;
|
|
}
|
|
this.activeReviews.add(input.sessionId);
|
|
try {
|
|
const state = await this.sessionLearningStateStore.read(input.sessionId);
|
|
const turnsSinceGate = Math.max(0, turnCount - state.lastGatedTurn);
|
|
if (turnsSinceGate < config.LEARNING_GATE_TURN_COOLDOWN) {
|
|
this.activeReviews.delete(input.sessionId);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
this.activeReviews.delete(input.sessionId);
|
|
throw error;
|
|
}
|
|
queueMicrotask(() => {
|
|
void this.runGate({
|
|
input,
|
|
recentTurns: transcript.turns.slice(-config.LEARNING_REVIEW_MAX_RECENT_TURNS),
|
|
turnCount,
|
|
}).finally(() => {
|
|
this.activeReviews.delete(input.sessionId);
|
|
});
|
|
});
|
|
}
|
|
|
|
private async runGate({
|
|
input,
|
|
recentTurns,
|
|
turnCount,
|
|
}: {
|
|
input: TurnReviewInput;
|
|
recentTurns: SessionTurnRecord[];
|
|
turnCount: number;
|
|
}) {
|
|
let gateSessionId: string | null = null;
|
|
try {
|
|
const gateSession = await this.runtime.createSession(
|
|
`learning-gate-${input.requestContext.clientSessionId}`,
|
|
);
|
|
gateSessionId = gateSession.id;
|
|
setRuntimeSessionContext({
|
|
actorKey: input.requestContext.actorKey,
|
|
allowLearningWrite: false,
|
|
clientSessionId: `gate-${input.requestContext.clientSessionId}`,
|
|
learningMode: "review",
|
|
projectId: input.requestContext.projectId,
|
|
projectKey: input.requestContext.projectKey,
|
|
sessionId: gateSession.id,
|
|
traceId: input.requestContext.traceId,
|
|
});
|
|
await this.runtime.prompt(
|
|
gateSession.id,
|
|
buildGatePrompt({ recentTurns }),
|
|
GATE_MODEL,
|
|
);
|
|
const messages = await this.runtime.messages(gateSession.id, 20);
|
|
const assistantMessage = [...messages]
|
|
.reverse()
|
|
.find((message) => message.info.role === "assistant");
|
|
const gateText = collectTextContent(assistantMessage?.parts ?? []);
|
|
const gate = parseGateResult(gateText);
|
|
if (!gate) {
|
|
await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount);
|
|
await writeLearningAuditLog({
|
|
action: "review-gate",
|
|
detail: "gate result was not valid JSON",
|
|
outcome: "error",
|
|
projectId: input.requestContext.projectId,
|
|
sessionId: input.sessionId,
|
|
traceId: input.requestContext.traceId,
|
|
});
|
|
return;
|
|
}
|
|
const shouldPromote =
|
|
gate.should_review &&
|
|
gate.confidence >= config.LEARNING_GATE_MIN_CONFIDENCE &&
|
|
gate.focus !== "none";
|
|
await writeLearningAuditLog({
|
|
action: "review-gate",
|
|
detail: sanitizeAuditDetail(gate.reason),
|
|
outcome: shouldPromote ? "accepted" : "skipped",
|
|
projectId: input.requestContext.projectId,
|
|
proposal: sanitizeGateForAudit(gate),
|
|
sessionId: input.sessionId,
|
|
traceId: input.requestContext.traceId,
|
|
});
|
|
if (!shouldPromote) {
|
|
await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount);
|
|
return;
|
|
}
|
|
await this.runReview({
|
|
focus: gate.focus,
|
|
input,
|
|
recentTurns,
|
|
turnCount,
|
|
});
|
|
} catch (error) {
|
|
logger.warn({ err: error, sessionId: input.sessionId }, "learning gate failed");
|
|
await writeLearningAuditLog({
|
|
action: "review-gate",
|
|
detail: sanitizeAuditDetail(error instanceof Error ? error.message : String(error)),
|
|
outcome: "error",
|
|
projectId: input.requestContext.projectId,
|
|
sessionId: input.sessionId,
|
|
traceId: input.requestContext.traceId,
|
|
});
|
|
} finally {
|
|
if (gateSessionId) {
|
|
removeRuntimeSessionContext(gateSessionId);
|
|
await this.runtime.abortSession(gateSessionId).catch(() => undefined);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async runReview({
|
|
focus,
|
|
input,
|
|
recentTurns,
|
|
turnCount,
|
|
}: {
|
|
focus: GateResult["focus"];
|
|
input: TurnReviewInput;
|
|
recentTurns: SessionTurnRecord[];
|
|
turnCount: number;
|
|
}) {
|
|
const reviewSession = await this.runtime.createSession(
|
|
`learning-review-${input.requestContext.clientSessionId}`,
|
|
);
|
|
setRuntimeSessionContext({
|
|
actorKey: input.requestContext.actorKey,
|
|
allowLearningWrite: false,
|
|
clientSessionId: `review-${input.requestContext.clientSessionId}`,
|
|
learningMode: "review",
|
|
projectId: input.requestContext.projectId,
|
|
projectKey: input.requestContext.projectKey,
|
|
sessionId: reviewSession.id,
|
|
traceId: input.requestContext.traceId,
|
|
});
|
|
try {
|
|
const existingMemory = await this.loadMemoryContext(input.requestContext);
|
|
await this.runtime.prompt(
|
|
reviewSession.id,
|
|
buildReviewPrompt({ existingMemory, focus, recentTurns }),
|
|
toRuntimeModel(input.model),
|
|
);
|
|
const messages = await this.runtime.messages(reviewSession.id, 20);
|
|
const assistantMessage = [...messages]
|
|
.reverse()
|
|
.find((message) => message.info.role === "assistant");
|
|
const reviewText = collectTextContent(assistantMessage?.parts ?? []);
|
|
const parsed = parseReviewResult(reviewText);
|
|
if (!parsed) {
|
|
await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount);
|
|
await writeLearningAuditLog({
|
|
action: "review-parse",
|
|
detail: "review result was not valid JSON",
|
|
outcome: "error",
|
|
projectId: input.requestContext.projectId,
|
|
sessionId: input.sessionId,
|
|
traceId: input.requestContext.traceId,
|
|
});
|
|
return;
|
|
}
|
|
await this.applyReviewResult(input, parsed, turnCount);
|
|
await this.sessionLearningStateStore.completeReview(input.sessionId, turnCount);
|
|
} catch (error) {
|
|
logger.warn({ err: error, sessionId: input.sessionId }, "learning review failed");
|
|
await writeLearningAuditLog({
|
|
action: "review-run",
|
|
detail: sanitizeAuditDetail(error instanceof Error ? error.message : String(error)),
|
|
outcome: "error",
|
|
projectId: input.requestContext.projectId,
|
|
sessionId: input.sessionId,
|
|
traceId: input.requestContext.traceId,
|
|
});
|
|
} finally {
|
|
removeRuntimeSessionContext(reviewSession.id);
|
|
await this.runtime.abortSession(reviewSession.id).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
private async applyReviewResult(
|
|
input: TurnReviewInput,
|
|
result: ReviewResult,
|
|
turnCount: number,
|
|
) {
|
|
const threshold = config.LEARNING_MIN_PROPOSAL_CONFIDENCE;
|
|
let accepted = 0;
|
|
for (const proposal of result.memories) {
|
|
const outcome = await this.applyMemoryProposal(input, proposal, threshold);
|
|
accepted += outcome ? 1 : 0;
|
|
}
|
|
for (const proposal of result.skills) {
|
|
const outcome = await this.applySkillProposal(input, proposal, threshold);
|
|
accepted += outcome ? 1 : 0;
|
|
}
|
|
await writeLearningAuditLog({
|
|
action: "review-summary",
|
|
detail: sanitizeAuditDetail(result.summary),
|
|
outcome: accepted > 0 ? "accepted" : "skipped",
|
|
projectId: input.requestContext.projectId,
|
|
proposal: {
|
|
accepted,
|
|
memories: result.memories.length,
|
|
skills: result.skills.length,
|
|
turnCount,
|
|
},
|
|
sessionId: input.sessionId,
|
|
traceId: input.requestContext.traceId,
|
|
});
|
|
}
|
|
|
|
private async loadMemoryContext(context: ChatRequestContext) {
|
|
const [userMemory, workspaceMemory] = await Promise.all([
|
|
this.memoryStore.list("user", context.actorKey),
|
|
this.memoryStore.list("workspace", context.projectKey),
|
|
]);
|
|
return { userMemory, workspaceMemory };
|
|
}
|
|
|
|
private async applyMemoryProposal(
|
|
input: TurnReviewInput,
|
|
proposal: ReviewResult["memories"][number],
|
|
threshold: number,
|
|
) {
|
|
if (proposal.confidence < threshold) {
|
|
await writeLearningAuditLog({
|
|
action: `memory-${proposal.action}`,
|
|
detail: "proposal below confidence threshold",
|
|
outcome: "skipped",
|
|
projectId: input.requestContext.projectId,
|
|
proposal: sanitizeMemoryProposalForAudit(proposal),
|
|
sessionId: input.sessionId,
|
|
traceId: input.requestContext.traceId,
|
|
});
|
|
return false;
|
|
}
|
|
const scopeKey =
|
|
proposal.scope === "user"
|
|
? input.requestContext.actorKey
|
|
: input.requestContext.projectKey;
|
|
const draft = {
|
|
content: proposal.content ?? "",
|
|
sessionId: input.sessionId,
|
|
source: "review" as const,
|
|
traceId: input.requestContext.traceId,
|
|
};
|
|
let accepted = false;
|
|
let detail = "memory rejected";
|
|
if (proposal.action === "add") {
|
|
const result = await this.memoryStore.upsert(proposal.scope as MemoryScope, scopeKey, draft);
|
|
accepted = Boolean(result.entry);
|
|
detail = result.detail;
|
|
} else if (proposal.action === "replace") {
|
|
const result = await this.memoryStore.replace(
|
|
proposal.scope as MemoryScope,
|
|
scopeKey,
|
|
proposal.target_id ?? "",
|
|
draft,
|
|
);
|
|
accepted = Boolean(result.changed);
|
|
detail = result.detail;
|
|
} else {
|
|
const result = await this.memoryStore.remove(
|
|
proposal.scope as MemoryScope,
|
|
scopeKey,
|
|
proposal.target_id ?? "",
|
|
);
|
|
accepted = Boolean(result.changed);
|
|
detail = result.detail;
|
|
}
|
|
await writeLearningAuditLog({
|
|
action: `memory-${proposal.action}`,
|
|
detail: sanitizeAuditDetail(detail),
|
|
outcome: accepted ? "accepted" : "rejected",
|
|
projectId: input.requestContext.projectId,
|
|
proposal: sanitizeMemoryProposalForAudit(proposal),
|
|
sessionId: input.sessionId,
|
|
traceId: input.requestContext.traceId,
|
|
});
|
|
return accepted;
|
|
}
|
|
|
|
private async applySkillProposal(
|
|
input: TurnReviewInput,
|
|
proposal: ReviewResult["skills"][number],
|
|
threshold: number,
|
|
) {
|
|
if (proposal.confidence < threshold) {
|
|
await writeLearningAuditLog({
|
|
action: `skill-${proposal.action}`,
|
|
detail: "proposal below confidence threshold",
|
|
outcome: "skipped",
|
|
projectId: input.requestContext.projectId,
|
|
proposal: sanitizeSkillProposalForAudit(proposal),
|
|
sessionId: input.sessionId,
|
|
traceId: input.requestContext.traceId,
|
|
});
|
|
return false;
|
|
}
|
|
const result =
|
|
proposal.action === "append_pattern"
|
|
? await this.skillStore.appendPattern(proposal.skill_path, proposal.pattern ?? "")
|
|
: proposal.action === "remove_pattern"
|
|
? await this.skillStore.removePattern(
|
|
proposal.skill_path,
|
|
proposal.target_id ?? "",
|
|
)
|
|
: proposal.action === "write_reference"
|
|
? await this.skillStore.writeReference(
|
|
proposal.skill_path,
|
|
proposal.file_path ?? "",
|
|
proposal.content ?? "",
|
|
)
|
|
: proposal.action === "write_skill"
|
|
? await this.skillStore.writeSkill(proposal.skill_path, proposal.content ?? "")
|
|
: await this.skillStore.removeSkill(proposal.skill_path);
|
|
await writeLearningAuditLog({
|
|
action: `skill-${proposal.action}`,
|
|
detail: sanitizeAuditDetail(result.detail),
|
|
outcome: result.changed ? "accepted" : "rejected",
|
|
projectId: input.requestContext.projectId,
|
|
proposal: sanitizeSkillProposalForAudit(proposal),
|
|
sessionId: input.sessionId,
|
|
traceId: input.requestContext.traceId,
|
|
});
|
|
return result.changed;
|
|
}
|
|
}
|
|
|
|
const buildGatePrompt = ({ recentTurns }: { recentTurns: SessionTurnRecord[] }) => {
|
|
const transcript = recentTurns
|
|
.map(
|
|
(turn, index) =>
|
|
`Turn ${index + 1}\nUser: ${turn.userMessage}\nAssistant: ${turn.assistantMessage}\nTool calls: ${turn.toolCallCount}`,
|
|
)
|
|
.join("\n\n");
|
|
return [
|
|
"You are the learning gate for TJWaterAgent.",
|
|
"Do NOT call any tools. Return JSON only. Do NOT wrap in markdown fences.",
|
|
"Decide whether this recent conversation is worth a deeper learning review.",
|
|
"A review is warranted only when there is likely durable memory or reusable skill signal.",
|
|
"Ignore one-off cases, temporary outcomes, and task-local noise.",
|
|
"",
|
|
'Return JSON schema: {"should_review":true|false,"reason":"string","confidence":0.0,"focus":"memory|skill|both|none"}',
|
|
"",
|
|
"Conversation transcript:",
|
|
transcript || "(empty)",
|
|
].join("\n");
|
|
};
|
|
|
|
const buildReviewPrompt = ({
|
|
existingMemory,
|
|
focus,
|
|
recentTurns,
|
|
}: {
|
|
existingMemory: {
|
|
userMemory: Array<{ content: string; id: string }>;
|
|
workspaceMemory: Array<{ content: string; id: string }>;
|
|
};
|
|
focus: GateResult["focus"];
|
|
recentTurns: SessionTurnRecord[];
|
|
}) => {
|
|
const transcript = recentTurns
|
|
.map(
|
|
(turn, index) =>
|
|
`Turn ${index + 1}\nUser: ${turn.userMessage}\nAssistant: ${turn.assistantMessage}\nTool calls: ${turn.toolCallCount}`,
|
|
)
|
|
.join("\n\n");
|
|
const userMemoryBlock =
|
|
existingMemory.userMemory.length > 0
|
|
? existingMemory.userMemory.map((entry) => `- [${entry.id}] ${entry.content}`).join("\n")
|
|
: "(empty)";
|
|
const workspaceMemoryBlock =
|
|
existingMemory.workspaceMemory.length > 0
|
|
? existingMemory.workspaceMemory
|
|
.map((entry) => `- [${entry.id}] ${entry.content}`)
|
|
.join("\n")
|
|
: "(empty)";
|
|
return [
|
|
"You are doing an internal self-improvement review for TJWaterAgent.",
|
|
"Do NOT call any tools. Return JSON only. Do NOT wrap in markdown fences.",
|
|
`Focus: ${focus}`,
|
|
"Decide what durable lessons to keep from the conversation below.",
|
|
"",
|
|
"Memory rules:",
|
|
"- Keep only stable user preferences, durable constraints, or stable workspace facts.",
|
|
"- Use scope='user' for user preferences and constraints.",
|
|
"- Use scope='workspace' for project or environment facts.",
|
|
"- Read the existing memories first before proposing changes.",
|
|
"- If a new lesson overlaps, refines, or supersedes an existing memory, prefer replace/remove using target_id instead of add.",
|
|
"- Use add only when the lesson is genuinely new after reviewing the existing memory list.",
|
|
"- Do not store one-off task outcomes, temporary facts, or speculative conclusions.",
|
|
"",
|
|
"Current persisted memories:",
|
|
"[User memory]",
|
|
userMemoryBlock,
|
|
"[Workspace memory]",
|
|
workspaceMemoryBlock,
|
|
"",
|
|
"Skill rules:",
|
|
"- Save only reusable workflows, methods, or pitfalls that will help in future similar tasks.",
|
|
"- Prefer append_pattern for concise reusable lessons.",
|
|
"- Use write_reference only for compact durable supporting notes under references/*.md.",
|
|
"- Use write_skill only when the conversation establishes a complete reusable SKILL.md with frontmatter name and description; it creates or overwrites the main SKILL.md.",
|
|
"- Use remove_skill only when the conversation clearly establishes the whole skill is obsolete or invalid.",
|
|
"",
|
|
"Output JSON schema:",
|
|
`{"summary":"string","memories":[{"action":"add|replace|remove","scope":"user|workspace","content":"string?","target_id":"string?","confidence":0.0,"evidence":"string"}],"skills":[{"action":"append_pattern|remove_pattern|write_reference|write_skill|remove_skill","skill_path":"string","pattern":"string?","target_id":"string?","file_path":"references/example.md?","content":"string?","confidence":0.0,"evidence":"string"}]}`,
|
|
"",
|
|
"If nothing should be saved, return empty arrays.",
|
|
"",
|
|
"Conversation transcript:",
|
|
transcript || "(empty)",
|
|
].join("\n");
|
|
};
|
|
|
|
const parseGateResult = (text: string): GateResult | null => {
|
|
const trimmed = text.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
const candidate = fenced?.[1]?.trim() ?? trimmed;
|
|
try {
|
|
return gateResultSchema.parse(JSON.parse(candidate));
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const parseReviewResult = (text: string): ReviewResult | null => {
|
|
const trimmed = text.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
const candidate = fenced?.[1]?.trim() ?? trimmed;
|
|
try {
|
|
return reviewResultSchema.parse(JSON.parse(candidate));
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const collectTextContent = (
|
|
parts: Array<{ type: string; text?: string }>,
|
|
) =>
|
|
parts
|
|
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
|
.map((part) => part.text)
|
|
.join("");
|
|
|
|
const toRuntimeModel = (model?: SupportedModel) => {
|
|
if (!model) {
|
|
return undefined;
|
|
}
|
|
const [providerID, modelID] = model.split("/");
|
|
if (!providerID || !modelID) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
modelID,
|
|
providerID,
|
|
};
|
|
};
|
|
|
|
const GATE_MODEL = {
|
|
modelID: "deepseek-v4-flash",
|
|
providerID: "deepseek",
|
|
} as const;
|
|
|
|
const REDACTED_AUDIT_FIELD = "[redacted by persistence policy]";
|
|
|
|
const sanitizeAuditDetail = (detail?: string) => {
|
|
if (!detail) {
|
|
return undefined;
|
|
}
|
|
return sanitizePersistentDocument(detail, 1000) || REDACTED_AUDIT_FIELD;
|
|
};
|
|
|
|
const sanitizeAuditLine = (value?: string, maxLength = 320) => {
|
|
if (value === undefined) {
|
|
return undefined;
|
|
}
|
|
return sanitizePersistentLine(value, maxLength) || REDACTED_AUDIT_FIELD;
|
|
};
|
|
|
|
const sanitizeGateForAudit = (gate: GateResult): Record<string, unknown> => ({
|
|
confidence: gate.confidence,
|
|
focus: gate.focus,
|
|
reason: sanitizeAuditLine(gate.reason),
|
|
should_review: gate.should_review,
|
|
});
|
|
|
|
const sanitizeMemoryProposalForAudit = (
|
|
proposal: ReviewResult["memories"][number],
|
|
): Record<string, unknown> => ({
|
|
action: proposal.action,
|
|
confidence: proposal.confidence,
|
|
content: sanitizeAuditLine(proposal.content),
|
|
evidence: sanitizeAuditLine(proposal.evidence),
|
|
scope: proposal.scope,
|
|
target_id: sanitizeAuditLine(proposal.target_id, 120),
|
|
});
|
|
|
|
const sanitizeSkillProposalForAudit = (
|
|
proposal: ReviewResult["skills"][number],
|
|
): Record<string, unknown> => ({
|
|
action: proposal.action,
|
|
confidence: proposal.confidence,
|
|
content: sanitizeAuditDetail(proposal.content),
|
|
evidence: sanitizeAuditLine(proposal.evidence),
|
|
file_path: sanitizeAuditLine(proposal.file_path, 200),
|
|
pattern: sanitizeAuditLine(proposal.pattern),
|
|
skill_path: sanitizeAuditLine(proposal.skill_path, 200),
|
|
target_id: sanitizeAuditLine(proposal.target_id, 120),
|
|
});
|