claude-memory-layer 1.0.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/.claude-plugin/commands/memory-forget.md +42 -0
- package/.claude-plugin/commands/memory-history.md +34 -0
- package/.claude-plugin/commands/memory-import.md +56 -0
- package/.claude-plugin/commands/memory-list.md +37 -0
- package/.claude-plugin/commands/memory-search.md +36 -0
- package/.claude-plugin/commands/memory-stats.md +34 -0
- package/.claude-plugin/hooks.json +59 -0
- package/.claude-plugin/plugin.json +24 -0
- package/.history/package_20260201112328.json +45 -0
- package/.history/package_20260201113602.json +45 -0
- package/.history/package_20260201113713.json +45 -0
- package/.history/package_20260201114110.json +45 -0
- package/Memo.txt +558 -0
- package/README.md +520 -0
- package/context.md +636 -0
- package/dist/.claude-plugin/commands/memory-forget.md +42 -0
- package/dist/.claude-plugin/commands/memory-history.md +34 -0
- package/dist/.claude-plugin/commands/memory-import.md +56 -0
- package/dist/.claude-plugin/commands/memory-list.md +37 -0
- package/dist/.claude-plugin/commands/memory-search.md +36 -0
- package/dist/.claude-plugin/commands/memory-stats.md +34 -0
- package/dist/.claude-plugin/hooks.json +59 -0
- package/dist/.claude-plugin/plugin.json +24 -0
- package/dist/cli/index.js +3539 -0
- package/dist/cli/index.js.map +7 -0
- package/dist/core/index.js +4408 -0
- package/dist/core/index.js.map +7 -0
- package/dist/hooks/session-end.js +2971 -0
- package/dist/hooks/session-end.js.map +7 -0
- package/dist/hooks/session-start.js +2969 -0
- package/dist/hooks/session-start.js.map +7 -0
- package/dist/hooks/stop.js +3123 -0
- package/dist/hooks/stop.js.map +7 -0
- package/dist/hooks/user-prompt-submit.js +2960 -0
- package/dist/hooks/user-prompt-submit.js.map +7 -0
- package/dist/services/memory-service.js +2931 -0
- package/dist/services/memory-service.js.map +7 -0
- package/package.json +45 -0
- package/plan.md +1642 -0
- package/scripts/build.ts +102 -0
- package/spec.md +624 -0
- package/specs/citations-system/context.md +243 -0
- package/specs/citations-system/plan.md +495 -0
- package/specs/citations-system/spec.md +371 -0
- package/specs/endless-mode/context.md +305 -0
- package/specs/endless-mode/plan.md +620 -0
- package/specs/endless-mode/spec.md +455 -0
- package/specs/entity-edge-model/context.md +401 -0
- package/specs/entity-edge-model/plan.md +459 -0
- package/specs/entity-edge-model/spec.md +391 -0
- package/specs/evidence-aligner-v2/context.md +401 -0
- package/specs/evidence-aligner-v2/plan.md +303 -0
- package/specs/evidence-aligner-v2/spec.md +312 -0
- package/specs/mcp-desktop-integration/context.md +278 -0
- package/specs/mcp-desktop-integration/plan.md +550 -0
- package/specs/mcp-desktop-integration/spec.md +494 -0
- package/specs/post-tool-use-hook/context.md +319 -0
- package/specs/post-tool-use-hook/plan.md +469 -0
- package/specs/post-tool-use-hook/spec.md +364 -0
- package/specs/private-tags/context.md +288 -0
- package/specs/private-tags/plan.md +412 -0
- package/specs/private-tags/spec.md +345 -0
- package/specs/progressive-disclosure/context.md +346 -0
- package/specs/progressive-disclosure/plan.md +663 -0
- package/specs/progressive-disclosure/spec.md +415 -0
- package/specs/task-entity-system/context.md +297 -0
- package/specs/task-entity-system/plan.md +301 -0
- package/specs/task-entity-system/spec.md +314 -0
- package/specs/vector-outbox-v2/context.md +470 -0
- package/specs/vector-outbox-v2/plan.md +562 -0
- package/specs/vector-outbox-v2/spec.md +466 -0
- package/specs/web-viewer-ui/context.md +384 -0
- package/specs/web-viewer-ui/plan.md +797 -0
- package/specs/web-viewer-ui/spec.md +516 -0
- package/src/cli/index.ts +570 -0
- package/src/core/canonical-key.ts +186 -0
- package/src/core/citation-generator.ts +63 -0
- package/src/core/consolidated-store.ts +279 -0
- package/src/core/consolidation-worker.ts +384 -0
- package/src/core/context-formatter.ts +276 -0
- package/src/core/continuity-manager.ts +336 -0
- package/src/core/edge-repo.ts +324 -0
- package/src/core/embedder.ts +124 -0
- package/src/core/entity-repo.ts +342 -0
- package/src/core/event-store.ts +672 -0
- package/src/core/evidence-aligner.ts +635 -0
- package/src/core/graduation.ts +365 -0
- package/src/core/index.ts +32 -0
- package/src/core/matcher.ts +210 -0
- package/src/core/metadata-extractor.ts +203 -0
- package/src/core/privacy/filter.ts +179 -0
- package/src/core/privacy/index.ts +20 -0
- package/src/core/privacy/tag-parser.ts +145 -0
- package/src/core/progressive-retriever.ts +415 -0
- package/src/core/retriever.ts +235 -0
- package/src/core/task/blocker-resolver.ts +325 -0
- package/src/core/task/index.ts +9 -0
- package/src/core/task/task-matcher.ts +238 -0
- package/src/core/task/task-projector.ts +345 -0
- package/src/core/task/task-resolver.ts +414 -0
- package/src/core/types.ts +841 -0
- package/src/core/vector-outbox.ts +295 -0
- package/src/core/vector-store.ts +182 -0
- package/src/core/vector-worker.ts +488 -0
- package/src/core/working-set-store.ts +244 -0
- package/src/hooks/post-tool-use.ts +127 -0
- package/src/hooks/session-end.ts +78 -0
- package/src/hooks/session-start.ts +57 -0
- package/src/hooks/stop.ts +78 -0
- package/src/hooks/user-prompt-submit.ts +54 -0
- package/src/mcp/handlers.ts +212 -0
- package/src/mcp/index.ts +47 -0
- package/src/mcp/tools.ts +78 -0
- package/src/server/api/citations.ts +101 -0
- package/src/server/api/events.ts +101 -0
- package/src/server/api/index.ts +18 -0
- package/src/server/api/search.ts +98 -0
- package/src/server/api/sessions.ts +111 -0
- package/src/server/api/stats.ts +97 -0
- package/src/server/index.ts +91 -0
- package/src/services/memory-service.ts +626 -0
- package/src/services/session-history-importer.ts +367 -0
- package/tests/canonical-key.test.ts +101 -0
- package/tests/evidence-aligner.test.ts +152 -0
- package/tests/matcher.test.ts +112 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Continuity Manager
|
|
3
|
+
* Tracks and calculates context continuity between interactions
|
|
4
|
+
* Biomimetic: Simulates context-dependent memory retrieval
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
8
|
+
import { Database } from 'duckdb';
|
|
9
|
+
import type {
|
|
10
|
+
EndlessModeConfig,
|
|
11
|
+
ContextSnapshot,
|
|
12
|
+
ContinuityScore,
|
|
13
|
+
TransitionType,
|
|
14
|
+
ContinuityLog
|
|
15
|
+
} from './types.js';
|
|
16
|
+
import { EventStore } from './event-store.js';
|
|
17
|
+
|
|
18
|
+
export class ContinuityManager {
|
|
19
|
+
private lastContext: ContextSnapshot | null = null;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
private eventStore: EventStore,
|
|
23
|
+
private config: EndlessModeConfig
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
private get db(): Database {
|
|
27
|
+
return this.eventStore.getDatabase();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Calculate continuity score between current and previous context
|
|
32
|
+
*/
|
|
33
|
+
async calculateScore(
|
|
34
|
+
currentContext: ContextSnapshot,
|
|
35
|
+
previousContext?: ContextSnapshot
|
|
36
|
+
): Promise<ContinuityScore> {
|
|
37
|
+
const prev = previousContext || this.lastContext;
|
|
38
|
+
|
|
39
|
+
if (!prev) {
|
|
40
|
+
// No previous context - this is a fresh start
|
|
41
|
+
this.lastContext = currentContext;
|
|
42
|
+
return { score: 0.5, transitionType: 'break' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let score = 0;
|
|
46
|
+
|
|
47
|
+
// Topic continuity (30%)
|
|
48
|
+
const topicOverlap = this.calculateOverlap(
|
|
49
|
+
currentContext.topics,
|
|
50
|
+
prev.topics
|
|
51
|
+
);
|
|
52
|
+
score += topicOverlap * 0.3;
|
|
53
|
+
|
|
54
|
+
// File continuity (20%)
|
|
55
|
+
const fileOverlap = this.calculateOverlap(
|
|
56
|
+
currentContext.files,
|
|
57
|
+
prev.files
|
|
58
|
+
);
|
|
59
|
+
score += fileOverlap * 0.2;
|
|
60
|
+
|
|
61
|
+
// Time proximity (30%)
|
|
62
|
+
const timeDiff = currentContext.timestamp - prev.timestamp;
|
|
63
|
+
const decayHours = this.config.continuity.topicDecayHours;
|
|
64
|
+
const timeScore = Math.exp(-timeDiff / (decayHours * 3600000));
|
|
65
|
+
score += timeScore * 0.3;
|
|
66
|
+
|
|
67
|
+
// Entity continuity (20%)
|
|
68
|
+
const entityOverlap = this.calculateOverlap(
|
|
69
|
+
currentContext.entities,
|
|
70
|
+
prev.entities
|
|
71
|
+
);
|
|
72
|
+
score += entityOverlap * 0.2;
|
|
73
|
+
|
|
74
|
+
// Determine transition type
|
|
75
|
+
const transitionType = this.determineTransitionType(score);
|
|
76
|
+
|
|
77
|
+
// Log the transition
|
|
78
|
+
await this.logTransition(currentContext, prev, score, transitionType);
|
|
79
|
+
|
|
80
|
+
// Update last context
|
|
81
|
+
this.lastContext = currentContext;
|
|
82
|
+
|
|
83
|
+
return { score, transitionType };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create a context snapshot from current state
|
|
88
|
+
*/
|
|
89
|
+
createSnapshot(
|
|
90
|
+
id: string,
|
|
91
|
+
content: string,
|
|
92
|
+
metadata?: {
|
|
93
|
+
files?: string[];
|
|
94
|
+
entities?: string[];
|
|
95
|
+
}
|
|
96
|
+
): ContextSnapshot {
|
|
97
|
+
return {
|
|
98
|
+
id,
|
|
99
|
+
timestamp: Date.now(),
|
|
100
|
+
topics: this.extractTopics(content),
|
|
101
|
+
files: metadata?.files || this.extractFiles(content),
|
|
102
|
+
entities: metadata?.entities || this.extractEntities(content)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get recent continuity logs
|
|
108
|
+
*/
|
|
109
|
+
async getRecentLogs(limit: number = 10): Promise<ContinuityLog[]> {
|
|
110
|
+
const rows = await this.db.all<Array<Record<string, unknown>>>(
|
|
111
|
+
`SELECT * FROM continuity_log
|
|
112
|
+
ORDER BY created_at DESC
|
|
113
|
+
LIMIT ?`,
|
|
114
|
+
[limit]
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return rows.map(row => ({
|
|
118
|
+
logId: row.log_id as string,
|
|
119
|
+
fromContextId: row.from_context_id as string | undefined,
|
|
120
|
+
toContextId: row.to_context_id as string | undefined,
|
|
121
|
+
continuityScore: row.continuity_score as number,
|
|
122
|
+
transitionType: row.transition_type as TransitionType,
|
|
123
|
+
createdAt: new Date(row.created_at as string)
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get average continuity score over time period
|
|
129
|
+
*/
|
|
130
|
+
async getAverageScore(hours: number = 1): Promise<number> {
|
|
131
|
+
const result = await this.db.all<Array<{ avg_score: number | null }>>(
|
|
132
|
+
`SELECT AVG(continuity_score) as avg_score
|
|
133
|
+
FROM continuity_log
|
|
134
|
+
WHERE created_at > datetime('now', '-${hours} hours')`
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
return result[0]?.avg_score ?? 0.5;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get transition type distribution
|
|
142
|
+
*/
|
|
143
|
+
async getTransitionStats(hours: number = 24): Promise<Record<TransitionType, number>> {
|
|
144
|
+
const rows = await this.db.all<Array<{ transition_type: string; count: number }>>(
|
|
145
|
+
`SELECT transition_type, COUNT(*) as count
|
|
146
|
+
FROM continuity_log
|
|
147
|
+
WHERE created_at > datetime('now', '-${hours} hours')
|
|
148
|
+
GROUP BY transition_type`
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const stats: Record<TransitionType, number> = {
|
|
152
|
+
seamless: 0,
|
|
153
|
+
topic_shift: 0,
|
|
154
|
+
break: 0
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
for (const row of rows) {
|
|
158
|
+
stats[row.transition_type as TransitionType] = row.count;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return stats;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Clear old continuity logs
|
|
166
|
+
*/
|
|
167
|
+
async cleanup(olderThanDays: number = 7): Promise<number> {
|
|
168
|
+
const result = await this.db.all<Array<{ changes: number }>>(
|
|
169
|
+
`DELETE FROM continuity_log
|
|
170
|
+
WHERE created_at < datetime('now', '-${olderThanDays} days')
|
|
171
|
+
RETURNING COUNT(*) as changes`
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
return result[0]?.changes || 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Calculate overlap between two arrays
|
|
179
|
+
*/
|
|
180
|
+
private calculateOverlap(a: string[], b: string[]): number {
|
|
181
|
+
if (a.length === 0 || b.length === 0) return 0;
|
|
182
|
+
|
|
183
|
+
const setA = new Set(a.map(s => s.toLowerCase()));
|
|
184
|
+
const setB = new Set(b.map(s => s.toLowerCase()));
|
|
185
|
+
|
|
186
|
+
const intersection = [...setA].filter(x => setB.has(x));
|
|
187
|
+
const union = new Set([...setA, ...setB]);
|
|
188
|
+
|
|
189
|
+
return intersection.length / union.size; // Jaccard similarity
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Determine transition type based on score
|
|
194
|
+
*/
|
|
195
|
+
private determineTransitionType(score: number): TransitionType {
|
|
196
|
+
if (score >= this.config.continuity.minScoreForSeamless) {
|
|
197
|
+
return 'seamless';
|
|
198
|
+
} else if (score >= 0.4) {
|
|
199
|
+
return 'topic_shift';
|
|
200
|
+
} else {
|
|
201
|
+
return 'break';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Log a context transition
|
|
207
|
+
*/
|
|
208
|
+
private async logTransition(
|
|
209
|
+
current: ContextSnapshot,
|
|
210
|
+
previous: ContextSnapshot,
|
|
211
|
+
score: number,
|
|
212
|
+
type: TransitionType
|
|
213
|
+
): Promise<void> {
|
|
214
|
+
await this.db.run(
|
|
215
|
+
`INSERT INTO continuity_log
|
|
216
|
+
(log_id, from_context_id, to_context_id, continuity_score, transition_type, created_at)
|
|
217
|
+
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
|
218
|
+
[randomUUID(), previous.id, current.id, score, type]
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Extract topics from content
|
|
224
|
+
*/
|
|
225
|
+
private extractTopics(content: string): string[] {
|
|
226
|
+
const topics: string[] = [];
|
|
227
|
+
const contentLower = content.toLowerCase();
|
|
228
|
+
|
|
229
|
+
// Programming language keywords
|
|
230
|
+
const langPatterns = [
|
|
231
|
+
{ pattern: /typescript|\.ts\b/i, topic: 'typescript' },
|
|
232
|
+
{ pattern: /javascript|\.js\b/i, topic: 'javascript' },
|
|
233
|
+
{ pattern: /python|\.py\b/i, topic: 'python' },
|
|
234
|
+
{ pattern: /rust|\.rs\b/i, topic: 'rust' },
|
|
235
|
+
{ pattern: /go\b|golang/i, topic: 'go' }
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
for (const { pattern, topic } of langPatterns) {
|
|
239
|
+
if (pattern.test(content)) {
|
|
240
|
+
topics.push(topic);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Common development topics
|
|
245
|
+
const devTopics = [
|
|
246
|
+
'api', 'database', 'test', 'bug', 'feature', 'refactor',
|
|
247
|
+
'component', 'function', 'class', 'module', 'hook',
|
|
248
|
+
'deploy', 'build', 'config', 'docker', 'git'
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
for (const topic of devTopics) {
|
|
252
|
+
if (contentLower.includes(topic)) {
|
|
253
|
+
topics.push(topic);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return [...new Set(topics)].slice(0, 10);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Extract file paths from content
|
|
262
|
+
*/
|
|
263
|
+
private extractFiles(content: string): string[] {
|
|
264
|
+
const filePatterns = [
|
|
265
|
+
/(?:^|\s)([a-zA-Z0-9_\-./]+\.[a-zA-Z0-9]+)(?:\s|$|:)/gm,
|
|
266
|
+
/['"](\.?\/[^'"]+\.[a-zA-Z0-9]+)['"]/g,
|
|
267
|
+
/file[:\s]+([^\s,]+)/gi
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
const files = new Set<string>();
|
|
271
|
+
|
|
272
|
+
for (const pattern of filePatterns) {
|
|
273
|
+
let match;
|
|
274
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
275
|
+
const file = match[1];
|
|
276
|
+
if (file && file.length > 3 && file.length < 100) {
|
|
277
|
+
// Filter out common non-file patterns
|
|
278
|
+
if (!file.match(/^(https?:|mailto:|ftp:)/i)) {
|
|
279
|
+
files.add(file);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return Array.from(files).slice(0, 10);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Extract entity names from content (functions, classes, variables)
|
|
290
|
+
*/
|
|
291
|
+
private extractEntities(content: string): string[] {
|
|
292
|
+
const entities = new Set<string>();
|
|
293
|
+
|
|
294
|
+
const entityPatterns = [
|
|
295
|
+
/\b(function|const|let|var|class|interface|type)\s+([a-zA-Z_][a-zA-Z0-9_]*)/g,
|
|
296
|
+
/\b([A-Z][a-zA-Z0-9_]*(?:Component|Service|Store|Manager|Handler|Factory|Provider))\b/g,
|
|
297
|
+
/\b(use[A-Z][a-zA-Z0-9_]*)\b/g // React hooks
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
for (const pattern of entityPatterns) {
|
|
301
|
+
let match;
|
|
302
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
303
|
+
const entity = match[2] || match[1];
|
|
304
|
+
if (entity && entity.length > 2) {
|
|
305
|
+
entities.add(entity);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return Array.from(entities).slice(0, 20);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Reset the last context (for testing or manual reset)
|
|
315
|
+
*/
|
|
316
|
+
resetLastContext(): void {
|
|
317
|
+
this.lastContext = null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Get the last context snapshot
|
|
322
|
+
*/
|
|
323
|
+
getLastContext(): ContextSnapshot | null {
|
|
324
|
+
return this.lastContext;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Create a Continuity Manager instance
|
|
330
|
+
*/
|
|
331
|
+
export function createContinuityManager(
|
|
332
|
+
eventStore: EventStore,
|
|
333
|
+
config: EndlessModeConfig
|
|
334
|
+
): ContinuityManager {
|
|
335
|
+
return new ContinuityManager(eventStore, config);
|
|
336
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge Repository - CRUD operations for entity/entry relationships
|
|
3
|
+
* AXIOMMIND Entity-Edge Model
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Database } from 'duckdb';
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
8
|
+
import type { Edge, NodeType, RelationType } from './types.js';
|
|
9
|
+
|
|
10
|
+
export interface CreateEdgeInput {
|
|
11
|
+
srcType: NodeType;
|
|
12
|
+
srcId: string;
|
|
13
|
+
relType: RelationType;
|
|
14
|
+
dstType: NodeType;
|
|
15
|
+
dstId: string;
|
|
16
|
+
metaJson?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class EdgeRepo {
|
|
20
|
+
constructor(private db: Database) {}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a new edge (idempotent - ignores duplicates)
|
|
24
|
+
*/
|
|
25
|
+
async create(input: CreateEdgeInput): Promise<Edge> {
|
|
26
|
+
const edgeId = randomUUID();
|
|
27
|
+
const now = new Date();
|
|
28
|
+
|
|
29
|
+
await this.db.run(
|
|
30
|
+
`INSERT INTO edges (edge_id, src_type, src_id, rel_type, dst_type, dst_id, meta_json, created_at)
|
|
31
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
32
|
+
ON CONFLICT DO NOTHING`,
|
|
33
|
+
[
|
|
34
|
+
edgeId,
|
|
35
|
+
input.srcType,
|
|
36
|
+
input.srcId,
|
|
37
|
+
input.relType,
|
|
38
|
+
input.dstType,
|
|
39
|
+
input.dstId,
|
|
40
|
+
JSON.stringify(input.metaJson ?? {}),
|
|
41
|
+
now.toISOString()
|
|
42
|
+
]
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
edgeId,
|
|
47
|
+
srcType: input.srcType,
|
|
48
|
+
srcId: input.srcId,
|
|
49
|
+
relType: input.relType,
|
|
50
|
+
dstType: input.dstType,
|
|
51
|
+
dstId: input.dstId,
|
|
52
|
+
metaJson: input.metaJson,
|
|
53
|
+
createdAt: now
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create or update edge
|
|
59
|
+
*/
|
|
60
|
+
async upsert(input: CreateEdgeInput): Promise<Edge> {
|
|
61
|
+
// Check for existing edge
|
|
62
|
+
const existing = await this.findByEndpoints(
|
|
63
|
+
input.srcType,
|
|
64
|
+
input.srcId,
|
|
65
|
+
input.relType,
|
|
66
|
+
input.dstType,
|
|
67
|
+
input.dstId
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (existing) {
|
|
71
|
+
// Update meta_json
|
|
72
|
+
await this.db.run(
|
|
73
|
+
`UPDATE edges SET meta_json = ? WHERE edge_id = ?`,
|
|
74
|
+
[JSON.stringify(input.metaJson ?? {}), existing.edgeId]
|
|
75
|
+
);
|
|
76
|
+
return { ...existing, metaJson: input.metaJson };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return this.create(input);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Find edge by endpoints
|
|
84
|
+
*/
|
|
85
|
+
async findByEndpoints(
|
|
86
|
+
srcType: NodeType,
|
|
87
|
+
srcId: string,
|
|
88
|
+
relType: RelationType,
|
|
89
|
+
dstType: NodeType,
|
|
90
|
+
dstId: string
|
|
91
|
+
): Promise<Edge | null> {
|
|
92
|
+
const rows = await this.db.all<Array<Record<string, unknown>>>(
|
|
93
|
+
`SELECT * FROM edges
|
|
94
|
+
WHERE src_type = ? AND src_id = ? AND rel_type = ?
|
|
95
|
+
AND dst_type = ? AND dst_id = ?`,
|
|
96
|
+
[srcType, srcId, relType, dstType, dstId]
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (rows.length === 0) return null;
|
|
100
|
+
return this.rowToEdge(rows[0]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Find edges by source
|
|
105
|
+
*/
|
|
106
|
+
async findBySrc(
|
|
107
|
+
srcId: string,
|
|
108
|
+
relType?: RelationType
|
|
109
|
+
): Promise<Edge[]> {
|
|
110
|
+
let query = `SELECT * FROM edges WHERE src_id = ?`;
|
|
111
|
+
const params: unknown[] = [srcId];
|
|
112
|
+
|
|
113
|
+
if (relType) {
|
|
114
|
+
query += ` AND rel_type = ?`;
|
|
115
|
+
params.push(relType);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
query += ` ORDER BY created_at DESC`;
|
|
119
|
+
|
|
120
|
+
const rows = await this.db.all<Array<Record<string, unknown>>>(query, params);
|
|
121
|
+
return rows.map(row => this.rowToEdge(row));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Find edges by destination
|
|
126
|
+
*/
|
|
127
|
+
async findByDst(
|
|
128
|
+
dstId: string,
|
|
129
|
+
relType?: RelationType
|
|
130
|
+
): Promise<Edge[]> {
|
|
131
|
+
let query = `SELECT * FROM edges WHERE dst_id = ?`;
|
|
132
|
+
const params: unknown[] = [dstId];
|
|
133
|
+
|
|
134
|
+
if (relType) {
|
|
135
|
+
query += ` AND rel_type = ?`;
|
|
136
|
+
params.push(relType);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
query += ` ORDER BY created_at DESC`;
|
|
140
|
+
|
|
141
|
+
const rows = await this.db.all<Array<Record<string, unknown>>>(query, params);
|
|
142
|
+
return rows.map(row => this.rowToEdge(row));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Find all edges for a node (both directions)
|
|
147
|
+
*/
|
|
148
|
+
async findByNode(nodeId: string): Promise<{ outgoing: Edge[]; incoming: Edge[] }> {
|
|
149
|
+
const outgoing = await this.findBySrc(nodeId);
|
|
150
|
+
const incoming = await this.findByDst(nodeId);
|
|
151
|
+
return { outgoing, incoming };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Delete edge by ID
|
|
156
|
+
*/
|
|
157
|
+
async delete(edgeId: string): Promise<boolean> {
|
|
158
|
+
const result = await this.db.run(
|
|
159
|
+
`DELETE FROM edges WHERE edge_id = ?`,
|
|
160
|
+
[edgeId]
|
|
161
|
+
);
|
|
162
|
+
return true; // DuckDB doesn't return affected rows easily
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Delete edges by source and relation type
|
|
167
|
+
*/
|
|
168
|
+
async deleteBySrcAndRel(srcId: string, relType: RelationType): Promise<number> {
|
|
169
|
+
await this.db.run(
|
|
170
|
+
`DELETE FROM edges WHERE src_id = ? AND rel_type = ?`,
|
|
171
|
+
[srcId, relType]
|
|
172
|
+
);
|
|
173
|
+
return 0; // DuckDB doesn't return affected rows easily
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Delete edges by destination and relation type
|
|
178
|
+
*/
|
|
179
|
+
async deleteByDstAndRel(dstId: string, relType: RelationType): Promise<number> {
|
|
180
|
+
await this.db.run(
|
|
181
|
+
`DELETE FROM edges WHERE dst_id = ? AND rel_type = ?`,
|
|
182
|
+
[dstId, relType]
|
|
183
|
+
);
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Replace edges for a source and relation type
|
|
189
|
+
* Used for mode=replace in task_blockers_set
|
|
190
|
+
*/
|
|
191
|
+
async replaceEdges(
|
|
192
|
+
srcId: string,
|
|
193
|
+
relType: RelationType,
|
|
194
|
+
newEdges: Omit<CreateEdgeInput, 'srcId' | 'relType'>[]
|
|
195
|
+
): Promise<Edge[]> {
|
|
196
|
+
// Delete existing edges
|
|
197
|
+
await this.deleteBySrcAndRel(srcId, relType);
|
|
198
|
+
|
|
199
|
+
// Create new edges
|
|
200
|
+
const created: Edge[] = [];
|
|
201
|
+
for (const edge of newEdges) {
|
|
202
|
+
const newEdge = await this.create({
|
|
203
|
+
srcType: edge.srcType,
|
|
204
|
+
srcId,
|
|
205
|
+
relType,
|
|
206
|
+
dstType: edge.dstType,
|
|
207
|
+
dstId: edge.dstId,
|
|
208
|
+
metaJson: edge.metaJson
|
|
209
|
+
});
|
|
210
|
+
created.push(newEdge);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return created;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get effective blockers (resolving condition → task)
|
|
218
|
+
* Returns resolved blocker if condition has resolves_to edge
|
|
219
|
+
*/
|
|
220
|
+
async getEffectiveBlockers(taskId: string): Promise<Array<{
|
|
221
|
+
originalId: string;
|
|
222
|
+
effectiveId: string;
|
|
223
|
+
isResolved: boolean;
|
|
224
|
+
}>> {
|
|
225
|
+
const blockerEdges = await this.findBySrc(taskId, 'blocked_by');
|
|
226
|
+
const results: Array<{
|
|
227
|
+
originalId: string;
|
|
228
|
+
effectiveId: string;
|
|
229
|
+
isResolved: boolean;
|
|
230
|
+
}> = [];
|
|
231
|
+
|
|
232
|
+
for (const edge of blockerEdges) {
|
|
233
|
+
// Check if blocker has resolves_to edge
|
|
234
|
+
const resolvesTo = await this.db.all<Array<Record<string, unknown>>>(
|
|
235
|
+
`SELECT dst_id FROM edges
|
|
236
|
+
WHERE src_id = ? AND rel_type = 'resolves_to'
|
|
237
|
+
LIMIT 1`,
|
|
238
|
+
[edge.dstId]
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
if (resolvesTo.length > 0) {
|
|
242
|
+
results.push({
|
|
243
|
+
originalId: edge.dstId,
|
|
244
|
+
effectiveId: resolvesTo[0].dst_id as string,
|
|
245
|
+
isResolved: true
|
|
246
|
+
});
|
|
247
|
+
} else {
|
|
248
|
+
results.push({
|
|
249
|
+
originalId: edge.dstId,
|
|
250
|
+
effectiveId: edge.dstId,
|
|
251
|
+
isResolved: false
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return results;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Find 2-hop related entries (Entry → Entity → Entry)
|
|
261
|
+
*/
|
|
262
|
+
async findRelatedEntries(entryId: string): Promise<Array<{
|
|
263
|
+
entryId: string;
|
|
264
|
+
viaEntityId: string;
|
|
265
|
+
relationPath: string;
|
|
266
|
+
}>> {
|
|
267
|
+
const rows = await this.db.all<Array<Record<string, unknown>>>(
|
|
268
|
+
`WITH first_hop AS (
|
|
269
|
+
SELECT e1.dst_id AS entity_id
|
|
270
|
+
FROM edges e1
|
|
271
|
+
WHERE e1.src_type = 'entry'
|
|
272
|
+
AND e1.rel_type = 'evidence_of'
|
|
273
|
+
AND e1.src_id = ?
|
|
274
|
+
)
|
|
275
|
+
SELECT
|
|
276
|
+
e2.src_id AS entry_id,
|
|
277
|
+
f.entity_id AS via_entity_id,
|
|
278
|
+
'evidence_of→evidence_of' AS relation_path
|
|
279
|
+
FROM first_hop f
|
|
280
|
+
JOIN edges e2 ON e2.dst_id = f.entity_id
|
|
281
|
+
AND e2.rel_type = 'evidence_of'
|
|
282
|
+
AND e2.src_type = 'entry'
|
|
283
|
+
WHERE e2.src_id != ?`,
|
|
284
|
+
[entryId, entryId]
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
return rows.map(row => ({
|
|
288
|
+
entryId: row.entry_id as string,
|
|
289
|
+
viaEntityId: row.via_entity_id as string,
|
|
290
|
+
relationPath: row.relation_path as string
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Count edges by relation type
|
|
296
|
+
*/
|
|
297
|
+
async countByRelType(): Promise<Array<{ relType: string; count: number }>> {
|
|
298
|
+
const rows = await this.db.all<Array<{ rel_type: string; count: number }>>(
|
|
299
|
+
`SELECT rel_type, COUNT(*) as count FROM edges GROUP BY rel_type`
|
|
300
|
+
);
|
|
301
|
+
return rows.map(row => ({
|
|
302
|
+
relType: row.rel_type,
|
|
303
|
+
count: Number(row.count)
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Convert database row to Edge
|
|
309
|
+
*/
|
|
310
|
+
private rowToEdge(row: Record<string, unknown>): Edge {
|
|
311
|
+
return {
|
|
312
|
+
edgeId: row.edge_id as string,
|
|
313
|
+
srcType: row.src_type as NodeType,
|
|
314
|
+
srcId: row.src_id as string,
|
|
315
|
+
relType: row.rel_type as RelationType,
|
|
316
|
+
dstType: row.dst_type as NodeType,
|
|
317
|
+
dstId: row.dst_id as string,
|
|
318
|
+
metaJson: typeof row.meta_json === 'string'
|
|
319
|
+
? JSON.parse(row.meta_json)
|
|
320
|
+
: row.meta_json as Record<string, unknown> | undefined,
|
|
321
|
+
createdAt: new Date(row.created_at as string)
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|