@gethmy/mcp 2.3.0 → 2.3.2

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.
Files changed (38) hide show
  1. package/dist/cli.js +80 -23
  2. package/dist/index.js +80 -23
  3. package/dist/lib/active-learning.js +939 -787
  4. package/dist/lib/api-client.js +2527 -638
  5. package/dist/lib/auto-session.js +177 -196
  6. package/dist/lib/cli.js +34954 -128
  7. package/dist/lib/config.js +235 -201
  8. package/dist/lib/consolidation.js +374 -289
  9. package/dist/lib/context-assembly.js +1265 -838
  10. package/dist/lib/graph-expansion.js +139 -155
  11. package/dist/lib/http.js +1917 -130
  12. package/dist/lib/index.js +29525 -5
  13. package/dist/lib/lifecycle-maintenance.js +663 -79
  14. package/dist/lib/memory-cleanup.js +1316 -381
  15. package/dist/lib/onboard.js +2588 -32
  16. package/dist/lib/prompt-builder.js +438 -445
  17. package/dist/lib/remote.js +31733 -143
  18. package/dist/lib/server.js +29389 -3216
  19. package/dist/lib/skills.js +315 -132
  20. package/dist/lib/tui/agents.js +128 -107
  21. package/dist/lib/tui/docs.js +1590 -687
  22. package/dist/lib/tui/setup.js +5698 -804
  23. package/dist/lib/tui/theme.js +183 -86
  24. package/dist/lib/tui/writer.js +1149 -176
  25. package/package.json +2 -2
  26. package/src/api-client.ts +37 -1
  27. package/src/memory-cleanup.ts +92 -52
  28. package/src/server.ts +16 -1
  29. package/dist/lib/__tests__/active-learning.test.js +0 -386
  30. package/dist/lib/__tests__/agent-performance-profiles.test.js +0 -325
  31. package/dist/lib/__tests__/auto-session.test.js +0 -661
  32. package/dist/lib/__tests__/context-assembly.test.js +0 -362
  33. package/dist/lib/__tests__/graph-expansion.test.js +0 -150
  34. package/dist/lib/__tests__/integration-memory-crud.test.js +0 -797
  35. package/dist/lib/__tests__/integration-memory-system.test.js +0 -281
  36. package/dist/lib/__tests__/lifecycle-maintenance.test.js +0 -207
  37. package/dist/lib/__tests__/pattern-detection.test.js +0 -295
  38. package/dist/lib/__tests__/prompt-builder.test.js +0 -418
@@ -1,822 +1,974 @@
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
- import { getActiveProjectId, getActiveWorkspaceId } from "./config.js";
8
- import { autoExpandGraph, findSimilarEntities, linkCrossTypeNeighbors, } from "./graph-expansion.js";
9
- // Entity types that can participate in `contradicts` relations (per schema.ts)
10
- const CONTRADICTION_TYPES = new Set([
11
- "decision",
12
- "pattern",
13
- "preference",
14
- "lesson",
15
- ]);
16
- // --- Mid-Session Learning ---
17
- /** Track previous task per session for change detection */
18
- const sessionTaskHistory = new Map();
19
- /** Rate limit: max 1 extraction per 2 minutes per session */
20
- const MID_SESSION_RATE_LIMIT_MS = 120_000;
21
- /**
22
- * Simple Levenshtein similarity (0-1 range, 1 = identical).
23
- */
24
- function levenshteinSimilarity(a, b) {
25
- if (a === b)
26
- return 1;
27
- if (a.length === 0 || b.length === 0)
28
- return 0;
29
- // Use shorter strings for performance (truncate to 200 chars)
30
- const sa = a.slice(0, 200).toLowerCase();
31
- const sb = b.slice(0, 200).toLowerCase();
32
- const matrix = [];
33
- for (let i = 0; i <= sa.length; i++) {
34
- matrix[i] = [i];
35
- }
36
- for (let j = 0; j <= sb.length; j++) {
37
- matrix[0][j] = j;
38
- }
39
- for (let i = 1; i <= sa.length; i++) {
40
- for (let j = 1; j <= sb.length; j++) {
41
- const cost = sa[i - 1] === sb[j - 1] ? 0 : 1;
42
- matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
43
- }
1
+ import { createRequire } from "node:module";
2
+ var __create = Object.create;
3
+ var __getProtoOf = Object.getPrototypeOf;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __toESM = (mod, isNodeMode, target) => {
8
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
9
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
10
+ for (let key of __getOwnPropNames(mod))
11
+ if (!__hasOwnProp.call(to, key))
12
+ __defProp(to, key, {
13
+ get: () => mod[key],
14
+ enumerable: true
15
+ });
16
+ return to;
17
+ };
18
+ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
19
+ var __export = (target, all) => {
20
+ for (var name in all)
21
+ __defProp(target, name, {
22
+ get: all[name],
23
+ enumerable: true,
24
+ configurable: true,
25
+ set: (newValue) => all[name] = () => newValue
26
+ });
27
+ };
28
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
29
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
30
+
31
+ // src/config.ts
32
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
33
+ import { homedir } from "node:os";
34
+ import { join } from "node:path";
35
+ var DEFAULT_API_URL = "https://app.gethmy.com/api";
36
+ var LOCAL_CONFIG_FILENAME = ".harmony-mcp.json";
37
+ function getConfigDir() {
38
+ return join(homedir(), ".harmony-mcp");
39
+ }
40
+ function getConfigPath() {
41
+ return join(getConfigDir(), "config.json");
42
+ }
43
+ function getLocalConfigPath(cwd) {
44
+ return join(cwd || process.cwd(), LOCAL_CONFIG_FILENAME);
45
+ }
46
+ function loadConfig() {
47
+ const configPath = getConfigPath();
48
+ if (!existsSync(configPath)) {
49
+ return {
50
+ apiKey: null,
51
+ apiUrl: DEFAULT_API_URL,
52
+ activeWorkspaceId: null,
53
+ activeProjectId: null,
54
+ userEmail: null,
55
+ memoryDir: null
56
+ };
57
+ }
58
+ try {
59
+ const data = readFileSync(configPath, "utf-8");
60
+ const config = JSON.parse(data);
61
+ return {
62
+ apiKey: config.apiKey || null,
63
+ apiUrl: config.apiUrl || DEFAULT_API_URL,
64
+ activeWorkspaceId: config.activeWorkspaceId || null,
65
+ activeProjectId: config.activeProjectId || null,
66
+ userEmail: config.userEmail || null,
67
+ memoryDir: config.memoryDir || null
68
+ };
69
+ } catch {
70
+ return {
71
+ apiKey: null,
72
+ apiUrl: DEFAULT_API_URL,
73
+ activeWorkspaceId: null,
74
+ activeProjectId: null,
75
+ userEmail: null,
76
+ memoryDir: null
77
+ };
78
+ }
79
+ }
80
+ function saveConfig(config) {
81
+ const configDir = getConfigDir();
82
+ const configPath = getConfigPath();
83
+ if (!existsSync(configDir)) {
84
+ mkdirSync(configDir, { recursive: true, mode: 448 });
85
+ }
86
+ const existingConfig = loadConfig();
87
+ const newConfig = { ...existingConfig, ...config };
88
+ writeFileSync(configPath, JSON.stringify(newConfig, null, 2), {
89
+ mode: 384
90
+ });
91
+ }
92
+ function loadLocalConfig(cwd) {
93
+ const localConfigPath = getLocalConfigPath(cwd);
94
+ if (!existsSync(localConfigPath)) {
95
+ return null;
96
+ }
97
+ try {
98
+ const data = readFileSync(localConfigPath, "utf-8");
99
+ const config = JSON.parse(data);
100
+ return {
101
+ workspaceId: config.workspaceId || null,
102
+ projectId: config.projectId || null
103
+ };
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+ function saveLocalConfig(config, cwd) {
109
+ const localConfigPath = getLocalConfigPath(cwd);
110
+ const existingConfig = loadLocalConfig(cwd) || {
111
+ workspaceId: null,
112
+ projectId: null
113
+ };
114
+ const newConfig = { ...existingConfig, ...config };
115
+ const cleanConfig = {};
116
+ if (newConfig.workspaceId)
117
+ cleanConfig.workspaceId = newConfig.workspaceId;
118
+ if (newConfig.projectId)
119
+ cleanConfig.projectId = newConfig.projectId;
120
+ writeFileSync(localConfigPath, JSON.stringify(cleanConfig, null, 2));
121
+ }
122
+ function hasLocalConfig(cwd) {
123
+ return existsSync(getLocalConfigPath(cwd));
124
+ }
125
+ function getApiKey() {
126
+ const config = loadConfig();
127
+ if (!config.apiKey) {
128
+ throw new Error(`Not configured. Run "npx @gethmy/mcp setup" to set your API key.
129
+ ` + "You can generate an API key at https://gethmy.com → Settings → API Keys.");
130
+ }
131
+ return config.apiKey;
132
+ }
133
+ function getApiUrl() {
134
+ const config = loadConfig();
135
+ return config.apiUrl;
136
+ }
137
+ function getUserEmail() {
138
+ const config = loadConfig();
139
+ return config.userEmail;
140
+ }
141
+ function setUserEmail(email) {
142
+ saveConfig({ userEmail: email });
143
+ }
144
+ function setActiveWorkspace(workspaceId, options) {
145
+ if (options?.local) {
146
+ saveLocalConfig({ workspaceId }, options.cwd);
147
+ } else {
148
+ saveConfig({ activeWorkspaceId: workspaceId });
149
+ }
150
+ }
151
+ function setActiveProject(projectId, options) {
152
+ if (options?.local) {
153
+ saveLocalConfig({ projectId }, options.cwd);
154
+ } else {
155
+ saveConfig({ activeProjectId: projectId });
156
+ }
157
+ }
158
+ function getActiveWorkspaceId(cwd) {
159
+ const localConfig = loadLocalConfig(cwd);
160
+ if (localConfig?.workspaceId) {
161
+ return localConfig.workspaceId;
162
+ }
163
+ return loadConfig().activeWorkspaceId;
164
+ }
165
+ function getActiveProjectId(cwd) {
166
+ const localConfig = loadLocalConfig(cwd);
167
+ if (localConfig?.projectId) {
168
+ return localConfig.projectId;
169
+ }
170
+ return loadConfig().activeProjectId;
171
+ }
172
+ function isConfigured() {
173
+ const config = loadConfig();
174
+ return !!config.apiKey;
175
+ }
176
+ function areSkillsInstalled(cwd) {
177
+ const home = homedir();
178
+ const workingDir = cwd || process.cwd();
179
+ const foundPaths = [];
180
+ const globalSkillsDir = join(home, ".agents", "skills");
181
+ const globalSkillPath = join(globalSkillsDir, "hmy", "SKILL.md");
182
+ if (existsSync(globalSkillPath)) {
183
+ foundPaths.push(globalSkillPath);
184
+ return { installed: true, location: "global", paths: foundPaths };
185
+ }
186
+ const claudeGlobalSkill = join(home, ".claude", "skills", "hmy.md");
187
+ if (existsSync(claudeGlobalSkill)) {
188
+ foundPaths.push(claudeGlobalSkill);
189
+ return { installed: true, location: "global", paths: foundPaths };
190
+ }
191
+ const claudeGlobalSkillAlt = join(home, ".claude", "skills", "hmy", "SKILL.md");
192
+ if (existsSync(claudeGlobalSkillAlt)) {
193
+ foundPaths.push(claudeGlobalSkillAlt);
194
+ return { installed: true, location: "global", paths: foundPaths };
195
+ }
196
+ const localSkillPath = join(workingDir, ".claude", "skills", "hmy.md");
197
+ if (existsSync(localSkillPath)) {
198
+ foundPaths.push(localSkillPath);
199
+ return { installed: true, location: "local", paths: foundPaths };
200
+ }
201
+ const localSkillPathAlt = join(workingDir, ".claude", "skills", "hmy", "SKILL.md");
202
+ if (existsSync(localSkillPathAlt)) {
203
+ foundPaths.push(localSkillPathAlt);
204
+ return { installed: true, location: "local", paths: foundPaths };
205
+ }
206
+ return { installed: false, location: null, paths: [] };
207
+ }
208
+ function hasProjectContext(cwd) {
209
+ const localConfig = loadLocalConfig(cwd);
210
+ return !!(localConfig?.workspaceId || localConfig?.projectId);
211
+ }
212
+ function getMemoryDir() {
213
+ const config = loadConfig();
214
+ if (config.memoryDir)
215
+ return config.memoryDir;
216
+ return join(homedir(), ".harmony", "memory");
217
+ }
218
+
219
+ // src/graph-expansion.ts
220
+ async function autoExpandGraph(client, entityId, title, content, _tags, workspaceId, projectId, maxRelations = 5) {
221
+ try {
222
+ const contentSnippet = content.slice(0, 200).trim();
223
+ const query = [title, contentSnippet].filter(Boolean).join(" ");
224
+ let candidates = [];
225
+ const { entities } = await client.searchMemoryEntities(workspaceId, query, {
226
+ project_id: projectId,
227
+ limit: 20
228
+ });
229
+ candidates = entities.filter((e) => e.id !== entityId).slice(0, maxRelations);
230
+ if (candidates.length === 0) {
231
+ await new Promise((resolve) => setTimeout(resolve, 2000));
232
+ const retry = await client.searchMemoryEntities(workspaceId, query, {
233
+ project_id: projectId,
234
+ limit: 20
235
+ });
236
+ candidates = retry.entities.filter((e) => e.id !== entityId).slice(0, maxRelations);
44
237
  }
45
- const maxLen = Math.max(sa.length, sb.length);
46
- return 1 - matrix[sa.length][sb.length] / maxLen;
47
- }
48
- /**
49
- * Extract learnings from mid-session progress updates.
50
- * Called from harmony_update_agent_progress.
51
- */
52
- export async function extractMidSessionLearnings(_client, ctx) {
53
- const workspaceId = getActiveWorkspaceId();
54
- if (!workspaceId)
55
- return { count: 0, entityIds: [] };
56
- const _projectId = getActiveProjectId() || undefined;
57
- const now = Date.now();
58
- const _entityIds = [];
59
- const history = sessionTaskHistory.get(ctx.cardId);
60
- // Always track step history regardless of rate limit
61
- if (ctx.currentTask) {
62
- const previousTask = history?.lastTask || "";
63
- const similarity = levenshteinSimilarity(previousTask, ctx.currentTask);
64
- const existingSteps = history?.steps || [];
65
- if (existingSteps.length === 0 ||
66
- (similarity < 0.6 && previousTask.length > 0)) {
67
- const newStep = {
68
- task: ctx.currentTask,
69
- progress: ctx.progressPercent ?? 0,
70
- timestamp: now,
71
- };
72
- if (existingSteps.length === 0 && previousTask.length > 0) {
73
- existingSteps.push({
74
- task: previousTask,
75
- progress: 0,
76
- timestamp: history?.lastExtractionAt ?? now,
77
- });
78
- }
79
- existingSteps.push(newStep);
80
- sessionTaskHistory.set(ctx.cardId, {
81
- lastTask: ctx.currentTask,
82
- lastExtractionAt: history?.lastExtractionAt ?? 0,
83
- steps: existingSteps,
84
- });
85
- }
238
+ let relationsCreated = 0;
239
+ for (const candidate of candidates) {
240
+ try {
241
+ await client.createMemoryRelation({
242
+ source_id: entityId,
243
+ target_id: candidate.id,
244
+ relation_type: "relates_to",
245
+ confidence: 0.6
246
+ });
247
+ relationsCreated++;
248
+ } catch (err) {
249
+ const status = err?.status;
250
+ if (status !== 409) {}
251
+ }
86
252
  }
87
- // Rate limit check (only gates memory entity creation, not step tracking)
88
- if (history && now - history.lastExtractionAt < MID_SESSION_RATE_LIMIT_MS) {
89
- // Still within rate limit, but always extract blockers immediately
90
- if (ctx.status !== "blocked" || !ctx.blockers?.length) {
91
- return { count: 0, entityIds: [] };
92
- }
253
+ return { relationsCreated };
254
+ } catch {
255
+ return { relationsCreated: 0 };
256
+ }
257
+ }
258
+ async function findSimilarEntities(client, title, content, workspaceId, options) {
259
+ const contentSnippet = content.slice(0, 200).trim();
260
+ const query = [title, contentSnippet].filter(Boolean).join(" ");
261
+ try {
262
+ const { entities } = await client.searchMemoryEntities(workspaceId, query, {
263
+ project_id: options?.projectId,
264
+ limit: options?.limit ?? 20,
265
+ type: options?.type
266
+ });
267
+ const minScore = options?.minRrfScore ?? 0;
268
+ const excludeSet = new Set(options?.excludeIds || []);
269
+ return entities.filter((e) => {
270
+ if (excludeSet.has(e.id))
271
+ return false;
272
+ if (minScore > 0 && (e.rrf_score ?? 0) < minScore)
273
+ return false;
274
+ return true;
275
+ });
276
+ } catch {
277
+ return [];
278
+ }
279
+ }
280
+ var CAUSAL_LOOKUP = [
281
+ {
282
+ sourceType: "error",
283
+ targetType: "solution",
284
+ relation: "resolved_by",
285
+ direction: "forward"
286
+ },
287
+ {
288
+ sourceType: "solution",
289
+ targetType: "error",
290
+ relation: "resolved_by",
291
+ direction: "reverse"
292
+ },
293
+ {
294
+ sourceType: "lesson",
295
+ targetType: "error",
296
+ relation: "learned_from",
297
+ direction: "forward"
298
+ }
299
+ ];
300
+ async function linkCrossTypeNeighbors(client, entityId, entityType, title, content, workspaceId, projectId) {
301
+ const rules = CAUSAL_LOOKUP.filter((r) => r.sourceType === entityType);
302
+ if (rules.length === 0)
303
+ return { relationsCreated: 0 };
304
+ let relationsCreated = 0;
305
+ for (const rule of rules) {
306
+ try {
307
+ const matches = await findSimilarEntities(client, title, content, workspaceId, {
308
+ projectId,
309
+ limit: 10,
310
+ minRrfScore: 0.04,
311
+ excludeIds: [entityId],
312
+ type: rule.targetType
313
+ });
314
+ for (const match of matches.slice(0, 3)) {
315
+ const sourceId = rule.direction === "forward" ? entityId : match.id;
316
+ const targetId = rule.direction === "forward" ? match.id : entityId;
317
+ try {
318
+ await client.createMemoryRelation({
319
+ source_id: sourceId,
320
+ target_id: targetId,
321
+ relation_type: rule.relation,
322
+ confidence: 0.65
323
+ });
324
+ relationsCreated++;
325
+ } catch {}
326
+ }
327
+ } catch {}
328
+ }
329
+ return { relationsCreated };
330
+ }
331
+
332
+ // src/active-learning.ts
333
+ var CONTRADICTION_TYPES = new Set([
334
+ "decision",
335
+ "pattern",
336
+ "preference",
337
+ "lesson"
338
+ ]);
339
+ var sessionTaskHistory = new Map;
340
+ var MID_SESSION_RATE_LIMIT_MS = 120000;
341
+ function levenshteinSimilarity(a, b) {
342
+ if (a === b)
343
+ return 1;
344
+ if (a.length === 0 || b.length === 0)
345
+ return 0;
346
+ const sa = a.slice(0, 200).toLowerCase();
347
+ const sb = b.slice(0, 200).toLowerCase();
348
+ const matrix = [];
349
+ for (let i = 0;i <= sa.length; i++) {
350
+ matrix[i] = [i];
351
+ }
352
+ for (let j = 0;j <= sb.length; j++) {
353
+ matrix[0][j] = j;
354
+ }
355
+ for (let i = 1;i <= sa.length; i++) {
356
+ for (let j = 1;j <= sb.length; j++) {
357
+ const cost = sa[i - 1] === sb[j - 1] ? 0 : 1;
358
+ matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
93
359
  }
94
- // Rule 1: Status transitions to "blocked" → track but DON'T create mid-session entities.
95
- // Blockers are captured at session end with full context — mid-session entities are
96
- // low-confidence duplicates that add noise to the knowledge graph.
97
- if (ctx.status === "blocked" && ctx.blockers?.length) {
98
- sessionTaskHistory.set(ctx.cardId, {
99
- lastTask: ctx.currentTask || "",
100
- lastExtractionAt: now,
101
- steps: history?.steps || [],
360
+ }
361
+ const maxLen = Math.max(sa.length, sb.length);
362
+ return 1 - matrix[sa.length][sb.length] / maxLen;
363
+ }
364
+ async function extractMidSessionLearnings(_client, ctx) {
365
+ const workspaceId = getActiveWorkspaceId();
366
+ if (!workspaceId)
367
+ return { count: 0, entityIds: [] };
368
+ const _projectId = getActiveProjectId() || undefined;
369
+ const now = Date.now();
370
+ const _entityIds = [];
371
+ const history = sessionTaskHistory.get(ctx.cardId);
372
+ if (ctx.currentTask) {
373
+ const previousTask = history?.lastTask || "";
374
+ const similarity = levenshteinSimilarity(previousTask, ctx.currentTask);
375
+ const existingSteps = history?.steps || [];
376
+ if (existingSteps.length === 0 || similarity < 0.6 && previousTask.length > 0) {
377
+ const newStep = {
378
+ task: ctx.currentTask,
379
+ progress: ctx.progressPercent ?? 0,
380
+ timestamp: now
381
+ };
382
+ if (existingSteps.length === 0 && previousTask.length > 0) {
383
+ existingSteps.push({
384
+ task: previousTask,
385
+ progress: 0,
386
+ timestamp: history?.lastExtractionAt ?? now
102
387
  });
103
- return { count: 0, entityIds: [] };
388
+ }
389
+ existingSteps.push(newStep);
390
+ sessionTaskHistory.set(ctx.cardId, {
391
+ lastTask: ctx.currentTask,
392
+ lastExtractionAt: history?.lastExtractionAt ?? 0,
393
+ steps: existingSteps
394
+ });
104
395
  }
105
- // Rule 2: Task transitions are tracked in step history (above) but no longer
106
- // create separate context entities. The step history feeds procedure extraction
107
- // at session end, which is more valuable than individual transition snapshots.
108
- if (ctx.currentTask) {
109
- const currentHistory = sessionTaskHistory.get(ctx.cardId);
110
- sessionTaskHistory.set(ctx.cardId, {
111
- lastTask: ctx.currentTask,
112
- lastExtractionAt: currentHistory?.lastExtractionAt ?? 0,
113
- steps: currentHistory?.steps || [],
114
- });
396
+ }
397
+ if (history && now - history.lastExtractionAt < MID_SESSION_RATE_LIMIT_MS) {
398
+ if (ctx.status !== "blocked" || !ctx.blockers?.length) {
399
+ return { count: 0, entityIds: [] };
115
400
  }
401
+ }
402
+ if (ctx.status === "blocked" && ctx.blockers?.length) {
403
+ sessionTaskHistory.set(ctx.cardId, {
404
+ lastTask: ctx.currentTask || "",
405
+ lastExtractionAt: now,
406
+ steps: history?.steps || []
407
+ });
116
408
  return { count: 0, entityIds: [] };
409
+ }
410
+ if (ctx.currentTask) {
411
+ const currentHistory = sessionTaskHistory.get(ctx.cardId);
412
+ sessionTaskHistory.set(ctx.cardId, {
413
+ lastTask: ctx.currentTask,
414
+ lastExtractionAt: currentHistory?.lastExtractionAt ?? 0,
415
+ steps: currentHistory?.steps || []
416
+ });
417
+ }
418
+ return { count: 0, entityIds: [] };
117
419
  }
118
- /**
119
- * Clean up mid-session tracking for a card (call on session end).
120
- */
121
- export function clearMidSessionTracking(cardId) {
122
- sessionTaskHistory.delete(cardId);
420
+ function clearMidSessionTracking(cardId) {
421
+ sessionTaskHistory.delete(cardId);
123
422
  }
124
- /**
125
- * Enrich raw steps with progress deltas and key decision detection.
126
- * A step is a "key decision" if it represents a >20% progress jump.
127
- */
128
423
  function enrichSteps(steps) {
129
- return steps.map((step, i) => {
130
- const prevProgress = i > 0 ? steps[i - 1].progress : 0;
131
- const prevTimestamp = i > 0 ? steps[i - 1].timestamp : step.timestamp;
132
- const progressDelta = step.progress - prevProgress;
133
- return {
134
- task: step.task,
135
- progress: step.progress,
136
- progressDelta,
137
- durationMs: step.timestamp - prevTimestamp,
138
- isKeyDecision: progressDelta >= 20,
139
- };
140
- });
424
+ return steps.map((step, i) => {
425
+ const prevProgress = i > 0 ? steps[i - 1].progress : 0;
426
+ const prevTimestamp = i > 0 ? steps[i - 1].timestamp : step.timestamp;
427
+ const progressDelta = step.progress - prevProgress;
428
+ return {
429
+ task: step.task,
430
+ progress: step.progress,
431
+ progressDelta,
432
+ durationMs: step.timestamp - prevTimestamp,
433
+ isKeyDecision: progressDelta >= 20
434
+ };
435
+ });
141
436
  }
142
- /**
143
- * Build structured procedure content from session data and enriched steps.
144
- */
145
437
  function buildProcedureContent(session, enrichedSteps, wikiLinksLine) {
146
- const triggerLabels = session.cardLabels.length > 0
147
- ? `Labels: ${session.cardLabels.join(", ")}`
148
- : "";
149
- const stepsMarkdown = enrichedSteps
150
- .map((s, i) => {
151
- const marker = s.isKeyDecision ? " **[key step]**" : "";
152
- const duration = s.durationMs > 0 ? ` (~${Math.round(s.durationMs / 60000)}min)` : "";
153
- return `${i + 1}. ${s.task} (${s.progress}%, +${s.progressDelta}%)${marker}${duration}`;
154
- })
155
- .join("\n");
156
- const subtaskSection = session.cardSubtasks && session.cardSubtasks.length > 0
157
- ? [
158
- "",
159
- "## Subtasks Completed",
160
- ...session.cardSubtasks.map((s) => `- [${s.done ? "x" : " "}] ${s.title}`),
161
- ].join("\n")
162
- : "";
163
- const durationInfo = session.sessionDurationMs
164
- ? `\nDuration: ${Math.round(session.sessionDurationMs / 60000)} minutes`
165
- : "";
166
- return [
167
- "## Trigger",
168
- `When working on: "${session.cardTitle}"`,
169
- triggerLabels,
170
- "",
171
- "## Steps",
172
- stepsMarkdown,
173
- subtaskSection,
174
- "",
175
- "## Outcome",
176
- `Completed at ${session.progressPercent ?? "unknown"}%`,
177
- session.currentTask ? `Final state: ${session.currentTask}` : "",
178
- `Agent: ${session.agentName}`,
179
- durationInfo,
180
- wikiLinksLine,
181
- ]
182
- .filter((line) => line !== undefined)
183
- .join("\n");
184
- }
185
- /**
186
- * Extract a new procedure or reinforce an existing similar one.
187
- *
188
- * Deduplication: searches for existing procedure entities with similar titles.
189
- * If found, merges new steps and bumps confidence. Otherwise creates a new one.
190
- */
438
+ const triggerLabels = session.cardLabels.length > 0 ? `Labels: ${session.cardLabels.join(", ")}` : "";
439
+ const stepsMarkdown = enrichedSteps.map((s, i) => {
440
+ const marker = s.isKeyDecision ? " **[key step]**" : "";
441
+ const duration = s.durationMs > 0 ? ` (~${Math.round(s.durationMs / 60000)}min)` : "";
442
+ return `${i + 1}. ${s.task} (${s.progress}%, +${s.progressDelta}%)${marker}${duration}`;
443
+ }).join(`
444
+ `);
445
+ const subtaskSection = session.cardSubtasks && session.cardSubtasks.length > 0 ? [
446
+ "",
447
+ "## Subtasks Completed",
448
+ ...session.cardSubtasks.map((s) => `- [${s.done ? "x" : " "}] ${s.title}`)
449
+ ].join(`
450
+ `) : "";
451
+ const durationInfo = session.sessionDurationMs ? `
452
+ Duration: ${Math.round(session.sessionDurationMs / 60000)} minutes` : "";
453
+ return [
454
+ "## Trigger",
455
+ `When working on: "${session.cardTitle}"`,
456
+ triggerLabels,
457
+ "",
458
+ "## Steps",
459
+ stepsMarkdown,
460
+ subtaskSection,
461
+ "",
462
+ "## Outcome",
463
+ `Completed at ${session.progressPercent ?? "unknown"}%`,
464
+ session.currentTask ? `Final state: ${session.currentTask}` : "",
465
+ `Agent: ${session.agentName}`,
466
+ durationInfo,
467
+ wikiLinksLine
468
+ ].filter((line) => line !== undefined).join(`
469
+ `);
470
+ }
191
471
  async function extractOrReinforceProcedure(client, session, steps, workspaceId, projectId, wikiLinksLine = "") {
192
- const enrichedSteps = enrichSteps(steps);
193
- const newContent = buildProcedureContent(session, enrichedSteps, wikiLinksLine);
194
- const tags = [
195
- "auto-extracted",
196
- "procedure",
197
- ...session.cardLabels.slice(0, 3),
198
- ];
199
- // Try to find an existing similar procedure to reinforce
200
- try {
201
- const similar = await findSimilarEntities(client, `Procedure: ${session.cardTitle}`, newContent, workspaceId, { projectId, limit: 5, minRrfScore: 0.03 });
202
- const matchingProcedure = similar.find((e) => e.type === "procedure" && (e.rrf_score ?? 0) >= 0.05);
203
- if (matchingProcedure) {
204
- // Fetch the full entity to get metadata and memory_tier
205
- const { entity: rawEntity } = await client.getMemoryEntity(matchingProcedure.id);
206
- const fullEntity = rawEntity;
207
- const currentMeta = fullEntity.metadata || {};
208
- const sourceCards = (currentMeta.source_cards || []).slice();
209
- if (!sourceCards.includes(session.cardId)) {
210
- sourceCards.push(session.cardId);
211
- }
212
- const reuseCount = (currentMeta.reuse_count || 0) + 1;
213
- const currentConfidence = fullEntity.confidence ?? 0.7;
214
- // Bump confidence by 0.05 per reinforcement, max 0.95
215
- const newConfidence = Math.min(0.95, currentConfidence + 0.05);
216
- // Append new steps as an additional execution record
217
- const stepsAppendix = enrichedSteps
218
- .map((s, i) => `${i + 1}. ${s.task} (${s.progress}%)`)
219
- .join("\n");
220
- const appendix = `\n\n---\n### Execution ${reuseCount + 1}: ${session.cardTitle}\n${stepsAppendix}\nAgent: ${session.agentName} | ${new Date().toISOString().split("T")[0]}`;
221
- const updatedMeta = {
222
- ...currentMeta,
223
- reuse_count: reuseCount,
224
- source_cards: sourceCards,
225
- last_reinforced_at: new Date().toISOString(),
226
- step_count: Math.max(currentMeta.step_count || 0, steps.length),
227
- };
228
- // Auto-promote to reference tier if reinforced enough (≥3 executions)
229
- const shouldPromote = reuseCount >= 2 && fullEntity.memory_tier !== "reference";
230
- await client.updateMemoryEntity(fullEntity.id, {
231
- content: (fullEntity.content || "") + appendix,
232
- confidence: newConfidence,
233
- metadata: {
234
- ...updatedMeta,
235
- ...(shouldPromote
236
- ? {
237
- promoted_reason: `Reinforced by ${reuseCount + 1} successful sessions`,
238
- promoted_at: new Date().toISOString(),
239
- }
240
- : {}),
241
- },
242
- ...(shouldPromote ? { memory_tier: "reference" } : {}),
243
- });
244
- return { mode: "reinforced", entityId: fullEntity.id };
245
- }
472
+ const enrichedSteps = enrichSteps(steps);
473
+ const newContent = buildProcedureContent(session, enrichedSteps, wikiLinksLine);
474
+ const tags = [
475
+ "auto-extracted",
476
+ "procedure",
477
+ ...session.cardLabels.slice(0, 3)
478
+ ];
479
+ try {
480
+ const similar = await findSimilarEntities(client, `Procedure: ${session.cardTitle}`, newContent, workspaceId, { projectId, limit: 5, minRrfScore: 0.03 });
481
+ const matchingProcedure = similar.find((e) => e.type === "procedure" && (e.rrf_score ?? 0) >= 0.05);
482
+ if (matchingProcedure) {
483
+ const { entity: rawEntity } = await client.getMemoryEntity(matchingProcedure.id);
484
+ const fullEntity = rawEntity;
485
+ const currentMeta = fullEntity.metadata || {};
486
+ const sourceCards = (currentMeta.source_cards || []).slice();
487
+ if (!sourceCards.includes(session.cardId)) {
488
+ sourceCards.push(session.cardId);
489
+ }
490
+ const reuseCount = (currentMeta.reuse_count || 0) + 1;
491
+ const currentConfidence = fullEntity.confidence ?? 0.7;
492
+ const newConfidence = Math.min(0.95, currentConfidence + 0.05);
493
+ const stepsAppendix = enrichedSteps.map((s, i) => `${i + 1}. ${s.task} (${s.progress}%)`).join(`
494
+ `);
495
+ const appendix = `
496
+
497
+ ---
498
+ ### Execution ${reuseCount + 1}: ${session.cardTitle}
499
+ ${stepsAppendix}
500
+ Agent: ${session.agentName} | ${new Date().toISOString().split("T")[0]}`;
501
+ const updatedMeta = {
502
+ ...currentMeta,
503
+ reuse_count: reuseCount,
504
+ source_cards: sourceCards,
505
+ last_reinforced_at: new Date().toISOString(),
506
+ step_count: Math.max(currentMeta.step_count || 0, steps.length)
507
+ };
508
+ const shouldPromote = reuseCount >= 2 && fullEntity.memory_tier !== "reference";
509
+ await client.updateMemoryEntity(fullEntity.id, {
510
+ content: (fullEntity.content || "") + appendix,
511
+ confidence: newConfidence,
512
+ metadata: {
513
+ ...updatedMeta,
514
+ ...shouldPromote ? {
515
+ promoted_reason: `Reinforced by ${reuseCount + 1} successful sessions`,
516
+ promoted_at: new Date().toISOString()
517
+ } : {}
518
+ },
519
+ ...shouldPromote ? { memory_tier: "reference" } : {}
520
+ });
521
+ return { mode: "reinforced", entityId: fullEntity.id };
246
522
  }
247
- catch {
248
- // Similarity search failed, fall through to create new
523
+ } catch {}
524
+ return {
525
+ mode: "created",
526
+ learning: {
527
+ title: `Procedure: ${session.cardTitle}`,
528
+ content: newContent,
529
+ type: "procedure",
530
+ tier: "episode",
531
+ confidence: 0.7,
532
+ tags,
533
+ metadata: {
534
+ source: "active_learning",
535
+ card_id: session.cardId,
536
+ source_cards: [session.cardId],
537
+ step_count: steps.length,
538
+ key_step_count: enrichedSteps.filter((s) => s.isKeyDecision).length,
539
+ reuse_count: 0
540
+ }
249
541
  }
250
- // No existing procedure found — create a new one
251
- return {
252
- mode: "created",
253
- learning: {
254
- title: `Procedure: ${session.cardTitle}`,
255
- content: newContent,
256
- type: "procedure",
257
- tier: "episode",
258
- confidence: 0.7,
259
- tags,
260
- metadata: {
261
- source: "active_learning",
262
- card_id: session.cardId,
263
- source_cards: [session.cardId],
264
- step_count: steps.length,
265
- key_step_count: enrichedSteps.filter((s) => s.isKeyDecision).length,
266
- reuse_count: 0,
267
- },
268
- },
269
- };
542
+ };
270
543
  }
271
- // --- Same-Session Causal Linking ---
272
- /** Causal pairing rules for entities created within the same session */
273
- const SESSION_CAUSAL_RULES = [
274
- {
275
- sourceType: "error",
276
- targetType: "solution",
277
- relation: "resolved_by",
278
- confidence: 0.8,
279
- },
280
- {
281
- sourceType: "lesson",
282
- targetType: "error",
283
- relation: "learned_from",
284
- confidence: 0.75,
285
- },
544
+ var SESSION_CAUSAL_RULES = [
545
+ {
546
+ sourceType: "error",
547
+ targetType: "solution",
548
+ relation: "resolved_by",
549
+ confidence: 0.8
550
+ },
551
+ {
552
+ sourceType: "lesson",
553
+ targetType: "error",
554
+ relation: "learned_from",
555
+ confidence: 0.75
556
+ }
286
557
  ];
287
- /**
288
- * Link entities created within the same session using causal relations.
289
- *
290
- * For example, if a session produces both an `error` and a `solution`,
291
- * creates an `error -[resolved_by]-> solution` relation.
292
- *
293
- * Non-fatal: all errors are caught silently.
294
- */
295
- export async function linkSessionEntities(client, createdPairs, _workspaceId, _projectId) {
296
- let relationsCreated = 0;
297
- for (const rule of SESSION_CAUSAL_RULES) {
298
- const sources = createdPairs.filter((p) => p.learning.type === rule.sourceType);
299
- const targets = createdPairs.filter((p) => p.learning.type === rule.targetType);
300
- for (const source of sources) {
301
- for (const target of targets) {
302
- try {
303
- await client.createMemoryRelation({
304
- source_id: source.id,
305
- target_id: target.id,
306
- relation_type: rule.relation,
307
- confidence: rule.confidence,
308
- });
309
- relationsCreated++;
310
- }
311
- catch {
312
- // Skip duplicate/failed relations silently
313
- }
314
- }
315
- }
316
- }
317
- return { relationsCreated };
318
- }
319
- // --- Session-End Learning ---
320
- /**
321
- * Extract learnings from a completed agent session.
322
- * Returns the number of memories created.
323
- */
324
- export async function extractLearnings(client, session) {
325
- const workspaceId = getActiveWorkspaceId();
326
- if (!workspaceId) {
327
- return { count: 0, entityIds: [] };
328
- }
329
- const projectId = getActiveProjectId() || undefined;
330
- const learnings = [];
331
- // Search for related entities to enrich summaries with wiki-links
332
- let relatedEntityTitles = [];
333
- if (workspaceId) {
558
+ async function linkSessionEntities(client, createdPairs, _workspaceId, _projectId) {
559
+ let relationsCreated = 0;
560
+ for (const rule of SESSION_CAUSAL_RULES) {
561
+ const sources = createdPairs.filter((p) => p.learning.type === rule.sourceType);
562
+ const targets = createdPairs.filter((p) => p.learning.type === rule.targetType);
563
+ for (const source of sources) {
564
+ for (const target of targets) {
334
565
  try {
335
- const searchQuery = [
336
- session.cardTitle,
337
- session.currentTask || "",
338
- ...session.cardLabels,
339
- ]
340
- .filter(Boolean)
341
- .join(" ");
342
- const similar = await findSimilarEntities(client, searchQuery, session.currentTask || session.cardTitle, workspaceId, { projectId, limit: 5, minRrfScore: 0.01 });
343
- relatedEntityTitles = similar.slice(0, 3).map((e) => e.title);
344
- }
345
- catch {
346
- /* non-fatal */
347
- }
348
- }
349
- const wikiLinksLine = relatedEntityTitles.length > 0
350
- ? `\nRelated: ${relatedEntityTitles.map((t) => `[[${t}]]`).join(", ")}`
351
- : "";
352
- // Rule 1: Session had blockers → create error entity (only for substantial blockers)
353
- // Skip trivial blocker strings — only store if the blocker text contains
354
- // enough detail to be useful to a future agent (>80 chars).
355
- if (session.blockers && session.blockers.length > 0) {
356
- for (const blocker of session.blockers) {
357
- if (blocker.length < 80)
358
- continue; // Skip trivial blockers like "stuck" or "waiting on API"
359
- // Dedup: check if a similar error entity already exists
360
- let isDuplicate = false;
361
- try {
362
- const similar = await findSimilarEntities(client, blocker.slice(0, 200), blocker, workspaceId, { projectId, limit: 3, minRrfScore: 0.05 });
363
- isDuplicate = similar.some((e) => e.type === "error" && (e.rrf_score ?? 0) >= 0.06);
364
- }
365
- catch {
366
- /* non-fatal */
367
- }
368
- if (!isDuplicate) {
369
- learnings.push({
370
- title: `Blocker: ${blocker.slice(0, 100)}`,
371
- content: `Encountered while working on "${session.cardTitle}":\n\n${blocker}\n\nAgent: ${session.agentName}\nSession status: ${session.status}`,
372
- type: "error",
373
- tier: "episode",
374
- confidence: 0.6,
375
- tags: [
376
- "auto-extracted",
377
- "blocker",
378
- ...session.cardLabels.slice(0, 3),
379
- ],
380
- metadata: {
381
- source: "active_learning",
382
- card_id: session.cardId,
383
- },
384
- });
385
- }
386
- }
566
+ await client.createMemoryRelation({
567
+ source_id: source.id,
568
+ target_id: target.id,
569
+ relation_type: rule.relation,
570
+ confidence: rule.confidence
571
+ });
572
+ relationsCreated++;
573
+ } catch {}
574
+ }
387
575
  }
388
- // Rule 2: Session paused with blockers → create lesson (paused only, not clean completions).
389
- // Clean completions produce no reusable knowledge — the work is in the code/PR.
390
- // Only create a lesson when the session was interrupted (paused with blockers),
391
- // so a future agent can understand what was left unfinished and why.
392
- if (session.status === "paused" && (session.blockers?.length ?? 0) > 0) {
393
- const durationInfo = session.sessionDurationMs
394
- ? `\nDuration: ${Math.round(session.sessionDurationMs / 60000)} minutes`
395
- : "";
576
+ }
577
+ return { relationsCreated };
578
+ }
579
+ async function extractLearnings(client, session) {
580
+ const workspaceId = getActiveWorkspaceId();
581
+ if (!workspaceId) {
582
+ return { count: 0, entityIds: [] };
583
+ }
584
+ const projectId = getActiveProjectId() || undefined;
585
+ const learnings = [];
586
+ let relatedEntityTitles = [];
587
+ if (workspaceId) {
588
+ try {
589
+ const searchQuery = [
590
+ session.cardTitle,
591
+ session.currentTask || "",
592
+ ...session.cardLabels
593
+ ].filter(Boolean).join(" ");
594
+ const similar = await findSimilarEntities(client, searchQuery, session.currentTask || session.cardTitle, workspaceId, { projectId, limit: 5, minRrfScore: 0.01 });
595
+ relatedEntityTitles = similar.slice(0, 3).map((e) => e.title);
596
+ } catch {}
597
+ }
598
+ const wikiLinksLine = relatedEntityTitles.length > 0 ? `
599
+ Related: ${relatedEntityTitles.map((t) => `[[${t}]]`).join(", ")}` : "";
600
+ if (session.blockers && session.blockers.length > 0) {
601
+ for (const blocker of session.blockers) {
602
+ if (blocker.length < 80)
603
+ continue;
604
+ let isDuplicate = false;
605
+ try {
606
+ const similar = await findSimilarEntities(client, blocker.slice(0, 200), blocker, workspaceId, { projectId, limit: 3, minRrfScore: 0.05 });
607
+ isDuplicate = similar.some((e) => e.type === "error" && (e.rrf_score ?? 0) >= 0.06);
608
+ } catch {}
609
+ if (!isDuplicate) {
396
610
  learnings.push({
397
- title: `Paused: ${session.cardTitle}`,
398
- content: [
399
- `Paused work on "${session.cardTitle}".`,
400
- session.currentTask ? `Last task: ${session.currentTask}` : "",
401
- session.progressPercent !== undefined
402
- ? `Progress: ${session.progressPercent}%`
403
- : "",
404
- durationInfo,
405
- session.blockers?.length
406
- ? `Blockers: ${session.blockers.join("; ")}`
407
- : "",
408
- `\nAgent: ${session.agentName}`,
409
- wikiLinksLine,
410
- ]
411
- .filter(Boolean)
412
- .join("\n"),
413
- type: "lesson",
414
- tier: "draft",
415
- confidence: 0.6,
416
- tags: [
417
- "auto-extracted",
418
- "session-paused",
419
- ...session.cardLabels.slice(0, 3),
420
- ],
421
- metadata: {
422
- source: "active_learning",
423
- card_id: session.cardId,
424
- },
611
+ title: `Blocker: ${blocker.slice(0, 100)}`,
612
+ content: `Encountered while working on "${session.cardTitle}":
613
+
614
+ ${blocker}
615
+
616
+ Agent: ${session.agentName}
617
+ Session status: ${session.status}`,
618
+ type: "error",
619
+ tier: "episode",
620
+ confidence: 0.6,
621
+ tags: [
622
+ "auto-extracted",
623
+ "blocker",
624
+ ...session.cardLabels.slice(0, 3)
625
+ ],
626
+ metadata: {
627
+ source: "active_learning",
628
+ card_id: session.cardId
629
+ }
425
630
  });
631
+ }
426
632
  }
427
- // Rule 3: Bug solution — REMOVED.
428
- // Storing "Resolved bug: {card title}" with no detail about the actual fix
429
- // adds zero value. The real solution is in the code diff / PR. Agents should
430
- // use `harmony_remember` to store non-obvious root cause details manually.
431
- // Store learnings, tracking entity ID → learning for graph expansion
432
- const entityIds = [];
433
- // Rule 4: Successful session with tracked steps → create or reinforce procedure entity
434
- // Thresholds raised: require 5+ distinct steps AND 10+ minute duration to avoid
435
- // creating "procedures" from trivial tasks (e.g., a 2-step "investigate → fix" session).
436
- const stepHistory = sessionTaskHistory.get(session.cardId);
437
- const MIN_PROCEDURE_STEPS = 5;
438
- const MIN_PROCEDURE_DURATION_MS = 10 * 60 * 1000; // 10 minutes
439
- const hasEnoughSteps = stepHistory && stepHistory.steps.length >= MIN_PROCEDURE_STEPS;
440
- const hasMinDuration = (session.sessionDurationMs ?? 0) >= MIN_PROCEDURE_DURATION_MS;
441
- const isSuccessful = session.status === "completed" &&
442
- (session.progressPercent === undefined || session.progressPercent >= 85) &&
443
- !session.blockers?.length;
444
- if (isSuccessful && hasEnoughSteps && hasMinDuration) {
445
- const procedureResult = await extractOrReinforceProcedure(client, session, stepHistory.steps, workspaceId, projectId, wikiLinksLine);
446
- if (procedureResult) {
447
- if (procedureResult.mode === "created") {
448
- learnings.push(procedureResult.learning);
449
- }
450
- else {
451
- // Reinforced existing procedure — already saved via API, just track the ID
452
- entityIds.push(procedureResult.entityId);
453
- }
454
- }
455
- }
456
- const createdPairs = [];
457
- for (const learning of learnings) {
458
- try {
459
- const metadata = {
460
- ...learning.metadata,
461
- };
462
- const result = await client.createMemoryEntity({
463
- workspace_id: workspaceId,
464
- project_id: projectId,
465
- type: learning.type,
466
- scope: "project",
467
- memory_tier: learning.tier,
468
- title: learning.title,
469
- content: learning.content,
470
- confidence: learning.confidence,
471
- tags: learning.tags,
472
- metadata,
473
- agent_identifier: session.agentIdentifier,
474
- });
475
- const entity = result.entity;
476
- if (entity?.id) {
477
- entityIds.push(entity.id);
478
- createdPairs.push({ id: entity.id, learning });
479
- }
480
- }
481
- catch {
482
- // Non-fatal: individual learning extraction failure shouldn't block others
483
- }
484
- }
485
- // Auto-expand the knowledge graph and detect contradictions for each newly created entity (fire-and-forget)
486
- for (const { id, learning } of createdPairs) {
487
- autoExpandGraph(client, id, learning.title, learning.content, learning.tags, workspaceId, projectId).catch(() => { });
488
- detectContradictions(client, id, learning.type, learning.title, learning.content, learning.tags, workspaceId, projectId).catch(() => { });
489
- // Cross-type causal linking (e.g., new error → existing solutions)
490
- linkCrossTypeNeighbors(client, id, learning.type, learning.title, learning.content, workspaceId, projectId).catch(() => { });
491
- }
492
- // Same-session causal linking (e.g., error + solution in same session → resolved_by)
493
- if (createdPairs.length >= 2) {
494
- linkSessionEntities(client, createdPairs, workspaceId, projectId).catch(() => { });
633
+ }
634
+ if (session.status === "paused" && (session.blockers?.length ?? 0) > 0) {
635
+ const durationInfo = session.sessionDurationMs ? `
636
+ Duration: ${Math.round(session.sessionDurationMs / 60000)} minutes` : "";
637
+ learnings.push({
638
+ title: `Paused: ${session.cardTitle}`,
639
+ content: [
640
+ `Paused work on "${session.cardTitle}".`,
641
+ session.currentTask ? `Last task: ${session.currentTask}` : "",
642
+ session.progressPercent !== undefined ? `Progress: ${session.progressPercent}%` : "",
643
+ durationInfo,
644
+ session.blockers?.length ? `Blockers: ${session.blockers.join("; ")}` : "",
645
+ `
646
+ Agent: ${session.agentName}`,
647
+ wikiLinksLine
648
+ ].filter(Boolean).join(`
649
+ `),
650
+ type: "lesson",
651
+ tier: "draft",
652
+ confidence: 0.6,
653
+ tags: [
654
+ "auto-extracted",
655
+ "session-paused",
656
+ ...session.cardLabels.slice(0, 3)
657
+ ],
658
+ metadata: {
659
+ source: "active_learning",
660
+ card_id: session.cardId
661
+ }
662
+ });
663
+ }
664
+ const entityIds = [];
665
+ const stepHistory = sessionTaskHistory.get(session.cardId);
666
+ const MIN_PROCEDURE_STEPS = 5;
667
+ const MIN_PROCEDURE_DURATION_MS = 10 * 60 * 1000;
668
+ const hasEnoughSteps = stepHistory && stepHistory.steps.length >= MIN_PROCEDURE_STEPS;
669
+ const hasMinDuration = (session.sessionDurationMs ?? 0) >= MIN_PROCEDURE_DURATION_MS;
670
+ const isSuccessful = session.status === "completed" && (session.progressPercent === undefined || session.progressPercent >= 85) && !session.blockers?.length;
671
+ if (isSuccessful && hasEnoughSteps && hasMinDuration) {
672
+ const procedureResult = await extractOrReinforceProcedure(client, session, stepHistory.steps, workspaceId, projectId, wikiLinksLine);
673
+ if (procedureResult) {
674
+ if (procedureResult.mode === "created") {
675
+ learnings.push(procedureResult.learning);
676
+ } else {
677
+ entityIds.push(procedureResult.entityId);
678
+ }
495
679
  }
496
- // Pattern detection DISABLED — these create noise entities like
497
- // "Pattern: recurring procedure (N instances)" that are just catalogs of
498
- // entity titles, eating token budget with zero actionable content.
499
- // The consolidation tool (harmony_consolidate_memories) serves a similar
500
- // purpose and can be improved separately with LLM synthesis.
501
- // See: https://github.com/getharmony/getharmony/issues/memory-quality
502
- // Clean up mid-session tracking
503
- clearMidSessionTracking(session.cardId);
504
- return { count: entityIds.length, entityIds };
505
- }
506
- const PATTERN_THRESHOLD = 3;
507
- /**
508
- * Detect recurring patterns across sessions.
509
- *
510
- * Uses embedding-based similarity (via hybrid search) to find related entities
511
- * instead of naive tag matching. When ≥3 similar entities cluster, creates or
512
- * updates a `pattern` entity with `part_of` relations from members.
513
- */
514
- export async function detectAndCreatePatterns(client, newEntityIds, session, workspaceId, projectId) {
515
- const patternEntityIds = [];
516
- for (const newEntityId of newEntityIds) {
517
- try {
518
- // Fetch the newly created entity to get its type, title, and content
519
- const { entity: rawEntity } = await client.getMemoryEntity(newEntityId);
520
- const entity = rawEntity;
521
- if (!entity?.type)
522
- continue;
523
- // Use embedding-based search with title + content snippet as query
524
- const similar = await findSimilarEntities(client, entity.title, entity.content, workspaceId, { projectId, limit: 30, minRrfScore: 0.01 });
525
- // Exclude newly created entities
526
- const existing = similar.filter((c) => !newEntityIds.includes(c.id) && c.type === entity.type);
527
- if (existing.length < PATTERN_THRESHOLD)
528
- continue;
529
- // Build a descriptive pattern title from member entity titles
530
- const memberTitles = [
531
- entity.title,
532
- ...existing.slice(0, 4).map((e) => e.title),
533
- ];
534
- const patternTitle = `Pattern: recurring ${entity.type} (${existing.length + 1} instances)`;
535
- // Check if a pattern entity for this type already exists
536
- const { entities: existingPatterns } = await client.listMemoryEntities({
537
- workspace_id: workspaceId,
538
- project_id: projectId,
539
- type: "pattern",
540
- limit: 10,
541
- });
542
- // Find a pattern that covers this entity type
543
- const matchingPattern = existingPatterns.find((p) => p.metadata?.pattern_type === entity.type);
544
- let patternId = null;
545
- if (matchingPattern) {
546
- patternId = matchingPattern.id;
547
- await client.updateMemoryEntity(patternId, {
548
- 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()}`,
549
- metadata: {
550
- pattern_count: existing.length + 1,
551
- pattern_type: entity.type,
552
- last_updated: new Date().toISOString(),
553
- },
554
- });
555
- }
556
- else {
557
- const result = await client.createMemoryEntity({
558
- workspace_id: workspaceId,
559
- project_id: projectId,
560
- type: "pattern",
561
- scope: "project",
562
- memory_tier: "reference",
563
- title: patternTitle,
564
- content: `Recurring pattern: ${entity.type} entities detected ${existing.length + 1} times.\n\nMembers:\n${memberTitles.map((t) => `- ${t}`).join("\n")}`,
565
- confidence: 0.75,
566
- tags: ["auto-extracted", "pattern", entity.type],
567
- metadata: {
568
- source: "pattern_detection",
569
- pattern_type: entity.type,
570
- pattern_count: existing.length + 1,
571
- },
572
- agent_identifier: session.agentIdentifier,
573
- });
574
- const created = result.entity;
575
- if (created?.id) {
576
- patternId = created.id;
577
- patternEntityIds.push(patternId);
578
- }
579
- }
580
- if (!patternId)
581
- continue;
582
- // Link members → pattern using part_of relations
583
- const toLink = [newEntityId, ...existing.slice(0, 4).map((e) => e.id)];
584
- for (const sourceId of toLink) {
585
- try {
586
- await client.createMemoryRelation({
587
- source_id: sourceId,
588
- target_id: patternId,
589
- relation_type: "part_of",
590
- confidence: 0.75,
591
- });
592
- }
593
- catch {
594
- // Skip duplicate/failed relations silently
595
- }
596
- }
597
- }
598
- catch {
599
- // Non-fatal: pattern detection failure should not block anything
680
+ }
681
+ const createdPairs = [];
682
+ for (const learning of learnings) {
683
+ try {
684
+ const metadata = {
685
+ ...learning.metadata
686
+ };
687
+ const result = await client.createMemoryEntity({
688
+ workspace_id: workspaceId,
689
+ project_id: projectId,
690
+ type: learning.type,
691
+ scope: "project",
692
+ memory_tier: learning.tier,
693
+ title: learning.title,
694
+ content: learning.content,
695
+ confidence: learning.confidence,
696
+ tags: learning.tags,
697
+ metadata,
698
+ agent_identifier: session.agentIdentifier
699
+ });
700
+ const entity = result.entity;
701
+ if (entity?.id) {
702
+ entityIds.push(entity.id);
703
+ createdPairs.push({ id: entity.id, learning });
704
+ }
705
+ } catch {}
706
+ }
707
+ for (const { id, learning } of createdPairs) {
708
+ autoExpandGraph(client, id, learning.title, learning.content, learning.tags, workspaceId, projectId).catch(() => {});
709
+ detectContradictions(client, id, learning.type, learning.title, learning.content, learning.tags, workspaceId, projectId).catch(() => {});
710
+ linkCrossTypeNeighbors(client, id, learning.type, learning.title, learning.content, workspaceId, projectId).catch(() => {});
711
+ }
712
+ if (createdPairs.length >= 2) {
713
+ linkSessionEntities(client, createdPairs, workspaceId, projectId).catch(() => {});
714
+ }
715
+ clearMidSessionTracking(session.cardId);
716
+ return { count: entityIds.length, entityIds };
717
+ }
718
+ var PATTERN_THRESHOLD = 3;
719
+ async function detectAndCreatePatterns(client, newEntityIds, session, workspaceId, projectId) {
720
+ const patternEntityIds = [];
721
+ for (const newEntityId of newEntityIds) {
722
+ try {
723
+ const { entity: rawEntity } = await client.getMemoryEntity(newEntityId);
724
+ const entity = rawEntity;
725
+ if (!entity?.type)
726
+ continue;
727
+ const similar = await findSimilarEntities(client, entity.title, entity.content, workspaceId, { projectId, limit: 30, minRrfScore: 0.01 });
728
+ const existing = similar.filter((c) => !newEntityIds.includes(c.id) && c.type === entity.type);
729
+ if (existing.length < PATTERN_THRESHOLD)
730
+ continue;
731
+ const memberTitles = [
732
+ entity.title,
733
+ ...existing.slice(0, 4).map((e) => e.title)
734
+ ];
735
+ const patternTitle = `Pattern: recurring ${entity.type} (${existing.length + 1} instances)`;
736
+ const { entities: existingPatterns } = await client.listMemoryEntities({
737
+ workspace_id: workspaceId,
738
+ project_id: projectId,
739
+ type: "pattern",
740
+ limit: 10
741
+ });
742
+ const matchingPattern = existingPatterns.find((p) => p.metadata?.pattern_type === entity.type);
743
+ let patternId = null;
744
+ if (matchingPattern) {
745
+ patternId = matchingPattern.id;
746
+ await client.updateMemoryEntity(patternId, {
747
+ content: `Recurring pattern: ${entity.type} entities appearing ${existing.length + 1} times.
748
+
749
+ Members:
750
+ ${memberTitles.map((t) => `- ${t}`).join(`
751
+ `)}
752
+
753
+ Last updated: ${new Date().toISOString()}`,
754
+ metadata: {
755
+ pattern_count: existing.length + 1,
756
+ pattern_type: entity.type,
757
+ last_updated: new Date().toISOString()
758
+ }
759
+ });
760
+ } else {
761
+ const result = await client.createMemoryEntity({
762
+ workspace_id: workspaceId,
763
+ project_id: projectId,
764
+ type: "pattern",
765
+ scope: "project",
766
+ memory_tier: "reference",
767
+ title: patternTitle,
768
+ content: `Recurring pattern: ${entity.type} entities detected ${existing.length + 1} times.
769
+
770
+ Members:
771
+ ${memberTitles.map((t) => `- ${t}`).join(`
772
+ `)}`,
773
+ confidence: 0.75,
774
+ tags: ["auto-extracted", "pattern", entity.type],
775
+ metadata: {
776
+ source: "pattern_detection",
777
+ pattern_type: entity.type,
778
+ pattern_count: existing.length + 1
779
+ },
780
+ agent_identifier: session.agentIdentifier
781
+ });
782
+ const created = result.entity;
783
+ if (created?.id) {
784
+ patternId = created.id;
785
+ patternEntityIds.push(patternId);
600
786
  }
601
- }
602
- return patternEntityIds;
603
- }
604
- // --- Cross-Session Causal Pattern Detection ---
605
- const CAUSAL_PATTERN_THRESHOLD = 3;
606
- /**
607
- * Detect recurring error→solution chains across sessions.
608
- *
609
- * When a session produces both error and solution entities, searches for
610
- * similar errors from other sessions that also have `resolved_by` relations.
611
- * If ≥3 such pairs exist, creates a `pattern` entity to capture the
612
- * recurring causal chain.
613
- */
614
- export async function detectCausalPatterns(client, createdPairs, session, workspaceId, projectId) {
615
- const patternIds = [];
616
- // Find error+solution pairs from this session
617
- const errors = createdPairs.filter((p) => p.learning.type === "error");
618
- const solutions = createdPairs.filter((p) => p.learning.type === "solution");
619
- if (errors.length === 0 || solutions.length === 0)
620
- return patternIds;
621
- for (const errorPair of errors) {
787
+ }
788
+ if (!patternId)
789
+ continue;
790
+ const toLink = [newEntityId, ...existing.slice(0, 4).map((e) => e.id)];
791
+ for (const sourceId of toLink) {
622
792
  try {
623
- // Search for similar errors from other sessions
624
- const similarErrors = await findSimilarEntities(client, errorPair.learning.title, errorPair.learning.content, workspaceId, {
625
- projectId,
626
- limit: 20,
627
- minRrfScore: 0.03,
628
- excludeIds: createdPairs.map((p) => p.id),
629
- type: "error",
630
- });
631
- // Filter to errors that have resolved_by relations
632
- const resolvedErrors = [];
633
- for (const similar of similarErrors.slice(0, 10)) {
634
- try {
635
- const { outgoing } = await client.getRelatedEntities(similar.id);
636
- const resolvedByRel = outgoing.find((r) => r.relation_type === "resolved_by");
637
- if (resolvedByRel) {
638
- resolvedErrors.push({
639
- errorId: similar.id,
640
- errorTitle: similar.title,
641
- solutionTitle: resolvedByRel.target_title ||
642
- "unknown",
643
- });
644
- }
645
- }
646
- catch {
647
- // Non-fatal: skip entities where relation lookup fails
648
- }
649
- }
650
- // Need at least CAUSAL_PATTERN_THRESHOLD similar resolved errors (including current)
651
- if (resolvedErrors.length + 1 < CAUSAL_PATTERN_THRESHOLD)
652
- continue;
653
- // Check if a causal pattern already exists for this domain
654
- const { entities: existingPatterns } = await client.listMemoryEntities({
655
- workspace_id: workspaceId,
656
- project_id: projectId,
657
- type: "pattern",
658
- limit: 10,
659
- });
660
- const matchingPattern = existingPatterns.find((p) => p.metadata?.pattern_chain_type === "error_resolved_by_solution");
661
- if (matchingPattern) {
662
- // Update existing causal pattern
663
- await client.updateMemoryEntity(matchingPattern.id, {
664
- content: [
665
- `Recurring error→solution chain detected (${resolvedErrors.length + 1} instances).`,
666
- "",
667
- "## Error→Solution Pairs",
668
- `- ${errorPair.learning.title} → ${solutions[0].learning.title}`,
669
- ...resolvedErrors
670
- .slice(0, 5)
671
- .map((r) => `- ${r.errorTitle} → ${r.solutionTitle}`),
672
- "",
673
- `Last updated: ${new Date().toISOString()}`,
674
- ].join("\n"),
675
- metadata: {
676
- pattern_chain_type: "error_resolved_by_solution",
677
- pattern_count: resolvedErrors.length + 1,
678
- last_updated: new Date().toISOString(),
679
- },
680
- });
681
- // Link current error+solution to the pattern
682
- for (const pair of [errorPair, solutions[0]]) {
683
- try {
684
- await client.createMemoryRelation({
685
- source_id: pair.id,
686
- target_id: matchingPattern.id,
687
- relation_type: "part_of",
688
- confidence: 0.75,
689
- });
690
- }
691
- catch {
692
- // Skip duplicate relations
693
- }
694
- }
695
- }
696
- else {
697
- // Create new causal pattern
698
- const result = await client.createMemoryEntity({
699
- workspace_id: workspaceId,
700
- project_id: projectId,
701
- type: "pattern",
702
- scope: "project",
703
- memory_tier: "reference",
704
- title: `Pattern: recurring error→solution chain (${resolvedErrors.length + 1} instances)`,
705
- content: [
706
- `Recurring error→solution chain detected across ${resolvedErrors.length + 1} sessions.`,
707
- "",
708
- "## Error→Solution Pairs",
709
- `- ${errorPair.learning.title} → ${solutions[0].learning.title}`,
710
- ...resolvedErrors
711
- .slice(0, 5)
712
- .map((r) => `- ${r.errorTitle} → ${r.solutionTitle}`),
713
- ].join("\n"),
714
- confidence: 0.8,
715
- tags: ["auto-extracted", "pattern", "causal-chain"],
716
- metadata: {
717
- source: "causal_pattern_detection",
718
- pattern_chain_type: "error_resolved_by_solution",
719
- pattern_count: resolvedErrors.length + 1,
720
- },
721
- agent_identifier: session.agentIdentifier,
722
- });
723
- const created = result.entity;
724
- if (created?.id) {
725
- patternIds.push(created.id);
726
- // Link members to the pattern
727
- const memberIds = [
728
- errorPair.id,
729
- solutions[0].id,
730
- ...resolvedErrors.slice(0, 4).map((r) => r.errorId),
731
- ];
732
- for (const memberId of memberIds) {
733
- try {
734
- await client.createMemoryRelation({
735
- source_id: memberId,
736
- target_id: created.id,
737
- relation_type: "part_of",
738
- confidence: 0.75,
739
- });
740
- }
741
- catch {
742
- // Skip duplicate relations
743
- }
744
- }
745
- }
746
- }
747
- }
748
- catch {
749
- // Non-fatal: causal pattern detection failure should not block anything
750
- }
751
- }
752
- return patternIds;
793
+ await client.createMemoryRelation({
794
+ source_id: sourceId,
795
+ target_id: patternId,
796
+ relation_type: "part_of",
797
+ confidence: 0.75
798
+ });
799
+ } catch {}
800
+ }
801
+ } catch {}
802
+ }
803
+ return patternEntityIds;
753
804
  }
754
- /**
755
- * Detect potential contradictions for a newly created entity using semantic search.
756
- *
757
- * Uses hybrid FTS+vector search to find same-type entities with similar content,
758
- * then creates `contradicts` relations. Only applies to entity types allowed by
759
- * the schema: decision, pattern, preference, lesson.
760
- *
761
- * Returns the list of contradicting entities found (for inclusion in tool response).
762
- */
763
- export async function detectContradictions(client, entityId, entityType, title, content, tags, workspaceId, projectId) {
764
- // Only types that can participate in contradicts relations
765
- if (!CONTRADICTION_TYPES.has(entityType))
766
- return [];
767
- // Need at least a title to search
768
- if (!title.trim())
769
- return [];
805
+ var CAUSAL_PATTERN_THRESHOLD = 3;
806
+ async function detectCausalPatterns(client, createdPairs, session, workspaceId, projectId) {
807
+ const patternIds = [];
808
+ const errors = createdPairs.filter((p) => p.learning.type === "error");
809
+ const solutions = createdPairs.filter((p) => p.learning.type === "solution");
810
+ if (errors.length === 0 || solutions.length === 0)
811
+ return patternIds;
812
+ for (const errorPair of errors) {
770
813
  try {
771
- const similar = await findSimilarEntities(client, title, content, workspaceId, {
772
- projectId,
773
- limit: 10,
774
- excludeIds: [entityId],
814
+ const similarErrors = await findSimilarEntities(client, errorPair.learning.title, errorPair.learning.content, workspaceId, {
815
+ projectId,
816
+ limit: 20,
817
+ minRrfScore: 0.03,
818
+ excludeIds: createdPairs.map((p) => p.id),
819
+ type: "error"
820
+ });
821
+ const resolvedErrors = [];
822
+ for (const similar of similarErrors.slice(0, 10)) {
823
+ try {
824
+ const { outgoing } = await client.getRelatedEntities(similar.id);
825
+ const resolvedByRel = outgoing.find((r) => r.relation_type === "resolved_by");
826
+ if (resolvedByRel) {
827
+ resolvedErrors.push({
828
+ errorId: similar.id,
829
+ errorTitle: similar.title,
830
+ solutionTitle: resolvedByRel.target_title || "unknown"
831
+ });
832
+ }
833
+ } catch {}
834
+ }
835
+ if (resolvedErrors.length + 1 < CAUSAL_PATTERN_THRESHOLD)
836
+ continue;
837
+ const { entities: existingPatterns } = await client.listMemoryEntities({
838
+ workspace_id: workspaceId,
839
+ project_id: projectId,
840
+ type: "pattern",
841
+ limit: 10
842
+ });
843
+ const matchingPattern = existingPatterns.find((p) => p.metadata?.pattern_chain_type === "error_resolved_by_solution");
844
+ if (matchingPattern) {
845
+ await client.updateMemoryEntity(matchingPattern.id, {
846
+ content: [
847
+ `Recurring error→solution chain detected (${resolvedErrors.length + 1} instances).`,
848
+ "",
849
+ "## Error→Solution Pairs",
850
+ `- ${errorPair.learning.title} → ${solutions[0].learning.title}`,
851
+ ...resolvedErrors.slice(0, 5).map((r) => `- ${r.errorTitle} → ${r.solutionTitle}`),
852
+ "",
853
+ `Last updated: ${new Date().toISOString()}`
854
+ ].join(`
855
+ `),
856
+ metadata: {
857
+ pattern_chain_type: "error_resolved_by_solution",
858
+ pattern_count: resolvedErrors.length + 1,
859
+ last_updated: new Date().toISOString()
860
+ }
775
861
  });
776
- // Filter to same type only (contradictions are within the same type)
777
- const candidates = similar.filter((e) => e.type === entityType && CONTRADICTION_TYPES.has(e.type));
778
- if (candidates.length === 0)
779
- return [];
780
- // Check for actual content divergence: similar topic but different stance.
781
- // Entities that are very similar (high RRF) are likely supporting, not contradicting.
782
- // We want entities that share topic keywords but have meaningful differences.
783
- const contradictions = [];
784
- for (const candidate of candidates) {
785
- // Skip entities with very high similarity — they're duplicates/supporting, not contradictions
786
- if ((candidate.rrf_score ?? 0) > 0.8)
787
- continue;
788
- // Skip entities with very low similarity — they're unrelated
789
- if ((candidate.rrf_score ?? 0) < 0.02)
790
- continue;
791
- // Check tag overlap: need at least 1 shared tag to indicate same domain
792
- const sharedTags = tags.filter((t) => candidate.tags?.includes(t));
793
- if (tags.length > 0 && sharedTags.length === 0)
794
- continue;
795
- contradictions.push({
796
- entityId: candidate.id,
797
- title: candidate.title,
798
- type: candidate.type,
799
- tags: candidate.tags || [],
862
+ for (const pair of [errorPair, solutions[0]]) {
863
+ try {
864
+ await client.createMemoryRelation({
865
+ source_id: pair.id,
866
+ target_id: matchingPattern.id,
867
+ relation_type: "part_of",
868
+ confidence: 0.75
800
869
  });
870
+ } catch {}
801
871
  }
802
- // Create contradicts relations (fire-and-forget style, but await for response)
803
- for (const c of contradictions) {
872
+ } else {
873
+ const result = await client.createMemoryEntity({
874
+ workspace_id: workspaceId,
875
+ project_id: projectId,
876
+ type: "pattern",
877
+ scope: "project",
878
+ memory_tier: "reference",
879
+ title: `Pattern: recurring error→solution chain (${resolvedErrors.length + 1} instances)`,
880
+ content: [
881
+ `Recurring error→solution chain detected across ${resolvedErrors.length + 1} sessions.`,
882
+ "",
883
+ "## Error→Solution Pairs",
884
+ `- ${errorPair.learning.title} → ${solutions[0].learning.title}`,
885
+ ...resolvedErrors.slice(0, 5).map((r) => `- ${r.errorTitle} → ${r.solutionTitle}`)
886
+ ].join(`
887
+ `),
888
+ confidence: 0.8,
889
+ tags: ["auto-extracted", "pattern", "causal-chain"],
890
+ metadata: {
891
+ source: "causal_pattern_detection",
892
+ pattern_chain_type: "error_resolved_by_solution",
893
+ pattern_count: resolvedErrors.length + 1
894
+ },
895
+ agent_identifier: session.agentIdentifier
896
+ });
897
+ const created = result.entity;
898
+ if (created?.id) {
899
+ patternIds.push(created.id);
900
+ const memberIds = [
901
+ errorPair.id,
902
+ solutions[0].id,
903
+ ...resolvedErrors.slice(0, 4).map((r) => r.errorId)
904
+ ];
905
+ for (const memberId of memberIds) {
804
906
  try {
805
- await client.createMemoryRelation({
806
- source_id: entityId,
807
- target_id: c.entityId,
808
- relation_type: "contradicts",
809
- confidence: 0.5,
810
- });
811
- }
812
- catch {
813
- // Skip duplicate/failed relations silently (409 Conflict is expected)
814
- }
907
+ await client.createMemoryRelation({
908
+ source_id: memberId,
909
+ target_id: created.id,
910
+ relation_type: "part_of",
911
+ confidence: 0.75
912
+ });
913
+ } catch {}
914
+ }
815
915
  }
816
- return contradictions;
916
+ }
917
+ } catch {}
918
+ }
919
+ return patternIds;
920
+ }
921
+ async function detectContradictions(client, entityId, entityType, title, content, tags, workspaceId, projectId) {
922
+ if (!CONTRADICTION_TYPES.has(entityType))
923
+ return [];
924
+ if (!title.trim())
925
+ return [];
926
+ try {
927
+ const similar = await findSimilarEntities(client, title, content, workspaceId, {
928
+ projectId,
929
+ limit: 10,
930
+ excludeIds: [entityId]
931
+ });
932
+ const candidates = similar.filter((e) => e.type === entityType && CONTRADICTION_TYPES.has(e.type));
933
+ if (candidates.length === 0)
934
+ return [];
935
+ const contradictions = [];
936
+ for (const candidate of candidates) {
937
+ if ((candidate.rrf_score ?? 0) > 0.8)
938
+ continue;
939
+ if ((candidate.rrf_score ?? 0) < 0.02)
940
+ continue;
941
+ const sharedTags = tags.filter((t) => candidate.tags?.includes(t));
942
+ if (tags.length > 0 && sharedTags.length === 0)
943
+ continue;
944
+ contradictions.push({
945
+ entityId: candidate.id,
946
+ title: candidate.title,
947
+ type: candidate.type,
948
+ tags: candidate.tags || []
949
+ });
817
950
  }
818
- catch {
819
- // Non-fatal: contradiction detection should never block entity creation
820
- return [];
951
+ for (const c of contradictions) {
952
+ try {
953
+ await client.createMemoryRelation({
954
+ source_id: entityId,
955
+ target_id: c.entityId,
956
+ relation_type: "contradicts",
957
+ confidence: 0.5
958
+ });
959
+ } catch {}
821
960
  }
961
+ return contradictions;
962
+ } catch {
963
+ return [];
964
+ }
822
965
  }
966
+ export {
967
+ linkSessionEntities,
968
+ extractMidSessionLearnings,
969
+ extractLearnings,
970
+ detectContradictions,
971
+ detectCausalPatterns,
972
+ detectAndCreatePatterns,
973
+ clearMidSessionTracking
974
+ };