memorix 1.1.7 → 1.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/CLAUDE.md +6 -1
- package/README.md +21 -0
- package/README.zh-CN.md +21 -0
- package/TEAM.md +86 -86
- package/dist/cli/index.js +730 -150
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/static/index.html +201 -201
- package/dist/dashboard/static/style.css +3584 -3584
- package/dist/index.js +15 -8
- package/dist/index.js.map +1 -1
- package/dist/memcode-runtime/CHANGELOG.md +13 -0
- package/dist/memcode-runtime/package.json +4 -4
- package/dist/sdk.js +15 -8
- package/dist/sdk.js.map +1 -1
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +18 -0
- package/docs/DESIGN_DECISIONS.md +357 -357
- package/docs/SETUP.md +10 -0
- package/docs/dev-log/progress.txt +35 -26
- package/package.json +1 -1
- package/src/audit/index.ts +156 -156
- package/src/cli/commands/agent-integrations.ts +623 -0
- package/src/cli/commands/audit-list.ts +89 -89
- package/src/cli/commands/background.ts +659 -659
- package/src/cli/commands/cleanup.ts +255 -255
- package/src/cli/commands/doctor.ts +23 -0
- package/src/cli/commands/formation.ts +48 -48
- package/src/cli/commands/git-hook-install.ts +111 -111
- package/src/cli/commands/handoff.ts +66 -66
- package/src/cli/commands/hooks-status.ts +63 -63
- package/src/cli/commands/ingest-commit.ts +153 -153
- package/src/cli/commands/ingest-image.ts +73 -73
- package/src/cli/commands/ingest-log.ts +180 -180
- package/src/cli/commands/ingest.ts +44 -44
- package/src/cli/commands/integrate-shared.ts +15 -15
- package/src/cli/commands/lock.ts +96 -96
- package/src/cli/commands/message.ts +121 -121
- package/src/cli/commands/poll.ts +70 -70
- package/src/cli/commands/purge-all-memory.ts +85 -85
- package/src/cli/commands/purge-project-memory.ts +83 -83
- package/src/cli/commands/reasoning.ts +132 -132
- package/src/cli/commands/repair.ts +60 -0
- package/src/cli/commands/retention.ts +108 -108
- package/src/cli/commands/serve-shared.ts +118 -118
- package/src/cli/commands/setup.ts +3 -3
- package/src/cli/commands/skills.ts +123 -123
- package/src/cli/commands/task.ts +192 -192
- package/src/cli/commands/transfer.ts +73 -73
- package/src/cli/commands/uninstall-project-artifacts.ts +85 -85
- package/src/cli/index.ts +3 -1
- package/src/cli/tui/ChatView.tsx +234 -234
- package/src/cli/tui/CommandBar.tsx +312 -312
- package/src/cli/tui/ContextRail.tsx +118 -118
- package/src/cli/tui/HeaderBar.tsx +72 -72
- package/src/cli/tui/LogoBanner.tsx +51 -51
- package/src/cli/tui/Panels.tsx +632 -632
- package/src/cli/tui/Sidebar.tsx +179 -179
- package/src/cli/tui/chat-service.ts +742 -742
- package/src/cli/tui/data.ts +547 -547
- package/src/cli/tui/index.ts +41 -41
- package/src/cli/tui/markdown-render.tsx +371 -371
- package/src/cli/tui/theme.ts +178 -178
- package/src/cli/tui/use-mouse.ts +157 -157
- package/src/cli/tui/useNavigation.ts +56 -56
- package/src/cli/update-checker.ts +211 -211
- package/src/cli/version.ts +7 -7
- package/src/cli/workbench.ts +1 -1
- package/src/codegraph/context-pack.ts +5 -1
- package/src/codegraph/lite-provider.ts +5 -1
- package/src/codegraph/project-context.ts +5 -1
- package/src/compact/token-budget.ts +74 -74
- package/src/config/behavior.ts +59 -59
- package/src/dashboard/project-classification.ts +64 -64
- package/src/dashboard/static/index.html +201 -201
- package/src/dashboard/static/style.css +3584 -3584
- package/src/embedding/fastembed-provider.ts +142 -142
- package/src/embedding/transformers-provider.ts +111 -111
- package/src/git/extractor.ts +209 -209
- package/src/git/hooks-path.ts +85 -85
- package/src/git/noise-filter.ts +210 -210
- package/src/hooks/installers/index.ts +4 -4
- package/src/hooks/official-skills.ts +1 -1
- package/src/hooks/pattern-detector.ts +173 -173
- package/src/hooks/rules/memorix-agent-rules.md +2 -2
- package/src/hooks/significance-filter.ts +250 -250
- package/src/llm/memory-manager.ts +328 -328
- package/src/llm/provider.ts +885 -885
- package/src/llm/quality.ts +248 -248
- package/src/memory/attribution-guard.ts +249 -249
- package/src/memory/auto-relations.ts +107 -107
- package/src/memory/consolidation.ts +302 -302
- package/src/memory/disclosure-policy.ts +141 -141
- package/src/memory/entity-extractor.ts +197 -197
- package/src/memory/formation/evaluate.ts +217 -217
- package/src/memory/formation/extract.ts +361 -361
- package/src/memory/formation/index.ts +417 -417
- package/src/memory/formation/resolve.ts +344 -344
- package/src/memory/formation/types.ts +315 -315
- package/src/memory/freshness.ts +122 -122
- package/src/memory/graph.ts +197 -197
- package/src/memory/refs.ts +94 -94
- package/src/memory/retention.ts +433 -433
- package/src/memory/secret-filter.ts +79 -79
- package/src/memory/session.ts +523 -523
- package/src/multimodal/image-loader.ts +143 -143
- package/src/orchestrate/adapters/claude-stream.ts +192 -192
- package/src/orchestrate/adapters/claude.ts +111 -111
- package/src/orchestrate/adapters/codex-stream.ts +134 -134
- package/src/orchestrate/adapters/codex.ts +41 -41
- package/src/orchestrate/adapters/gemini-stream.ts +166 -166
- package/src/orchestrate/adapters/gemini.ts +42 -42
- package/src/orchestrate/adapters/index.ts +73 -73
- package/src/orchestrate/adapters/opencode-stream.ts +143 -143
- package/src/orchestrate/adapters/opencode.ts +47 -47
- package/src/orchestrate/adapters/spawn-helper.ts +286 -286
- package/src/orchestrate/adapters/types.ts +77 -77
- package/src/orchestrate/capability-router.ts +284 -284
- package/src/orchestrate/context-compact.ts +188 -188
- package/src/orchestrate/cost-tracker.ts +219 -219
- package/src/orchestrate/error-recovery.ts +191 -191
- package/src/orchestrate/evidence.ts +140 -140
- package/src/orchestrate/ledger.ts +110 -110
- package/src/orchestrate/memorix-bridge.ts +380 -380
- package/src/orchestrate/output-budget.ts +80 -80
- package/src/orchestrate/permission.ts +152 -152
- package/src/orchestrate/pipeline-trace.ts +131 -131
- package/src/orchestrate/prompt-builder.ts +155 -155
- package/src/orchestrate/ring-buffer.ts +37 -37
- package/src/orchestrate/task-graph.ts +389 -389
- package/src/orchestrate/verify-gate.ts +219 -219
- package/src/orchestrate/worktree.ts +232 -232
- package/src/project/aliases.ts +374 -374
- package/src/project/detector.ts +268 -268
- package/src/rules/adapters/claude-code.ts +99 -99
- package/src/rules/adapters/codex.ts +97 -97
- package/src/rules/adapters/copilot.ts +124 -124
- package/src/rules/adapters/cursor.ts +114 -114
- package/src/rules/adapters/kiro.ts +126 -126
- package/src/rules/adapters/trae.ts +56 -56
- package/src/rules/adapters/windsurf.ts +83 -83
- package/src/rules/syncer.ts +235 -235
- package/src/sdk.ts +327 -327
- package/src/search/intent-detector.ts +289 -289
- package/src/search/query-expansion.ts +52 -52
- package/src/server/formation-timeout.ts +27 -27
- package/src/skills/mini-skills.ts +386 -386
- package/src/store/chat-store.ts +119 -119
- package/src/store/file-lock.ts +100 -100
- package/src/store/graph-store.ts +249 -249
- package/src/store/mini-skill-store.ts +349 -349
- package/src/store/obs-store.ts +255 -255
- package/src/store/persistence-json.ts +212 -212
- package/src/store/persistence.ts +291 -291
- package/src/store/project-affinity.ts +195 -195
- package/src/store/session-store.ts +259 -259
- package/src/store/sqlite-store.ts +339 -339
- package/src/team/event-bus.ts +76 -76
- package/src/team/file-locks.ts +173 -173
- package/src/team/handoff.ts +167 -167
- package/src/team/messages.ts +203 -203
- package/src/team/poll.ts +132 -132
- package/src/team/tasks.ts +211 -211
- package/src/wiki/generator.ts +237 -237
- package/src/wiki/knowledge-graph.ts +334 -334
- package/src/wiki/types.ts +85 -85
- package/src/workspace/mcp-adapters/codex.ts +191 -191
- package/src/workspace/mcp-adapters/copilot.ts +105 -105
- package/src/workspace/mcp-adapters/cursor.ts +53 -53
- package/src/workspace/mcp-adapters/kiro.ts +64 -64
- package/src/workspace/mcp-adapters/opencode.ts +123 -123
- package/src/workspace/mcp-adapters/trae.ts +134 -134
- package/src/workspace/mcp-adapters/windsurf.ts +91 -91
- package/src/workspace/sanitizer.ts +60 -60
- package/src/workspace/workflow-sync.ts +131 -131
package/src/memory/retention.ts
CHANGED
|
@@ -1,433 +1,433 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Retention & Decay Engine
|
|
3
|
-
*
|
|
4
|
-
* Manages memory relevance over time using exponential decay.
|
|
5
|
-
* Sources:
|
|
6
|
-
* - mcp-memory-service: ExponentialDecayCalculator (importance × decay × access_boost)
|
|
7
|
-
* - MemCP: Active → Archive → Purge lifecycle with immunity rules
|
|
8
|
-
*
|
|
9
|
-
* Relevance formula:
|
|
10
|
-
* score = baseImportance × e^(-ageDays / retentionPeriod) × accessBoost × connectionBoost
|
|
11
|
-
*
|
|
12
|
-
* Immunity: observations with importance=critical, valueCategory=core, accessCount>=3,
|
|
13
|
-
* or tagged "keep"/"pinned" are never auto-archived.
|
|
14
|
-
* High-importance types (gotcha/decision/trade-off/reasoning) retain a long 180-day
|
|
15
|
-
* retention period but are no longer permanently immune — they decay normally.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import type { MemorixDocument, Observation } from '../types.js';
|
|
19
|
-
import { getObservationStore } from '../store/obs-store.js';
|
|
20
|
-
|
|
21
|
-
// ── Importance → Retention Period mapping ────────────────────────────
|
|
22
|
-
|
|
23
|
-
export type ImportanceLevel = 'critical' | 'high' | 'medium' | 'low';
|
|
24
|
-
|
|
25
|
-
const RETENTION_DAYS: Record<ImportanceLevel, number> = {
|
|
26
|
-
critical: 365,
|
|
27
|
-
high: 180,
|
|
28
|
-
medium: 90,
|
|
29
|
-
low: 30,
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const BASE_IMPORTANCE: Record<ImportanceLevel, number> = {
|
|
33
|
-
critical: 1.0,
|
|
34
|
-
high: 0.8,
|
|
35
|
-
medium: 0.5,
|
|
36
|
-
low: 0.3,
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
// ── Observation Type → Default Importance ────────────────────────────
|
|
40
|
-
|
|
41
|
-
const TYPE_IMPORTANCE: Record<string, ImportanceLevel> = {
|
|
42
|
-
gotcha: 'high',
|
|
43
|
-
decision: 'high',
|
|
44
|
-
'trade-off': 'high',
|
|
45
|
-
reasoning: 'high',
|
|
46
|
-
'problem-solution': 'medium',
|
|
47
|
-
'how-it-works': 'medium',
|
|
48
|
-
'what-changed': 'low',
|
|
49
|
-
'why-it-exists': 'medium',
|
|
50
|
-
discovery: 'low',
|
|
51
|
-
'session-request': 'low',
|
|
52
|
-
probe: 'low',
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
/** Per-type retention period overrides (in days). When set, replaces the importance-based base retention. */
|
|
56
|
-
const TYPE_RETENTION_OVERRIDE: Partial<Record<string, number>> = {
|
|
57
|
-
probe: 7, // Operational heartbeats: expire after ~7 days regardless of source/valueCategory
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
// ── Immunity ─────────────────────────────────────────────────────────
|
|
61
|
-
|
|
62
|
-
const PROTECTED_TAGS = new Set(['keep', 'important', 'pinned', 'critical']);
|
|
63
|
-
const MIN_ACCESS_FOR_IMMUNITY = 3;
|
|
64
|
-
|
|
65
|
-
/** Minimum effective retention period in days — prevents extreme multiplier combinations
|
|
66
|
-
* (e.g. hook × ephemeral = 0.25×) from producing unreasonably short windows. */
|
|
67
|
-
const MIN_RETENTION_DAYS = 7;
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Get retention period multiplier based on sourceDetail.
|
|
71
|
-
* Neutral (1.0) for unknown/undefined sourceDetail — backward-compatible.
|
|
72
|
-
*/
|
|
73
|
-
function getSourceRetentionMultiplier(doc: MemorixDocument): number {
|
|
74
|
-
if (doc.sourceDetail === 'hook') return 0.5; // hook auto-captures: half the retention period
|
|
75
|
-
if (doc.sourceDetail === 'git-ingest') return 1.5; // git-backed truth: extend retention
|
|
76
|
-
return 1.0; // explicit/undefined: neutral
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Get retention period multiplier based on valueCategory.
|
|
81
|
-
* Neutral (1.0) for undefined/contextual — backward-compatible.
|
|
82
|
-
*/
|
|
83
|
-
function getValueCategoryMultiplier(doc: MemorixDocument): number {
|
|
84
|
-
if (doc.valueCategory === 'ephemeral') return 0.5; // short-lived context: decay faster
|
|
85
|
-
if (doc.valueCategory === 'core') return 2.0; // durable knowledge: extend retention
|
|
86
|
-
return 1.0; // contextual/undefined: neutral
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Compute the effective retention period in days, with floor applied.
|
|
91
|
-
* Combines base retention (from importance/type) with source and valueCategory multipliers.
|
|
92
|
-
*/
|
|
93
|
-
export function getEffectiveRetentionDays(doc: MemorixDocument): number {
|
|
94
|
-
const typeOverride = TYPE_RETENTION_OVERRIDE[doc.type];
|
|
95
|
-
if (typeOverride !== undefined) {
|
|
96
|
-
// Type-specific override: use the override directly, ignoring importance-based base retention.
|
|
97
|
-
// Source multiplier still applies so that e.g. hook-sourced probes decay even faster.
|
|
98
|
-
// valueCategory multiplier is intentionally excluded for type-override types:
|
|
99
|
-
// probe + core must NOT extend to 14 days -- probe is always short-lived.
|
|
100
|
-
const raw = typeOverride * getSourceRetentionMultiplier(doc);
|
|
101
|
-
return Math.max(MIN_RETENTION_DAYS, raw);
|
|
102
|
-
}
|
|
103
|
-
const importance = getImportanceLevel(doc);
|
|
104
|
-
const raw = RETENTION_DAYS[importance] * getSourceRetentionMultiplier(doc) * getValueCategoryMultiplier(doc);
|
|
105
|
-
return Math.max(MIN_RETENTION_DAYS, raw);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Check if an observation is immune from archiving/decay.
|
|
110
|
-
* Immune observations maintain a minimum relevance score.
|
|
111
|
-
*/
|
|
112
|
-
export function isImmune(doc: MemorixDocument): boolean {
|
|
113
|
-
// Probe observations are operational heartbeats -- never immune, regardless of valueCategory or access.
|
|
114
|
-
if (doc.type === 'probe') return false;
|
|
115
|
-
|
|
116
|
-
// formation-classified core memories are immune regardless of type
|
|
117
|
-
if (doc.valueCategory === 'core') return true;
|
|
118
|
-
|
|
119
|
-
const importance = getImportanceLevel(doc);
|
|
120
|
-
// Only 'critical' importance grants type-based immunity.
|
|
121
|
-
// 'high' importance types (gotcha/decision/trade-off/reasoning) keep their long
|
|
122
|
-
// 180-day retention period but are no longer permanently immune — this prevents
|
|
123
|
-
// unbounded growth of never-accessed high-type observations.
|
|
124
|
-
if (importance === 'critical') return true;
|
|
125
|
-
if ((doc.accessCount ?? 0) >= MIN_ACCESS_FOR_IMMUNITY) return true;
|
|
126
|
-
|
|
127
|
-
const concepts = doc.concepts?.split(', ').map((c) => c.toLowerCase()) ?? [];
|
|
128
|
-
return concepts.some((c) => PROTECTED_TAGS.has(c));
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Return a human-readable reason for why an observation is immune, or null if not immune.
|
|
133
|
-
*/
|
|
134
|
-
export function getImmunityReason(doc: MemorixDocument): string | null {
|
|
135
|
-
// Probe observations are never immune
|
|
136
|
-
if (doc.type === 'probe') return null;
|
|
137
|
-
|
|
138
|
-
if (doc.valueCategory === 'core') return 'core valueCategory (formation-classified)';
|
|
139
|
-
const importance = getImportanceLevel(doc);
|
|
140
|
-
if (importance === 'critical') return 'critical importance';
|
|
141
|
-
if ((doc.accessCount ?? 0) >= MIN_ACCESS_FOR_IMMUNITY) return `frequently accessed (${doc.accessCount}×)`;
|
|
142
|
-
const concepts = doc.concepts?.split(', ').map((c) => c.toLowerCase()) ?? [];
|
|
143
|
-
if (concepts.some((c) => PROTECTED_TAGS.has(c))) return 'protected tag';
|
|
144
|
-
return null;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// ── Relevance Scoring ────────────────────────────────────────────────
|
|
148
|
-
|
|
149
|
-
export interface RelevanceScore {
|
|
150
|
-
observationId: number;
|
|
151
|
-
totalScore: number;
|
|
152
|
-
baseImportance: number;
|
|
153
|
-
decayFactor: number;
|
|
154
|
-
accessBoost: number;
|
|
155
|
-
ageDays: number;
|
|
156
|
-
isImmune: boolean;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Get the importance level for an observation based on its type.
|
|
161
|
-
*/
|
|
162
|
-
export function getImportanceLevel(doc: MemorixDocument): ImportanceLevel {
|
|
163
|
-
return TYPE_IMPORTANCE[doc.type] ?? 'medium';
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Calculate the relevance score for a single observation.
|
|
168
|
-
*
|
|
169
|
-
* Formula (from mcp-memory-service):
|
|
170
|
-
* score = baseImportance × e^(-ageDays / retentionPeriod) × accessBoost
|
|
171
|
-
*
|
|
172
|
-
* Access boost (from mcp-memory-service):
|
|
173
|
-
* 1 + 0.1 × accessCount (10% boost per access, capped at 2.0)
|
|
174
|
-
*/
|
|
175
|
-
export function calculateRelevance(
|
|
176
|
-
doc: MemorixDocument,
|
|
177
|
-
referenceTime?: Date,
|
|
178
|
-
): RelevanceScore {
|
|
179
|
-
const now = referenceTime ?? new Date();
|
|
180
|
-
const importance = getImportanceLevel(doc);
|
|
181
|
-
const base = BASE_IMPORTANCE[importance];
|
|
182
|
-
const retention = getEffectiveRetentionDays(doc);
|
|
183
|
-
|
|
184
|
-
// Age in days
|
|
185
|
-
const createdAt = new Date(doc.createdAt);
|
|
186
|
-
const ageDays = Math.max(0, (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24));
|
|
187
|
-
|
|
188
|
-
// Exponential decay
|
|
189
|
-
const decayFactor = Math.exp(-ageDays / retention);
|
|
190
|
-
|
|
191
|
-
// Access boost: 10% per access, capped at 2.0×
|
|
192
|
-
const accessCount = doc.accessCount ?? 0;
|
|
193
|
-
const accessBoost = Math.min(2.0, 1 + 0.1 * accessCount);
|
|
194
|
-
|
|
195
|
-
let totalScore = base * decayFactor * accessBoost;
|
|
196
|
-
|
|
197
|
-
// Immune observations get minimum 0.5 relevance
|
|
198
|
-
const immune = isImmune(doc);
|
|
199
|
-
if (immune) {
|
|
200
|
-
totalScore = Math.max(totalScore, 0.5);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return {
|
|
204
|
-
observationId: doc.observationId,
|
|
205
|
-
totalScore,
|
|
206
|
-
baseImportance: base,
|
|
207
|
-
decayFactor,
|
|
208
|
-
accessBoost,
|
|
209
|
-
ageDays,
|
|
210
|
-
isImmune: immune,
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Score and rank observations by relevance.
|
|
216
|
-
* Returns sorted (highest relevance first) with scores.
|
|
217
|
-
*/
|
|
218
|
-
export function rankByRelevance(
|
|
219
|
-
docs: MemorixDocument[],
|
|
220
|
-
referenceTime?: Date,
|
|
221
|
-
): RelevanceScore[] {
|
|
222
|
-
return docs
|
|
223
|
-
.map((doc) => calculateRelevance(doc, referenceTime))
|
|
224
|
-
.sort((a, b) => b.totalScore - a.totalScore);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// ── Retention Lifecycle ──────────────────────────────────────────────
|
|
228
|
-
|
|
229
|
-
export type RetentionZone = 'active' | 'stale' | 'archive-candidate';
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Classify an observation into a retention zone.
|
|
233
|
-
*
|
|
234
|
-
* Lifecycle (from MemCP):
|
|
235
|
-
* Active: recently accessed or high importance
|
|
236
|
-
* Stale: not accessed, beyond 50% of retention period
|
|
237
|
-
* Archive-candidate: not accessed, beyond 100% of retention period, not immune
|
|
238
|
-
*/
|
|
239
|
-
export function getRetentionZone(doc: MemorixDocument, referenceTime?: Date): RetentionZone {
|
|
240
|
-
const now = referenceTime ?? new Date();
|
|
241
|
-
const importance = getImportanceLevel(doc);
|
|
242
|
-
const retention = getEffectiveRetentionDays(doc);
|
|
243
|
-
|
|
244
|
-
const createdAt = new Date(doc.createdAt);
|
|
245
|
-
const ageDays = (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
246
|
-
|
|
247
|
-
// Recently accessed = active regardless of age
|
|
248
|
-
if (doc.lastAccessedAt) {
|
|
249
|
-
const lastAccess = new Date(doc.lastAccessedAt);
|
|
250
|
-
const daysSinceAccess = (now.getTime() - lastAccess.getTime()) / (1000 * 60 * 60 * 24);
|
|
251
|
-
if (daysSinceAccess < 7) return 'active';
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
if (isImmune(doc)) return 'active';
|
|
255
|
-
if (ageDays > retention) return 'archive-candidate';
|
|
256
|
-
if (ageDays > retention * 0.5) return 'stale';
|
|
257
|
-
return 'active';
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Get archive candidates from a list of observations.
|
|
262
|
-
* Returns observations that are beyond their retention period and not immune.
|
|
263
|
-
*/
|
|
264
|
-
export function getArchiveCandidates(
|
|
265
|
-
docs: MemorixDocument[],
|
|
266
|
-
referenceTime?: Date,
|
|
267
|
-
): MemorixDocument[] {
|
|
268
|
-
return docs.filter((doc) => getRetentionZone(doc, referenceTime) === 'archive-candidate');
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Get retention summary statistics.
|
|
273
|
-
*/
|
|
274
|
-
export function getRetentionSummary(
|
|
275
|
-
docs: MemorixDocument[],
|
|
276
|
-
referenceTime?: Date,
|
|
277
|
-
): { active: number; stale: number; archiveCandidates: number; immune: number } {
|
|
278
|
-
let active = 0;
|
|
279
|
-
let stale = 0;
|
|
280
|
-
let archiveCandidates = 0;
|
|
281
|
-
let immune = 0;
|
|
282
|
-
|
|
283
|
-
for (const doc of docs) {
|
|
284
|
-
const zone = getRetentionZone(doc, referenceTime);
|
|
285
|
-
if (zone === 'active') active++;
|
|
286
|
-
else if (zone === 'stale') stale++;
|
|
287
|
-
else archiveCandidates++;
|
|
288
|
-
if (isImmune(doc)) immune++;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
return { active, stale, archiveCandidates, immune };
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// ── Retention Explainability ─────────────────────────────────────────
|
|
295
|
-
|
|
296
|
-
export interface RetentionExplanation {
|
|
297
|
-
observationId: number;
|
|
298
|
-
importanceLevel: ImportanceLevel;
|
|
299
|
-
baseRetentionDays: number;
|
|
300
|
-
sourceMultiplier: number;
|
|
301
|
-
valueCategoryMultiplier: number;
|
|
302
|
-
effectiveRetentionDays: number;
|
|
303
|
-
zone: RetentionZone;
|
|
304
|
-
immune: boolean;
|
|
305
|
-
/** Human-readable reason for immunity, or null when not immune. */
|
|
306
|
-
immunityReason: string | null;
|
|
307
|
-
ageDays: number;
|
|
308
|
-
/** Short human-readable summary of the retention posture. */
|
|
309
|
-
summary: string;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Produce a structured explanation of why an observation has its current
|
|
314
|
-
* retention posture. Designed for operator-facing reporting.
|
|
315
|
-
*/
|
|
316
|
-
export function explainRetention(
|
|
317
|
-
doc: MemorixDocument,
|
|
318
|
-
referenceTime?: Date,
|
|
319
|
-
): RetentionExplanation {
|
|
320
|
-
const importance = getImportanceLevel(doc);
|
|
321
|
-
const baseRetention = RETENTION_DAYS[importance];
|
|
322
|
-
const srcMul = getSourceRetentionMultiplier(doc);
|
|
323
|
-
const vcMul = getValueCategoryMultiplier(doc);
|
|
324
|
-
const effective = getEffectiveRetentionDays(doc);
|
|
325
|
-
const zone = getRetentionZone(doc, referenceTime);
|
|
326
|
-
const immuneFlag = isImmune(doc);
|
|
327
|
-
const immunityReason = getImmunityReason(doc);
|
|
328
|
-
|
|
329
|
-
const now = referenceTime ?? new Date();
|
|
330
|
-
const ageDays = Math.max(
|
|
331
|
-
0,
|
|
332
|
-
(now.getTime() - new Date(doc.createdAt).getTime()) / (1000 * 60 * 60 * 24),
|
|
333
|
-
);
|
|
334
|
-
|
|
335
|
-
// Build a concise human-readable summary
|
|
336
|
-
const parts: string[] = [];
|
|
337
|
-
parts.push(`${importance} importance (${baseRetention}d base)`);
|
|
338
|
-
if (srcMul !== 1.0) parts.push(`source ${srcMul}×`);
|
|
339
|
-
if (vcMul !== 1.0) parts.push(`valueCategory ${vcMul}×`);
|
|
340
|
-
parts.push(`→ ${effective}d effective`);
|
|
341
|
-
if (immuneFlag) {
|
|
342
|
-
parts.push(`immune: ${immunityReason}`);
|
|
343
|
-
} else {
|
|
344
|
-
parts.push(`zone: ${zone}`);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
return {
|
|
348
|
-
observationId: doc.observationId,
|
|
349
|
-
importanceLevel: importance,
|
|
350
|
-
baseRetentionDays: baseRetention,
|
|
351
|
-
sourceMultiplier: srcMul,
|
|
352
|
-
valueCategoryMultiplier: vcMul,
|
|
353
|
-
effectiveRetentionDays: effective,
|
|
354
|
-
zone,
|
|
355
|
-
immune: immuneFlag,
|
|
356
|
-
immunityReason,
|
|
357
|
-
ageDays: Math.round(ageDays),
|
|
358
|
-
// Summary is rendered inside markdown table cells in memorix_retention output,
|
|
359
|
-
// so avoid pipe separators that would split the row into extra columns.
|
|
360
|
-
summary: parts.join(' ; '),
|
|
361
|
-
};
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// ── Auto-Archive ────────────────────────────────────────────────────
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Archive expired observations by setting status='archived' in-place.
|
|
368
|
-
*
|
|
369
|
-
* Phase 2 change: instead of moving observations to a separate
|
|
370
|
-
* observations.archived.json file, we update their status in the
|
|
371
|
-
* canonical store (SQLite or JSON). This eliminates the separate
|
|
372
|
-
* archive file and keeps all observation data in one place.
|
|
373
|
-
*
|
|
374
|
-
* Returns the count of archived observations.
|
|
375
|
-
*/
|
|
376
|
-
export async function archiveExpired(
|
|
377
|
-
projectDir: string,
|
|
378
|
-
referenceTime?: Date,
|
|
379
|
-
accessMap?: Map<number, { accessCount: number; lastAccessedAt: string }>,
|
|
380
|
-
): Promise<{ archived: number; remaining: number }> {
|
|
381
|
-
const store = getObservationStore();
|
|
382
|
-
return await store.atomic(async (tx) => {
|
|
383
|
-
const allObs = await tx.loadAll();
|
|
384
|
-
|
|
385
|
-
// Convert to MemorixDocument-like shape for zone calculation
|
|
386
|
-
// Use accessMap (from Orama index) when available for accurate immunity checks
|
|
387
|
-
const toDoc = (obs: Observation): MemorixDocument => {
|
|
388
|
-
const access = accessMap?.get(obs.id);
|
|
389
|
-
return {
|
|
390
|
-
id: `obs-${obs.id}`,
|
|
391
|
-
observationId: obs.id,
|
|
392
|
-
entityName: obs.entityName,
|
|
393
|
-
type: obs.type,
|
|
394
|
-
title: obs.title,
|
|
395
|
-
narrative: obs.narrative,
|
|
396
|
-
facts: obs.facts.join('\n'),
|
|
397
|
-
filesModified: obs.filesModified.join('\n'),
|
|
398
|
-
concepts: obs.concepts.join(', '),
|
|
399
|
-
tokens: obs.tokens,
|
|
400
|
-
createdAt: obs.createdAt,
|
|
401
|
-
projectId: obs.projectId,
|
|
402
|
-
accessCount: access?.accessCount ?? 0,
|
|
403
|
-
lastAccessedAt: access?.lastAccessedAt ?? '',
|
|
404
|
-
status: obs.status ?? 'active',
|
|
405
|
-
source: obs.source ?? 'agent',
|
|
406
|
-
sourceDetail: obs.sourceDetail ?? '',
|
|
407
|
-
valueCategory: obs.valueCategory ?? '',
|
|
408
|
-
};
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
// Only consider active observations for archiving
|
|
412
|
-
const activeObs = allObs.filter(o => (o.status ?? 'active') === 'active');
|
|
413
|
-
let archivedCount = 0;
|
|
414
|
-
|
|
415
|
-
for (const obs of activeObs) {
|
|
416
|
-
const doc = toDoc(obs);
|
|
417
|
-
const zone = getRetentionZone(doc, referenceTime);
|
|
418
|
-
if (zone === 'archive-candidate') {
|
|
419
|
-
obs.status = 'archived';
|
|
420
|
-
archivedCount++;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
if (archivedCount === 0) {
|
|
425
|
-
return { archived: 0, remaining: activeObs.length };
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// Persist all observations with updated statuses
|
|
429
|
-
await tx.saveAll(allObs);
|
|
430
|
-
|
|
431
|
-
return { archived: archivedCount, remaining: activeObs.length - archivedCount };
|
|
432
|
-
});
|
|
433
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Retention & Decay Engine
|
|
3
|
+
*
|
|
4
|
+
* Manages memory relevance over time using exponential decay.
|
|
5
|
+
* Sources:
|
|
6
|
+
* - mcp-memory-service: ExponentialDecayCalculator (importance × decay × access_boost)
|
|
7
|
+
* - MemCP: Active → Archive → Purge lifecycle with immunity rules
|
|
8
|
+
*
|
|
9
|
+
* Relevance formula:
|
|
10
|
+
* score = baseImportance × e^(-ageDays / retentionPeriod) × accessBoost × connectionBoost
|
|
11
|
+
*
|
|
12
|
+
* Immunity: observations with importance=critical, valueCategory=core, accessCount>=3,
|
|
13
|
+
* or tagged "keep"/"pinned" are never auto-archived.
|
|
14
|
+
* High-importance types (gotcha/decision/trade-off/reasoning) retain a long 180-day
|
|
15
|
+
* retention period but are no longer permanently immune — they decay normally.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { MemorixDocument, Observation } from '../types.js';
|
|
19
|
+
import { getObservationStore } from '../store/obs-store.js';
|
|
20
|
+
|
|
21
|
+
// ── Importance → Retention Period mapping ────────────────────────────
|
|
22
|
+
|
|
23
|
+
export type ImportanceLevel = 'critical' | 'high' | 'medium' | 'low';
|
|
24
|
+
|
|
25
|
+
const RETENTION_DAYS: Record<ImportanceLevel, number> = {
|
|
26
|
+
critical: 365,
|
|
27
|
+
high: 180,
|
|
28
|
+
medium: 90,
|
|
29
|
+
low: 30,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const BASE_IMPORTANCE: Record<ImportanceLevel, number> = {
|
|
33
|
+
critical: 1.0,
|
|
34
|
+
high: 0.8,
|
|
35
|
+
medium: 0.5,
|
|
36
|
+
low: 0.3,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ── Observation Type → Default Importance ────────────────────────────
|
|
40
|
+
|
|
41
|
+
const TYPE_IMPORTANCE: Record<string, ImportanceLevel> = {
|
|
42
|
+
gotcha: 'high',
|
|
43
|
+
decision: 'high',
|
|
44
|
+
'trade-off': 'high',
|
|
45
|
+
reasoning: 'high',
|
|
46
|
+
'problem-solution': 'medium',
|
|
47
|
+
'how-it-works': 'medium',
|
|
48
|
+
'what-changed': 'low',
|
|
49
|
+
'why-it-exists': 'medium',
|
|
50
|
+
discovery: 'low',
|
|
51
|
+
'session-request': 'low',
|
|
52
|
+
probe: 'low',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Per-type retention period overrides (in days). When set, replaces the importance-based base retention. */
|
|
56
|
+
const TYPE_RETENTION_OVERRIDE: Partial<Record<string, number>> = {
|
|
57
|
+
probe: 7, // Operational heartbeats: expire after ~7 days regardless of source/valueCategory
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ── Immunity ─────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const PROTECTED_TAGS = new Set(['keep', 'important', 'pinned', 'critical']);
|
|
63
|
+
const MIN_ACCESS_FOR_IMMUNITY = 3;
|
|
64
|
+
|
|
65
|
+
/** Minimum effective retention period in days — prevents extreme multiplier combinations
|
|
66
|
+
* (e.g. hook × ephemeral = 0.25×) from producing unreasonably short windows. */
|
|
67
|
+
const MIN_RETENTION_DAYS = 7;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get retention period multiplier based on sourceDetail.
|
|
71
|
+
* Neutral (1.0) for unknown/undefined sourceDetail — backward-compatible.
|
|
72
|
+
*/
|
|
73
|
+
function getSourceRetentionMultiplier(doc: MemorixDocument): number {
|
|
74
|
+
if (doc.sourceDetail === 'hook') return 0.5; // hook auto-captures: half the retention period
|
|
75
|
+
if (doc.sourceDetail === 'git-ingest') return 1.5; // git-backed truth: extend retention
|
|
76
|
+
return 1.0; // explicit/undefined: neutral
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get retention period multiplier based on valueCategory.
|
|
81
|
+
* Neutral (1.0) for undefined/contextual — backward-compatible.
|
|
82
|
+
*/
|
|
83
|
+
function getValueCategoryMultiplier(doc: MemorixDocument): number {
|
|
84
|
+
if (doc.valueCategory === 'ephemeral') return 0.5; // short-lived context: decay faster
|
|
85
|
+
if (doc.valueCategory === 'core') return 2.0; // durable knowledge: extend retention
|
|
86
|
+
return 1.0; // contextual/undefined: neutral
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Compute the effective retention period in days, with floor applied.
|
|
91
|
+
* Combines base retention (from importance/type) with source and valueCategory multipliers.
|
|
92
|
+
*/
|
|
93
|
+
export function getEffectiveRetentionDays(doc: MemorixDocument): number {
|
|
94
|
+
const typeOverride = TYPE_RETENTION_OVERRIDE[doc.type];
|
|
95
|
+
if (typeOverride !== undefined) {
|
|
96
|
+
// Type-specific override: use the override directly, ignoring importance-based base retention.
|
|
97
|
+
// Source multiplier still applies so that e.g. hook-sourced probes decay even faster.
|
|
98
|
+
// valueCategory multiplier is intentionally excluded for type-override types:
|
|
99
|
+
// probe + core must NOT extend to 14 days -- probe is always short-lived.
|
|
100
|
+
const raw = typeOverride * getSourceRetentionMultiplier(doc);
|
|
101
|
+
return Math.max(MIN_RETENTION_DAYS, raw);
|
|
102
|
+
}
|
|
103
|
+
const importance = getImportanceLevel(doc);
|
|
104
|
+
const raw = RETENTION_DAYS[importance] * getSourceRetentionMultiplier(doc) * getValueCategoryMultiplier(doc);
|
|
105
|
+
return Math.max(MIN_RETENTION_DAYS, raw);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check if an observation is immune from archiving/decay.
|
|
110
|
+
* Immune observations maintain a minimum relevance score.
|
|
111
|
+
*/
|
|
112
|
+
export function isImmune(doc: MemorixDocument): boolean {
|
|
113
|
+
// Probe observations are operational heartbeats -- never immune, regardless of valueCategory or access.
|
|
114
|
+
if (doc.type === 'probe') return false;
|
|
115
|
+
|
|
116
|
+
// formation-classified core memories are immune regardless of type
|
|
117
|
+
if (doc.valueCategory === 'core') return true;
|
|
118
|
+
|
|
119
|
+
const importance = getImportanceLevel(doc);
|
|
120
|
+
// Only 'critical' importance grants type-based immunity.
|
|
121
|
+
// 'high' importance types (gotcha/decision/trade-off/reasoning) keep their long
|
|
122
|
+
// 180-day retention period but are no longer permanently immune — this prevents
|
|
123
|
+
// unbounded growth of never-accessed high-type observations.
|
|
124
|
+
if (importance === 'critical') return true;
|
|
125
|
+
if ((doc.accessCount ?? 0) >= MIN_ACCESS_FOR_IMMUNITY) return true;
|
|
126
|
+
|
|
127
|
+
const concepts = doc.concepts?.split(', ').map((c) => c.toLowerCase()) ?? [];
|
|
128
|
+
return concepts.some((c) => PROTECTED_TAGS.has(c));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Return a human-readable reason for why an observation is immune, or null if not immune.
|
|
133
|
+
*/
|
|
134
|
+
export function getImmunityReason(doc: MemorixDocument): string | null {
|
|
135
|
+
// Probe observations are never immune
|
|
136
|
+
if (doc.type === 'probe') return null;
|
|
137
|
+
|
|
138
|
+
if (doc.valueCategory === 'core') return 'core valueCategory (formation-classified)';
|
|
139
|
+
const importance = getImportanceLevel(doc);
|
|
140
|
+
if (importance === 'critical') return 'critical importance';
|
|
141
|
+
if ((doc.accessCount ?? 0) >= MIN_ACCESS_FOR_IMMUNITY) return `frequently accessed (${doc.accessCount}×)`;
|
|
142
|
+
const concepts = doc.concepts?.split(', ').map((c) => c.toLowerCase()) ?? [];
|
|
143
|
+
if (concepts.some((c) => PROTECTED_TAGS.has(c))) return 'protected tag';
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Relevance Scoring ────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
export interface RelevanceScore {
|
|
150
|
+
observationId: number;
|
|
151
|
+
totalScore: number;
|
|
152
|
+
baseImportance: number;
|
|
153
|
+
decayFactor: number;
|
|
154
|
+
accessBoost: number;
|
|
155
|
+
ageDays: number;
|
|
156
|
+
isImmune: boolean;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get the importance level for an observation based on its type.
|
|
161
|
+
*/
|
|
162
|
+
export function getImportanceLevel(doc: MemorixDocument): ImportanceLevel {
|
|
163
|
+
return TYPE_IMPORTANCE[doc.type] ?? 'medium';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Calculate the relevance score for a single observation.
|
|
168
|
+
*
|
|
169
|
+
* Formula (from mcp-memory-service):
|
|
170
|
+
* score = baseImportance × e^(-ageDays / retentionPeriod) × accessBoost
|
|
171
|
+
*
|
|
172
|
+
* Access boost (from mcp-memory-service):
|
|
173
|
+
* 1 + 0.1 × accessCount (10% boost per access, capped at 2.0)
|
|
174
|
+
*/
|
|
175
|
+
export function calculateRelevance(
|
|
176
|
+
doc: MemorixDocument,
|
|
177
|
+
referenceTime?: Date,
|
|
178
|
+
): RelevanceScore {
|
|
179
|
+
const now = referenceTime ?? new Date();
|
|
180
|
+
const importance = getImportanceLevel(doc);
|
|
181
|
+
const base = BASE_IMPORTANCE[importance];
|
|
182
|
+
const retention = getEffectiveRetentionDays(doc);
|
|
183
|
+
|
|
184
|
+
// Age in days
|
|
185
|
+
const createdAt = new Date(doc.createdAt);
|
|
186
|
+
const ageDays = Math.max(0, (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24));
|
|
187
|
+
|
|
188
|
+
// Exponential decay
|
|
189
|
+
const decayFactor = Math.exp(-ageDays / retention);
|
|
190
|
+
|
|
191
|
+
// Access boost: 10% per access, capped at 2.0×
|
|
192
|
+
const accessCount = doc.accessCount ?? 0;
|
|
193
|
+
const accessBoost = Math.min(2.0, 1 + 0.1 * accessCount);
|
|
194
|
+
|
|
195
|
+
let totalScore = base * decayFactor * accessBoost;
|
|
196
|
+
|
|
197
|
+
// Immune observations get minimum 0.5 relevance
|
|
198
|
+
const immune = isImmune(doc);
|
|
199
|
+
if (immune) {
|
|
200
|
+
totalScore = Math.max(totalScore, 0.5);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
observationId: doc.observationId,
|
|
205
|
+
totalScore,
|
|
206
|
+
baseImportance: base,
|
|
207
|
+
decayFactor,
|
|
208
|
+
accessBoost,
|
|
209
|
+
ageDays,
|
|
210
|
+
isImmune: immune,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Score and rank observations by relevance.
|
|
216
|
+
* Returns sorted (highest relevance first) with scores.
|
|
217
|
+
*/
|
|
218
|
+
export function rankByRelevance(
|
|
219
|
+
docs: MemorixDocument[],
|
|
220
|
+
referenceTime?: Date,
|
|
221
|
+
): RelevanceScore[] {
|
|
222
|
+
return docs
|
|
223
|
+
.map((doc) => calculateRelevance(doc, referenceTime))
|
|
224
|
+
.sort((a, b) => b.totalScore - a.totalScore);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Retention Lifecycle ──────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
export type RetentionZone = 'active' | 'stale' | 'archive-candidate';
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Classify an observation into a retention zone.
|
|
233
|
+
*
|
|
234
|
+
* Lifecycle (from MemCP):
|
|
235
|
+
* Active: recently accessed or high importance
|
|
236
|
+
* Stale: not accessed, beyond 50% of retention period
|
|
237
|
+
* Archive-candidate: not accessed, beyond 100% of retention period, not immune
|
|
238
|
+
*/
|
|
239
|
+
export function getRetentionZone(doc: MemorixDocument, referenceTime?: Date): RetentionZone {
|
|
240
|
+
const now = referenceTime ?? new Date();
|
|
241
|
+
const importance = getImportanceLevel(doc);
|
|
242
|
+
const retention = getEffectiveRetentionDays(doc);
|
|
243
|
+
|
|
244
|
+
const createdAt = new Date(doc.createdAt);
|
|
245
|
+
const ageDays = (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
246
|
+
|
|
247
|
+
// Recently accessed = active regardless of age
|
|
248
|
+
if (doc.lastAccessedAt) {
|
|
249
|
+
const lastAccess = new Date(doc.lastAccessedAt);
|
|
250
|
+
const daysSinceAccess = (now.getTime() - lastAccess.getTime()) / (1000 * 60 * 60 * 24);
|
|
251
|
+
if (daysSinceAccess < 7) return 'active';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (isImmune(doc)) return 'active';
|
|
255
|
+
if (ageDays > retention) return 'archive-candidate';
|
|
256
|
+
if (ageDays > retention * 0.5) return 'stale';
|
|
257
|
+
return 'active';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Get archive candidates from a list of observations.
|
|
262
|
+
* Returns observations that are beyond their retention period and not immune.
|
|
263
|
+
*/
|
|
264
|
+
export function getArchiveCandidates(
|
|
265
|
+
docs: MemorixDocument[],
|
|
266
|
+
referenceTime?: Date,
|
|
267
|
+
): MemorixDocument[] {
|
|
268
|
+
return docs.filter((doc) => getRetentionZone(doc, referenceTime) === 'archive-candidate');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Get retention summary statistics.
|
|
273
|
+
*/
|
|
274
|
+
export function getRetentionSummary(
|
|
275
|
+
docs: MemorixDocument[],
|
|
276
|
+
referenceTime?: Date,
|
|
277
|
+
): { active: number; stale: number; archiveCandidates: number; immune: number } {
|
|
278
|
+
let active = 0;
|
|
279
|
+
let stale = 0;
|
|
280
|
+
let archiveCandidates = 0;
|
|
281
|
+
let immune = 0;
|
|
282
|
+
|
|
283
|
+
for (const doc of docs) {
|
|
284
|
+
const zone = getRetentionZone(doc, referenceTime);
|
|
285
|
+
if (zone === 'active') active++;
|
|
286
|
+
else if (zone === 'stale') stale++;
|
|
287
|
+
else archiveCandidates++;
|
|
288
|
+
if (isImmune(doc)) immune++;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { active, stale, archiveCandidates, immune };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Retention Explainability ─────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
export interface RetentionExplanation {
|
|
297
|
+
observationId: number;
|
|
298
|
+
importanceLevel: ImportanceLevel;
|
|
299
|
+
baseRetentionDays: number;
|
|
300
|
+
sourceMultiplier: number;
|
|
301
|
+
valueCategoryMultiplier: number;
|
|
302
|
+
effectiveRetentionDays: number;
|
|
303
|
+
zone: RetentionZone;
|
|
304
|
+
immune: boolean;
|
|
305
|
+
/** Human-readable reason for immunity, or null when not immune. */
|
|
306
|
+
immunityReason: string | null;
|
|
307
|
+
ageDays: number;
|
|
308
|
+
/** Short human-readable summary of the retention posture. */
|
|
309
|
+
summary: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Produce a structured explanation of why an observation has its current
|
|
314
|
+
* retention posture. Designed for operator-facing reporting.
|
|
315
|
+
*/
|
|
316
|
+
export function explainRetention(
|
|
317
|
+
doc: MemorixDocument,
|
|
318
|
+
referenceTime?: Date,
|
|
319
|
+
): RetentionExplanation {
|
|
320
|
+
const importance = getImportanceLevel(doc);
|
|
321
|
+
const baseRetention = RETENTION_DAYS[importance];
|
|
322
|
+
const srcMul = getSourceRetentionMultiplier(doc);
|
|
323
|
+
const vcMul = getValueCategoryMultiplier(doc);
|
|
324
|
+
const effective = getEffectiveRetentionDays(doc);
|
|
325
|
+
const zone = getRetentionZone(doc, referenceTime);
|
|
326
|
+
const immuneFlag = isImmune(doc);
|
|
327
|
+
const immunityReason = getImmunityReason(doc);
|
|
328
|
+
|
|
329
|
+
const now = referenceTime ?? new Date();
|
|
330
|
+
const ageDays = Math.max(
|
|
331
|
+
0,
|
|
332
|
+
(now.getTime() - new Date(doc.createdAt).getTime()) / (1000 * 60 * 60 * 24),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
// Build a concise human-readable summary
|
|
336
|
+
const parts: string[] = [];
|
|
337
|
+
parts.push(`${importance} importance (${baseRetention}d base)`);
|
|
338
|
+
if (srcMul !== 1.0) parts.push(`source ${srcMul}×`);
|
|
339
|
+
if (vcMul !== 1.0) parts.push(`valueCategory ${vcMul}×`);
|
|
340
|
+
parts.push(`→ ${effective}d effective`);
|
|
341
|
+
if (immuneFlag) {
|
|
342
|
+
parts.push(`immune: ${immunityReason}`);
|
|
343
|
+
} else {
|
|
344
|
+
parts.push(`zone: ${zone}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
observationId: doc.observationId,
|
|
349
|
+
importanceLevel: importance,
|
|
350
|
+
baseRetentionDays: baseRetention,
|
|
351
|
+
sourceMultiplier: srcMul,
|
|
352
|
+
valueCategoryMultiplier: vcMul,
|
|
353
|
+
effectiveRetentionDays: effective,
|
|
354
|
+
zone,
|
|
355
|
+
immune: immuneFlag,
|
|
356
|
+
immunityReason,
|
|
357
|
+
ageDays: Math.round(ageDays),
|
|
358
|
+
// Summary is rendered inside markdown table cells in memorix_retention output,
|
|
359
|
+
// so avoid pipe separators that would split the row into extra columns.
|
|
360
|
+
summary: parts.join(' ; '),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── Auto-Archive ────────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Archive expired observations by setting status='archived' in-place.
|
|
368
|
+
*
|
|
369
|
+
* Phase 2 change: instead of moving observations to a separate
|
|
370
|
+
* observations.archived.json file, we update their status in the
|
|
371
|
+
* canonical store (SQLite or JSON). This eliminates the separate
|
|
372
|
+
* archive file and keeps all observation data in one place.
|
|
373
|
+
*
|
|
374
|
+
* Returns the count of archived observations.
|
|
375
|
+
*/
|
|
376
|
+
export async function archiveExpired(
|
|
377
|
+
projectDir: string,
|
|
378
|
+
referenceTime?: Date,
|
|
379
|
+
accessMap?: Map<number, { accessCount: number; lastAccessedAt: string }>,
|
|
380
|
+
): Promise<{ archived: number; remaining: number }> {
|
|
381
|
+
const store = getObservationStore();
|
|
382
|
+
return await store.atomic(async (tx) => {
|
|
383
|
+
const allObs = await tx.loadAll();
|
|
384
|
+
|
|
385
|
+
// Convert to MemorixDocument-like shape for zone calculation
|
|
386
|
+
// Use accessMap (from Orama index) when available for accurate immunity checks
|
|
387
|
+
const toDoc = (obs: Observation): MemorixDocument => {
|
|
388
|
+
const access = accessMap?.get(obs.id);
|
|
389
|
+
return {
|
|
390
|
+
id: `obs-${obs.id}`,
|
|
391
|
+
observationId: obs.id,
|
|
392
|
+
entityName: obs.entityName,
|
|
393
|
+
type: obs.type,
|
|
394
|
+
title: obs.title,
|
|
395
|
+
narrative: obs.narrative,
|
|
396
|
+
facts: obs.facts.join('\n'),
|
|
397
|
+
filesModified: obs.filesModified.join('\n'),
|
|
398
|
+
concepts: obs.concepts.join(', '),
|
|
399
|
+
tokens: obs.tokens,
|
|
400
|
+
createdAt: obs.createdAt,
|
|
401
|
+
projectId: obs.projectId,
|
|
402
|
+
accessCount: access?.accessCount ?? 0,
|
|
403
|
+
lastAccessedAt: access?.lastAccessedAt ?? '',
|
|
404
|
+
status: obs.status ?? 'active',
|
|
405
|
+
source: obs.source ?? 'agent',
|
|
406
|
+
sourceDetail: obs.sourceDetail ?? '',
|
|
407
|
+
valueCategory: obs.valueCategory ?? '',
|
|
408
|
+
};
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// Only consider active observations for archiving
|
|
412
|
+
const activeObs = allObs.filter(o => (o.status ?? 'active') === 'active');
|
|
413
|
+
let archivedCount = 0;
|
|
414
|
+
|
|
415
|
+
for (const obs of activeObs) {
|
|
416
|
+
const doc = toDoc(obs);
|
|
417
|
+
const zone = getRetentionZone(doc, referenceTime);
|
|
418
|
+
if (zone === 'archive-candidate') {
|
|
419
|
+
obs.status = 'archived';
|
|
420
|
+
archivedCount++;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (archivedCount === 0) {
|
|
425
|
+
return { archived: 0, remaining: activeObs.length };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Persist all observations with updated statuses
|
|
429
|
+
await tx.saveAll(allObs);
|
|
430
|
+
|
|
431
|
+
return { archived: archivedCount, remaining: activeObs.length - archivedCount };
|
|
432
|
+
});
|
|
433
|
+
}
|