memorix 1.2.9 → 1.3.0
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 +24 -0
- package/README.md +30 -4
- package/README.zh-CN.md +28 -2
- package/assets/star-history-dark.svg +30 -0
- package/assets/star-history-light.svg +30 -0
- package/assets/star-history.json +599 -0
- package/dist/cli/index.js +1945 -579
- package/dist/cli/index.js.map +1 -1
- package/dist/index.js +1292 -267
- package/dist/index.js.map +1 -1
- package/dist/maintenance-runner.js +97 -21
- package/dist/maintenance-runner.js.map +1 -1
- package/dist/memcode-runtime/CHANGELOG.md +24 -0
- package/dist/sdk.d.ts +1 -1
- package/dist/sdk.js +1294 -269
- package/dist/sdk.js.map +1 -1
- package/dist/types.d.ts +99 -1
- package/dist/types.js.map +1 -1
- package/dist/vector-backfill-runner.js +97 -21
- package/dist/vector-backfill-runner.js.map +1 -1
- package/docs/1.3-MEMORY-ARCHITECTURE.md +236 -0
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +5 -3
- package/docs/API_REFERENCE.md +20 -1
- package/docs/ARCHITECTURE.md +34 -1
- package/docs/DEVELOPMENT.md +10 -3
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/MODULES.md +27 -1
- package/docs/README.md +3 -3
- package/docs/SETUP.md +6 -4
- package/docs/dev-log/progress.txt +22 -0
- package/docs/hooks-architecture.md +1 -1
- package/llms-full.txt +5 -1
- package/llms.txt +2 -0
- package/package.json +7 -2
- package/plugins/antigravity/memorix/rules/memorix.md +2 -0
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/memorix/skills/memorix-memory/SKILL.md +3 -0
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/skills/memorix-memory/SKILL.md +3 -0
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/copilot/memorix/skills/memorix-memory/SKILL.md +3 -0
- package/plugins/cursor/memorix/skills/memorix-memory/SKILL.md +3 -0
- package/plugins/gemini/memorix/GEMINI.md +2 -0
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/plugins/pi/memorix/skills/memorix-memory/SKILL.md +3 -0
- package/server.json +38 -0
- package/src/cli/command-guide.ts +9 -1
- package/src/cli/commands/agent-integrations.ts +12 -6
- package/src/cli/commands/codegraph.ts +1 -0
- package/src/cli/commands/memory.ts +217 -1
- package/src/cli/commands/setup.ts +200 -23
- package/src/codegraph/auto-context.ts +11 -0
- package/src/codegraph/context-pack.ts +3 -0
- package/src/embedding/api-provider.ts +23 -9
- package/src/embedding/provider.ts +21 -10
- package/src/hooks/installers/index.ts +2 -0
- package/src/knowledge/context-assembly.ts +2 -1
- package/src/knowledge/workset.ts +77 -4
- package/src/memory/long-term-store.ts +242 -0
- package/src/memory/long-term-types.ts +136 -0
- package/src/memory/long-term.ts +626 -0
- package/src/memory/owner.ts +75 -0
- package/src/server.ts +112 -30
- package/src/store/sqlite-db.ts +74 -0
- package/src/types.ts +20 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { countTextTokens, truncateToTokenBudget } from '../compact/token-budget.js';
|
|
3
|
+
import type { EmbeddingProvider } from '../embedding/provider.js';
|
|
4
|
+
import { withTimeout } from '../timeout.js';
|
|
5
|
+
import { sanitizeCredentials } from './secret-filter.js';
|
|
6
|
+
import type { Observation } from '../types.js';
|
|
7
|
+
import { resolveLocalMemoryOwner } from './owner.js';
|
|
8
|
+
import { LongTermMemoryStore } from './long-term-store.js';
|
|
9
|
+
import {
|
|
10
|
+
LONG_TERM_MEMORY_EVIDENCE_KINDS,
|
|
11
|
+
LONG_TERM_MEMORY_EVIDENCE_RELATIONS,
|
|
12
|
+
LONG_TERM_MEMORY_KINDS,
|
|
13
|
+
LONG_TERM_MEMORY_PORTABILITIES,
|
|
14
|
+
LONG_TERM_MEMORY_SCOPES,
|
|
15
|
+
type CreateLongTermMemoryInput,
|
|
16
|
+
type LongTermMemory,
|
|
17
|
+
type LongTermMemoryEvidence,
|
|
18
|
+
type LongTermMemoryEvidenceInput,
|
|
19
|
+
type LongTermMemoryKind,
|
|
20
|
+
type LongTermMemoryPortability,
|
|
21
|
+
type LongTermMemoryReader,
|
|
22
|
+
type LongTermMemoryScope,
|
|
23
|
+
type LongTermMemorySelection,
|
|
24
|
+
} from './long-term-types.js';
|
|
25
|
+
|
|
26
|
+
const MAX_TEXT_LENGTH = 12_000;
|
|
27
|
+
const MAX_LIST_ITEMS = 40;
|
|
28
|
+
const MAX_SEMANTIC_CANDIDATES = 48;
|
|
29
|
+
const SEMANTIC_RETRIEVAL_TIMEOUT_MS = 1_800;
|
|
30
|
+
const MIN_SEMANTIC_SIMILARITY = 0.58;
|
|
31
|
+
const PORTABLE_EVIDENCE_KINDS = new Set(['manual', 'user']);
|
|
32
|
+
const STOP_TERMS = new Set([
|
|
33
|
+
'the', 'and', 'for', 'with', 'from', 'that', 'this', 'into', 'when', 'what',
|
|
34
|
+
'如何', '什么', '这个', '那个', '我们', '项目', '进行', '需要', '继续', '处理',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
function isOneOf<T extends readonly string[]>(value: unknown, values: T): value is T[number] {
|
|
38
|
+
return typeof value === 'string' && (values as readonly string[]).includes(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function compactText(value: string, field: string, max = MAX_TEXT_LENGTH): string {
|
|
42
|
+
if (typeof value !== 'string') throw new Error('Long-term memory ' + field + ' must be text.');
|
|
43
|
+
const safe = sanitizeCredentials(value).replace(/\s+/g, ' ').trim();
|
|
44
|
+
if (!safe) throw new Error('Long-term memory ' + field + ' is required.');
|
|
45
|
+
if (safe.length > max) throw new Error('Long-term memory ' + field + ' is too long.');
|
|
46
|
+
return safe;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function compactList(values: string[] | undefined, field: string): string[] {
|
|
50
|
+
if (!values) return [];
|
|
51
|
+
if (!Array.isArray(values)) throw new Error('Long-term memory ' + field + ' must be an array.');
|
|
52
|
+
return [...new Set(values
|
|
53
|
+
.filter((value): value is string => typeof value === 'string')
|
|
54
|
+
.map(value => compactText(value, field, 500)))]
|
|
55
|
+
.slice(0, MAX_LIST_ITEMS);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeEvidence(input: LongTermMemoryEvidenceInput[]): LongTermMemoryEvidenceInput[] {
|
|
59
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
60
|
+
throw new Error('A long-term memory requires at least one source evidence reference.');
|
|
61
|
+
}
|
|
62
|
+
return input.map((evidence) => {
|
|
63
|
+
if (!evidence || !isOneOf(evidence.kind, LONG_TERM_MEMORY_EVIDENCE_KINDS)) {
|
|
64
|
+
throw new Error('Long-term memory evidence kind is invalid.');
|
|
65
|
+
}
|
|
66
|
+
if (!isOneOf(evidence.relation, LONG_TERM_MEMORY_EVIDENCE_RELATIONS)) {
|
|
67
|
+
throw new Error('Long-term memory evidence relation is invalid.');
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
kind: evidence.kind,
|
|
71
|
+
referenceId: compactText(evidence.referenceId, 'evidence reference', 1_000),
|
|
72
|
+
relation: evidence.relation,
|
|
73
|
+
...(evidence.locator ? { locator: compactText(evidence.locator, 'evidence locator', 1_000) } : {}),
|
|
74
|
+
...(evidence.capturedHash ? { capturedHash: compactText(evidence.capturedHash, 'evidence hash', 1_000) } : {}),
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function assertScope(scope: unknown): asserts scope is LongTermMemoryScope {
|
|
80
|
+
if (!isOneOf(scope, LONG_TERM_MEMORY_SCOPES)) throw new Error('Long-term memory scope is invalid.');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function assertKind(kind: unknown): asserts kind is LongTermMemoryKind {
|
|
84
|
+
if (!isOneOf(kind, LONG_TERM_MEMORY_KINDS)) throw new Error('Long-term memory kind is invalid.');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assertPortability(value: unknown): asserts value is LongTermMemoryPortability {
|
|
88
|
+
if (!isOneOf(value, LONG_TERM_MEMORY_PORTABILITIES)) {
|
|
89
|
+
throw new Error('Long-term memory portability is invalid.');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function assertPortableEvidence(scope: LongTermMemoryScope, portability: LongTermMemoryPortability, evidence: LongTermMemoryEvidenceInput[]): void {
|
|
94
|
+
if (portability !== 'portable') return;
|
|
95
|
+
if (scope !== 'user') {
|
|
96
|
+
throw new Error('Only user-scoped long-term memories may be portable across projects.');
|
|
97
|
+
}
|
|
98
|
+
if (evidence.some(item => !PORTABLE_EVIDENCE_KINDS.has(item.kind))) {
|
|
99
|
+
throw new Error('Portable user memory may only use manual or user-confirmed evidence; project code, Git, observation, test, session, claim, and workflow evidence must remain project-bound.');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function requireTeamReader(scope: LongTermMemoryScope, reader?: LongTermMemoryReader): void {
|
|
104
|
+
if (scope === 'team' && reader?.isTeamMember !== true) {
|
|
105
|
+
throw new Error('Team long-term memory requires an explicit active team identity for this project.');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function matchesScope(memory: LongTermMemory, reader: LongTermMemoryReader): boolean {
|
|
110
|
+
if (memory.scope === 'project') return memory.originProjectId === reader.projectId;
|
|
111
|
+
if (memory.scope === 'team') {
|
|
112
|
+
return memory.originProjectId === reader.projectId && reader.isTeamMember === true;
|
|
113
|
+
}
|
|
114
|
+
if (!reader.ownerId || memory.ownerId !== reader.ownerId) return false;
|
|
115
|
+
return memory.portability === 'portable' || memory.originProjectId === reader.projectId;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function canReadLongTermMemory(memory: LongTermMemory, reader: LongTermMemoryReader): boolean {
|
|
119
|
+
return matchesScope(memory, reader);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function isEligibleLongTermMemory(memory: LongTermMemory): boolean {
|
|
123
|
+
return memory.state === 'qualified' || memory.state === 'approved';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function taskTerms(task: string): string[] {
|
|
127
|
+
const matches = task.toLocaleLowerCase('en-US').match(/[\p{L}\p{N}_./:-]+/gu) ?? [];
|
|
128
|
+
return [...new Set(matches.filter(term => term.length > 1 && !STOP_TERMS.has(term)))].slice(0, 16);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function kindFitScore(kind: LongTermMemoryKind, task: string): number {
|
|
132
|
+
const normalized = task.toLocaleLowerCase('en-US');
|
|
133
|
+
const hints: Record<LongTermMemoryKind, string[]> = {
|
|
134
|
+
episodic: ['resume', 'continue', 'handoff', 'previous', 'session', '接手', '继续', '交接', '上次', '会话'],
|
|
135
|
+
semantic: ['why', 'what', 'architecture', 'design', 'decision', 'reason', '为什么', '是什么', '架构', '设计', '决策', '原因'],
|
|
136
|
+
procedural: ['how', 'steps', 'procedure', 'run', 'release', 'publish', 'verify', 'implement', '流程', '步骤', '发布', '验证', '修复', '实现'],
|
|
137
|
+
};
|
|
138
|
+
return hints[kind].some(hint => normalized.includes(hint)) ? 1 : 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function validationFreshnessScore(memory: LongTermMemory): number {
|
|
142
|
+
const validatedAt = memory.lastValidatedAt ?? memory.approvedAt ?? memory.qualifiedAt;
|
|
143
|
+
if (!validatedAt) return 0;
|
|
144
|
+
const time = Date.parse(validatedAt);
|
|
145
|
+
if (!Number.isFinite(time)) return 0;
|
|
146
|
+
const ageDays = Math.max(0, Date.now() - time) / (24 * 60 * 60 * 1000);
|
|
147
|
+
if (ageDays <= 30) return 0.35;
|
|
148
|
+
if (ageDays <= 90) return 0.15;
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function scoreForTask(memory: LongTermMemory, task: string): { score: number; reason: string } | undefined {
|
|
153
|
+
const terms = taskTerms(task);
|
|
154
|
+
if (terms.length === 0) return undefined;
|
|
155
|
+
const title = memory.title.toLocaleLowerCase('en-US');
|
|
156
|
+
const content = memory.content.toLocaleLowerCase('en-US');
|
|
157
|
+
const facts = memory.facts.join(' ').toLocaleLowerCase('en-US');
|
|
158
|
+
const tags = memory.tags.join(' ').toLocaleLowerCase('en-US');
|
|
159
|
+
const applicability = (memory.applicability ?? '').toLocaleLowerCase('en-US');
|
|
160
|
+
let score = memory.state === 'approved' ? 0.5 : 0;
|
|
161
|
+
const matched: string[] = [];
|
|
162
|
+
for (const term of terms) {
|
|
163
|
+
let termScore = 0;
|
|
164
|
+
if (title.includes(term)) termScore += 5;
|
|
165
|
+
if (tags.includes(term)) termScore += 4;
|
|
166
|
+
if (applicability.includes(term)) termScore += 3;
|
|
167
|
+
if (facts.includes(term)) termScore += 2;
|
|
168
|
+
if (content.includes(term)) termScore += 1;
|
|
169
|
+
if (termScore > 0) {
|
|
170
|
+
score += termScore;
|
|
171
|
+
matched.push(term);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (matched.length === 0) return undefined;
|
|
175
|
+
score += kindFitScore(memory.kind, task);
|
|
176
|
+
score += validationFreshnessScore(memory);
|
|
177
|
+
const kindLabel = memory.kind === 'procedural' ? 'procedure' : memory.kind === 'semantic' ? 'fact' : 'episode';
|
|
178
|
+
const freshness = validationFreshnessScore(memory) > 0 ? '; recently validated' : '';
|
|
179
|
+
return { score, reason: kindLabel + ' matches ' + matched.slice(0, 3).join(', ') + freshness };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
type LongTermEmbeddingProvider = Pick<EmbeddingProvider, 'embedBatch'>;
|
|
183
|
+
|
|
184
|
+
function semanticText(memory: LongTermMemory): string {
|
|
185
|
+
return sanitizeCredentials([
|
|
186
|
+
memory.title,
|
|
187
|
+
memory.applicability,
|
|
188
|
+
memory.tags.join(' '),
|
|
189
|
+
memory.facts.join(' '),
|
|
190
|
+
memory.content,
|
|
191
|
+
].filter((value): value is string => Boolean(value)).join('\n'))
|
|
192
|
+
.replace(/\s+/g, ' ')
|
|
193
|
+
.trim()
|
|
194
|
+
.slice(0, MAX_TEXT_LENGTH);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function cosineSimilarity(left: number[], right: number[]): number {
|
|
198
|
+
if (left.length === 0 || left.length !== right.length) return 0;
|
|
199
|
+
let dot = 0;
|
|
200
|
+
let leftMagnitude = 0;
|
|
201
|
+
let rightMagnitude = 0;
|
|
202
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
203
|
+
const a = left[index];
|
|
204
|
+
const b = right[index];
|
|
205
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return 0;
|
|
206
|
+
dot += a * b;
|
|
207
|
+
leftMagnitude += a * a;
|
|
208
|
+
rightMagnitude += b * b;
|
|
209
|
+
}
|
|
210
|
+
if (leftMagnitude === 0 || rightMagnitude === 0) return 0;
|
|
211
|
+
return dot / Math.sqrt(leftMagnitude * rightMagnitude);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function semanticSelection(memory: LongTermMemory, similarity: number, task: string): { score: number; reason: string } | undefined {
|
|
215
|
+
if (similarity < MIN_SEMANTIC_SIMILARITY) return undefined;
|
|
216
|
+
const kindLabel = memory.kind === 'procedural' ? 'procedure' : memory.kind === 'semantic' ? 'fact' : 'episode';
|
|
217
|
+
const freshness = validationFreshnessScore(memory) > 0 ? '; recently validated' : '';
|
|
218
|
+
return {
|
|
219
|
+
score: similarity * 10 + (memory.state === 'approved' ? 0.5 : 0) + kindFitScore(memory.kind, task) + validationFreshnessScore(memory),
|
|
220
|
+
reason: kindLabel + ' semantic match ' + similarity.toFixed(2) + freshness,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function resolveSemanticProvider(explicit?: LongTermEmbeddingProvider | null): Promise<LongTermEmbeddingProvider | null> {
|
|
225
|
+
if (explicit !== undefined) return explicit;
|
|
226
|
+
try {
|
|
227
|
+
const { getEmbeddingProvider } = await import('../embedding/provider.js');
|
|
228
|
+
return await withTimeout(
|
|
229
|
+
getEmbeddingProvider({ requestTimeoutMs: SEMANTIC_RETRIEVAL_TIMEOUT_MS, retry: false }),
|
|
230
|
+
SEMANTIC_RETRIEVAL_TIMEOUT_MS,
|
|
231
|
+
'Long-term semantic provider initialization',
|
|
232
|
+
);
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function createStore(dataDir: string): Promise<LongTermMemoryStore> {
|
|
239
|
+
const store = new LongTermMemoryStore();
|
|
240
|
+
return store.init(dataDir).then(() => store);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface CreateManualLongTermMemoryInput {
|
|
244
|
+
dataDir: string;
|
|
245
|
+
projectId: string;
|
|
246
|
+
scope: LongTermMemoryScope;
|
|
247
|
+
kind: LongTermMemoryKind;
|
|
248
|
+
title: string;
|
|
249
|
+
content: string;
|
|
250
|
+
facts?: string[];
|
|
251
|
+
tags?: string[];
|
|
252
|
+
applicability?: string;
|
|
253
|
+
portability?: LongTermMemoryPortability;
|
|
254
|
+
reader?: LongTermMemoryReader;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Create a local manually declared candidate with explicit user/manual provenance. */
|
|
258
|
+
export async function createManualLongTermMemory(input: CreateManualLongTermMemoryInput): Promise<{
|
|
259
|
+
memory: LongTermMemory;
|
|
260
|
+
evidence: LongTermMemoryEvidence[];
|
|
261
|
+
}> {
|
|
262
|
+
const owner = await resolveLocalMemoryOwner(input.dataDir, { create: true });
|
|
263
|
+
if (!owner) throw new Error('Memorix could not resolve a local memory owner.');
|
|
264
|
+
assertScope(input.scope);
|
|
265
|
+
const evidence: LongTermMemoryEvidenceInput[] = input.scope === 'user'
|
|
266
|
+
? [{ kind: 'user', referenceId: owner.id, relation: 'user-confirmed' }]
|
|
267
|
+
: [{ kind: 'manual', referenceId: 'manual:' + randomUUID(), relation: 'supports' }];
|
|
268
|
+
return createLongTermMemoryWithDataDir({
|
|
269
|
+
dataDir: input.dataDir,
|
|
270
|
+
originProjectId: input.projectId,
|
|
271
|
+
ownerId: owner.id,
|
|
272
|
+
scope: input.scope,
|
|
273
|
+
kind: input.kind,
|
|
274
|
+
portability: input.portability,
|
|
275
|
+
title: input.title,
|
|
276
|
+
content: input.content,
|
|
277
|
+
facts: input.facts,
|
|
278
|
+
tags: input.tags,
|
|
279
|
+
applicability: input.applicability,
|
|
280
|
+
origin: 'manual',
|
|
281
|
+
evidence,
|
|
282
|
+
reader: input.reader,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface PromoteObservationToLongTermInput {
|
|
287
|
+
dataDir: string;
|
|
288
|
+
observation: Observation;
|
|
289
|
+
scope: LongTermMemoryScope;
|
|
290
|
+
kind: LongTermMemoryKind;
|
|
291
|
+
tags?: string[];
|
|
292
|
+
applicability?: string;
|
|
293
|
+
reader?: LongTermMemoryReader;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Promote an existing observation without duplicating or hiding its source.
|
|
298
|
+
* Observation-derived items are deliberately project-bound.
|
|
299
|
+
*/
|
|
300
|
+
export async function promoteObservationToLongTermMemory(input: PromoteObservationToLongTermInput): Promise<{
|
|
301
|
+
memory: LongTermMemory;
|
|
302
|
+
evidence: LongTermMemoryEvidence[];
|
|
303
|
+
}> {
|
|
304
|
+
const owner = await resolveLocalMemoryOwner(input.dataDir, { create: true });
|
|
305
|
+
if (!owner) throw new Error('Memorix could not resolve a local memory owner.');
|
|
306
|
+
return createLongTermMemoryWithDataDir({
|
|
307
|
+
dataDir: input.dataDir,
|
|
308
|
+
originProjectId: input.observation.projectId,
|
|
309
|
+
ownerId: owner.id,
|
|
310
|
+
scope: input.scope,
|
|
311
|
+
kind: input.kind,
|
|
312
|
+
portability: 'project-bound',
|
|
313
|
+
title: input.observation.title,
|
|
314
|
+
content: input.observation.narrative,
|
|
315
|
+
facts: input.observation.facts,
|
|
316
|
+
tags: input.tags ?? input.observation.concepts,
|
|
317
|
+
applicability: input.applicability,
|
|
318
|
+
origin: input.observation.source === 'git' ? 'git' : 'agent',
|
|
319
|
+
evidence: [{
|
|
320
|
+
kind: 'observation',
|
|
321
|
+
referenceId: 'obs:' + input.observation.projectId + ':' + input.observation.id,
|
|
322
|
+
relation: 'derives',
|
|
323
|
+
}],
|
|
324
|
+
reader: input.reader,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export interface CreateLongTermMemoryCandidateInput extends CreateLongTermMemoryInput {
|
|
329
|
+
dataDir: string;
|
|
330
|
+
reader?: LongTermMemoryReader;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Create a candidate only. Qualification and approval are deliberate later transitions. */
|
|
334
|
+
export async function createLongTermMemoryCandidate(input: CreateLongTermMemoryCandidateInput): Promise<{
|
|
335
|
+
memory: LongTermMemory;
|
|
336
|
+
evidence: LongTermMemoryEvidence[];
|
|
337
|
+
}> {
|
|
338
|
+
return createLongTermMemoryWithDataDir(input);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function createLongTermMemoryWithDataDir(input: CreateLongTermMemoryCandidateInput): Promise<{
|
|
342
|
+
memory: LongTermMemory;
|
|
343
|
+
evidence: LongTermMemoryEvidence[];
|
|
344
|
+
}> {
|
|
345
|
+
assertScope(input.scope);
|
|
346
|
+
assertKind(input.kind);
|
|
347
|
+
const portability = input.portability ?? 'project-bound';
|
|
348
|
+
assertPortability(portability);
|
|
349
|
+
requireTeamReader(input.scope, input.reader);
|
|
350
|
+
const evidenceInputs = normalizeEvidence(input.evidence);
|
|
351
|
+
assertPortableEvidence(input.scope, portability, evidenceInputs);
|
|
352
|
+
const now = new Date().toISOString();
|
|
353
|
+
const memory: LongTermMemory = {
|
|
354
|
+
id: randomUUID(),
|
|
355
|
+
originProjectId: compactText(input.originProjectId, 'origin project id', 1_000),
|
|
356
|
+
ownerId: compactText(input.ownerId, 'owner id', 160),
|
|
357
|
+
scope: input.scope,
|
|
358
|
+
kind: input.kind,
|
|
359
|
+
state: 'candidate',
|
|
360
|
+
portability,
|
|
361
|
+
title: compactText(input.title, 'title', 500),
|
|
362
|
+
content: compactText(input.content, 'content'),
|
|
363
|
+
facts: compactList(input.facts, 'facts'),
|
|
364
|
+
tags: compactList(input.tags, 'tags'),
|
|
365
|
+
...(input.applicability ? { applicability: compactText(input.applicability, 'applicability', 1_000) } : {}),
|
|
366
|
+
origin: input.origin ?? 'manual',
|
|
367
|
+
createdAt: now,
|
|
368
|
+
updatedAt: now,
|
|
369
|
+
accessCount: 0,
|
|
370
|
+
};
|
|
371
|
+
const store = await createStore(input.dataDir);
|
|
372
|
+
const evidence = store.transaction(() => {
|
|
373
|
+
store.insert(memory);
|
|
374
|
+
const written = evidenceInputs.map(item => store.insertEvidence({ memoryId: memory.id, ...item, createdAt: now }));
|
|
375
|
+
store.recordEvent({
|
|
376
|
+
memoryId: memory.id,
|
|
377
|
+
kind: 'created',
|
|
378
|
+
toState: 'candidate',
|
|
379
|
+
detail: 'Created as a source-backed long-term memory candidate.',
|
|
380
|
+
createdAt: now,
|
|
381
|
+
});
|
|
382
|
+
return written;
|
|
383
|
+
});
|
|
384
|
+
return { memory, evidence };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function transitionDetail(value: string): string {
|
|
388
|
+
return compactText(value, 'transition reason', 1_000);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function transitionMemory(input: {
|
|
392
|
+
dataDir: string;
|
|
393
|
+
id: string;
|
|
394
|
+
expected: 'candidate' | 'qualified';
|
|
395
|
+
next: 'qualified' | 'approved';
|
|
396
|
+
event: 'qualified' | 'approved';
|
|
397
|
+
reason: string;
|
|
398
|
+
}): Promise<LongTermMemory> {
|
|
399
|
+
const store = await createStore(input.dataDir);
|
|
400
|
+
return store.transaction(() => {
|
|
401
|
+
const current = store.get(input.id);
|
|
402
|
+
if (!current) throw new Error('Long-term memory was not found.');
|
|
403
|
+
if (current.state !== input.expected) {
|
|
404
|
+
throw new Error('Long-term memory must be ' + input.expected + ' before it can be ' + input.next + '.');
|
|
405
|
+
}
|
|
406
|
+
const evidence = store.listEvidence(current.id);
|
|
407
|
+
if (evidence.length === 0) throw new Error('Long-term memory cannot advance without source evidence.');
|
|
408
|
+
const now = new Date().toISOString();
|
|
409
|
+
const next: LongTermMemory = {
|
|
410
|
+
...current,
|
|
411
|
+
state: input.next,
|
|
412
|
+
updatedAt: now,
|
|
413
|
+
lastValidatedAt: now,
|
|
414
|
+
...(input.next === 'qualified' ? { qualifiedAt: now } : { approvedAt: now }),
|
|
415
|
+
};
|
|
416
|
+
store.update(next);
|
|
417
|
+
store.recordEvent({
|
|
418
|
+
memoryId: next.id,
|
|
419
|
+
kind: input.event,
|
|
420
|
+
fromState: current.state,
|
|
421
|
+
toState: next.state,
|
|
422
|
+
detail: transitionDetail(input.reason),
|
|
423
|
+
createdAt: now,
|
|
424
|
+
});
|
|
425
|
+
return next;
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export async function qualifyLongTermMemory(input: { dataDir: string; id: string; reason: string }): Promise<LongTermMemory> {
|
|
430
|
+
return transitionMemory({ ...input, expected: 'candidate', next: 'qualified', event: 'qualified' });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export async function approveLongTermMemory(input: { dataDir: string; id: string; reason: string }): Promise<LongTermMemory> {
|
|
434
|
+
return transitionMemory({ ...input, expected: 'qualified', next: 'approved', event: 'approved' });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export async function archiveLongTermMemory(input: { dataDir: string; id: string; reason: string }): Promise<LongTermMemory> {
|
|
438
|
+
const store = await createStore(input.dataDir);
|
|
439
|
+
return store.transaction(() => {
|
|
440
|
+
const current = store.get(input.id);
|
|
441
|
+
if (!current) throw new Error('Long-term memory was not found.');
|
|
442
|
+
if (current.state === 'archived' || current.state === 'superseded') {
|
|
443
|
+
throw new Error('Long-term memory is already inactive.');
|
|
444
|
+
}
|
|
445
|
+
const now = new Date().toISOString();
|
|
446
|
+
const next: LongTermMemory = { ...current, state: 'archived', archivedAt: now, updatedAt: now };
|
|
447
|
+
store.update(next);
|
|
448
|
+
store.recordEvent({
|
|
449
|
+
memoryId: next.id,
|
|
450
|
+
kind: 'archived',
|
|
451
|
+
fromState: current.state,
|
|
452
|
+
toState: 'archived',
|
|
453
|
+
detail: transitionDetail(input.reason),
|
|
454
|
+
createdAt: now,
|
|
455
|
+
});
|
|
456
|
+
return next;
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Retire an active record in favor of another qualified or approved record.
|
|
462
|
+
* The replacement remains its own audited artifact; this only records the
|
|
463
|
+
* relationship and prevents the old record from entering future Worksets.
|
|
464
|
+
*/
|
|
465
|
+
export async function supersedeLongTermMemory(input: {
|
|
466
|
+
dataDir: string;
|
|
467
|
+
id: string;
|
|
468
|
+
supersededBy: string;
|
|
469
|
+
reason: string;
|
|
470
|
+
}): Promise<LongTermMemory> {
|
|
471
|
+
const store = await createStore(input.dataDir);
|
|
472
|
+
return store.transaction(() => {
|
|
473
|
+
const current = store.get(input.id);
|
|
474
|
+
if (!current) throw new Error('Long-term memory was not found.');
|
|
475
|
+
if (current.state === 'archived' || current.state === 'superseded') {
|
|
476
|
+
throw new Error('Long-term memory is already inactive.');
|
|
477
|
+
}
|
|
478
|
+
if (current.id === input.supersededBy) {
|
|
479
|
+
throw new Error('Long-term memory cannot supersede itself.');
|
|
480
|
+
}
|
|
481
|
+
const replacement = store.get(input.supersededBy);
|
|
482
|
+
if (!replacement || !isEligibleLongTermMemory(replacement)) {
|
|
483
|
+
throw new Error('Replacement long-term memory must be qualified or approved.');
|
|
484
|
+
}
|
|
485
|
+
if (replacement.scope !== current.scope) {
|
|
486
|
+
throw new Error('Replacement long-term memory must use the same scope.');
|
|
487
|
+
}
|
|
488
|
+
if (current.scope === 'user' && replacement.ownerId !== current.ownerId) {
|
|
489
|
+
throw new Error('User long-term memory may only be superseded by the same local owner.');
|
|
490
|
+
}
|
|
491
|
+
if (current.scope !== 'user' && replacement.originProjectId !== current.originProjectId) {
|
|
492
|
+
throw new Error('Project and team long-term memory may only be superseded within the same project.');
|
|
493
|
+
}
|
|
494
|
+
const now = new Date().toISOString();
|
|
495
|
+
const next: LongTermMemory = {
|
|
496
|
+
...current,
|
|
497
|
+
state: 'superseded',
|
|
498
|
+
supersededBy: replacement.id,
|
|
499
|
+
updatedAt: now,
|
|
500
|
+
};
|
|
501
|
+
store.update(next);
|
|
502
|
+
store.recordEvent({
|
|
503
|
+
memoryId: next.id,
|
|
504
|
+
kind: 'superseded',
|
|
505
|
+
fromState: current.state,
|
|
506
|
+
toState: 'superseded',
|
|
507
|
+
detail: transitionDetail(input.reason),
|
|
508
|
+
createdAt: now,
|
|
509
|
+
});
|
|
510
|
+
return next;
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export async function listLongTermMemories(input: {
|
|
515
|
+
dataDir: string;
|
|
516
|
+
reader: LongTermMemoryReader;
|
|
517
|
+
includeInactive?: boolean;
|
|
518
|
+
limit?: number;
|
|
519
|
+
}): Promise<Array<{ memory: LongTermMemory; evidence: LongTermMemoryEvidence[] }>> {
|
|
520
|
+
const store = await createStore(input.dataDir);
|
|
521
|
+
return store.list({ limit: input.limit ?? 100 })
|
|
522
|
+
.filter(memory => canReadLongTermMemory(memory, input.reader))
|
|
523
|
+
.filter(memory => input.includeInactive || memory.state !== 'archived' && memory.state !== 'superseded')
|
|
524
|
+
.map(memory => ({ memory, evidence: store.listEvidence(memory.id) }));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export async function getLongTermMemoryDetail(input: {
|
|
528
|
+
dataDir: string;
|
|
529
|
+
id: string;
|
|
530
|
+
reader: LongTermMemoryReader;
|
|
531
|
+
}): Promise<{ memory: LongTermMemory; evidence: LongTermMemoryEvidence[]; events: ReturnType<LongTermMemoryStore['listEvents']> }> {
|
|
532
|
+
const store = await createStore(input.dataDir);
|
|
533
|
+
const memory = store.get(input.id);
|
|
534
|
+
if (!memory || !canReadLongTermMemory(memory, input.reader)) {
|
|
535
|
+
throw new Error('Long-term memory was not found in the active scope.');
|
|
536
|
+
}
|
|
537
|
+
store.markAccess([memory.id]);
|
|
538
|
+
return { memory, evidence: store.listEvidence(memory.id), events: store.listEvents(memory.id) };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export async function selectLongTermMemoriesForTask(input: {
|
|
542
|
+
dataDir: string;
|
|
543
|
+
projectId: string;
|
|
544
|
+
task?: string;
|
|
545
|
+
agentId?: string;
|
|
546
|
+
isTeamMember?: boolean;
|
|
547
|
+
limit?: number;
|
|
548
|
+
/** Tests and bounded integrations may provide an already-ready provider. Null disables semantic fallback. */
|
|
549
|
+
embeddingProvider?: LongTermEmbeddingProvider | null;
|
|
550
|
+
}): Promise<LongTermMemorySelection[]> {
|
|
551
|
+
const task = input.task?.trim();
|
|
552
|
+
if (!task) return [];
|
|
553
|
+
const owner = await resolveLocalMemoryOwner(input.dataDir, { create: false });
|
|
554
|
+
const reader: LongTermMemoryReader = {
|
|
555
|
+
projectId: input.projectId,
|
|
556
|
+
...(owner ? { ownerId: owner.id } : {}),
|
|
557
|
+
...(input.agentId ? { agentId: input.agentId } : {}),
|
|
558
|
+
...(input.isTeamMember ? { isTeamMember: true } : {}),
|
|
559
|
+
};
|
|
560
|
+
const store = await createStore(input.dataDir);
|
|
561
|
+
const eligible = store.list({ limit: 1_000 })
|
|
562
|
+
.filter(memory => isEligibleLongTermMemory(memory) && canReadLongTermMemory(memory, reader));
|
|
563
|
+
let selected = eligible
|
|
564
|
+
.map(memory => {
|
|
565
|
+
const ranked = scoreForTask(memory, task);
|
|
566
|
+
return ranked ? {
|
|
567
|
+
memory,
|
|
568
|
+
evidence: store.listEvidence(memory.id),
|
|
569
|
+
score: ranked.score,
|
|
570
|
+
reason: ranked.reason,
|
|
571
|
+
} : undefined;
|
|
572
|
+
})
|
|
573
|
+
.filter((item): item is LongTermMemorySelection => Boolean(item))
|
|
574
|
+
.sort((left, right) => right.score - left.score || right.memory.updatedAt.localeCompare(left.memory.updatedAt) || left.memory.id.localeCompare(right.memory.id));
|
|
575
|
+
|
|
576
|
+
// Keyword matches remain the fast, deterministic primary route. Semantic
|
|
577
|
+
// fallback only runs when none matched, after scope and lifecycle filtering,
|
|
578
|
+
// so it cannot turn an unrelated global record into default context.
|
|
579
|
+
if (selected.length === 0 && eligible.length > 0) {
|
|
580
|
+
const provider = await resolveSemanticProvider(input.embeddingProvider);
|
|
581
|
+
const candidates = eligible.slice(0, MAX_SEMANTIC_CANDIDATES);
|
|
582
|
+
if (provider && candidates.length > 0) {
|
|
583
|
+
try {
|
|
584
|
+
const vectors = await withTimeout(
|
|
585
|
+
provider.embedBatch([task, ...candidates.map(semanticText)], {
|
|
586
|
+
timeoutMs: SEMANTIC_RETRIEVAL_TIMEOUT_MS,
|
|
587
|
+
retry: false,
|
|
588
|
+
}),
|
|
589
|
+
SEMANTIC_RETRIEVAL_TIMEOUT_MS,
|
|
590
|
+
'Long-term semantic retrieval',
|
|
591
|
+
);
|
|
592
|
+
const query = vectors[0];
|
|
593
|
+
if (Array.isArray(query)) {
|
|
594
|
+
selected = candidates
|
|
595
|
+
.map((memory, index) => {
|
|
596
|
+
const vector = vectors[index + 1];
|
|
597
|
+
const ranked = Array.isArray(vector)
|
|
598
|
+
? semanticSelection(memory, cosineSimilarity(query, vector), task)
|
|
599
|
+
: undefined;
|
|
600
|
+
return ranked ? {
|
|
601
|
+
memory,
|
|
602
|
+
evidence: store.listEvidence(memory.id),
|
|
603
|
+
score: ranked.score,
|
|
604
|
+
reason: ranked.reason,
|
|
605
|
+
} : undefined;
|
|
606
|
+
})
|
|
607
|
+
.filter((item): item is LongTermMemorySelection => Boolean(item))
|
|
608
|
+
.sort((left, right) => right.score - left.score || right.memory.updatedAt.localeCompare(left.memory.updatedAt) || left.memory.id.localeCompare(right.memory.id));
|
|
609
|
+
}
|
|
610
|
+
} catch {
|
|
611
|
+
// Semantic enrichment is optional. A slow or unavailable provider must
|
|
612
|
+
// preserve the normal keyword-only retrieval path.
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
selected = selected.slice(0, Math.max(1, Math.min(input.limit ?? 3, 5)));
|
|
618
|
+
store.markAccess(selected.map(item => item.memory.id));
|
|
619
|
+
return selected;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export function renderLongTermMemorySummary(memory: LongTermMemory, maxTokens = 22): string {
|
|
623
|
+
const source = [memory.title, memory.applicability, memory.content].filter(Boolean).join(': ');
|
|
624
|
+
const safe = sanitizeCredentials(source).replace(/\s+/g, ' ').trim();
|
|
625
|
+
return countTextTokens(safe) <= maxTokens ? safe : truncateToTokenBudget(safe, maxTokens);
|
|
626
|
+
}
|