@gethmy/mcp 2.0.0 → 2.1.1
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/README.md +6 -1
- package/dist/cli.js +711 -59
- package/dist/index.js +5 -3
- package/dist/lib/__tests__/active-learning.test.js +386 -0
- package/dist/lib/__tests__/agent-performance-profiles.test.js +325 -0
- package/dist/lib/__tests__/auto-session.test.js +661 -0
- package/dist/lib/__tests__/context-assembly.test.js +362 -0
- package/dist/lib/__tests__/graph-expansion.test.js +150 -0
- package/dist/lib/__tests__/integration-memory-crud.test.js +797 -0
- package/dist/lib/__tests__/integration-memory-system.test.js +281 -0
- package/dist/lib/__tests__/lifecycle-maintenance.test.js +207 -0
- package/dist/lib/__tests__/pattern-detection.test.js +295 -0
- package/dist/lib/__tests__/prompt-builder.test.js +418 -0
- package/dist/lib/active-learning.js +878 -0
- package/dist/lib/api-client.js +550 -0
- package/dist/lib/auto-session.js +173 -0
- package/dist/lib/cli.js +127 -0
- package/dist/lib/config.js +205 -0
- package/dist/lib/consolidation.js +243 -0
- package/dist/lib/context-assembly.js +606 -0
- package/dist/lib/graph-expansion.js +163 -0
- package/dist/lib/http.js +174 -0
- package/dist/lib/index.js +7 -0
- package/dist/lib/lifecycle-maintenance.js +88 -0
- package/dist/lib/prompt-builder.js +483 -0
- package/dist/lib/remote.js +166 -0
- package/dist/lib/server.js +3132 -0
- package/dist/lib/tui/agents.js +116 -0
- package/dist/lib/tui/docs.js +744 -0
- package/dist/lib/tui/setup.js +1068 -0
- package/dist/lib/tui/theme.js +95 -0
- package/dist/lib/tui/writer.js +200 -0
- package/package.json +15 -6
- package/src/__tests__/active-learning.test.ts +483 -0
- package/src/__tests__/agent-performance-profiles.test.ts +468 -0
- package/src/__tests__/auto-session.test.ts +912 -0
- package/src/__tests__/context-assembly.test.ts +506 -0
- package/src/__tests__/graph-expansion.test.ts +285 -0
- package/src/__tests__/integration-memory-crud.test.ts +948 -0
- package/src/__tests__/integration-memory-system.test.ts +321 -0
- package/src/__tests__/lifecycle-maintenance.test.ts +238 -0
- package/src/__tests__/pattern-detection.test.ts +438 -0
- package/src/__tests__/prompt-builder.test.ts +505 -0
- package/src/active-learning.ts +1227 -0
- package/src/api-client.ts +969 -0
- package/src/auto-session.ts +218 -0
- package/src/cli.ts +166 -0
- package/src/config.ts +285 -0
- package/src/consolidation.ts +314 -0
- package/src/context-assembly.ts +842 -0
- package/src/graph-expansion.ts +234 -0
- package/src/http.ts +265 -0
- package/src/index.ts +8 -0
- package/src/lifecycle-maintenance.ts +120 -0
- package/src/prompt-builder.ts +681 -0
- package/src/remote.ts +227 -0
- package/src/server.ts +3858 -0
- package/src/tui/agents.ts +154 -0
- package/src/tui/docs.ts +863 -0
- package/src/tui/setup.ts +1281 -0
- package/src/tui/theme.ts +114 -0
- package/src/tui/writer.ts +260 -0
|
@@ -0,0 +1,1227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Active Learning Loop
|
|
3
|
+
*
|
|
4
|
+
* Auto-extracts memories from agent work sessions.
|
|
5
|
+
* Triggered on harmony_end_agent_session and harmony_update_agent_progress.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { HarmonyApiClient } from "./api-client.js";
|
|
9
|
+
import { getActiveProjectId, getActiveWorkspaceId } from "./config.js";
|
|
10
|
+
import {
|
|
11
|
+
autoExpandGraph,
|
|
12
|
+
findSimilarEntities,
|
|
13
|
+
linkCrossTypeNeighbors,
|
|
14
|
+
} from "./graph-expansion.js";
|
|
15
|
+
|
|
16
|
+
// Entity types that can participate in `contradicts` relations (per schema.ts)
|
|
17
|
+
const CONTRADICTION_TYPES = new Set([
|
|
18
|
+
"decision",
|
|
19
|
+
"pattern",
|
|
20
|
+
"preference",
|
|
21
|
+
"lesson",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export interface SessionContext {
|
|
25
|
+
cardId: string;
|
|
26
|
+
cardTitle: string;
|
|
27
|
+
cardLabels: string[];
|
|
28
|
+
agentIdentifier: string;
|
|
29
|
+
agentName: string;
|
|
30
|
+
status: "completed" | "paused";
|
|
31
|
+
progressPercent?: number;
|
|
32
|
+
blockers?: string[];
|
|
33
|
+
currentTask?: string;
|
|
34
|
+
sessionDurationMs?: number;
|
|
35
|
+
cardDescription?: string;
|
|
36
|
+
cardSubtasks?: Array<{ title: string; done: boolean }>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ExtractedLearning {
|
|
40
|
+
title: string;
|
|
41
|
+
content: string;
|
|
42
|
+
type: string;
|
|
43
|
+
tier: string;
|
|
44
|
+
confidence: number;
|
|
45
|
+
tags: string[];
|
|
46
|
+
metadata: Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Mid-Session Learning ---
|
|
50
|
+
|
|
51
|
+
/** Track previous task per session for change detection */
|
|
52
|
+
const sessionTaskHistory = new Map<
|
|
53
|
+
string,
|
|
54
|
+
{
|
|
55
|
+
lastTask: string;
|
|
56
|
+
lastExtractionAt: number;
|
|
57
|
+
steps: Array<{ task: string; progress: number; timestamp: number }>;
|
|
58
|
+
}
|
|
59
|
+
>();
|
|
60
|
+
|
|
61
|
+
/** Rate limit: max 1 extraction per 2 minutes per session */
|
|
62
|
+
const MID_SESSION_RATE_LIMIT_MS = 120_000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Simple Levenshtein similarity (0-1 range, 1 = identical).
|
|
66
|
+
*/
|
|
67
|
+
function levenshteinSimilarity(a: string, b: string): number {
|
|
68
|
+
if (a === b) return 1;
|
|
69
|
+
if (a.length === 0 || b.length === 0) return 0;
|
|
70
|
+
|
|
71
|
+
// Use shorter strings for performance (truncate to 200 chars)
|
|
72
|
+
const sa = a.slice(0, 200).toLowerCase();
|
|
73
|
+
const sb = b.slice(0, 200).toLowerCase();
|
|
74
|
+
|
|
75
|
+
const matrix: number[][] = [];
|
|
76
|
+
for (let i = 0; i <= sa.length; i++) {
|
|
77
|
+
matrix[i] = [i];
|
|
78
|
+
}
|
|
79
|
+
for (let j = 0; j <= sb.length; j++) {
|
|
80
|
+
matrix[0][j] = j;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (let i = 1; i <= sa.length; i++) {
|
|
84
|
+
for (let j = 1; j <= sb.length; j++) {
|
|
85
|
+
const cost = sa[i - 1] === sb[j - 1] ? 0 : 1;
|
|
86
|
+
matrix[i][j] = Math.min(
|
|
87
|
+
matrix[i - 1][j] + 1,
|
|
88
|
+
matrix[i][j - 1] + 1,
|
|
89
|
+
matrix[i - 1][j - 1] + cost,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const maxLen = Math.max(sa.length, sb.length);
|
|
95
|
+
return 1 - matrix[sa.length][sb.length] / maxLen;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface MidSessionContext {
|
|
99
|
+
cardId: string;
|
|
100
|
+
cardTitle: string;
|
|
101
|
+
agentIdentifier: string;
|
|
102
|
+
agentName: string;
|
|
103
|
+
currentTask?: string;
|
|
104
|
+
status?: string;
|
|
105
|
+
blockers?: string[];
|
|
106
|
+
progressPercent?: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Extract learnings from mid-session progress updates.
|
|
111
|
+
* Called from harmony_update_agent_progress.
|
|
112
|
+
*/
|
|
113
|
+
export async function extractMidSessionLearnings(
|
|
114
|
+
client: HarmonyApiClient,
|
|
115
|
+
ctx: MidSessionContext,
|
|
116
|
+
): Promise<{ count: number; entityIds: string[] }> {
|
|
117
|
+
const workspaceId = getActiveWorkspaceId();
|
|
118
|
+
if (!workspaceId) return { count: 0, entityIds: [] };
|
|
119
|
+
|
|
120
|
+
const projectId = getActiveProjectId() || undefined;
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
const entityIds: string[] = [];
|
|
123
|
+
|
|
124
|
+
const history = sessionTaskHistory.get(ctx.cardId);
|
|
125
|
+
|
|
126
|
+
// Always track step history regardless of rate limit
|
|
127
|
+
if (ctx.currentTask) {
|
|
128
|
+
const previousTask = history?.lastTask || "";
|
|
129
|
+
const similarity = levenshteinSimilarity(previousTask, ctx.currentTask);
|
|
130
|
+
const existingSteps = history?.steps || [];
|
|
131
|
+
|
|
132
|
+
if (
|
|
133
|
+
existingSteps.length === 0 ||
|
|
134
|
+
(similarity < 0.6 && previousTask.length > 0)
|
|
135
|
+
) {
|
|
136
|
+
const newStep = {
|
|
137
|
+
task: ctx.currentTask,
|
|
138
|
+
progress: ctx.progressPercent ?? 0,
|
|
139
|
+
timestamp: now,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (existingSteps.length === 0 && previousTask.length > 0) {
|
|
143
|
+
existingSteps.push({
|
|
144
|
+
task: previousTask,
|
|
145
|
+
progress: 0,
|
|
146
|
+
timestamp: history?.lastExtractionAt ?? now,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
existingSteps.push(newStep);
|
|
150
|
+
|
|
151
|
+
sessionTaskHistory.set(ctx.cardId, {
|
|
152
|
+
lastTask: ctx.currentTask,
|
|
153
|
+
lastExtractionAt: history?.lastExtractionAt ?? 0,
|
|
154
|
+
steps: existingSteps,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Rate limit check (only gates memory entity creation, not step tracking)
|
|
160
|
+
if (history && now - history.lastExtractionAt < MID_SESSION_RATE_LIMIT_MS) {
|
|
161
|
+
// Still within rate limit, but always extract blockers immediately
|
|
162
|
+
if (ctx.status !== "blocked" || !ctx.blockers?.length) {
|
|
163
|
+
return { count: 0, entityIds: [] };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Rule 1: Status transitions to "blocked" → create error entity immediately
|
|
168
|
+
if (ctx.status === "blocked" && ctx.blockers?.length) {
|
|
169
|
+
for (const blocker of ctx.blockers) {
|
|
170
|
+
try {
|
|
171
|
+
const result = await client.createMemoryEntity({
|
|
172
|
+
workspace_id: workspaceId,
|
|
173
|
+
project_id: projectId,
|
|
174
|
+
type: "error",
|
|
175
|
+
scope: "project",
|
|
176
|
+
memory_tier: "draft",
|
|
177
|
+
title: `Blocker (mid-session): ${blocker.slice(0, 100)}`,
|
|
178
|
+
content: `Encountered while working on "${ctx.cardTitle}":\n\n${blocker}\n\nAgent: ${ctx.agentName}\nProgress: ${ctx.progressPercent ?? "unknown"}%`,
|
|
179
|
+
confidence: 0.5,
|
|
180
|
+
tags: ["auto-extracted", "blocker", "mid-session"],
|
|
181
|
+
metadata: {
|
|
182
|
+
source: "mid_session",
|
|
183
|
+
card_id: ctx.cardId,
|
|
184
|
+
},
|
|
185
|
+
agent_identifier: ctx.agentIdentifier,
|
|
186
|
+
});
|
|
187
|
+
const entity = result.entity as { id: string };
|
|
188
|
+
if (entity?.id) entityIds.push(entity.id);
|
|
189
|
+
} catch {
|
|
190
|
+
// Non-fatal
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
sessionTaskHistory.set(ctx.cardId, {
|
|
194
|
+
lastTask: ctx.currentTask || "",
|
|
195
|
+
lastExtractionAt: now,
|
|
196
|
+
steps: history?.steps || [],
|
|
197
|
+
});
|
|
198
|
+
return { count: entityIds.length, entityIds };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Rule 2: Task changed significantly → capture context entity
|
|
202
|
+
if (ctx.currentTask) {
|
|
203
|
+
const previousTask = history?.lastTask || "";
|
|
204
|
+
const similarity = levenshteinSimilarity(previousTask, ctx.currentTask);
|
|
205
|
+
|
|
206
|
+
if (similarity < 0.6 && previousTask.length > 0) {
|
|
207
|
+
try {
|
|
208
|
+
const result = await client.createMemoryEntity({
|
|
209
|
+
workspace_id: workspaceId,
|
|
210
|
+
project_id: projectId,
|
|
211
|
+
type: "context",
|
|
212
|
+
scope: "project",
|
|
213
|
+
memory_tier: "draft",
|
|
214
|
+
title: `Task transition: ${ctx.cardTitle}`,
|
|
215
|
+
content: `Agent transitioned tasks on "${ctx.cardTitle}".\n\nPrevious: ${previousTask}\nCurrent: ${ctx.currentTask}\nProgress: ${ctx.progressPercent ?? "unknown"}%`,
|
|
216
|
+
confidence: 0.5,
|
|
217
|
+
tags: ["auto-extracted", "task-transition", "mid-session"],
|
|
218
|
+
metadata: {
|
|
219
|
+
source: "mid_session",
|
|
220
|
+
card_id: ctx.cardId,
|
|
221
|
+
previous_task: previousTask,
|
|
222
|
+
current_task: ctx.currentTask,
|
|
223
|
+
},
|
|
224
|
+
agent_identifier: ctx.agentIdentifier,
|
|
225
|
+
});
|
|
226
|
+
const entity = result.entity as { id: string };
|
|
227
|
+
if (entity?.id) entityIds.push(entity.id);
|
|
228
|
+
} catch {
|
|
229
|
+
// Non-fatal
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Update lastExtractionAt only when entities were created
|
|
234
|
+
const currentHistory = sessionTaskHistory.get(ctx.cardId);
|
|
235
|
+
sessionTaskHistory.set(ctx.cardId, {
|
|
236
|
+
lastTask: ctx.currentTask,
|
|
237
|
+
lastExtractionAt:
|
|
238
|
+
entityIds.length > 0 ? now : (currentHistory?.lastExtractionAt ?? 0),
|
|
239
|
+
steps: currentHistory?.steps || [],
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { count: entityIds.length, entityIds };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Clean up mid-session tracking for a card (call on session end).
|
|
248
|
+
*/
|
|
249
|
+
export function clearMidSessionTracking(cardId: string): void {
|
|
250
|
+
sessionTaskHistory.delete(cardId);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// --- Procedure Extraction ---
|
|
254
|
+
|
|
255
|
+
interface StepEntry {
|
|
256
|
+
task: string;
|
|
257
|
+
progress: number;
|
|
258
|
+
timestamp: number;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
interface EnrichedStep {
|
|
262
|
+
task: string;
|
|
263
|
+
progress: number;
|
|
264
|
+
progressDelta: number;
|
|
265
|
+
durationMs: number;
|
|
266
|
+
isKeyDecision: boolean;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Enrich raw steps with progress deltas and key decision detection.
|
|
271
|
+
* A step is a "key decision" if it represents a >20% progress jump.
|
|
272
|
+
*/
|
|
273
|
+
function enrichSteps(steps: StepEntry[]): EnrichedStep[] {
|
|
274
|
+
return steps.map((step, i) => {
|
|
275
|
+
const prevProgress = i > 0 ? steps[i - 1].progress : 0;
|
|
276
|
+
const prevTimestamp = i > 0 ? steps[i - 1].timestamp : step.timestamp;
|
|
277
|
+
const progressDelta = step.progress - prevProgress;
|
|
278
|
+
return {
|
|
279
|
+
task: step.task,
|
|
280
|
+
progress: step.progress,
|
|
281
|
+
progressDelta,
|
|
282
|
+
durationMs: step.timestamp - prevTimestamp,
|
|
283
|
+
isKeyDecision: progressDelta >= 20,
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Build structured procedure content from session data and enriched steps.
|
|
290
|
+
*/
|
|
291
|
+
function buildProcedureContent(
|
|
292
|
+
session: SessionContext,
|
|
293
|
+
enrichedSteps: EnrichedStep[],
|
|
294
|
+
wikiLinksLine: string,
|
|
295
|
+
): string {
|
|
296
|
+
const triggerLabels =
|
|
297
|
+
session.cardLabels.length > 0
|
|
298
|
+
? `Labels: ${session.cardLabels.join(", ")}`
|
|
299
|
+
: "";
|
|
300
|
+
|
|
301
|
+
const stepsMarkdown = enrichedSteps
|
|
302
|
+
.map((s, i) => {
|
|
303
|
+
const marker = s.isKeyDecision ? " **[key step]**" : "";
|
|
304
|
+
const duration =
|
|
305
|
+
s.durationMs > 0 ? ` (~${Math.round(s.durationMs / 60000)}min)` : "";
|
|
306
|
+
return `${i + 1}. ${s.task} (${s.progress}%, +${s.progressDelta}%)${marker}${duration}`;
|
|
307
|
+
})
|
|
308
|
+
.join("\n");
|
|
309
|
+
|
|
310
|
+
const subtaskSection =
|
|
311
|
+
session.cardSubtasks && session.cardSubtasks.length > 0
|
|
312
|
+
? [
|
|
313
|
+
"",
|
|
314
|
+
"## Subtasks Completed",
|
|
315
|
+
...session.cardSubtasks.map(
|
|
316
|
+
(s) => `- [${s.done ? "x" : " "}] ${s.title}`,
|
|
317
|
+
),
|
|
318
|
+
].join("\n")
|
|
319
|
+
: "";
|
|
320
|
+
|
|
321
|
+
const durationInfo = session.sessionDurationMs
|
|
322
|
+
? `\nDuration: ${Math.round(session.sessionDurationMs / 60000)} minutes`
|
|
323
|
+
: "";
|
|
324
|
+
|
|
325
|
+
return [
|
|
326
|
+
"## Trigger",
|
|
327
|
+
`When working on: "${session.cardTitle}"`,
|
|
328
|
+
triggerLabels,
|
|
329
|
+
"",
|
|
330
|
+
"## Steps",
|
|
331
|
+
stepsMarkdown,
|
|
332
|
+
subtaskSection,
|
|
333
|
+
"",
|
|
334
|
+
"## Outcome",
|
|
335
|
+
`Completed at ${session.progressPercent ?? "unknown"}%`,
|
|
336
|
+
session.currentTask ? `Final state: ${session.currentTask}` : "",
|
|
337
|
+
`Agent: ${session.agentName}`,
|
|
338
|
+
durationInfo,
|
|
339
|
+
wikiLinksLine,
|
|
340
|
+
]
|
|
341
|
+
.filter((line) => line !== undefined)
|
|
342
|
+
.join("\n");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Extract a new procedure or reinforce an existing similar one.
|
|
347
|
+
*
|
|
348
|
+
* Deduplication: searches for existing procedure entities with similar titles.
|
|
349
|
+
* If found, merges new steps and bumps confidence. Otherwise creates a new one.
|
|
350
|
+
*/
|
|
351
|
+
async function extractOrReinforceProcedure(
|
|
352
|
+
client: HarmonyApiClient,
|
|
353
|
+
session: SessionContext,
|
|
354
|
+
steps: StepEntry[],
|
|
355
|
+
workspaceId: string,
|
|
356
|
+
projectId?: string,
|
|
357
|
+
wikiLinksLine = "",
|
|
358
|
+
): Promise<
|
|
359
|
+
| { mode: "created"; learning: ExtractedLearning }
|
|
360
|
+
| { mode: "reinforced"; entityId: string }
|
|
361
|
+
| null
|
|
362
|
+
> {
|
|
363
|
+
const enrichedSteps = enrichSteps(steps);
|
|
364
|
+
const newContent = buildProcedureContent(
|
|
365
|
+
session,
|
|
366
|
+
enrichedSteps,
|
|
367
|
+
wikiLinksLine,
|
|
368
|
+
);
|
|
369
|
+
const tags = [
|
|
370
|
+
"auto-extracted",
|
|
371
|
+
"procedure",
|
|
372
|
+
...session.cardLabels.slice(0, 3),
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
// Try to find an existing similar procedure to reinforce
|
|
376
|
+
try {
|
|
377
|
+
const similar = await findSimilarEntities(
|
|
378
|
+
client,
|
|
379
|
+
`Procedure: ${session.cardTitle}`,
|
|
380
|
+
newContent,
|
|
381
|
+
workspaceId,
|
|
382
|
+
{ projectId, limit: 5, minRrfScore: 0.03 },
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const matchingProcedure = similar.find(
|
|
386
|
+
(e) => e.type === "procedure" && (e.rrf_score ?? 0) >= 0.05,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
if (matchingProcedure) {
|
|
390
|
+
// Fetch the full entity to get metadata and memory_tier
|
|
391
|
+
const { entity: rawEntity } = await client.getMemoryEntity(
|
|
392
|
+
matchingProcedure.id,
|
|
393
|
+
);
|
|
394
|
+
const fullEntity = rawEntity as {
|
|
395
|
+
id: string;
|
|
396
|
+
content?: string;
|
|
397
|
+
confidence?: number;
|
|
398
|
+
memory_tier?: string;
|
|
399
|
+
metadata?: Record<string, unknown>;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const currentMeta = fullEntity.metadata || {};
|
|
403
|
+
const sourceCards = (
|
|
404
|
+
(currentMeta.source_cards as string[]) || []
|
|
405
|
+
).slice();
|
|
406
|
+
if (!sourceCards.includes(session.cardId)) {
|
|
407
|
+
sourceCards.push(session.cardId);
|
|
408
|
+
}
|
|
409
|
+
const reuseCount = ((currentMeta.reuse_count as number) || 0) + 1;
|
|
410
|
+
const currentConfidence = fullEntity.confidence ?? 0.7;
|
|
411
|
+
// Bump confidence by 0.05 per reinforcement, max 0.95
|
|
412
|
+
const newConfidence = Math.min(0.95, currentConfidence + 0.05);
|
|
413
|
+
|
|
414
|
+
// Append new steps as an additional execution record
|
|
415
|
+
const stepsAppendix = enrichedSteps
|
|
416
|
+
.map((s, i) => `${i + 1}. ${s.task} (${s.progress}%)`)
|
|
417
|
+
.join("\n");
|
|
418
|
+
const appendix = `\n\n---\n### Execution ${reuseCount + 1}: ${session.cardTitle}\n${stepsAppendix}\nAgent: ${session.agentName} | ${new Date().toISOString().split("T")[0]}`;
|
|
419
|
+
|
|
420
|
+
const updatedMeta = {
|
|
421
|
+
...currentMeta,
|
|
422
|
+
reuse_count: reuseCount,
|
|
423
|
+
source_cards: sourceCards,
|
|
424
|
+
last_reinforced_at: new Date().toISOString(),
|
|
425
|
+
step_count: Math.max(
|
|
426
|
+
(currentMeta.step_count as number) || 0,
|
|
427
|
+
steps.length,
|
|
428
|
+
),
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// Auto-promote to reference tier if reinforced enough (≥3 executions)
|
|
432
|
+
const shouldPromote =
|
|
433
|
+
reuseCount >= 2 && fullEntity.memory_tier !== "reference";
|
|
434
|
+
|
|
435
|
+
await client.updateMemoryEntity(fullEntity.id, {
|
|
436
|
+
content: (fullEntity.content || "") + appendix,
|
|
437
|
+
confidence: newConfidence,
|
|
438
|
+
metadata: {
|
|
439
|
+
...updatedMeta,
|
|
440
|
+
...(shouldPromote
|
|
441
|
+
? {
|
|
442
|
+
promoted_reason: `Reinforced by ${reuseCount + 1} successful sessions`,
|
|
443
|
+
promoted_at: new Date().toISOString(),
|
|
444
|
+
}
|
|
445
|
+
: {}),
|
|
446
|
+
},
|
|
447
|
+
...(shouldPromote ? { memory_tier: "reference" } : {}),
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
return { mode: "reinforced", entityId: fullEntity.id };
|
|
451
|
+
}
|
|
452
|
+
} catch {
|
|
453
|
+
// Similarity search failed, fall through to create new
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// No existing procedure found — create a new one
|
|
457
|
+
return {
|
|
458
|
+
mode: "created",
|
|
459
|
+
learning: {
|
|
460
|
+
title: `Procedure: ${session.cardTitle}`,
|
|
461
|
+
content: newContent,
|
|
462
|
+
type: "procedure",
|
|
463
|
+
tier: "episode",
|
|
464
|
+
confidence: 0.7,
|
|
465
|
+
tags,
|
|
466
|
+
metadata: {
|
|
467
|
+
source: "active_learning",
|
|
468
|
+
card_id: session.cardId,
|
|
469
|
+
source_cards: [session.cardId],
|
|
470
|
+
step_count: steps.length,
|
|
471
|
+
key_step_count: enrichedSteps.filter((s) => s.isKeyDecision).length,
|
|
472
|
+
reuse_count: 0,
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// --- Same-Session Causal Linking ---
|
|
479
|
+
|
|
480
|
+
/** Causal pairing rules for entities created within the same session */
|
|
481
|
+
const SESSION_CAUSAL_RULES: Array<{
|
|
482
|
+
sourceType: string;
|
|
483
|
+
targetType: string;
|
|
484
|
+
relation: string;
|
|
485
|
+
confidence: number;
|
|
486
|
+
}> = [
|
|
487
|
+
{
|
|
488
|
+
sourceType: "error",
|
|
489
|
+
targetType: "solution",
|
|
490
|
+
relation: "resolved_by",
|
|
491
|
+
confidence: 0.8,
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
sourceType: "lesson",
|
|
495
|
+
targetType: "error",
|
|
496
|
+
relation: "learned_from",
|
|
497
|
+
confidence: 0.75,
|
|
498
|
+
},
|
|
499
|
+
];
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Link entities created within the same session using causal relations.
|
|
503
|
+
*
|
|
504
|
+
* For example, if a session produces both an `error` and a `solution`,
|
|
505
|
+
* creates an `error -[resolved_by]-> solution` relation.
|
|
506
|
+
*
|
|
507
|
+
* Non-fatal: all errors are caught silently.
|
|
508
|
+
*/
|
|
509
|
+
export async function linkSessionEntities(
|
|
510
|
+
client: HarmonyApiClient,
|
|
511
|
+
createdPairs: Array<{ id: string; learning: ExtractedLearning }>,
|
|
512
|
+
_workspaceId: string,
|
|
513
|
+
_projectId?: string,
|
|
514
|
+
): Promise<{ relationsCreated: number }> {
|
|
515
|
+
let relationsCreated = 0;
|
|
516
|
+
|
|
517
|
+
for (const rule of SESSION_CAUSAL_RULES) {
|
|
518
|
+
const sources = createdPairs.filter(
|
|
519
|
+
(p) => p.learning.type === rule.sourceType,
|
|
520
|
+
);
|
|
521
|
+
const targets = createdPairs.filter(
|
|
522
|
+
(p) => p.learning.type === rule.targetType,
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
for (const source of sources) {
|
|
526
|
+
for (const target of targets) {
|
|
527
|
+
try {
|
|
528
|
+
await client.createMemoryRelation({
|
|
529
|
+
source_id: source.id,
|
|
530
|
+
target_id: target.id,
|
|
531
|
+
relation_type: rule.relation,
|
|
532
|
+
confidence: rule.confidence,
|
|
533
|
+
});
|
|
534
|
+
relationsCreated++;
|
|
535
|
+
} catch {
|
|
536
|
+
// Skip duplicate/failed relations silently
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return { relationsCreated };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// --- Session-End Learning ---
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Extract learnings from a completed agent session.
|
|
549
|
+
* Returns the number of memories created.
|
|
550
|
+
*/
|
|
551
|
+
export async function extractLearnings(
|
|
552
|
+
client: HarmonyApiClient,
|
|
553
|
+
session: SessionContext,
|
|
554
|
+
): Promise<{ count: number; entityIds: string[] }> {
|
|
555
|
+
const workspaceId = getActiveWorkspaceId();
|
|
556
|
+
if (!workspaceId) {
|
|
557
|
+
return { count: 0, entityIds: [] };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const projectId = getActiveProjectId() || undefined;
|
|
561
|
+
const learnings: ExtractedLearning[] = [];
|
|
562
|
+
|
|
563
|
+
// Search for related entities to enrich summaries with wiki-links
|
|
564
|
+
let relatedEntityTitles: string[] = [];
|
|
565
|
+
if (workspaceId) {
|
|
566
|
+
try {
|
|
567
|
+
const searchQuery = [
|
|
568
|
+
session.cardTitle,
|
|
569
|
+
session.currentTask || "",
|
|
570
|
+
...session.cardLabels,
|
|
571
|
+
]
|
|
572
|
+
.filter(Boolean)
|
|
573
|
+
.join(" ");
|
|
574
|
+
const similar = await findSimilarEntities(
|
|
575
|
+
client,
|
|
576
|
+
searchQuery,
|
|
577
|
+
session.currentTask || session.cardTitle,
|
|
578
|
+
workspaceId,
|
|
579
|
+
{ projectId, limit: 5, minRrfScore: 0.01 },
|
|
580
|
+
);
|
|
581
|
+
relatedEntityTitles = similar.slice(0, 3).map((e) => e.title);
|
|
582
|
+
} catch {
|
|
583
|
+
/* non-fatal */
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const wikiLinksLine =
|
|
588
|
+
relatedEntityTitles.length > 0
|
|
589
|
+
? `\nRelated: ${relatedEntityTitles.map((t) => `[[${t}]]`).join(", ")}`
|
|
590
|
+
: "";
|
|
591
|
+
|
|
592
|
+
// Rule 1: Session had blockers → create error entities
|
|
593
|
+
if (session.blockers && session.blockers.length > 0) {
|
|
594
|
+
for (const blocker of session.blockers) {
|
|
595
|
+
learnings.push({
|
|
596
|
+
title: `Blocker: ${blocker.slice(0, 100)}`,
|
|
597
|
+
content: `Encountered while working on "${session.cardTitle}":\n\n${blocker}\n\nAgent: ${session.agentName}\nSession status: ${session.status}`,
|
|
598
|
+
type: "error",
|
|
599
|
+
tier: "reference",
|
|
600
|
+
confidence: 0.7,
|
|
601
|
+
tags: ["auto-extracted", "blocker", ...session.cardLabels.slice(0, 3)],
|
|
602
|
+
metadata: {
|
|
603
|
+
source: "active_learning",
|
|
604
|
+
card_id: session.cardId,
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Rule 2: Session completed → create lesson entity summarizing work
|
|
611
|
+
// Only create when there's meaningful content beyond "completed X at 100%"
|
|
612
|
+
const hasMeaningfulContent =
|
|
613
|
+
(session.blockers?.length ?? 0) > 0 ||
|
|
614
|
+
session.status === "paused" ||
|
|
615
|
+
((session.cardSubtasks?.length ?? 0) > 0 &&
|
|
616
|
+
session.cardSubtasks?.some((s) => !s.done));
|
|
617
|
+
|
|
618
|
+
if (session.status === "completed" && hasMeaningfulContent) {
|
|
619
|
+
const durationInfo = session.sessionDurationMs
|
|
620
|
+
? `\nDuration: ${Math.round(session.sessionDurationMs / 60000)} minutes`
|
|
621
|
+
: "";
|
|
622
|
+
|
|
623
|
+
learnings.push({
|
|
624
|
+
title: `Session: ${session.cardTitle}`,
|
|
625
|
+
content: [
|
|
626
|
+
`Completed work on "${session.cardTitle}".`,
|
|
627
|
+
session.currentTask ? `Final task: ${session.currentTask}` : "",
|
|
628
|
+
session.progressPercent !== undefined
|
|
629
|
+
? `Progress: ${session.progressPercent}%`
|
|
630
|
+
: "",
|
|
631
|
+
durationInfo,
|
|
632
|
+
session.cardLabels.length > 0
|
|
633
|
+
? `Labels: ${session.cardLabels.join(", ")}`
|
|
634
|
+
: "",
|
|
635
|
+
session.blockers?.length
|
|
636
|
+
? `Blockers encountered: ${session.blockers.join("; ")}`
|
|
637
|
+
: "",
|
|
638
|
+
`\nAgent: ${session.agentName}`,
|
|
639
|
+
wikiLinksLine,
|
|
640
|
+
]
|
|
641
|
+
.filter(Boolean)
|
|
642
|
+
.join("\n"),
|
|
643
|
+
type: "lesson",
|
|
644
|
+
tier: "episode",
|
|
645
|
+
confidence: 0.7,
|
|
646
|
+
tags: [
|
|
647
|
+
"auto-extracted",
|
|
648
|
+
"session-summary",
|
|
649
|
+
...session.cardLabels.slice(0, 3),
|
|
650
|
+
],
|
|
651
|
+
metadata: {
|
|
652
|
+
source: "active_learning",
|
|
653
|
+
card_id: session.cardId,
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Rule 3: Card had "bug" label + completed → create solution entity
|
|
659
|
+
const hasBugLabel = session.cardLabels.some((l) =>
|
|
660
|
+
["bug", "fix", "hotfix", "defect", "error"].includes(l.toLowerCase()),
|
|
661
|
+
);
|
|
662
|
+
if (hasBugLabel && session.status === "completed") {
|
|
663
|
+
learnings.push({
|
|
664
|
+
title: `Solution: ${session.cardTitle}`,
|
|
665
|
+
content: [
|
|
666
|
+
`Resolved bug: "${session.cardTitle}"`,
|
|
667
|
+
session.currentTask ? `\nApproach: ${session.currentTask}` : "",
|
|
668
|
+
`\nAgent: ${session.agentName}`,
|
|
669
|
+
wikiLinksLine,
|
|
670
|
+
]
|
|
671
|
+
.filter(Boolean)
|
|
672
|
+
.join("\n"),
|
|
673
|
+
type: "solution",
|
|
674
|
+
tier: "reference",
|
|
675
|
+
confidence: 0.8,
|
|
676
|
+
tags: ["auto-extracted", "bug-fix", ...session.cardLabels.slice(0, 3)],
|
|
677
|
+
metadata: {
|
|
678
|
+
source: "active_learning",
|
|
679
|
+
card_id: session.cardId,
|
|
680
|
+
auto_confidence: true,
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Store learnings, tracking entity ID → learning for graph expansion
|
|
686
|
+
const entityIds: string[] = [];
|
|
687
|
+
|
|
688
|
+
// Rule 4: Successful session with tracked steps → create or reinforce procedure entity
|
|
689
|
+
const stepHistory = sessionTaskHistory.get(session.cardId);
|
|
690
|
+
const hasEnoughSteps = stepHistory && stepHistory.steps.length >= 2;
|
|
691
|
+
const isSuccessful =
|
|
692
|
+
session.status === "completed" &&
|
|
693
|
+
(session.progressPercent === undefined || session.progressPercent >= 85) &&
|
|
694
|
+
!session.blockers?.length;
|
|
695
|
+
|
|
696
|
+
if (isSuccessful && hasEnoughSteps) {
|
|
697
|
+
const procedureResult = await extractOrReinforceProcedure(
|
|
698
|
+
client,
|
|
699
|
+
session,
|
|
700
|
+
stepHistory.steps,
|
|
701
|
+
workspaceId,
|
|
702
|
+
projectId,
|
|
703
|
+
wikiLinksLine,
|
|
704
|
+
);
|
|
705
|
+
if (procedureResult) {
|
|
706
|
+
if (procedureResult.mode === "created") {
|
|
707
|
+
learnings.push(procedureResult.learning);
|
|
708
|
+
} else {
|
|
709
|
+
// Reinforced existing procedure — already saved via API, just track the ID
|
|
710
|
+
entityIds.push(procedureResult.entityId);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
const createdPairs: Array<{ id: string; learning: ExtractedLearning }> = [];
|
|
715
|
+
for (const learning of learnings) {
|
|
716
|
+
try {
|
|
717
|
+
const metadata = {
|
|
718
|
+
...learning.metadata,
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const result = await client.createMemoryEntity({
|
|
722
|
+
workspace_id: workspaceId,
|
|
723
|
+
project_id: projectId,
|
|
724
|
+
type: learning.type,
|
|
725
|
+
scope: "project",
|
|
726
|
+
memory_tier: learning.tier,
|
|
727
|
+
title: learning.title,
|
|
728
|
+
content: learning.content,
|
|
729
|
+
confidence: learning.confidence,
|
|
730
|
+
tags: learning.tags,
|
|
731
|
+
metadata,
|
|
732
|
+
agent_identifier: session.agentIdentifier,
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
const entity = result.entity as { id: string };
|
|
736
|
+
if (entity?.id) {
|
|
737
|
+
entityIds.push(entity.id);
|
|
738
|
+
createdPairs.push({ id: entity.id, learning });
|
|
739
|
+
}
|
|
740
|
+
} catch {
|
|
741
|
+
// Non-fatal: individual learning extraction failure shouldn't block others
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Auto-expand the knowledge graph and detect contradictions for each newly created entity (fire-and-forget)
|
|
746
|
+
for (const { id, learning } of createdPairs) {
|
|
747
|
+
autoExpandGraph(
|
|
748
|
+
client,
|
|
749
|
+
id,
|
|
750
|
+
learning.title,
|
|
751
|
+
learning.content,
|
|
752
|
+
learning.tags,
|
|
753
|
+
workspaceId,
|
|
754
|
+
projectId,
|
|
755
|
+
).catch(() => {});
|
|
756
|
+
|
|
757
|
+
detectContradictions(
|
|
758
|
+
client,
|
|
759
|
+
id,
|
|
760
|
+
learning.type,
|
|
761
|
+
learning.title,
|
|
762
|
+
learning.content,
|
|
763
|
+
learning.tags,
|
|
764
|
+
workspaceId,
|
|
765
|
+
projectId,
|
|
766
|
+
).catch(() => {});
|
|
767
|
+
|
|
768
|
+
// Cross-type causal linking (e.g., new error → existing solutions)
|
|
769
|
+
linkCrossTypeNeighbors(
|
|
770
|
+
client,
|
|
771
|
+
id,
|
|
772
|
+
learning.type,
|
|
773
|
+
learning.title,
|
|
774
|
+
learning.content,
|
|
775
|
+
workspaceId,
|
|
776
|
+
projectId,
|
|
777
|
+
).catch(() => {});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Same-session causal linking (e.g., error + solution in same session → resolved_by)
|
|
781
|
+
if (createdPairs.length >= 2) {
|
|
782
|
+
linkSessionEntities(client, createdPairs, workspaceId, projectId).catch(
|
|
783
|
+
() => {},
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Detect recurring patterns across sessions (fire-and-forget)
|
|
788
|
+
if (entityIds.length > 0) {
|
|
789
|
+
detectAndCreatePatterns(
|
|
790
|
+
client,
|
|
791
|
+
entityIds,
|
|
792
|
+
session,
|
|
793
|
+
workspaceId,
|
|
794
|
+
projectId,
|
|
795
|
+
).catch(() => {});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Detect recurring causal patterns (error→solution chains across sessions)
|
|
799
|
+
if (createdPairs.length > 0) {
|
|
800
|
+
detectCausalPatterns(
|
|
801
|
+
client,
|
|
802
|
+
createdPairs,
|
|
803
|
+
session,
|
|
804
|
+
workspaceId,
|
|
805
|
+
projectId,
|
|
806
|
+
).catch(() => {});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Clean up mid-session tracking
|
|
810
|
+
clearMidSessionTracking(session.cardId);
|
|
811
|
+
|
|
812
|
+
return { count: entityIds.length, entityIds };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const PATTERN_THRESHOLD = 3;
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Detect recurring patterns across sessions.
|
|
819
|
+
*
|
|
820
|
+
* Uses embedding-based similarity (via hybrid search) to find related entities
|
|
821
|
+
* instead of naive tag matching. When ≥3 similar entities cluster, creates or
|
|
822
|
+
* updates a `pattern` entity with `part_of` relations from members.
|
|
823
|
+
*/
|
|
824
|
+
export async function detectAndCreatePatterns(
|
|
825
|
+
client: HarmonyApiClient,
|
|
826
|
+
newEntityIds: string[],
|
|
827
|
+
session: SessionContext,
|
|
828
|
+
workspaceId: string,
|
|
829
|
+
projectId?: string,
|
|
830
|
+
): Promise<string[]> {
|
|
831
|
+
const patternEntityIds: string[] = [];
|
|
832
|
+
|
|
833
|
+
for (const newEntityId of newEntityIds) {
|
|
834
|
+
try {
|
|
835
|
+
// Fetch the newly created entity to get its type, title, and content
|
|
836
|
+
const { entity: rawEntity } = await client.getMemoryEntity(newEntityId);
|
|
837
|
+
const entity = rawEntity as {
|
|
838
|
+
id: string;
|
|
839
|
+
type: string;
|
|
840
|
+
title: string;
|
|
841
|
+
content: string;
|
|
842
|
+
tags?: string[];
|
|
843
|
+
};
|
|
844
|
+
if (!entity?.type) continue;
|
|
845
|
+
|
|
846
|
+
// Use embedding-based search with title + content snippet as query
|
|
847
|
+
const similar = await findSimilarEntities(
|
|
848
|
+
client,
|
|
849
|
+
entity.title,
|
|
850
|
+
entity.content,
|
|
851
|
+
workspaceId,
|
|
852
|
+
{ projectId, limit: 30, minRrfScore: 0.01 },
|
|
853
|
+
);
|
|
854
|
+
|
|
855
|
+
// Exclude newly created entities
|
|
856
|
+
const existing = similar.filter(
|
|
857
|
+
(c) => !newEntityIds.includes(c.id) && c.type === entity.type,
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
if (existing.length < PATTERN_THRESHOLD) continue;
|
|
861
|
+
|
|
862
|
+
// Build a descriptive pattern title from member entity titles
|
|
863
|
+
const memberTitles = [
|
|
864
|
+
entity.title,
|
|
865
|
+
...existing.slice(0, 4).map((e) => e.title),
|
|
866
|
+
];
|
|
867
|
+
const patternTitle = `Pattern: recurring ${entity.type} (${existing.length + 1} instances)`;
|
|
868
|
+
|
|
869
|
+
// Check if a pattern entity for this type already exists
|
|
870
|
+
const { entities: existingPatterns } = await client.listMemoryEntities({
|
|
871
|
+
workspace_id: workspaceId,
|
|
872
|
+
project_id: projectId,
|
|
873
|
+
type: "pattern",
|
|
874
|
+
limit: 10,
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
// Find a pattern that covers this entity type
|
|
878
|
+
const matchingPattern = (
|
|
879
|
+
existingPatterns as Array<{
|
|
880
|
+
id: string;
|
|
881
|
+
metadata?: Record<string, unknown>;
|
|
882
|
+
}>
|
|
883
|
+
).find((p) => p.metadata?.pattern_type === entity.type);
|
|
884
|
+
|
|
885
|
+
let patternId: string | null = null;
|
|
886
|
+
|
|
887
|
+
if (matchingPattern) {
|
|
888
|
+
patternId = matchingPattern.id;
|
|
889
|
+
await client.updateMemoryEntity(patternId, {
|
|
890
|
+
content: `Recurring pattern: ${entity.type} entities appearing ${existing.length + 1} times.\n\nMembers:\n${memberTitles.map((t) => `- ${t}`).join("\n")}\n\nLast updated: ${new Date().toISOString()}`,
|
|
891
|
+
metadata: {
|
|
892
|
+
pattern_count: existing.length + 1,
|
|
893
|
+
pattern_type: entity.type,
|
|
894
|
+
last_updated: new Date().toISOString(),
|
|
895
|
+
},
|
|
896
|
+
});
|
|
897
|
+
} else {
|
|
898
|
+
const result = await client.createMemoryEntity({
|
|
899
|
+
workspace_id: workspaceId,
|
|
900
|
+
project_id: projectId,
|
|
901
|
+
type: "pattern",
|
|
902
|
+
scope: "project",
|
|
903
|
+
memory_tier: "reference",
|
|
904
|
+
title: patternTitle,
|
|
905
|
+
content: `Recurring pattern: ${entity.type} entities detected ${existing.length + 1} times.\n\nMembers:\n${memberTitles.map((t) => `- ${t}`).join("\n")}`,
|
|
906
|
+
confidence: 0.75,
|
|
907
|
+
tags: ["auto-extracted", "pattern", entity.type],
|
|
908
|
+
metadata: {
|
|
909
|
+
source: "pattern_detection",
|
|
910
|
+
pattern_type: entity.type,
|
|
911
|
+
pattern_count: existing.length + 1,
|
|
912
|
+
},
|
|
913
|
+
agent_identifier: session.agentIdentifier,
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
const created = result.entity as { id: string };
|
|
917
|
+
if (created?.id) {
|
|
918
|
+
patternId = created.id;
|
|
919
|
+
patternEntityIds.push(patternId);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (!patternId) continue;
|
|
924
|
+
|
|
925
|
+
// Link members → pattern using part_of relations
|
|
926
|
+
const toLink = [newEntityId, ...existing.slice(0, 4).map((e) => e.id)];
|
|
927
|
+
for (const sourceId of toLink) {
|
|
928
|
+
try {
|
|
929
|
+
await client.createMemoryRelation({
|
|
930
|
+
source_id: sourceId,
|
|
931
|
+
target_id: patternId,
|
|
932
|
+
relation_type: "part_of",
|
|
933
|
+
confidence: 0.75,
|
|
934
|
+
});
|
|
935
|
+
} catch {
|
|
936
|
+
// Skip duplicate/failed relations silently
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
} catch {
|
|
940
|
+
// Non-fatal: pattern detection failure should not block anything
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
return patternEntityIds;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// --- Cross-Session Causal Pattern Detection ---
|
|
948
|
+
|
|
949
|
+
const CAUSAL_PATTERN_THRESHOLD = 3;
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Detect recurring error→solution chains across sessions.
|
|
953
|
+
*
|
|
954
|
+
* When a session produces both error and solution entities, searches for
|
|
955
|
+
* similar errors from other sessions that also have `resolved_by` relations.
|
|
956
|
+
* If ≥3 such pairs exist, creates a `pattern` entity to capture the
|
|
957
|
+
* recurring causal chain.
|
|
958
|
+
*/
|
|
959
|
+
export async function detectCausalPatterns(
|
|
960
|
+
client: HarmonyApiClient,
|
|
961
|
+
createdPairs: Array<{ id: string; learning: ExtractedLearning }>,
|
|
962
|
+
session: SessionContext,
|
|
963
|
+
workspaceId: string,
|
|
964
|
+
projectId?: string,
|
|
965
|
+
): Promise<string[]> {
|
|
966
|
+
const patternIds: string[] = [];
|
|
967
|
+
|
|
968
|
+
// Find error+solution pairs from this session
|
|
969
|
+
const errors = createdPairs.filter((p) => p.learning.type === "error");
|
|
970
|
+
const solutions = createdPairs.filter((p) => p.learning.type === "solution");
|
|
971
|
+
if (errors.length === 0 || solutions.length === 0) return patternIds;
|
|
972
|
+
|
|
973
|
+
for (const errorPair of errors) {
|
|
974
|
+
try {
|
|
975
|
+
// Search for similar errors from other sessions
|
|
976
|
+
const similarErrors = await findSimilarEntities(
|
|
977
|
+
client,
|
|
978
|
+
errorPair.learning.title,
|
|
979
|
+
errorPair.learning.content,
|
|
980
|
+
workspaceId,
|
|
981
|
+
{
|
|
982
|
+
projectId,
|
|
983
|
+
limit: 20,
|
|
984
|
+
minRrfScore: 0.03,
|
|
985
|
+
excludeIds: createdPairs.map((p) => p.id),
|
|
986
|
+
type: "error",
|
|
987
|
+
},
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
// Filter to errors that have resolved_by relations
|
|
991
|
+
const resolvedErrors: Array<{
|
|
992
|
+
errorId: string;
|
|
993
|
+
errorTitle: string;
|
|
994
|
+
solutionTitle: string;
|
|
995
|
+
}> = [];
|
|
996
|
+
for (const similar of similarErrors.slice(0, 10)) {
|
|
997
|
+
try {
|
|
998
|
+
const { outgoing } = await client.getRelatedEntities(similar.id);
|
|
999
|
+
const resolvedByRel = (
|
|
1000
|
+
outgoing as Array<{ relation_type: string; target_title?: string }>
|
|
1001
|
+
).find((r) => r.relation_type === "resolved_by");
|
|
1002
|
+
if (resolvedByRel) {
|
|
1003
|
+
resolvedErrors.push({
|
|
1004
|
+
errorId: similar.id,
|
|
1005
|
+
errorTitle: similar.title,
|
|
1006
|
+
solutionTitle:
|
|
1007
|
+
(resolvedByRel as { target_title?: string }).target_title ||
|
|
1008
|
+
"unknown",
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
} catch {
|
|
1012
|
+
// Non-fatal: skip entities where relation lookup fails
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Need at least CAUSAL_PATTERN_THRESHOLD similar resolved errors (including current)
|
|
1017
|
+
if (resolvedErrors.length + 1 < CAUSAL_PATTERN_THRESHOLD) continue;
|
|
1018
|
+
|
|
1019
|
+
// Check if a causal pattern already exists for this domain
|
|
1020
|
+
const { entities: existingPatterns } = await client.listMemoryEntities({
|
|
1021
|
+
workspace_id: workspaceId,
|
|
1022
|
+
project_id: projectId,
|
|
1023
|
+
type: "pattern",
|
|
1024
|
+
limit: 10,
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
const matchingPattern = (
|
|
1028
|
+
existingPatterns as Array<{
|
|
1029
|
+
id: string;
|
|
1030
|
+
metadata?: Record<string, unknown>;
|
|
1031
|
+
}>
|
|
1032
|
+
).find(
|
|
1033
|
+
(p) => p.metadata?.pattern_chain_type === "error_resolved_by_solution",
|
|
1034
|
+
);
|
|
1035
|
+
|
|
1036
|
+
if (matchingPattern) {
|
|
1037
|
+
// Update existing causal pattern
|
|
1038
|
+
await client.updateMemoryEntity(matchingPattern.id, {
|
|
1039
|
+
content: [
|
|
1040
|
+
`Recurring error→solution chain detected (${resolvedErrors.length + 1} instances).`,
|
|
1041
|
+
"",
|
|
1042
|
+
"## Error→Solution Pairs",
|
|
1043
|
+
`- ${errorPair.learning.title} → ${solutions[0].learning.title}`,
|
|
1044
|
+
...resolvedErrors
|
|
1045
|
+
.slice(0, 5)
|
|
1046
|
+
.map((r) => `- ${r.errorTitle} → ${r.solutionTitle}`),
|
|
1047
|
+
"",
|
|
1048
|
+
`Last updated: ${new Date().toISOString()}`,
|
|
1049
|
+
].join("\n"),
|
|
1050
|
+
metadata: {
|
|
1051
|
+
pattern_chain_type: "error_resolved_by_solution",
|
|
1052
|
+
pattern_count: resolvedErrors.length + 1,
|
|
1053
|
+
last_updated: new Date().toISOString(),
|
|
1054
|
+
},
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
// Link current error+solution to the pattern
|
|
1058
|
+
for (const pair of [errorPair, solutions[0]]) {
|
|
1059
|
+
try {
|
|
1060
|
+
await client.createMemoryRelation({
|
|
1061
|
+
source_id: pair.id,
|
|
1062
|
+
target_id: matchingPattern.id,
|
|
1063
|
+
relation_type: "part_of",
|
|
1064
|
+
confidence: 0.75,
|
|
1065
|
+
});
|
|
1066
|
+
} catch {
|
|
1067
|
+
// Skip duplicate relations
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
} else {
|
|
1071
|
+
// Create new causal pattern
|
|
1072
|
+
const result = await client.createMemoryEntity({
|
|
1073
|
+
workspace_id: workspaceId,
|
|
1074
|
+
project_id: projectId,
|
|
1075
|
+
type: "pattern",
|
|
1076
|
+
scope: "project",
|
|
1077
|
+
memory_tier: "reference",
|
|
1078
|
+
title: `Pattern: recurring error→solution chain (${resolvedErrors.length + 1} instances)`,
|
|
1079
|
+
content: [
|
|
1080
|
+
`Recurring error→solution chain detected across ${resolvedErrors.length + 1} sessions.`,
|
|
1081
|
+
"",
|
|
1082
|
+
"## Error→Solution Pairs",
|
|
1083
|
+
`- ${errorPair.learning.title} → ${solutions[0].learning.title}`,
|
|
1084
|
+
...resolvedErrors
|
|
1085
|
+
.slice(0, 5)
|
|
1086
|
+
.map((r) => `- ${r.errorTitle} → ${r.solutionTitle}`),
|
|
1087
|
+
].join("\n"),
|
|
1088
|
+
confidence: 0.8,
|
|
1089
|
+
tags: ["auto-extracted", "pattern", "causal-chain"],
|
|
1090
|
+
metadata: {
|
|
1091
|
+
source: "causal_pattern_detection",
|
|
1092
|
+
pattern_chain_type: "error_resolved_by_solution",
|
|
1093
|
+
pattern_count: resolvedErrors.length + 1,
|
|
1094
|
+
},
|
|
1095
|
+
agent_identifier: session.agentIdentifier,
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
const created = result.entity as { id: string };
|
|
1099
|
+
if (created?.id) {
|
|
1100
|
+
patternIds.push(created.id);
|
|
1101
|
+
|
|
1102
|
+
// Link members to the pattern
|
|
1103
|
+
const memberIds = [
|
|
1104
|
+
errorPair.id,
|
|
1105
|
+
solutions[0].id,
|
|
1106
|
+
...resolvedErrors.slice(0, 4).map((r) => r.errorId),
|
|
1107
|
+
];
|
|
1108
|
+
for (const memberId of memberIds) {
|
|
1109
|
+
try {
|
|
1110
|
+
await client.createMemoryRelation({
|
|
1111
|
+
source_id: memberId,
|
|
1112
|
+
target_id: created.id,
|
|
1113
|
+
relation_type: "part_of",
|
|
1114
|
+
confidence: 0.75,
|
|
1115
|
+
});
|
|
1116
|
+
} catch {
|
|
1117
|
+
// Skip duplicate relations
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
} catch {
|
|
1123
|
+
// Non-fatal: causal pattern detection failure should not block anything
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
return patternIds;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// --- Contradiction Detection ---
|
|
1131
|
+
|
|
1132
|
+
export interface ContradictionResult {
|
|
1133
|
+
entityId: string;
|
|
1134
|
+
title: string;
|
|
1135
|
+
type: string;
|
|
1136
|
+
tags: string[];
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Detect potential contradictions for a newly created entity using semantic search.
|
|
1141
|
+
*
|
|
1142
|
+
* Uses hybrid FTS+vector search to find same-type entities with similar content,
|
|
1143
|
+
* then creates `contradicts` relations. Only applies to entity types allowed by
|
|
1144
|
+
* the schema: decision, pattern, preference, lesson.
|
|
1145
|
+
*
|
|
1146
|
+
* Returns the list of contradicting entities found (for inclusion in tool response).
|
|
1147
|
+
*/
|
|
1148
|
+
export async function detectContradictions(
|
|
1149
|
+
client: HarmonyApiClient,
|
|
1150
|
+
entityId: string,
|
|
1151
|
+
entityType: string,
|
|
1152
|
+
title: string,
|
|
1153
|
+
content: string,
|
|
1154
|
+
tags: string[],
|
|
1155
|
+
workspaceId: string,
|
|
1156
|
+
projectId?: string,
|
|
1157
|
+
): Promise<ContradictionResult[]> {
|
|
1158
|
+
// Only types that can participate in contradicts relations
|
|
1159
|
+
if (!CONTRADICTION_TYPES.has(entityType)) return [];
|
|
1160
|
+
|
|
1161
|
+
// Need at least a title to search
|
|
1162
|
+
if (!title.trim()) return [];
|
|
1163
|
+
|
|
1164
|
+
try {
|
|
1165
|
+
const similar = await findSimilarEntities(
|
|
1166
|
+
client,
|
|
1167
|
+
title,
|
|
1168
|
+
content,
|
|
1169
|
+
workspaceId,
|
|
1170
|
+
{
|
|
1171
|
+
projectId,
|
|
1172
|
+
limit: 10,
|
|
1173
|
+
excludeIds: [entityId],
|
|
1174
|
+
},
|
|
1175
|
+
);
|
|
1176
|
+
|
|
1177
|
+
// Filter to same type only (contradictions are within the same type)
|
|
1178
|
+
const candidates = similar.filter(
|
|
1179
|
+
(e) => e.type === entityType && CONTRADICTION_TYPES.has(e.type),
|
|
1180
|
+
);
|
|
1181
|
+
|
|
1182
|
+
if (candidates.length === 0) return [];
|
|
1183
|
+
|
|
1184
|
+
// Check for actual content divergence: similar topic but different stance.
|
|
1185
|
+
// Entities that are very similar (high RRF) are likely supporting, not contradicting.
|
|
1186
|
+
// We want entities that share topic keywords but have meaningful differences.
|
|
1187
|
+
const contradictions: ContradictionResult[] = [];
|
|
1188
|
+
|
|
1189
|
+
for (const candidate of candidates) {
|
|
1190
|
+
// Skip entities with very high similarity — they're duplicates/supporting, not contradictions
|
|
1191
|
+
if ((candidate.rrf_score ?? 0) > 0.8) continue;
|
|
1192
|
+
|
|
1193
|
+
// Skip entities with very low similarity — they're unrelated
|
|
1194
|
+
if ((candidate.rrf_score ?? 0) < 0.02) continue;
|
|
1195
|
+
|
|
1196
|
+
// Check tag overlap: need at least 1 shared tag to indicate same domain
|
|
1197
|
+
const sharedTags = tags.filter((t) => candidate.tags?.includes(t));
|
|
1198
|
+
if (tags.length > 0 && sharedTags.length === 0) continue;
|
|
1199
|
+
|
|
1200
|
+
contradictions.push({
|
|
1201
|
+
entityId: candidate.id,
|
|
1202
|
+
title: candidate.title,
|
|
1203
|
+
type: candidate.type,
|
|
1204
|
+
tags: candidate.tags || [],
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Create contradicts relations (fire-and-forget style, but await for response)
|
|
1209
|
+
for (const c of contradictions) {
|
|
1210
|
+
try {
|
|
1211
|
+
await client.createMemoryRelation({
|
|
1212
|
+
source_id: entityId,
|
|
1213
|
+
target_id: c.entityId,
|
|
1214
|
+
relation_type: "contradicts",
|
|
1215
|
+
confidence: 0.5,
|
|
1216
|
+
});
|
|
1217
|
+
} catch {
|
|
1218
|
+
// Skip duplicate/failed relations silently (409 Conflict is expected)
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
return contradictions;
|
|
1223
|
+
} catch {
|
|
1224
|
+
// Non-fatal: contradiction detection should never block entity creation
|
|
1225
|
+
return [];
|
|
1226
|
+
}
|
|
1227
|
+
}
|