@gethmy/mcp 2.3.1 → 2.3.3
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/dist/lib/api-client.js +2099 -648
- package/dist/lib/config.js +217 -201
- package/package.json +9 -5
- package/src/memory-cleanup.ts +2 -4
- package/dist/lib/__tests__/active-learning.test.js +0 -386
- package/dist/lib/__tests__/agent-performance-profiles.test.js +0 -325
- package/dist/lib/__tests__/auto-session.test.js +0 -661
- package/dist/lib/__tests__/context-assembly.test.js +0 -362
- package/dist/lib/__tests__/graph-expansion.test.js +0 -150
- package/dist/lib/__tests__/integration-memory-crud.test.js +0 -797
- package/dist/lib/__tests__/integration-memory-system.test.js +0 -281
- package/dist/lib/__tests__/lifecycle-maintenance.test.js +0 -207
- package/dist/lib/__tests__/pattern-detection.test.js +0 -295
- package/dist/lib/__tests__/prompt-builder.test.js +0 -418
- package/dist/lib/active-learning.js +0 -822
- package/dist/lib/auto-session.js +0 -214
- package/dist/lib/cli.js +0 -138
- package/dist/lib/consolidation.js +0 -303
- package/dist/lib/context-assembly.js +0 -884
- package/dist/lib/graph-expansion.js +0 -163
- package/dist/lib/http.js +0 -175
- package/dist/lib/index.js +0 -7
- package/dist/lib/lifecycle-maintenance.js +0 -88
- package/dist/lib/memory-cleanup.js +0 -455
- package/dist/lib/onboard.js +0 -36
- package/dist/lib/prompt-builder.js +0 -488
- package/dist/lib/remote.js +0 -166
- package/dist/lib/server.js +0 -3365
- package/dist/lib/skills.js +0 -593
- package/dist/lib/tui/agents.js +0 -116
- package/dist/lib/tui/docs.js +0 -744
- package/dist/lib/tui/setup.js +0 -934
- package/dist/lib/tui/theme.js +0 -95
- package/dist/lib/tui/writer.js +0 -200
package/dist/lib/auto-session.js
DELETED
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auto-Session Tracking
|
|
3
|
-
*
|
|
4
|
-
* Automatically detects agent session boundaries by monitoring tool calls.
|
|
5
|
-
* Sessions auto-start when card-mutating tools are called, and auto-end
|
|
6
|
-
* after 10 minutes of inactivity or when a different card is worked on.
|
|
7
|
-
*
|
|
8
|
-
* Agent identity is resolved from the MCP client's `initialize` handshake
|
|
9
|
-
* (clientInfo.name), so "Claude Code", "Cursor", "Codex", etc. are
|
|
10
|
-
* detected automatically — no hardcoded fallback needed.
|
|
11
|
-
*/
|
|
12
|
-
/** Well-known MCP client names → human-friendly display names */
|
|
13
|
-
const CLIENT_DISPLAY_NAMES = {
|
|
14
|
-
"claude-code": "Claude Code",
|
|
15
|
-
"claude-desktop": "Claude Desktop",
|
|
16
|
-
cursor: "Cursor",
|
|
17
|
-
windsurf: "Windsurf",
|
|
18
|
-
cline: "Cline",
|
|
19
|
-
continue: "Continue",
|
|
20
|
-
"codex-cli": "OpenAI Codex",
|
|
21
|
-
zed: "Zed",
|
|
22
|
-
"gemini-cli": "Gemini CLI",
|
|
23
|
-
};
|
|
24
|
-
/** Derive a slug-style identifier from a client name */
|
|
25
|
-
function toIdentifier(name) {
|
|
26
|
-
return name.toLowerCase().replace(/\s+/g, "-");
|
|
27
|
-
}
|
|
28
|
-
/** Resolve agent identity from MCP client info */
|
|
29
|
-
export function resolveAgentIdentity(info) {
|
|
30
|
-
if (!info?.name) {
|
|
31
|
-
return { agentIdentifier: "unknown", agentName: "Unknown Agent" };
|
|
32
|
-
}
|
|
33
|
-
const key = toIdentifier(info.name);
|
|
34
|
-
const displayName = CLIENT_DISPLAY_NAMES[key] ?? info.name; // use raw name if not in map
|
|
35
|
-
return { agentIdentifier: key, agentName: displayName };
|
|
36
|
-
}
|
|
37
|
-
/** Tools that trigger auto-start of a session */
|
|
38
|
-
export const AUTO_START_TRIGGERS = new Set([
|
|
39
|
-
"harmony_generate_prompt",
|
|
40
|
-
"harmony_update_card",
|
|
41
|
-
"harmony_move_card",
|
|
42
|
-
"harmony_create_subtask",
|
|
43
|
-
"harmony_toggle_subtask",
|
|
44
|
-
"harmony_add_label_to_card",
|
|
45
|
-
"harmony_remove_label_from_card",
|
|
46
|
-
]);
|
|
47
|
-
export const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
48
|
-
const CHECK_INTERVAL_MS = 60 * 1000; // 60 seconds
|
|
49
|
-
const activeSessions = new Map();
|
|
50
|
-
let inactivityTimer = null;
|
|
51
|
-
let endCallback = null;
|
|
52
|
-
let clientGetter = null;
|
|
53
|
-
let clientInfoGetter = null;
|
|
54
|
-
/**
|
|
55
|
-
* Initialize auto-session tracking.
|
|
56
|
-
* @param callback Called when an auto-session ends (runs the learning pipeline)
|
|
57
|
-
* @param getClient Function to get the current API client
|
|
58
|
-
* @param getClientInfo Function to get MCP client identity from the initialize handshake
|
|
59
|
-
*/
|
|
60
|
-
export function initAutoSession(callback, getClient, getClientInfo) {
|
|
61
|
-
endCallback = callback;
|
|
62
|
-
clientGetter = getClient;
|
|
63
|
-
clientInfoGetter = getClientInfo ?? null;
|
|
64
|
-
if (inactivityTimer)
|
|
65
|
-
clearInterval(inactivityTimer);
|
|
66
|
-
inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Track activity on a card. Auto-starts a session if needed.
|
|
70
|
-
*/
|
|
71
|
-
export async function trackActivity(cardId, options) {
|
|
72
|
-
const now = Date.now();
|
|
73
|
-
const existing = activeSessions.get(cardId);
|
|
74
|
-
if (existing) {
|
|
75
|
-
// Update last activity timestamp
|
|
76
|
-
existing.lastActivityAt = now;
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
// Only auto-start if the tool is a trigger
|
|
80
|
-
if (!options?.autoStart)
|
|
81
|
-
return;
|
|
82
|
-
const client = options?.client ?? clientGetter?.();
|
|
83
|
-
if (!client)
|
|
84
|
-
return;
|
|
85
|
-
// Collect auto-sessions on other cards to end (avoid mutating map during iteration)
|
|
86
|
-
const toEnd = [];
|
|
87
|
-
for (const [otherCardId, session] of activeSessions) {
|
|
88
|
-
if (otherCardId !== cardId && !session.isExplicit) {
|
|
89
|
-
toEnd.push(otherCardId);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
for (const otherCardId of toEnd) {
|
|
93
|
-
await autoEndSession(client, otherCardId, "completed");
|
|
94
|
-
}
|
|
95
|
-
// Resolve agent identity from MCP client info
|
|
96
|
-
const info = clientInfoGetter?.() ?? null;
|
|
97
|
-
const { agentIdentifier, agentName } = resolveAgentIdentity(info);
|
|
98
|
-
// Start a new auto-session
|
|
99
|
-
try {
|
|
100
|
-
await client.startAgentSession(cardId, {
|
|
101
|
-
agentIdentifier,
|
|
102
|
-
agentName,
|
|
103
|
-
status: "working",
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
catch {
|
|
107
|
-
// Session start failed (might already have one), still track locally
|
|
108
|
-
}
|
|
109
|
-
activeSessions.set(cardId, {
|
|
110
|
-
cardId,
|
|
111
|
-
startedAt: now,
|
|
112
|
-
lastActivityAt: now,
|
|
113
|
-
isExplicit: false,
|
|
114
|
-
agentIdentifier,
|
|
115
|
-
agentName,
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* Mark a session as explicitly started (won't be auto-ended by card switching or inactivity).
|
|
120
|
-
* Optionally accepts the real agent identifier/name to store in the session.
|
|
121
|
-
*/
|
|
122
|
-
export function markExplicit(cardId, options) {
|
|
123
|
-
const existing = activeSessions.get(cardId);
|
|
124
|
-
if (existing) {
|
|
125
|
-
existing.isExplicit = true;
|
|
126
|
-
if (options?.agentIdentifier)
|
|
127
|
-
existing.agentIdentifier = options.agentIdentifier;
|
|
128
|
-
if (options?.agentName)
|
|
129
|
-
existing.agentName = options.agentName;
|
|
130
|
-
}
|
|
131
|
-
else {
|
|
132
|
-
// Track the explicit session even if we didn't auto-start it
|
|
133
|
-
activeSessions.set(cardId, {
|
|
134
|
-
cardId,
|
|
135
|
-
startedAt: Date.now(),
|
|
136
|
-
lastActivityAt: Date.now(),
|
|
137
|
-
isExplicit: true,
|
|
138
|
-
agentIdentifier: options?.agentIdentifier ?? "explicit",
|
|
139
|
-
agentName: options?.agentName ?? "Explicit Agent",
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* Remove a session from tracking (called when session is explicitly ended).
|
|
145
|
-
*/
|
|
146
|
-
export function untrack(cardId) {
|
|
147
|
-
activeSessions.delete(cardId);
|
|
148
|
-
}
|
|
149
|
-
/**
|
|
150
|
-
* End all active auto-sessions (called on process shutdown).
|
|
151
|
-
*/
|
|
152
|
-
export async function shutdownAllSessions() {
|
|
153
|
-
const client = clientGetter?.();
|
|
154
|
-
if (!client)
|
|
155
|
-
return;
|
|
156
|
-
// Snapshot keys to avoid mutating map during iteration
|
|
157
|
-
const cardIds = [...activeSessions.keys()];
|
|
158
|
-
const promises = cardIds.map((cardId) => autoEndSession(client, cardId, "paused"));
|
|
159
|
-
await Promise.allSettled(promises);
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Clean up the interval timer (for tests).
|
|
163
|
-
*/
|
|
164
|
-
export function destroyAutoSession() {
|
|
165
|
-
if (inactivityTimer) {
|
|
166
|
-
clearInterval(inactivityTimer);
|
|
167
|
-
inactivityTimer = null;
|
|
168
|
-
}
|
|
169
|
-
activeSessions.clear();
|
|
170
|
-
endCallback = null;
|
|
171
|
-
clientGetter = null;
|
|
172
|
-
clientInfoGetter = null;
|
|
173
|
-
}
|
|
174
|
-
/**
|
|
175
|
-
* Get a snapshot of active sessions (for testing/debugging).
|
|
176
|
-
*/
|
|
177
|
-
export function getActiveSessions() {
|
|
178
|
-
return activeSessions;
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* Run inactivity check immediately (exported for testing).
|
|
182
|
-
* In production, called by the setInterval timer every 60s.
|
|
183
|
-
*/
|
|
184
|
-
export function checkInactivity() {
|
|
185
|
-
const now = Date.now();
|
|
186
|
-
const client = clientGetter?.();
|
|
187
|
-
if (!client)
|
|
188
|
-
return;
|
|
189
|
-
// Snapshot keys to avoid mutating map during iteration
|
|
190
|
-
const entries = [...activeSessions.entries()];
|
|
191
|
-
for (const [cardId, session] of entries) {
|
|
192
|
-
if (session.isExplicit)
|
|
193
|
-
continue;
|
|
194
|
-
if (now - session.lastActivityAt > INACTIVITY_TIMEOUT_MS) {
|
|
195
|
-
autoEndSession(client, cardId, "completed").catch(() => { });
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
// --- Internal ---
|
|
200
|
-
async function autoEndSession(client, cardId, status) {
|
|
201
|
-
activeSessions.delete(cardId);
|
|
202
|
-
try {
|
|
203
|
-
await client.endAgentSession(cardId, { status });
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
// Best-effort end
|
|
207
|
-
}
|
|
208
|
-
try {
|
|
209
|
-
await endCallback?.(client, cardId, status);
|
|
210
|
-
}
|
|
211
|
-
catch {
|
|
212
|
-
// Best-effort pipeline
|
|
213
|
-
}
|
|
214
|
-
}
|
package/dist/lib/cli.js
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { createRequire } from "node:module";
|
|
3
|
-
import { program } from "commander";
|
|
4
|
-
import { areSkillsInstalled, getActiveProjectId, getActiveWorkspaceId, getConfigPath, getLocalConfigPath, hasLocalConfig, isConfigured, loadConfig, loadLocalConfig, saveConfig, } from "./config.js";
|
|
5
|
-
import { HarmonyMCPServer } from "./server.js";
|
|
6
|
-
import { refreshSkills } from "./skills.js";
|
|
7
|
-
import { runSetup } from "./tui/setup.js";
|
|
8
|
-
const require = createRequire(import.meta.url);
|
|
9
|
-
const { version } = require("../package.json");
|
|
10
|
-
program
|
|
11
|
-
.name("@gethmy/mcp")
|
|
12
|
-
.description("MCP server for Harmony Kanban board")
|
|
13
|
-
.version(version);
|
|
14
|
-
program
|
|
15
|
-
.command("serve")
|
|
16
|
-
.description("Start the MCP server (stdio transport)")
|
|
17
|
-
.action(async () => {
|
|
18
|
-
if (!isConfigured()) {
|
|
19
|
-
console.error("No API key configured.");
|
|
20
|
-
console.error("Run: npx @gethmy/mcp setup");
|
|
21
|
-
process.exit(1);
|
|
22
|
-
}
|
|
23
|
-
await refreshSkills();
|
|
24
|
-
const server = new HarmonyMCPServer();
|
|
25
|
-
await server.run();
|
|
26
|
-
});
|
|
27
|
-
program
|
|
28
|
-
.command("status")
|
|
29
|
-
.description("Show configuration status")
|
|
30
|
-
.action(() => {
|
|
31
|
-
const globalConfig = loadConfig();
|
|
32
|
-
const localConfig = loadLocalConfig();
|
|
33
|
-
const hasLocal = hasLocalConfig();
|
|
34
|
-
const skillsStatus = areSkillsInstalled();
|
|
35
|
-
// Helper to mask email for privacy (u***@example.com)
|
|
36
|
-
const maskEmail = (email) => {
|
|
37
|
-
const [local, domain] = email.split("@");
|
|
38
|
-
if (!domain)
|
|
39
|
-
return email;
|
|
40
|
-
return `${local[0]}***@${domain}`;
|
|
41
|
-
};
|
|
42
|
-
if (isConfigured()) {
|
|
43
|
-
console.log("Status: Configured\n");
|
|
44
|
-
console.log("API:");
|
|
45
|
-
console.log(` Key: ${globalConfig.apiKey?.slice(0, 8)}...`);
|
|
46
|
-
console.log(` URL: ${globalConfig.apiUrl}`);
|
|
47
|
-
console.log(` Email: ${globalConfig.userEmail ? maskEmail(globalConfig.userEmail) : "(not set)"}`);
|
|
48
|
-
console.log("\nSkills:");
|
|
49
|
-
if (skillsStatus.installed) {
|
|
50
|
-
console.log(` Installed: Yes (${skillsStatus.location})`);
|
|
51
|
-
for (const path of skillsStatus.paths) {
|
|
52
|
-
console.log(` ${path}`);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
else {
|
|
56
|
-
console.log(" Installed: No");
|
|
57
|
-
console.log(" Run: npx @gethmy/mcp setup");
|
|
58
|
-
}
|
|
59
|
-
console.log("\nContext:");
|
|
60
|
-
if (hasLocal) {
|
|
61
|
-
console.log(` Local config: ${getLocalConfigPath()}`);
|
|
62
|
-
console.log(` Workspace: ${localConfig?.workspaceId || "(not set)"}`);
|
|
63
|
-
console.log(` Project: ${localConfig?.projectId || "(not set)"}`);
|
|
64
|
-
}
|
|
65
|
-
console.log(` Global config: ${getConfigPath()}`);
|
|
66
|
-
console.log(` Workspace: ${globalConfig.activeWorkspaceId || "(not set)"}`);
|
|
67
|
-
console.log(` Project: ${globalConfig.activeProjectId || "(not set)"}`);
|
|
68
|
-
// Show effective (active) context
|
|
69
|
-
const effectiveWorkspace = getActiveWorkspaceId();
|
|
70
|
-
const effectiveProject = getActiveProjectId();
|
|
71
|
-
const wsSource = localConfig?.workspaceId
|
|
72
|
-
? "local"
|
|
73
|
-
: globalConfig.activeWorkspaceId
|
|
74
|
-
? "global"
|
|
75
|
-
: "";
|
|
76
|
-
const projSource = localConfig?.projectId
|
|
77
|
-
? "local"
|
|
78
|
-
: globalConfig.activeProjectId
|
|
79
|
-
? "global"
|
|
80
|
-
: "";
|
|
81
|
-
console.log("\n Active (effective):");
|
|
82
|
-
console.log(` Workspace: ${effectiveWorkspace || "(not set)"}${wsSource ? ` ← ${wsSource}` : ""}`);
|
|
83
|
-
console.log(` Project: ${effectiveProject || "(not set)"}${projSource ? ` ← ${projSource}` : ""}`);
|
|
84
|
-
}
|
|
85
|
-
else {
|
|
86
|
-
console.log("Status: Not configured\n");
|
|
87
|
-
console.log("Run: npx @gethmy/mcp setup");
|
|
88
|
-
console.log("Get an API key at: https://app.gethmy.com/user/keys");
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
program
|
|
92
|
-
.command("reset")
|
|
93
|
-
.description("Remove stored configuration")
|
|
94
|
-
.action(() => {
|
|
95
|
-
saveConfig({
|
|
96
|
-
apiKey: null,
|
|
97
|
-
activeWorkspaceId: null,
|
|
98
|
-
activeProjectId: null,
|
|
99
|
-
userEmail: null,
|
|
100
|
-
});
|
|
101
|
-
console.log("Configuration reset successfully");
|
|
102
|
-
console.log("\nTo reconfigure, run: npx @gethmy/mcp setup");
|
|
103
|
-
});
|
|
104
|
-
program
|
|
105
|
-
.command("setup")
|
|
106
|
-
.description("Smart setup wizard for Harmony MCP (recommended)")
|
|
107
|
-
.option("-f, --force", "Overwrite existing configuration files")
|
|
108
|
-
.option("-k, --api-key <key>", "API key (skips prompt)")
|
|
109
|
-
.option("-e, --email <email>", "Your email for auto-assignment")
|
|
110
|
-
.option("-a, --agents <agents...>", "Agents to configure: claude, codex, cursor, windsurf")
|
|
111
|
-
.option("-l, --local", "Install skills locally in project directory")
|
|
112
|
-
.option("-g, --global", "Install skills globally (recommended)")
|
|
113
|
-
.option("-w, --workspace <id>", "Set workspace context")
|
|
114
|
-
.option("-p, --project <id>", "Set project context")
|
|
115
|
-
.option("--skip-context", "Skip workspace/project selection")
|
|
116
|
-
.option("--skip-docs", "Skip project docs scaffold/verification")
|
|
117
|
-
.option("--new", "Create a new account (skip the choice prompt)")
|
|
118
|
-
.option("-n, --name <name>", "Full name (for account creation)")
|
|
119
|
-
.action(async (options) => {
|
|
120
|
-
await runSetup({
|
|
121
|
-
force: options.force,
|
|
122
|
-
apiKey: options.apiKey,
|
|
123
|
-
userEmail: options.email,
|
|
124
|
-
agents: options.agents,
|
|
125
|
-
installMode: options.global
|
|
126
|
-
? "global"
|
|
127
|
-
: options.local
|
|
128
|
-
? "local"
|
|
129
|
-
: undefined,
|
|
130
|
-
workspaceId: options.workspace,
|
|
131
|
-
projectId: options.project,
|
|
132
|
-
skipContext: options.skipContext,
|
|
133
|
-
skipDocs: options.skipDocs,
|
|
134
|
-
newAccount: options.new,
|
|
135
|
-
name: options.name,
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
program.parse();
|
|
@@ -1,303 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Smart Memory Consolidation
|
|
3
|
-
*
|
|
4
|
-
* Clusters similar draft/episode memories and merges them into
|
|
5
|
-
* consolidated reference entities to reduce noise and improve retrieval.
|
|
6
|
-
*/
|
|
7
|
-
import { findSimilarEntities } from "./graph-expansion.js";
|
|
8
|
-
/**
|
|
9
|
-
* Consolidate similar draft/episode memories into reference entities.
|
|
10
|
-
*
|
|
11
|
-
* 1. Lists all draft and episode tier entities in scope
|
|
12
|
-
* 2. Groups by entity type
|
|
13
|
-
* 3. For each type group, finds clusters via embedding similarity
|
|
14
|
-
* 4. Merges clusters into new reference entities with part_of relations
|
|
15
|
-
*/
|
|
16
|
-
export async function consolidateMemories(client, workspaceId, projectId, options) {
|
|
17
|
-
const dryRun = options?.dryRun !== false; // default true
|
|
18
|
-
const minClusterSize = options?.minClusterSize ?? 3; // raised from 2 to reduce noise
|
|
19
|
-
const result = {
|
|
20
|
-
consolidated: 0,
|
|
21
|
-
clustersFound: 0,
|
|
22
|
-
entitiesProcessed: 0,
|
|
23
|
-
details: [],
|
|
24
|
-
};
|
|
25
|
-
// Step 1: Fetch all draft and episode entities
|
|
26
|
-
const listResult = await client.listMemoryEntities({
|
|
27
|
-
workspace_id: workspaceId,
|
|
28
|
-
project_id: projectId,
|
|
29
|
-
limit: 100,
|
|
30
|
-
});
|
|
31
|
-
const allEntities = (listResult.entities || []).filter((e) => e.memory_tier === "draft" || e.memory_tier === "episode");
|
|
32
|
-
result.entitiesProcessed = allEntities.length;
|
|
33
|
-
if (allEntities.length < minClusterSize)
|
|
34
|
-
return result;
|
|
35
|
-
// Step 2: Group by type
|
|
36
|
-
const typeGroups = new Map();
|
|
37
|
-
for (const entity of allEntities) {
|
|
38
|
-
const group = typeGroups.get(entity.type) || [];
|
|
39
|
-
group.push(entity);
|
|
40
|
-
typeGroups.set(entity.type, group);
|
|
41
|
-
}
|
|
42
|
-
// Step 3: Find clusters within each type group
|
|
43
|
-
for (const [type, entities] of typeGroups) {
|
|
44
|
-
if (entities.length < minClusterSize)
|
|
45
|
-
continue;
|
|
46
|
-
const clustered = new Set();
|
|
47
|
-
const clusters = [];
|
|
48
|
-
for (const entity of entities) {
|
|
49
|
-
if (clustered.has(entity.id))
|
|
50
|
-
continue;
|
|
51
|
-
// Search for similar entities using embedding-based search
|
|
52
|
-
const similar = await findSimilarEntities(client, entity.title, entity.content, workspaceId, {
|
|
53
|
-
projectId,
|
|
54
|
-
limit: 20,
|
|
55
|
-
minRrfScore: 0.01,
|
|
56
|
-
excludeIds: [...clustered],
|
|
57
|
-
});
|
|
58
|
-
// Filter to only entities in our current type group that aren't yet clustered
|
|
59
|
-
const entityIdSet = new Set(entities.map((e) => e.id));
|
|
60
|
-
const clusterMembers = similar.filter((s) => entityIdSet.has(s.id) &&
|
|
61
|
-
!clustered.has(s.id) &&
|
|
62
|
-
s.id !== entity.id &&
|
|
63
|
-
s.type === type);
|
|
64
|
-
if (clusterMembers.length >= minClusterSize - 1) {
|
|
65
|
-
const cluster = [
|
|
66
|
-
entity,
|
|
67
|
-
...clusterMembers.slice(0, 5).map((s) => {
|
|
68
|
-
// Map back to full entity from our list
|
|
69
|
-
return entities.find((e) => e.id === s.id) || entity;
|
|
70
|
-
}),
|
|
71
|
-
];
|
|
72
|
-
// Deduplicate by id
|
|
73
|
-
const uniqueCluster = [];
|
|
74
|
-
const seen = new Set();
|
|
75
|
-
for (const member of cluster) {
|
|
76
|
-
if (!seen.has(member.id)) {
|
|
77
|
-
seen.add(member.id);
|
|
78
|
-
uniqueCluster.push(member);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
if (uniqueCluster.length >= minClusterSize) {
|
|
82
|
-
clusters.push(uniqueCluster);
|
|
83
|
-
for (const member of uniqueCluster) {
|
|
84
|
-
clustered.add(member.id);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
// Step 4: Create consolidated entities for each cluster
|
|
90
|
-
for (const cluster of clusters) {
|
|
91
|
-
result.clustersFound++;
|
|
92
|
-
// Derive title from most common words across cluster titles
|
|
93
|
-
const mergedTitle = deriveClusterTitle(cluster, type);
|
|
94
|
-
const memberTitles = cluster.map((e) => e.title);
|
|
95
|
-
// Synthesize content: extract unique knowledge from each member,
|
|
96
|
-
// not just a bullet list of titles. Each member's content is trimmed
|
|
97
|
-
// to its first meaningful paragraph (skipping headers and metadata).
|
|
98
|
-
const mergedContent = synthesizeClusterContent(cluster, type);
|
|
99
|
-
// Max confidence from cluster members
|
|
100
|
-
const maxConfidence = Math.max(...cluster.map((e) => e.confidence));
|
|
101
|
-
// Union of all tags (deduped)
|
|
102
|
-
const allTags = [...new Set(cluster.flatMap((e) => e.tags || []))];
|
|
103
|
-
const detail = {
|
|
104
|
-
clusterSize: cluster.length,
|
|
105
|
-
mergedTitle,
|
|
106
|
-
memberTitles,
|
|
107
|
-
};
|
|
108
|
-
if (!dryRun) {
|
|
109
|
-
try {
|
|
110
|
-
// Create consolidated reference entity
|
|
111
|
-
const createResult = await client.createMemoryEntity({
|
|
112
|
-
workspace_id: workspaceId,
|
|
113
|
-
project_id: projectId,
|
|
114
|
-
type,
|
|
115
|
-
scope: "project",
|
|
116
|
-
memory_tier: "reference",
|
|
117
|
-
title: mergedTitle,
|
|
118
|
-
content: mergedContent,
|
|
119
|
-
confidence: maxConfidence,
|
|
120
|
-
tags: [...allTags.slice(0, 15), "consolidated"],
|
|
121
|
-
metadata: {
|
|
122
|
-
source: "consolidation",
|
|
123
|
-
member_ids: cluster.map((e) => e.id),
|
|
124
|
-
consolidated_at: new Date().toISOString(),
|
|
125
|
-
},
|
|
126
|
-
});
|
|
127
|
-
const newEntity = createResult.entity;
|
|
128
|
-
if (newEntity?.id) {
|
|
129
|
-
detail.entityId = newEntity.id;
|
|
130
|
-
// Create part_of relations from members → consolidated entity
|
|
131
|
-
for (const member of cluster) {
|
|
132
|
-
try {
|
|
133
|
-
await client.createMemoryRelation({
|
|
134
|
-
source_id: member.id,
|
|
135
|
-
target_id: newEntity.id,
|
|
136
|
-
relation_type: "part_of",
|
|
137
|
-
confidence: 0.8,
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
catch {
|
|
141
|
-
// Skip duplicate relations
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
// Downgrade member confidence by 0.3 (min 0.1)
|
|
145
|
-
for (const member of cluster) {
|
|
146
|
-
try {
|
|
147
|
-
const newConf = Math.max(member.confidence - 0.3, 0.1);
|
|
148
|
-
await client.updateMemoryEntity(member.id, {
|
|
149
|
-
confidence: newConf,
|
|
150
|
-
metadata: {
|
|
151
|
-
consolidated_into: newEntity.id,
|
|
152
|
-
original_confidence: member.confidence,
|
|
153
|
-
},
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
catch {
|
|
157
|
-
// Non-fatal
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
result.consolidated++;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
// Non-fatal: consolidation failure for one cluster shouldn't block others
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
else {
|
|
168
|
-
result.consolidated++;
|
|
169
|
-
}
|
|
170
|
-
result.details.push(detail);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
return result;
|
|
174
|
-
}
|
|
175
|
-
/**
|
|
176
|
-
* Synthesize cluster content by extracting unique, actionable knowledge
|
|
177
|
-
* from each member entity. Skips boilerplate (headers, metadata, agent names)
|
|
178
|
-
* and deduplicates similar lines across members.
|
|
179
|
-
*/
|
|
180
|
-
function synthesizeClusterContent(cluster, type) {
|
|
181
|
-
// Lines to skip: headers, agent metadata, timestamps, progress percentages
|
|
182
|
-
const SKIP_PATTERNS = [
|
|
183
|
-
/^##\s/,
|
|
184
|
-
/^Agent:/,
|
|
185
|
-
/^Duration:/,
|
|
186
|
-
/^Labels:/,
|
|
187
|
-
/^Progress:/,
|
|
188
|
-
/^Session status:/,
|
|
189
|
-
/^Completed at/,
|
|
190
|
-
/^Final state:/,
|
|
191
|
-
/^Related:/,
|
|
192
|
-
/^When working on:/,
|
|
193
|
-
/^\d+\.\s+.+\(\d+%,\s*\+\d+%\)/, // procedure step with progress percentages
|
|
194
|
-
/^Last updated:/,
|
|
195
|
-
/^Recurring pattern:/,
|
|
196
|
-
/^Consolidated from/,
|
|
197
|
-
];
|
|
198
|
-
const seenLines = new Set();
|
|
199
|
-
const knowledgeLines = [];
|
|
200
|
-
for (const entity of cluster) {
|
|
201
|
-
const lines = entity.content.split("\n").map((l) => l.trim());
|
|
202
|
-
for (const line of lines) {
|
|
203
|
-
if (!line || line.length < 20)
|
|
204
|
-
continue;
|
|
205
|
-
if (SKIP_PATTERNS.some((p) => p.test(line)))
|
|
206
|
-
continue;
|
|
207
|
-
// Normalize for dedup: lowercase, strip markdown formatting
|
|
208
|
-
const normalized = line
|
|
209
|
-
.toLowerCase()
|
|
210
|
-
.replace(/[*_`#[\]]/g, "")
|
|
211
|
-
.trim();
|
|
212
|
-
if (seenLines.has(normalized))
|
|
213
|
-
continue;
|
|
214
|
-
seenLines.add(normalized);
|
|
215
|
-
knowledgeLines.push(line);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
if (knowledgeLines.length === 0) {
|
|
219
|
-
// Fallback: if no knowledge was extractable, use a compact summary
|
|
220
|
-
return `${cluster.length} related ${type} entities consolidated. Original titles:\n${cluster.map((e) => `- ${e.title}`).join("\n")}`;
|
|
221
|
-
}
|
|
222
|
-
// Cap at ~400 tokens worth of content (1600 chars)
|
|
223
|
-
const MAX_CHARS = 1600;
|
|
224
|
-
const result = [
|
|
225
|
-
`Consolidated knowledge from ${cluster.length} ${type} entities:\n`,
|
|
226
|
-
];
|
|
227
|
-
let charCount = result[0].length;
|
|
228
|
-
for (const line of knowledgeLines) {
|
|
229
|
-
if (charCount + line.length + 3 > MAX_CHARS)
|
|
230
|
-
break;
|
|
231
|
-
result.push(`- ${line}`);
|
|
232
|
-
charCount += line.length + 3;
|
|
233
|
-
}
|
|
234
|
-
return result.join("\n");
|
|
235
|
-
}
|
|
236
|
-
/**
|
|
237
|
-
* Derive a cluster title from the most common meaningful words across member titles.
|
|
238
|
-
*/
|
|
239
|
-
function deriveClusterTitle(cluster, type) {
|
|
240
|
-
const stopWords = new Set([
|
|
241
|
-
"the",
|
|
242
|
-
"a",
|
|
243
|
-
"an",
|
|
244
|
-
"is",
|
|
245
|
-
"are",
|
|
246
|
-
"was",
|
|
247
|
-
"were",
|
|
248
|
-
"be",
|
|
249
|
-
"been",
|
|
250
|
-
"being",
|
|
251
|
-
"have",
|
|
252
|
-
"has",
|
|
253
|
-
"had",
|
|
254
|
-
"do",
|
|
255
|
-
"does",
|
|
256
|
-
"did",
|
|
257
|
-
"will",
|
|
258
|
-
"shall",
|
|
259
|
-
"would",
|
|
260
|
-
"should",
|
|
261
|
-
"may",
|
|
262
|
-
"might",
|
|
263
|
-
"can",
|
|
264
|
-
"could",
|
|
265
|
-
"of",
|
|
266
|
-
"in",
|
|
267
|
-
"to",
|
|
268
|
-
"for",
|
|
269
|
-
"with",
|
|
270
|
-
"on",
|
|
271
|
-
"at",
|
|
272
|
-
"from",
|
|
273
|
-
"by",
|
|
274
|
-
"and",
|
|
275
|
-
"or",
|
|
276
|
-
"but",
|
|
277
|
-
"not",
|
|
278
|
-
"session",
|
|
279
|
-
"blocker",
|
|
280
|
-
"pattern",
|
|
281
|
-
"solution",
|
|
282
|
-
"error",
|
|
283
|
-
"task",
|
|
284
|
-
"mid-session",
|
|
285
|
-
]);
|
|
286
|
-
const wordCounts = new Map();
|
|
287
|
-
for (const entity of cluster) {
|
|
288
|
-
const words = entity.title
|
|
289
|
-
.toLowerCase()
|
|
290
|
-
.split(/\W+/)
|
|
291
|
-
.filter((w) => w.length > 2 && !stopWords.has(w));
|
|
292
|
-
for (const word of words) {
|
|
293
|
-
wordCounts.set(word, (wordCounts.get(word) || 0) + 1);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
// Sort by frequency, take top 4 for more descriptive titles
|
|
297
|
-
const topWords = [...wordCounts.entries()]
|
|
298
|
-
.sort((a, b) => b[1] - a[1])
|
|
299
|
-
.slice(0, 4)
|
|
300
|
-
.map(([word]) => word[0].toUpperCase() + word.slice(1));
|
|
301
|
-
const suffix = topWords.length > 0 ? topWords.join(" / ") : "Various";
|
|
302
|
-
return `${type[0].toUpperCase() + type.slice(1)}: ${suffix}`;
|
|
303
|
-
}
|