Skip to content

Commit 18ed0b9

Browse files
committed
feat: contextual support — 6-decision dedup with per-context preference tracking
Extends OpenViking's smart memory architecture with context-aware support: - smart-metadata.ts: add SupportInfoV2/ContextualSupport types, normalizeContext, parseSupportInfo (V1→V2 migration), updateSupportStats; fix LegacyStoreCategory missing 'reflection' - memory-categories.ts: extend DedupDecision with support/contextualize/contradict, add contextLabel to DedupResult, supported count to ExtractionStats - extraction-prompts.ts: extend dedup prompt with 3 new decisions + context_label - smart-extractor.ts: add handleSupport/handleContextualize/handleContradict handlers in processCandidate pipeline, extract contextLabel in llmDedupDecision - test: add smart-metadata-v2.mjs (6 tests, all passing)
1 parent c79a807 commit 18ed0b9

File tree

5 files changed

+496
-28
lines changed

5 files changed

+496
-28
lines changed

src/extraction-prompts.ts

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -149,20 +149,26 @@ Please decide:
149149
- SKIP: Candidate memory duplicates existing memories, no need to save. Also SKIP if the candidate contains LESS information than an existing memory on the same topic (information degradation — e.g., candidate says "programming language preference" but existing memory already says "programming language preference: Python, TypeScript")
150150
- CREATE: This is completely new information not covered by any existing memory, should be created
151151
- MERGE: Candidate memory adds genuinely NEW details to an existing memory and should be merged
152+
- SUPPORT: Candidate reinforces/confirms an existing memory in a specific context (e.g. "still prefers tea in the evening")
153+
- CONTEXTUALIZE: Candidate adds a situational nuance to an existing memory (e.g. existing: "likes coffee", candidate: "prefers tea at night" — different context, same topic)
154+
- CONTRADICT: Candidate directly contradicts an existing memory in a specific context (e.g. existing: "runs on weekends", candidate: "stopped running on weekends")
152155
153156
IMPORTANT:
154-
- "events" and "cases" categories are independent records — they do NOT support MERGE. For these categories, only use SKIP or CREATE.
157+
- "events" and "cases" categories are independent records — they do NOT support MERGE/SUPPORT/CONTEXTUALIZE/CONTRADICT. For these categories, only use SKIP or CREATE.
155158
- If the candidate appears to be derived from a recall question (e.g., "Do you remember X?" / "你记得X吗?") and an existing memory already covers topic X with equal or more detail, you MUST choose SKIP.
156159
- A candidate with less information than an existing memory on the same topic should NEVER be CREATED or MERGED — always SKIP.
160+
- For SUPPORT/CONTEXTUALIZE/CONTRADICT, you MUST provide a context_label from this vocabulary: general, morning, evening, night, weekday, weekend, work, leisure, summer, winter, travel.
157161
158162
Return JSON format:
159163
{
160-
"decision": "skip|create|merge",
164+
"decision": "skip|create|merge|support|contextualize|contradict",
161165
"match_index": 1,
162-
"reason": "Decision reason"
166+
"reason": "Decision reason",
167+
"context_label": "evening"
163168
}
164169
165-
If decision is "merge", set "match_index" to the number of the existing memory to merge with (1-based).`;
170+
- If decision is "merge"/"support"/"contextualize"/"contradict", set "match_index" to the number of the existing memory (1-based).
171+
- Only include "context_label" for support/contextualize/contradict decisions.`;
166172
}
167173

168174
export function buildMergePrompt(
@@ -176,32 +182,32 @@ export function buildMergePrompt(
176182
): string {
177183
return `Merge the following memory into a single coherent record with all three levels.
178184
179-
**Category**: ${category}
185+
** Category **: ${category}
180186
181-
**Existing Memory:**
182-
Abstract: ${existingAbstract}
183-
Overview:
187+
** Existing Memory:**
188+
Abstract: ${existingAbstract}
189+
Overview:
184190
${existingOverview}
185-
Content:
191+
Content:
186192
${existingContent}
187193
188-
**New Information:**
189-
Abstract: ${newAbstract}
190-
Overview:
194+
** New Information:**
195+
Abstract: ${newAbstract}
196+
Overview:
191197
${newOverview}
192-
Content:
198+
Content:
193199
${newContent}
194200
195-
Requirements:
196-
- Remove duplicate information
197-
- Keep the most up-to-date details
198-
- Maintain a coherent narrative
199-
- Keep code identifiers / URIs / model names unchanged when they are proper nouns
201+
Requirements:
202+
- Remove duplicate information
203+
- Keep the most up - to - date details
204+
- Maintain a coherent narrative
205+
- Keep code identifiers / URIs / model names unchanged when they are proper nouns
200206
201207
Return JSON:
202-
{
203-
"abstract": "Merged one-line abstract",
204-
"overview": "Merged structured Markdown overview",
205-
"content": "Merged full content"
206-
}`;
208+
{
209+
"abstract": "Merged one-line abstract",
210+
"overview": "Merged structured Markdown overview",
211+
"content": "Merged full content"
212+
} `;
207213
}

src/memory-categories.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,20 @@ export type CandidateMemory = {
4444
};
4545

4646
/** Dedup decision from LLM. */
47-
export type DedupDecision = "create" | "merge" | "skip";
47+
export type DedupDecision = "create" | "merge" | "skip" | "support" | "contextualize" | "contradict";
4848

4949
export type DedupResult = {
5050
decision: DedupDecision;
5151
reason: string;
5252
matchId?: string; // ID of existing memory to merge with
53+
contextLabel?: string; // Optional context label for support/contextualize/contradict
5354
};
5455

5556
export type ExtractionStats = {
5657
created: number;
5758
merged: number;
5859
skipped: number;
60+
supported?: number; // context-aware support count
5961
};
6062

6163
/** Validate and normalize a category string. */

src/smart-extractor.ts

Lines changed: 166 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
} from "./memory-categories.js";
2828
import { isNoise } from "./noise-filter.js";
2929
import type { NoisePrototypeBank } from "./noise-prototypes.js";
30-
import { buildSmartMetadata, parseSmartMetadata, stringifySmartMetadata } from "./smart-metadata.js";
30+
import { buildSmartMetadata, parseSmartMetadata, stringifySmartMetadata, parseSupportInfo, updateSupportStats } from "./smart-metadata.js";
3131

3232
// ============================================================================
3333
// Constants
@@ -36,7 +36,7 @@ import { buildSmartMetadata, parseSmartMetadata, stringifySmartMetadata } from "
3636
const SIMILARITY_THRESHOLD = 0.7;
3737
const MAX_SIMILAR_FOR_PROMPT = 3;
3838
const MAX_MEMORIES_PER_EXTRACTION = 5;
39-
const VALID_DECISIONS = new Set<string>(["create", "merge", "skip"]);
39+
const VALID_DECISIONS = new Set<string>(["create", "merge", "skip", "support", "contextualize", "contradict"]);
4040

4141
// ============================================================================
4242
// Smart Extractor
@@ -356,6 +356,36 @@ export class SmartExtractor {
356356
);
357357
stats.skipped++;
358358
break;
359+
360+
case "support":
361+
if (dedupResult.matchId) {
362+
await this.handleSupport(dedupResult.matchId, scopeFilter, { session: sessionKey, timestamp: Date.now() }, dedupResult.reason, dedupResult.contextLabel);
363+
stats.supported = (stats.supported ?? 0) + 1;
364+
} else {
365+
await this.storeCandidate(candidate, vector, sessionKey, targetScope);
366+
stats.created++;
367+
}
368+
break;
369+
370+
case "contextualize":
371+
if (dedupResult.matchId) {
372+
await this.handleContextualize(candidate, vector, dedupResult.matchId, sessionKey, targetScope, scopeFilter, dedupResult.contextLabel);
373+
stats.created++;
374+
} else {
375+
await this.storeCandidate(candidate, vector, sessionKey, targetScope);
376+
stats.created++;
377+
}
378+
break;
379+
380+
case "contradict":
381+
if (dedupResult.matchId) {
382+
await this.handleContradict(candidate, vector, dedupResult.matchId, sessionKey, targetScope, scopeFilter, dedupResult.contextLabel);
383+
stats.created++;
384+
} else {
385+
await this.storeCandidate(candidate, vector, sessionKey, targetScope);
386+
stats.created++;
387+
}
388+
break;
359389
}
360390
}
361391

@@ -445,7 +475,8 @@ export class SmartExtractor {
445475
return {
446476
decision,
447477
reason: data.reason ?? "",
448-
matchId: decision === "merge" ? matchEntry?.entry.id : undefined,
478+
matchId: ["merge", "support", "contextualize", "contradict"].includes(decision) ? matchEntry?.entry.id : undefined,
479+
contextLabel: typeof (data as any).context_label === "string" ? (data as any).context_label : undefined,
449480
};
450481
} catch (err) {
451482
this.log(
@@ -593,6 +624,138 @@ export class SmartExtractor {
593624
);
594625
}
595626

627+
// --------------------------------------------------------------------------
628+
// Context-Aware Handlers (support / contextualize / contradict)
629+
// --------------------------------------------------------------------------
630+
631+
/**
632+
* Handle SUPPORT: update support stats on existing memory for a specific context.
633+
*/
634+
private async handleSupport(
635+
matchId: string,
636+
scopeFilter: string[],
637+
source: { session: string; timestamp: number },
638+
reason: string,
639+
contextLabel?: string,
640+
): Promise<void> {
641+
const existing = await this.store.getById(matchId, scopeFilter);
642+
if (!existing) return;
643+
644+
const meta = parseSmartMetadata(existing.metadata, existing);
645+
const supportInfo = parseSupportInfo(meta.support_info);
646+
const updated = updateSupportStats(supportInfo, contextLabel, "support");
647+
meta.support_info = updated;
648+
649+
await this.store.update(
650+
matchId,
651+
{ metadata: stringifySmartMetadata(meta) },
652+
scopeFilter,
653+
);
654+
655+
this.log(
656+
`memory-pro: smart-extractor: support [${contextLabel || "general"}] on ${matchId.slice(0, 8)}${reason}`,
657+
);
658+
}
659+
660+
/**
661+
* Handle CONTEXTUALIZE: create a new entry that adds situational nuance,
662+
* linked to the original via a relation in metadata.
663+
*/
664+
private async handleContextualize(
665+
candidate: CandidateMemory,
666+
vector: number[],
667+
matchId: string,
668+
sessionKey: string,
669+
targetScope: string,
670+
scopeFilter: string[],
671+
contextLabel?: string,
672+
): Promise<void> {
673+
const storeCategory = this.mapToStoreCategory(candidate.category);
674+
const metadata = stringifySmartMetadata({
675+
l0_abstract: candidate.abstract,
676+
l1_overview: candidate.overview,
677+
l2_content: candidate.content,
678+
memory_category: candidate.category,
679+
tier: "working" as const,
680+
access_count: 0,
681+
confidence: 0.7,
682+
last_accessed_at: Date.now(),
683+
source_session: sessionKey,
684+
contexts: contextLabel ? [contextLabel] : [],
685+
relations: [{ type: "contextualizes", targetId: matchId }],
686+
});
687+
688+
await this.store.store({
689+
text: candidate.abstract,
690+
vector,
691+
category: storeCategory,
692+
scope: targetScope,
693+
importance: this.getDefaultImportance(candidate.category),
694+
metadata,
695+
});
696+
697+
this.log(
698+
`memory-pro: smart-extractor: contextualize [${contextLabel || "general"}] new entry linked to ${matchId.slice(0, 8)}`,
699+
);
700+
}
701+
702+
/**
703+
* Handle CONTRADICT: create contradicting entry + record contradiction evidence
704+
* on the original memory's support stats.
705+
*/
706+
private async handleContradict(
707+
candidate: CandidateMemory,
708+
vector: number[],
709+
matchId: string,
710+
sessionKey: string,
711+
targetScope: string,
712+
scopeFilter: string[],
713+
contextLabel?: string,
714+
): Promise<void> {
715+
// 1. Record contradiction on the existing memory
716+
const existing = await this.store.getById(matchId, scopeFilter);
717+
if (existing) {
718+
const meta = parseSmartMetadata(existing.metadata, existing);
719+
const supportInfo = parseSupportInfo(meta.support_info);
720+
const updated = updateSupportStats(supportInfo, contextLabel, "contradict");
721+
meta.support_info = updated;
722+
await this.store.update(
723+
matchId,
724+
{ metadata: stringifySmartMetadata(meta) },
725+
scopeFilter,
726+
);
727+
}
728+
729+
// 2. Store the contradicting entry as a new memory
730+
const storeCategory = this.mapToStoreCategory(candidate.category);
731+
const metadata = stringifySmartMetadata({
732+
l0_abstract: candidate.abstract,
733+
l1_overview: candidate.overview,
734+
l2_content: candidate.content,
735+
memory_category: candidate.category,
736+
tier: "working" as const,
737+
access_count: 0,
738+
confidence: 0.7,
739+
last_accessed_at: Date.now(),
740+
source_session: sessionKey,
741+
contexts: contextLabel ? [contextLabel] : [],
742+
relations: [{ type: "contradicts", targetId: matchId }],
743+
});
744+
745+
await this.store.store({
746+
text: candidate.abstract,
747+
vector,
748+
category: storeCategory,
749+
scope: targetScope,
750+
importance: this.getDefaultImportance(candidate.category),
751+
metadata,
752+
});
753+
754+
this.log(
755+
`memory-pro: smart-extractor: contradict [${contextLabel || "general"}] on ${matchId.slice(0, 8)}, new entry created`,
756+
);
757+
}
758+
596759
// --------------------------------------------------------------------------
597760
// Store Helper
598761
// --------------------------------------------------------------------------

0 commit comments

Comments
 (0)