mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
feat(memory): add numbered compaction refs
This commit is contained in:
@@ -20,6 +20,8 @@ export type EvidenceEventType =
|
||||
| "promotion_retry_scheduled"
|
||||
| "promotion_retry_exhausted"
|
||||
| "memory_reinforced"
|
||||
| "memory_replaced_numbered_ref"
|
||||
| "memory_reverted_numbered_ref"
|
||||
| "memory_migration_superseded"
|
||||
| "render_selected"
|
||||
| "render_omitted"
|
||||
@@ -71,10 +73,12 @@ export type EvidenceRelation = {
|
||||
| "promoted"
|
||||
| "retained"
|
||||
| "absorbed"
|
||||
| "target"
|
||||
| "superseded"
|
||||
| "superseded_by"
|
||||
| "reinforced"
|
||||
| "reinforced_by"
|
||||
| "recovered"
|
||||
| "rendered"
|
||||
| "omitted"
|
||||
| "removed";
|
||||
|
||||
+103
-10
@@ -52,9 +52,14 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
||||
|
||||
export type WorkspaceMemoryParseResult = {
|
||||
entries: LongTermMemoryEntry[];
|
||||
commands: WorkspaceMemoryCommand[];
|
||||
evidence: EvidenceEventInput[];
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryCommand =
|
||||
| { kind: "REINFORCE"; ref: string }
|
||||
| { kind: "REPLACE"; ref: string; type: LongTermType; text: string };
|
||||
|
||||
function evidenceTextPreview(text: string, maxChars = 120): string {
|
||||
return redactCredentials(text).replace(/\s+/g, " ").trim().slice(0, maxChars);
|
||||
}
|
||||
@@ -191,7 +196,7 @@ export function extractExplicitMemoriesWithEvidence(text: string): WorkspaceMemo
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, evidence };
|
||||
return { entries, commands: [], evidence };
|
||||
}
|
||||
|
||||
function classifyExplicitMemory(text: string): LongTermType {
|
||||
@@ -395,6 +400,76 @@ function shouldAcceptWorkspaceMemoryCandidate(
|
||||
return evaluateWorkspaceMemoryCandidate(entry, options).accepted;
|
||||
}
|
||||
|
||||
function commandAttemptReason(line: string): string {
|
||||
const normalized = line.replace(/^\s*-\s*/, "").trim();
|
||||
const reinforceMatch = normalized.match(/^REINFORCE\s+(.+)$/i);
|
||||
if (reinforceMatch) {
|
||||
return /^\[M[1-9]\d*\]$/i.test(reinforceMatch[1]?.trim() ?? "")
|
||||
? "invalid_memory_command"
|
||||
: "invalid_memory_ref";
|
||||
}
|
||||
|
||||
const replaceMatch = normalized.match(/^REPLACE\s+(.*)$/i);
|
||||
if (!replaceMatch) return "invalid_memory_command";
|
||||
|
||||
const rest = replaceMatch[1]?.trim() ?? "";
|
||||
const refMatch = rest.match(/^(\[[^\]]+\]|\S+)(?:\s+(.*))?$/);
|
||||
const ref = refMatch?.[1] ?? "";
|
||||
if (!/^\[M[1-9]\d*\]$/i.test(ref)) return "invalid_memory_ref";
|
||||
|
||||
const afterRef = refMatch?.[2]?.trim() ?? "";
|
||||
const typeMatch = afterRef.match(/^(\[[^\]]+\]|\S+)(?:\s+(.*))?$/);
|
||||
const typeToken = typeMatch?.[1] ?? "";
|
||||
if (!/^\[(feedback|project|decision|reference)\]$/i.test(typeToken)) {
|
||||
return "invalid_memory_type";
|
||||
}
|
||||
|
||||
const replacementText = typeMatch?.[2]?.trim() ?? "";
|
||||
return replacementText ? "invalid_memory_command" : "empty_replacement_text";
|
||||
}
|
||||
|
||||
function isCommandAttempt(line: string): boolean {
|
||||
const normalized = line.replace(/^\s*-\s*/, "").trim();
|
||||
return /^(REINFORCE|REPLACE)\b/i.test(normalized)
|
||||
|| /\b(REINFORCE|REPLACE)\b.*\[?\w+\]?/i.test(normalized);
|
||||
}
|
||||
|
||||
function parseWorkspaceMemoryCommand(line: string): WorkspaceMemoryCommand | null {
|
||||
const normalized = line.replace(/^\s*-\s*/, "").trim();
|
||||
const reinforce = normalized.match(/^REINFORCE\s+\[(M[1-9]\d*)\]\s*$/i);
|
||||
if (reinforce) {
|
||||
return { kind: "REINFORCE", ref: reinforce[1].toUpperCase() };
|
||||
}
|
||||
|
||||
const replace = normalized.match(/^REPLACE\s+\[(M[1-9]\d*)\]\s+\[(feedback|project|decision|reference)\]\s+(.+)$/i);
|
||||
if (replace) {
|
||||
const text = replace[3].trim();
|
||||
if (!text) return null;
|
||||
return {
|
||||
kind: "REPLACE",
|
||||
ref: replace[1].toUpperCase(),
|
||||
type: replace[2].toLowerCase() as LongTermType,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCandidateLine(line: string): { type: LongTermType; body: string } | null {
|
||||
const bracketed = line.trim().match(/^\s*-?\s*\[(feedback|project|decision|reference)\]\s+(.+)$/i);
|
||||
if (bracketed) {
|
||||
return { type: bracketed[1].toLowerCase() as LongTermType, body: bracketed[2] };
|
||||
}
|
||||
|
||||
const bracketless = line.trim().match(/^-\s*(feedback|project|decision|reference)\b\s+(.+)$/i);
|
||||
if (bracketless) {
|
||||
return { type: bracketless[1].toLowerCase() as LongTermType, body: bracketless[2] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract candidate block from summary using multiple formats.
|
||||
* Supports: Plain text label, Markdown section, legacy XML.
|
||||
@@ -431,21 +506,39 @@ export function parseWorkspaceMemoryCandidatesWithEvidence(
|
||||
options: WorkspaceMemoryCandidateParseOptions = {},
|
||||
): WorkspaceMemoryParseResult {
|
||||
const block = extractCandidateBlock(summary);
|
||||
if (!block) return { entries: [], evidence: [] };
|
||||
if (!block) return { entries: [], commands: [], evidence: [] };
|
||||
|
||||
const nowMs = Date.now();
|
||||
const now = new Date(nowMs).toISOString();
|
||||
const entries: LongTermMemoryEntry[] = [];
|
||||
const commands: WorkspaceMemoryCommand[] = [];
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
|
||||
for (const line of block.split("\n")) {
|
||||
if (!line.trim() || /^\s*\(?none\)?\s*$/i.test(line)) continue;
|
||||
|
||||
const command = parseWorkspaceMemoryCommand(line);
|
||||
if (command) {
|
||||
commands.push(command);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accept both "- [type] text" (bracketed) and "- type text" (bracketless)
|
||||
const item = line.trim().match(
|
||||
/^-\s*(?:\[(feedback|project|decision|reference)\]|(feedback|project|decision|reference)\b)\s+(.+)$/i,
|
||||
);
|
||||
if (!item) continue;
|
||||
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
|
||||
const normalizedBody = normalizeCandidateBody(item[3]);
|
||||
const item = parseCandidateLine(line);
|
||||
if (!item) {
|
||||
if (isCommandAttempt(line)) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "extraction_candidate_rejected",
|
||||
phase: "extraction",
|
||||
outcome: "rejected",
|
||||
reasonCodes: [commandAttemptReason(line)],
|
||||
textPreview: evidenceTextPreview(line, 80),
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const type = item.type;
|
||||
const normalizedBody = normalizeCandidateBody(item.body);
|
||||
if (!normalizedBody) {
|
||||
evidence.push(extractionEvidence({
|
||||
type: "extraction_candidate_rejected",
|
||||
@@ -453,7 +546,7 @@ export function parseWorkspaceMemoryCandidatesWithEvidence(
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["negated_request"],
|
||||
memory: { type, source: "compaction" },
|
||||
textPreview: evidenceTextPreview(item[3], 80),
|
||||
textPreview: evidenceTextPreview(item.body, 80),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
@@ -515,5 +608,5 @@ export function parseWorkspaceMemoryCandidatesWithEvidence(
|
||||
}));
|
||||
}
|
||||
|
||||
return { entries, evidence };
|
||||
return { entries, commands, evidence };
|
||||
}
|
||||
|
||||
+69
-1
@@ -7,6 +7,7 @@ export type MemoryQualityInput = Pick<LongTermMemoryEntry, "type" | "text"> & {
|
||||
export type MemoryQualityResult = {
|
||||
accepted: boolean;
|
||||
reasons: string[];
|
||||
diagnostics?: string[];
|
||||
};
|
||||
|
||||
export const HARD_QUALITY_REASONS: ReadonlySet<string> = new Set([
|
||||
@@ -18,6 +19,9 @@ export const HARD_QUALITY_REASONS: ReadonlySet<string> = new Set([
|
||||
"active_file_snapshot",
|
||||
"code_or_api_signature",
|
||||
"path_heavy",
|
||||
"unresolved_question",
|
||||
"transient_bug_state",
|
||||
"deployment_snapshot",
|
||||
]);
|
||||
|
||||
export function isHardQualityReason(reason: string): boolean {
|
||||
@@ -36,10 +40,18 @@ export function assessMemoryQuality(entry: MemoryQualityInput): MemoryQualityRes
|
||||
if (isTemporaryStatusViolation(text)) reasons.push("temporary_status");
|
||||
if (isActiveFileSnapshotViolation(text)) reasons.push("active_file_snapshot");
|
||||
if (isCodeOrApiSignatureViolation(text)) reasons.push("code_or_api_signature");
|
||||
if (isUnresolvedQuestionViolation(text)) reasons.push("unresolved_question");
|
||||
if (isTransientBugStateViolation(text)) reasons.push("transient_bug_state");
|
||||
if (isDeploymentSnapshotViolation(text)) reasons.push("deployment_snapshot");
|
||||
if (entry.type === "feedback" && isFeedbackQualityViolation(text)) reasons.push("bad_feedback");
|
||||
if (entry.type === "decision" && isDecisionQualityViolation(text)) reasons.push("bad_decision");
|
||||
|
||||
return { accepted: reasons.length === 0, reasons };
|
||||
const diagnostics = isTerseLabelDiagnostic(text) ? ["terse_label"] : [];
|
||||
return {
|
||||
accepted: reasons.length === 0,
|
||||
reasons,
|
||||
...(diagnostics.length > 0 ? { diagnostics } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function isProgressSnapshotViolation(text: string): boolean {
|
||||
@@ -83,6 +95,34 @@ export function hasFutureRule(text: string): boolean {
|
||||
|| /(?:使用|保持|避免|不要|必須|必须|應該|应该|選擇|选择)/u.test(text);
|
||||
}
|
||||
|
||||
function textWithoutUrls(text: string): string {
|
||||
return text.replace(/https?:\/\/[^\s`"'<>]+/gi, "");
|
||||
}
|
||||
|
||||
function hasDurableRuleMarker(text: string): boolean {
|
||||
return /\b(?:must|always|never|use|do\s+not|don't)\b/i.test(text)
|
||||
|| /\bshould\b(?!\s+we\b)/i.test(text)
|
||||
|| /(?:必須|必须|應該|应该|不要|使用|保持)/u.test(text);
|
||||
}
|
||||
|
||||
function isUnresolvedQuestionViolation(text: string): boolean {
|
||||
if (hasDurableRuleMarker(text)) return false;
|
||||
|
||||
const withoutUrls = textWithoutUrls(text).trim();
|
||||
const startsUnresolved = /^(?:question:|open question\b|unresolved\b|pending question\b|todo:\s*decide\b|TBD\b|TODO\b|待確認|未決|待決定)/iu.test(withoutUrls);
|
||||
if (startsUnresolved) return true;
|
||||
|
||||
if (/\b(?:need to decide|needs decision|not decided|whether to|should we|do we need)\b/i.test(withoutUrls)) return true;
|
||||
if (/(?:尚未決定|需要決定|是否要|要不要)/u.test(withoutUrls)) return true;
|
||||
|
||||
if (/[??]\s*$/.test(withoutUrls)) return true;
|
||||
|
||||
const hasQuestion = /[??]/.test(withoutUrls);
|
||||
const hasPlanningPhrase = /\b(?:we need|need to|next|later|follow up)\b/i.test(withoutUrls)
|
||||
|| /(?:確認|决定|決定)/u.test(withoutUrls);
|
||||
return hasQuestion && hasPlanningPhrase;
|
||||
}
|
||||
|
||||
export function isArchitectureLikeDecision(text: string): boolean {
|
||||
if (/\b(?:[A-Z][A-Z0-9]*_[A-Z0-9_]*|[A-Z][A-Z0-9]{3,})\b/.test(text)) return true;
|
||||
if (/\b(?:schema|model|scoring|retention|cap|evidence|normalization|root cause|architecture(?!\s+keywords?)|boundary|rule|memory system)\b/i.test(text)) return true;
|
||||
@@ -137,6 +177,34 @@ function isTemporaryStatusViolation(text: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTransientBugStateViolation(text: string): boolean {
|
||||
return /\b(?:currently debugging|still failing|unresolved bug|temporary workaround|next step is to fix|tests are failing)\b/i.test(text)
|
||||
|| /(?:待修|暫時|暂时|目前正在)/u.test(text);
|
||||
}
|
||||
|
||||
function isDeploymentSnapshotViolation(text: string): boolean {
|
||||
const hasDeploymentContext = /\b(?:deployed|current|latest|active|revision|build|release)\b/i.test(text)
|
||||
|| /(?:部署|版本|修訂|修订)/u.test(text);
|
||||
if (!hasDeploymentContext) return false;
|
||||
|
||||
const highEntropyId = /\b(?:rev|build|release|revision)[-_]?[A-Za-z0-9]{10,}\b/i.test(text)
|
||||
|| /\b[A-Za-z0-9]*[A-Z][A-Za-z0-9]*\d[A-Za-z0-9]*[A-Za-z0-9_-]{8,}\b/.test(text)
|
||||
|| /\b[A-Za-z0-9]*\d[A-Za-z0-9]*[A-Z][A-Za-z0-9]*[A-Za-z0-9_-]{8,}\b/.test(text);
|
||||
return highEntropyId;
|
||||
}
|
||||
|
||||
function isTerseLabelDiagnostic(text: string): boolean {
|
||||
if (/[::]/u.test(text)) return false;
|
||||
|
||||
const codePoints = [...text].length;
|
||||
const tokens = text.split(/\s+/u).filter(Boolean);
|
||||
if (codePoints >= 18 && tokens.length >= 4) return false;
|
||||
|
||||
const hasMarker = /\b(?:is|are|was|were|has|have|uses?|keeps?|requires?|prefers?|wants?|supports?|must|should|always|never|do\s+not|don't|because|for|with|when|after|before)\b/i.test(text)
|
||||
|| /(?:使用|保持|避免|不要|必須|必须|應該|应该|偏好|要求|支援|支持|因為|因为)/u.test(text);
|
||||
return !hasMarker;
|
||||
}
|
||||
|
||||
function isActiveFileSnapshotViolation(text: string): boolean {
|
||||
return /^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text);
|
||||
}
|
||||
|
||||
+315
-47
@@ -19,11 +19,11 @@
|
||||
* - Processes explicit memory from latest user text once per message id
|
||||
* - Injects frozen workspace memory and dynamic hot session state into system prompt
|
||||
* - Updates session state after tool execution
|
||||
* - Augments compaction context with memory, hot state, todos, and instruction
|
||||
* - Augments compaction context with numbered memory refs, todos, and instruction
|
||||
* - Parses compaction summaries for memory candidates and merges them
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { realpath, rm } from "fs/promises";
|
||||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import {
|
||||
@@ -31,12 +31,17 @@ import {
|
||||
extractActiveFiles,
|
||||
extractErrorsFromBash,
|
||||
parseWorkspaceMemoryCandidatesWithEvidence,
|
||||
staleAfterDaysFor,
|
||||
type WorkspaceMemoryCommand,
|
||||
} from "./extractors.ts";
|
||||
import { assessMemoryQuality } from "./memory-quality.ts";
|
||||
import {
|
||||
loadWorkspaceMemory,
|
||||
updateWorkspaceMemory,
|
||||
updateWorkspaceMemoryWithAccounting,
|
||||
accountWorkspaceMemoryRender,
|
||||
accountWorkspaceMemoryCompactionRefs,
|
||||
workspaceMemoryExactKey,
|
||||
workspaceMemoryIdentityKey,
|
||||
} from "./workspace-memory.ts";
|
||||
import { reinforceMemory } from "./retention.ts";
|
||||
@@ -66,7 +71,7 @@ import {
|
||||
} from "./opencode.ts";
|
||||
import { accountPendingPromotions, promotionAccountingEvidenceEvents } from "./promotion-accounting.ts";
|
||||
import { appendEvidenceEvent, appendEvidenceEvents, type EvidenceEventInput, type MemoryEvidenceRef } from "./evidence-log.ts";
|
||||
import { type LongTermMemoryEntry, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
|
||||
import { type CompactionMemoryRef, type LongTermMemoryEntry, LONG_TERM_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Build the complete compaction prompt.
|
||||
@@ -76,11 +81,14 @@ import { type LongTermMemoryEntry, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types
|
||||
* Our template uses only ## Markdown headings and explicitly forbids YAML frontmatter,
|
||||
* horizontal rules, and delimiter lines.
|
||||
*
|
||||
* @param privateContext - Background context (workspace memory, hot session state,
|
||||
* @param privateContext - Background context (numbered workspace memory refs,
|
||||
* pending todos) from our plugin and any other plugins. Shown to the model to
|
||||
* inform the summary but not copied verbatim.
|
||||
*/
|
||||
function buildCompactionPrompt(privateContext: string): string {
|
||||
function buildCompactionPrompt(privateContext: string, compactionId?: string): string {
|
||||
const snapshotInstruction = compactionId
|
||||
? `- If you emit any REINFORCE or REPLACE command, include \`Memory ref snapshot id: ${compactionId}\` as the first line under \"Memory candidates:\" so numbered refs match the correct compaction snapshot.`
|
||||
: "";
|
||||
return [
|
||||
"Provide a detailed summary for continuing our conversation above.",
|
||||
"Focus on information that would help another agent continue the work: the goal, user instructions, completed work, current state, decisions, relevant files, and next steps.",
|
||||
@@ -95,6 +103,7 @@ function buildCompactionPrompt(privateContext: string): string {
|
||||
"- Do not output horizontal rules.",
|
||||
"- Do not wrap the summary in delimiter lines such as ---.",
|
||||
"- Do not use code fences around the summary.",
|
||||
...(snapshotInstruction ? [snapshotInstruction] : []),
|
||||
"",
|
||||
"Use this structure:",
|
||||
"",
|
||||
@@ -116,19 +125,19 @@ function buildCompactionPrompt(privateContext: string): string {
|
||||
"",
|
||||
"CRITICAL MEMORY RULES:",
|
||||
"- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.",
|
||||
"- Existing workspace memory may already contain durable facts. If a fact is already present and still accurate, do not create a rephrased duplicate.",
|
||||
"- If the same durable fact truly needs to be emitted again, reuse the existing memory wording exactly whenever possible.",
|
||||
"- Only emit a new memory when the fact is new, materially corrected, or materially more specific than the existing memory.",
|
||||
"- Existing memories are numbered [M#]. If an existing memory is still accurate, emit at most 3 lines like `REINFORCE [M#]`; do not rephrase it.",
|
||||
"- Use `REPLACE [M#] [type] text` only for eligible unreinforced compaction-sourced memories where the old text itself needs correction; this is rarely the right choice.",
|
||||
"- To supplement or correct a memory, REINFORCE the existing [M#] if it is still accurate, and also emit a new complete [type] candidate with the addition or correction. Do not use REPLACE for additions; do not reinforce a memory that is now inaccurate.",
|
||||
"- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.",
|
||||
"- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.",
|
||||
"- feedback ONLY means stable user preferences or user instructions, written in imperative/future-facing form.",
|
||||
"- decision ONLY means rules that apply to FUTURE work, not decisions already implemented in this session.",
|
||||
"- project/reference ONLY when the fact is stable across sessions and hard to rediscover from the repository.",
|
||||
"- decision = future rule/architecture choice; reference = stable lookup fact; project = stable project fact; feedback = stable user preference.",
|
||||
"- Do not use decision for service names, IDs, URLs, file paths, or one-off session status; use reference/project or skip.",
|
||||
"- If unsure, skip it.",
|
||||
"",
|
||||
"Good memory examples:",
|
||||
"- REINFORCE [M1]",
|
||||
"- [feedback] User prefers architecture reviews in Traditional Chinese.",
|
||||
"- [decision] Do not add semantic merge to memory dedupe.",
|
||||
"- [decision] Keep memory dedupe exact-only for decisions.",
|
||||
"- [project] This repository is an OpenCode plugin using local JSON stores.",
|
||||
"- [reference] Workspace memory is rendered as frozen system[1]; pending memories remain in hot state until compaction.",
|
||||
"",
|
||||
@@ -139,11 +148,13 @@ function buildCompactionPrompt(privateContext: string): string {
|
||||
"- The assistant reviewed code reviewer feedback and updated the plan.",
|
||||
"- Commit a762e86 contains the owner scope fix.",
|
||||
"",
|
||||
"Format when there ARE durable memories:",
|
||||
"Format when there ARE REINFORCE/REPLACE commands or durable new candidates:",
|
||||
"Memory candidates:",
|
||||
"- [feedback|decision|project|reference] future-facing durable fact",
|
||||
"REINFORCE [M#]",
|
||||
"REPLACE [M#] [feedback|decision|project|reference] corrected durable fact",
|
||||
"- [feedback|decision|project|reference] new future-facing durable fact",
|
||||
"",
|
||||
"Format when there are NO durable memories:",
|
||||
"Format when there are NO REINFORCE/REPLACE commands or durable candidates:",
|
||||
"Memory candidates:",
|
||||
"(none)",
|
||||
"",
|
||||
@@ -173,6 +184,20 @@ function safeErrorMessage(error: unknown): string {
|
||||
return message.replace(/\s+/g, " ").slice(0, 240);
|
||||
}
|
||||
|
||||
type CompactionRefResolution =
|
||||
| {
|
||||
ok: true;
|
||||
refSnapshot: CompactionMemoryRef;
|
||||
target: LongTermMemoryEntry;
|
||||
targetIndex: number;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: "missing_memory_ref_snapshot" | "invalid_memory_ref" | "memory_ref_target_unavailable" | "memory_ref_target_changed";
|
||||
refSnapshot?: CompactionMemoryRef;
|
||||
target?: LongTermMemoryEntry;
|
||||
};
|
||||
|
||||
async function warnMemoryHook(scope: string, error: unknown, root?: string): Promise<void> {
|
||||
const message = safeErrorMessage(error);
|
||||
console.error(`[memory] ${scope} failed: ${message}`);
|
||||
@@ -200,6 +225,10 @@ async function workspaceIdentity(root: string): Promise<{ workspaceKey: string;
|
||||
return { workspaceKey: workspaceKeyValue, workspaceRootHash: workspaceRootHashValue };
|
||||
}
|
||||
|
||||
function compactionIdFromSummary(summary: string): string | undefined {
|
||||
return summary.match(/Memory ref snapshot id:\s*([a-zA-Z0-9_-]+)/i)?.[1];
|
||||
}
|
||||
|
||||
export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
const { directory, client } = input;
|
||||
|
||||
@@ -259,6 +288,227 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
};
|
||||
}
|
||||
|
||||
function memoryReinforcedEvidence(
|
||||
memory: LongTermMemoryEntry | undefined,
|
||||
ref: string,
|
||||
outcome: "reinforced" | "rejected",
|
||||
reasonCodes: string[],
|
||||
details: EvidenceEventInput["details"] = {},
|
||||
): EvidenceEventInput {
|
||||
const relationRole = outcome === "rejected" ? "target" : "reinforced";
|
||||
return {
|
||||
type: "memory_reinforced",
|
||||
phase: "reinforcement",
|
||||
outcome,
|
||||
memory: memory ? memoryEvidenceRef(memory) : undefined,
|
||||
relations: memory ? [{ role: relationRole, memory: memoryEvidenceRef(memory) }] : undefined,
|
||||
reasonCodes,
|
||||
details: {
|
||||
ref,
|
||||
...details,
|
||||
},
|
||||
textPreview: memory?.text,
|
||||
};
|
||||
}
|
||||
|
||||
function replacementMemoryId(): string {
|
||||
return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function memoryReplacedEvidence(
|
||||
oldMemory: LongTermMemoryEntry | undefined,
|
||||
newMemory: LongTermMemoryEntry | undefined,
|
||||
ref: string,
|
||||
outcome: "superseded" | "rejected",
|
||||
reasonCodes: string[],
|
||||
details: EvidenceEventInput["details"] = {},
|
||||
): EvidenceEventInput {
|
||||
const relations = outcome === "rejected"
|
||||
? [
|
||||
...(oldMemory ? [{ role: "target" as const, memory: memoryEvidenceRef(oldMemory) }] : []),
|
||||
]
|
||||
: [
|
||||
...(oldMemory ? [{ role: "superseded" as const, memory: memoryEvidenceRef(oldMemory) }] : []),
|
||||
...(newMemory ? [{ role: "superseded_by" as const, memory: memoryEvidenceRef(newMemory) }] : []),
|
||||
];
|
||||
return {
|
||||
type: "memory_replaced_numbered_ref",
|
||||
phase: "storage",
|
||||
outcome,
|
||||
memory: oldMemory ? memoryEvidenceRef(oldMemory) : undefined,
|
||||
relations: relations.length > 0 ? relations : undefined,
|
||||
reasonCodes,
|
||||
details: {
|
||||
ref,
|
||||
...details,
|
||||
},
|
||||
textPreview: newMemory?.text ?? oldMemory?.text,
|
||||
};
|
||||
}
|
||||
|
||||
function compactionRefByLabel(refs: CompactionMemoryRef[]): Map<string, CompactionMemoryRef> {
|
||||
return new Map(refs.map(ref => [ref.ref.toUpperCase(), ref]));
|
||||
}
|
||||
|
||||
function compactionSnapshotStatus(
|
||||
refs: CompactionMemoryRef[],
|
||||
expectedCompactionId: string | undefined,
|
||||
): { ok: true } | { ok: false; storedCompactionId: string } {
|
||||
if (refs.length === 0) return { ok: false, storedCompactionId: "none" };
|
||||
|
||||
const ids = new Set(refs.map(ref => ref.compactionId).filter((id): id is string => typeof id === "string" && id.length > 0));
|
||||
if (!expectedCompactionId) return { ok: true };
|
||||
if (ids.size === 1 && ids.has(expectedCompactionId)) return { ok: true };
|
||||
if (ids.size === 0) return { ok: false, storedCompactionId: "none" };
|
||||
if (ids.size === 1) return { ok: false, storedCompactionId: [...ids][0] };
|
||||
return { ok: false, storedCompactionId: "mixed" };
|
||||
}
|
||||
|
||||
function resolveCompactionMemoryRef(
|
||||
refs: CompactionMemoryRef[],
|
||||
refsByLabel: Map<string, CompactionMemoryRef>,
|
||||
entries: LongTermMemoryEntry[],
|
||||
ref: string,
|
||||
): CompactionRefResolution {
|
||||
if (refs.length === 0) {
|
||||
return { ok: false, reason: "missing_memory_ref_snapshot" };
|
||||
}
|
||||
|
||||
const refSnapshot = refsByLabel.get(ref.toUpperCase());
|
||||
if (!refSnapshot) {
|
||||
return { ok: false, reason: "invalid_memory_ref" };
|
||||
}
|
||||
|
||||
const targetIndex = entries.findIndex(entry => entry.id === refSnapshot.memoryId);
|
||||
const target = targetIndex >= 0 ? entries[targetIndex] : undefined;
|
||||
if (!target || target.status !== "active") {
|
||||
return { ok: false, reason: "memory_ref_target_unavailable", refSnapshot, target };
|
||||
}
|
||||
|
||||
if (workspaceMemoryExactKey(target) !== refSnapshot.exactKey) {
|
||||
return { ok: false, reason: "memory_ref_target_changed", refSnapshot, target };
|
||||
}
|
||||
|
||||
return { ok: true, refSnapshot, target, targetIndex };
|
||||
}
|
||||
|
||||
async function applyCompactionMemoryCommands(
|
||||
sessionID: string,
|
||||
commands: WorkspaceMemoryCommand[],
|
||||
compactionId: string | undefined,
|
||||
): Promise<void> {
|
||||
if (commands.length === 0) return;
|
||||
|
||||
const sessionState = await loadSessionState(directory, sessionID);
|
||||
const snapshotStatus = compactionSnapshotStatus(sessionState.compactionMemoryRefs, compactionId);
|
||||
const refs = snapshotStatus.ok ? sessionState.compactionMemoryRefs : [];
|
||||
const refsByLabel = compactionRefByLabel(refs);
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
const now = Date.now();
|
||||
let snapshotMismatchDetails: EvidenceEventInput["details"] = {};
|
||||
if ("storedCompactionId" in snapshotStatus) {
|
||||
snapshotMismatchDetails = {
|
||||
...(compactionId ? { compactionId } : {}),
|
||||
storedCompactionId: snapshotStatus.storedCompactionId,
|
||||
};
|
||||
}
|
||||
|
||||
const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => {
|
||||
for (const command of commands) {
|
||||
const resolution = resolveCompactionMemoryRef(refs, refsByLabel, workspaceMemory.entries, command.ref);
|
||||
if (resolution.ok === false) {
|
||||
const memoryId = resolution.refSnapshot?.memoryId;
|
||||
const details = memoryId ? { ...snapshotMismatchDetails, memoryId } : snapshotMismatchDetails;
|
||||
if (command.kind === "REINFORCE") {
|
||||
evidence.push(memoryReinforcedEvidence(resolution.target, command.ref, "rejected", [resolution.reason], details));
|
||||
} else {
|
||||
evidence.push(memoryReplacedEvidence(resolution.target, undefined, command.ref, "rejected", [resolution.reason], details));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const { refSnapshot, target, targetIndex } = resolution;
|
||||
if (command.kind === "REINFORCE") {
|
||||
const reinforced = reinforceMemory(target, sessionID, now);
|
||||
if (reinforced === target) {
|
||||
evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked"], {
|
||||
memoryId: refSnapshot.memoryId,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
workspaceMemory.entries[targetIndex] = reinforced;
|
||||
evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", ["numbered_ref_reinforce", "reinforcement_window_allowed"], {
|
||||
memoryId: refSnapshot.memoryId,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target.source !== "compaction") {
|
||||
evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", ["protected_memory_source"], {
|
||||
memoryId: refSnapshot.memoryId,
|
||||
source: target.source,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((target.reinforcementCount ?? 0) > 0) {
|
||||
evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", ["protected_reinforced_target"], {
|
||||
memoryId: refSnapshot.memoryId,
|
||||
reinforcementCount: target.reinforcementCount ?? 0,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const quality = assessMemoryQuality({ type: command.type, text: command.text, source: "compaction" });
|
||||
if (!quality.accepted) {
|
||||
evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", quality.reasons, {
|
||||
memoryId: refSnapshot.memoryId,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const supersededTarget: LongTermMemoryEntry = {
|
||||
...target,
|
||||
status: "superseded",
|
||||
updatedAt: new Date(now).toISOString(),
|
||||
};
|
||||
const replacement: LongTermMemoryEntry = {
|
||||
id: replacementMemoryId(),
|
||||
type: command.type,
|
||||
text: command.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: new Date(now).toISOString(),
|
||||
updatedAt: new Date(now).toISOString(),
|
||||
retentionClock: now,
|
||||
staleAfterDays: staleAfterDaysFor(command.type),
|
||||
supersedes: [target.id],
|
||||
};
|
||||
|
||||
workspaceMemory.entries[targetIndex] = supersededTarget;
|
||||
workspaceMemory.entries.push(replacement);
|
||||
evidence.push(memoryReplacedEvidence(supersededTarget, replacement, command.ref, "superseded", [
|
||||
"numbered_ref_replace",
|
||||
command.type === target.type ? "same_type_replace" : "cross_type_replace",
|
||||
], {
|
||||
oldMemoryId: target.id,
|
||||
newMemoryId: replacement.id,
|
||||
oldType: target.type,
|
||||
newType: command.type,
|
||||
}));
|
||||
}
|
||||
|
||||
return workspaceMemory;
|
||||
});
|
||||
|
||||
await appendEvidenceEvents(directory, [...updateResult.evidence, ...evidence].map(event => ({
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
}
|
||||
|
||||
function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void {
|
||||
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
|
||||
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
|
||||
@@ -564,6 +814,13 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
return props?.sessionID ?? props?.info?.id;
|
||||
}
|
||||
|
||||
async function clearCompactionMemoryRefs(sessionID: string): Promise<void> {
|
||||
await updateSessionState(directory, sessionID, state => {
|
||||
state.compactionMemoryRefs = [];
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
// Inject workspace memory and hot session state into system prompt
|
||||
"experimental.chat.system.transform": async (hookInput, output) => {
|
||||
@@ -692,23 +949,27 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
// so we must explicitly carry forward any existing output.context.
|
||||
const otherContext = output.context.filter(Boolean).join("\n\n");
|
||||
|
||||
// Build our private context (workspace memory, hot state, todos)
|
||||
// Build our private context (numbered workspace memory refs, todos)
|
||||
const contextParts: string[] = [];
|
||||
|
||||
// 1. Frozen workspace memory snapshot
|
||||
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
||||
if (workspaceSnapshot.renderedPrompt) {
|
||||
contextParts.push(workspaceSnapshot.renderedPrompt);
|
||||
// 1. Compaction-only numbered workspace memory snapshot
|
||||
const compactionId = randomUUID();
|
||||
const workspaceStore = await loadWorkspaceMemory(directory);
|
||||
const compactionRefs = accountWorkspaceMemoryCompactionRefs(workspaceStore);
|
||||
const refsWithCompactionId = compactionRefs.refs.map(ref => ({ ...ref, compactionId }));
|
||||
await updateSessionState(directory, sessionID, state => {
|
||||
state.compactionMemoryRefs = refsWithCompactionId;
|
||||
return state;
|
||||
});
|
||||
await appendEvidenceEvents(directory, compactionRefs.evidence.map(event => ({
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
if (compactionRefs.prompt) {
|
||||
contextParts.push(compactionRefs.prompt);
|
||||
}
|
||||
|
||||
// 2. Hot session state
|
||||
const sessionState = await loadSessionState(directory, sessionID);
|
||||
const hotPrompt = renderHotSessionState(sessionState, directory);
|
||||
if (hotPrompt) {
|
||||
contextParts.push(hotPrompt);
|
||||
}
|
||||
|
||||
// 3. Pending todos from OpenCode
|
||||
// 2. Pending todos from OpenCode
|
||||
const todos = await pendingTodos(client, sessionID);
|
||||
const todosPrompt = renderTodosForCompaction(todos);
|
||||
if (todosPrompt) {
|
||||
@@ -721,7 +982,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
.join("\n\n");
|
||||
|
||||
// Replace the default prompt entirely with our ---free template
|
||||
output.prompt = buildCompactionPrompt(privateContext);
|
||||
output.prompt = buildCompactionPrompt(privateContext, compactionId);
|
||||
|
||||
// Clear context array since we consumed it into output.prompt.
|
||||
// Subsequent plugins that set output.prompt will also need to check
|
||||
@@ -735,33 +996,40 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
// Handle session events
|
||||
event: async ({ event }) => {
|
||||
if (event.type === "session.compacted") {
|
||||
let sessionID: string | undefined;
|
||||
try {
|
||||
const sessionID = sessionIDFromEventProperties(event.properties);
|
||||
sessionID = sessionIDFromEventProperties(event.properties);
|
||||
if (!sessionID) return;
|
||||
|
||||
// Sub-agents don't need post-compaction processing
|
||||
if (await isSubAgent(sessionID)) return;
|
||||
|
||||
// Parse latest compaction summary for memory candidates, stage them into
|
||||
// durable pending journal, then promote pending memories.
|
||||
const summary = await latestCompactionSummary(client, sessionID);
|
||||
const parseResult = summary
|
||||
? parseWorkspaceMemoryCandidatesWithEvidence(summary, await workspaceIdentity(directory))
|
||||
: { entries: [], evidence: [] };
|
||||
await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
const candidates = parseResult.entries;
|
||||
if (candidates.length > 0) {
|
||||
await appendPendingMemories(directory, candidates);
|
||||
await appendEvidenceEvents(directory, candidates.map(memory => ({
|
||||
...pendingAppendedEvidence(memory),
|
||||
try {
|
||||
// Parse latest compaction summary for memory candidates, stage them into
|
||||
// durable pending journal, then promote pending memories.
|
||||
const summary = await latestCompactionSummary(client, sessionID);
|
||||
const compactionId = summary ? compactionIdFromSummary(summary) : undefined;
|
||||
const parseResult = summary
|
||||
? parseWorkspaceMemoryCandidatesWithEvidence(summary, await workspaceIdentity(directory))
|
||||
: { entries: [], commands: [], evidence: [] };
|
||||
await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({
|
||||
...event,
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
}
|
||||
await applyCompactionMemoryCommands(sessionID, parseResult.commands, compactionId);
|
||||
const candidates = parseResult.entries;
|
||||
if (candidates.length > 0) {
|
||||
await appendPendingMemories(directory, candidates);
|
||||
await appendEvidenceEvents(directory, candidates.map(memory => ({
|
||||
...pendingAppendedEvidence(memory),
|
||||
sessionHash: sessionID,
|
||||
})));
|
||||
}
|
||||
|
||||
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
|
||||
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
|
||||
} finally {
|
||||
await clearCompactionMemoryRefs(sessionID);
|
||||
}
|
||||
} catch (error) {
|
||||
// Keep pending memories in session/journal for retry on next event/session.
|
||||
await warnMemoryHook("event.session.compacted", error, directory);
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ export const USER_IMPORTANCE_FACTOR = {
|
||||
|
||||
export const RETENTION_TYPE_MAX = {
|
||||
feedback: 10,
|
||||
decision: 10,
|
||||
decision: 12,
|
||||
project: 8,
|
||||
reference: 6,
|
||||
} as const;
|
||||
|
||||
+46
-5
@@ -1,10 +1,14 @@
|
||||
import { relative } from "path";
|
||||
import { sessionStatePath } from "./paths.ts";
|
||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
import type { ActiveFile, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "./types.ts";
|
||||
import { HOT_STATE_LIMITS } from "./types.ts";
|
||||
import type { ActiveFile, CompactionMemoryRef, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "./types.ts";
|
||||
import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "./types.ts";
|
||||
import { memoryKey } from "./pending-journal.ts";
|
||||
|
||||
type SessionStateInput = Omit<SessionState, "compactionMemoryRefs"> & {
|
||||
compactionMemoryRefs?: unknown;
|
||||
};
|
||||
|
||||
const ACTION_WEIGHT: Record<ActiveFile["action"], number> = {
|
||||
edit: 50,
|
||||
write: 45,
|
||||
@@ -22,6 +26,7 @@ export function createEmptySessionState(sessionID: string): SessionState {
|
||||
openErrors: [],
|
||||
recentDecisions: [],
|
||||
pendingMemories: [],
|
||||
compactionMemoryRefs: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,10 +38,11 @@ export async function loadSessionState(root: string, sessionID: string): Promise
|
||||
loaded.openErrors = Array.isArray(loaded.openErrors) ? loaded.openErrors : [];
|
||||
loaded.recentDecisions = Array.isArray(loaded.recentDecisions) ? loaded.recentDecisions : [];
|
||||
loaded.pendingMemories = Array.isArray(loaded.pendingMemories) ? loaded.pendingMemories : [];
|
||||
loaded.compactionMemoryRefs = normalizeCompactionMemoryRefs((loaded as SessionStateInput).compactionMemoryRefs);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
export async function saveSessionState(root: string, state: SessionState): Promise<void> {
|
||||
export async function saveSessionState(root: string, state: SessionState | SessionStateInput): Promise<void> {
|
||||
await atomicWriteJSON(await sessionStatePath(root, state.sessionID), normalizeSessionState(state));
|
||||
}
|
||||
|
||||
@@ -52,18 +58,53 @@ export async function updateSessionState(
|
||||
current.openErrors = Array.isArray(current.openErrors) ? current.openErrors : [];
|
||||
current.recentDecisions = Array.isArray(current.recentDecisions) ? current.recentDecisions : [];
|
||||
current.pendingMemories = Array.isArray(current.pendingMemories) ? current.pendingMemories : [];
|
||||
current.compactionMemoryRefs = normalizeCompactionMemoryRefs((current as SessionStateInput).compactionMemoryRefs);
|
||||
return normalizeSessionState(await updater(current));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSessionState(state: SessionState): SessionState {
|
||||
function normalizeSessionState(state: SessionState | SessionStateInput): SessionState {
|
||||
state.updatedAt = new Date().toISOString();
|
||||
state.activeFiles = state.activeFiles.slice(0, HOT_STATE_LIMITS.maxActiveFilesStored);
|
||||
state.openErrors = state.openErrors.slice(0, HOT_STATE_LIMITS.maxOpenErrorsStored);
|
||||
state.recentDecisions = state.recentDecisions.slice(0, HOT_STATE_LIMITS.maxRecentDecisionsStored);
|
||||
state.pendingMemories = dedupePendingMemories(Array.isArray(state.pendingMemories) ? state.pendingMemories : [])
|
||||
.slice(-HOT_STATE_LIMITS.maxPendingMemoriesStored);
|
||||
return state;
|
||||
return {
|
||||
...state,
|
||||
compactionMemoryRefs: normalizeCompactionMemoryRefs(state.compactionMemoryRefs),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCompactionMemoryRefs(value: unknown): CompactionMemoryRef[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
if (value.some(item => !isCompactionMemoryRef(item))) return [];
|
||||
return value.slice(0, LONG_TERM_LIMITS.maxEntries);
|
||||
}
|
||||
|
||||
function isCompactionMemoryRef(value: unknown): value is CompactionMemoryRef {
|
||||
if (!isRecord(value)) return false;
|
||||
if (typeof value.ref !== "string" || !/^M[1-9]\d*$/.test(value.ref)) return false;
|
||||
if (typeof value.memoryId !== "string" || value.memoryId.trim() === "") return false;
|
||||
if (value.compactionId !== undefined && typeof value.compactionId !== "string") return false;
|
||||
if (!isLongTermType(value.type)) return false;
|
||||
if (!isLongTermSource(value.source)) return false;
|
||||
if (typeof value.exactKey !== "string" || value.exactKey.trim() === "") return false;
|
||||
if (typeof value.identityKey !== "string" || value.identityKey.trim() === "") return false;
|
||||
if (typeof value.textPreview !== "string") return false;
|
||||
return typeof value.capturedAt === "number" && Number.isFinite(value.capturedAt);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isLongTermType(value: unknown): value is CompactionMemoryRef["type"] {
|
||||
return value === "feedback" || value === "project" || value === "decision" || value === "reference";
|
||||
}
|
||||
|
||||
function isLongTermSource(value: unknown): value is CompactionMemoryRef["source"] {
|
||||
return value === "explicit" || value === "compaction" || value === "manual";
|
||||
}
|
||||
|
||||
function dedupePendingMemories(memories: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
||||
|
||||
@@ -28,6 +28,18 @@ export type LongTermMemoryEntry = {
|
||||
safetyCritical?: boolean;
|
||||
};
|
||||
|
||||
export type CompactionMemoryRef = {
|
||||
ref: string;
|
||||
compactionId?: string;
|
||||
memoryId: string;
|
||||
type: LongTermType;
|
||||
source: LongTermSource;
|
||||
exactKey: string;
|
||||
identityKey: string;
|
||||
textPreview: string;
|
||||
capturedAt: number;
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryStore = {
|
||||
version: 1;
|
||||
workspace: {
|
||||
@@ -92,6 +104,7 @@ export type SessionState = {
|
||||
openErrors: OpenError[];
|
||||
recentDecisions: SessionDecision[];
|
||||
pendingMemories: LongTermMemoryEntry[];
|
||||
compactionMemoryRefs: CompactionMemoryRef[];
|
||||
};
|
||||
|
||||
export const LONG_TERM_LIMITS = {
|
||||
|
||||
+81
-3
@@ -1,6 +1,6 @@
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
||||
import type { CompactionMemoryRef, LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
||||
import { LONG_TERM_LIMITS } from "./types.ts";
|
||||
import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts";
|
||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
@@ -59,6 +59,16 @@ export type WorkspaceMemoryRenderAccounting = {
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryCompactionRefsAccounting = WorkspaceMemoryRenderAccounting & {
|
||||
refs: CompactionMemoryRef[];
|
||||
};
|
||||
|
||||
type WorkspaceMemoryRenderSelection = {
|
||||
active: LongTermMemoryEntry[];
|
||||
omitted: WorkspaceMemoryRenderAccounting["omitted"];
|
||||
maxChars: number;
|
||||
};
|
||||
|
||||
export type QualityCleanupMigrationLogEntry = {
|
||||
migrationId: string;
|
||||
timestamp: string;
|
||||
@@ -850,14 +860,13 @@ export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string {
|
||||
return accountWorkspaceMemoryRender(store).prompt;
|
||||
}
|
||||
|
||||
export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderAccounting {
|
||||
function selectWorkspaceMemoryForRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderSelection {
|
||||
const now = Date.now();
|
||||
const maxChars = Math.min(
|
||||
store.limits.maxRenderedChars,
|
||||
LONG_TERM_LIMITS.maxRenderedChars
|
||||
);
|
||||
const omitted: WorkspaceMemoryRenderAccounting["omitted"] = [];
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
|
||||
for (const entry of store.entries) {
|
||||
if (entry.status === "superseded") {
|
||||
@@ -874,6 +883,13 @@ export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): Works
|
||||
const active = typeCapResult.kept.slice(0, LONG_TERM_LIMITS.maxEntries);
|
||||
for (const memory of typeCapResult.kept.slice(LONG_TERM_LIMITS.maxEntries)) omitted.push({ memory, reason: "global_cap" });
|
||||
|
||||
return { active, omitted, maxChars };
|
||||
}
|
||||
|
||||
export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderAccounting {
|
||||
const { active, omitted, maxChars } = selectWorkspaceMemoryForRender(store);
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
|
||||
if (active.length === 0) {
|
||||
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
|
||||
return { rendered: [], omitted, evidence, prompt: "" };
|
||||
@@ -918,6 +934,68 @@ export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): Works
|
||||
return { rendered, omitted, evidence, prompt: lines.join("\n") };
|
||||
}
|
||||
|
||||
export function accountWorkspaceMemoryCompactionRefs(store: WorkspaceMemoryStore): WorkspaceMemoryCompactionRefsAccounting {
|
||||
const { active, omitted, maxChars } = selectWorkspaceMemoryForRender(store);
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
const originalById = new Map(store.entries.map(entry => [entry.id, entry]));
|
||||
|
||||
if (active.length === 0) {
|
||||
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
|
||||
return { rendered: [], omitted, evidence, prompt: "", refs: [] };
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
"Existing workspace memories available for consolidation:",
|
||||
];
|
||||
const rendered: LongTermMemoryEntry[] = [];
|
||||
const refs: CompactionMemoryRef[] = [];
|
||||
const capturedAt = Date.now();
|
||||
|
||||
for (const type of ["feedback", "project", "decision", "reference"] as const) {
|
||||
const items = active.filter(entry => entry.type === type);
|
||||
if (items.length === 0) continue;
|
||||
|
||||
const sectionLines: string[] = [`${type}:`];
|
||||
|
||||
for (const item of items) {
|
||||
const ref = `M${refs.length + 1}`;
|
||||
const line = `[${ref}] ${item.text}`;
|
||||
if ([...lines, ...sectionLines, line].join("\n").length <= maxChars) {
|
||||
const original = originalById.get(item.id) ?? item;
|
||||
sectionLines.push(line);
|
||||
rendered.push(item);
|
||||
refs.push({
|
||||
ref,
|
||||
memoryId: item.id,
|
||||
type: original.type,
|
||||
source: original.source,
|
||||
exactKey: workspaceMemoryExactKey(original),
|
||||
identityKey: workspaceMemoryIdentityKey(original),
|
||||
textPreview: item.text,
|
||||
capturedAt,
|
||||
});
|
||||
} else {
|
||||
omitted.push({ memory: item, reason: "char_budget" });
|
||||
}
|
||||
}
|
||||
|
||||
if (sectionLines.length > 1) {
|
||||
lines.push(...sectionLines);
|
||||
}
|
||||
}
|
||||
|
||||
for (const memory of rendered) evidence.push(renderEvidence(memory, "rendered"));
|
||||
for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason));
|
||||
|
||||
return {
|
||||
rendered,
|
||||
omitted,
|
||||
evidence,
|
||||
prompt: rendered.length > 0 ? lines.join("\n") : "",
|
||||
refs,
|
||||
};
|
||||
}
|
||||
|
||||
function renderEvidence(
|
||||
memory: LongTermMemoryEntry,
|
||||
outcome: "rendered" | "omitted",
|
||||
|
||||
Reference in New Issue
Block a user