Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/extraction-prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,15 @@ Return JSON format:
"decision": "skip|create|merge|supersede|support|contextualize|contradict",
"match_index": 1,
"reason": "Decision reason",
"context_label": "evening"
"context_label": "evening",
"actions": [
{ "match_index": 2, "action": "delete", "reason": "outdated by candidate" }
]
}

- If decision is "merge"/"supersede"/"support"/"contextualize"/"contradict", set "match_index" to the number of the existing memory (1-based).
- Only include "context_label" for support/contextualize/contradict decisions.`;
- Only include "context_label" for support/contextualize/contradict decisions.
- "actions" is optional. Use it ONLY when an existing memory is outdated or fully superseded by the candidate. Each action targets a different existing memory. Only valid action: "delete".`;
}

export function buildMergePrompt(
Expand Down
11 changes: 11 additions & 0 deletions src/memory-categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,21 @@ export type DedupDecision =
| "contradict"
| "supersede";

/** Secondary action on an existing memory during dedup.
* Only "delete" is supported — "merge" requires a candidate. */
export type DedupAction = {
matchIndex: number;
action: "delete";
reason: string;
resolvedId?: string;
};

export type DedupResult = {
decision: DedupDecision;
reason: string;
matchId?: string; // ID of existing memory to merge with
contextLabel?: string; // Optional context label for support/contextualize/contradict
actions?: DedupAction[]; // Optional secondary actions on other existing memories
};

export type ExtractionStats = {
Expand All @@ -74,6 +84,7 @@ export type ExtractionStats = {
boundarySkipped?: number;
supported?: number; // context-aware support count
superseded?: number; // temporal fact replacements
actionsExecuted?: number; // secondary dedup actions executed
};

/** Validate and normalize a category string. */
Expand Down
59 changes: 59 additions & 0 deletions src/smart-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,38 @@ export class SmartExtractor {
}
break;
}

// Execute secondary actions (e.g., delete outdated memories found during dedup)
if (dedupResult.actions && dedupResult.actions.length > 0) {
await this.executeSecondaryActions(dedupResult.actions, scopeFilter, stats);
}
}

/**
* Execute secondary dedup actions on existing memories.
* Only "delete" is supported — IDs are pre-resolved during dedup (not re-queried)
* to prevent index drift after the primary action mutates the store.
*/
private async executeSecondaryActions(
actions: Array<{ matchIndex: number; action: "delete"; reason: string; resolvedId?: string }>,
_scopeFilter: string[] | undefined,
stats: ExtractionStats,
): Promise<void> {
for (const act of actions) {
const targetId = (act as any).resolvedId;
if (!targetId || typeof targetId !== "string") {
this.log(`memory-pro: smart-extractor: secondary action missing resolvedId for index ${act.matchIndex}, skipping`);
continue;
}

try {
await this.store.delete(targetId);
this.log(`memory-pro: smart-extractor: secondary delete of ${targetId.slice(0, 8)}: ${act.reason}`);
stats.actionsExecuted = (stats.actionsExecuted ?? 0) + 1;
} catch (err) {
this.log(`memory-pro: smart-extractor: secondary delete failed (id=${targetId.slice(0, 8)}): ${String(err)}`);
}
}
}

// --------------------------------------------------------------------------
Expand Down Expand Up @@ -713,11 +745,38 @@ export class SmartExtractor {
};
}

// Parse optional secondary actions on other existing memories.
// Resolve match_index to actual memory IDs NOW (not later) to prevent
// index drift after the primary action mutates the store.
// Only "delete" is supported as a secondary action — "merge" requires
// a candidate which is not available in the secondary context.
const rawActions = Array.isArray((data as any).actions) ? (data as any).actions : [];
const validActions: Array<{ matchIndex: number; action: "delete"; reason: string; resolvedId: string }> = [];
for (const a of rawActions) {
if (
typeof a === "object" && a !== null &&
typeof a.match_index === "number" && a.match_index >= 1 && a.match_index <= topSimilar.length &&
a.action === "delete" && // Only delete is safe as secondary action
a.match_index !== idx // Don't duplicate the primary action
) {
const targetEntry = topSimilar[a.match_index - 1];
if (targetEntry) {
validActions.push({
matchIndex: a.match_index,
action: "delete",
reason: typeof a.reason === "string" ? a.reason : "",
resolvedId: targetEntry.entry.id,
});
}
}
}

return {
decision,
reason: data.reason ?? "",
matchId: ["merge", "support", "contextualize", "contradict", "supersede"].includes(decision) ? matchEntry?.entry.id : undefined,
contextLabel: typeof (data as any).context_label === "string" ? (data as any).context_label : undefined,
actions: validActions.length > 0 ? validActions : undefined,
};
} catch (err) {
this.log(
Expand Down
103 changes: 103 additions & 0 deletions test/dedup-actions.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";

describe("DedupAction types", () => {
it("DedupAction type structure is correct", () => {
const action = { matchIndex: 1, action: "delete", reason: "outdated" };
assert.equal(action.matchIndex, 1);
assert.equal(action.action, "delete");
assert.equal(action.reason, "outdated");
});

it("DedupResult with actions is backward compatible", () => {
// Without actions — existing behavior
const resultNoActions = {
decision: "create",
reason: "new info",
};
assert.equal(resultNoActions.actions, undefined);

// With actions — new behavior
const resultWithActions = {
decision: "merge",
reason: "add details",
matchId: "abc123",
actions: [
{ matchIndex: 2, action: "delete", reason: "outdated by candidate" },
],
};
assert.equal(resultWithActions.actions.length, 1);
assert.equal(resultWithActions.actions[0].action, "delete");
});

it("ExtractionStats includes actionsExecuted", () => {
const stats = {
created: 2,
merged: 1,
skipped: 0,
actionsExecuted: 3,
};
assert.equal(stats.actionsExecuted, 3);
});
});

describe("DedupAction validation rules", () => {
it("only merge and delete are valid actions", () => {
const validActions = ["merge", "delete"];
const invalidActions = ["skip", "create", "supersede", "update"];

for (const a of validActions) {
assert.ok(["merge", "delete"].includes(a), `${a} should be valid`);
}
for (const a of invalidActions) {
assert.ok(!["merge", "delete"].includes(a), `${a} should be invalid`);
}
});

it("matchIndex must be 1-based positive integer", () => {
const validIndices = [1, 2, 3, 5];
const invalidIndices = [0, -1, 1.5, NaN, null, undefined];

for (const idx of validIndices) {
assert.ok(typeof idx === "number" && idx >= 1 && Number.isInteger(idx));
}
for (const idx of invalidIndices) {
assert.ok(!(typeof idx === "number" && idx >= 1 && Number.isInteger(idx)));
}
});

it("actions array can be empty", () => {
const result = { decision: "create", reason: "new", actions: [] };
assert.equal(result.actions.length, 0);
});

it("duplicate primary matchIndex should be filtered out", () => {
// If primary decision targets index 1, actions should not also target index 1
const primaryIdx = 1;
const rawActions = [
{ match_index: 1, action: "delete", reason: "dup" }, // same as primary — should be filtered
{ match_index: 2, action: "delete", reason: "outdated" }, // different — should be kept
];
const filtered = rawActions.filter(a => a.match_index !== primaryIdx);
assert.equal(filtered.length, 1);
assert.equal(filtered[0].match_index, 2);
});
});

describe("Action execution order", () => {
it("deletes should come before merges", () => {
const actions = [
{ matchIndex: 1, action: "merge", reason: "combine" },
{ matchIndex: 2, action: "delete", reason: "outdated" },
{ matchIndex: 3, action: "delete", reason: "duplicate" },
];

const sorted = [...actions].sort((a, b) =>
a.action === "delete" && b.action !== "delete" ? -1 : 1,
);

assert.equal(sorted[0].action, "delete");
assert.equal(sorted[1].action, "delete");
assert.equal(sorted[2].action, "merge");
});
});
Loading