@gethmy/mcp 2.8.5 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +93 -53
- package/dist/index.js +93 -53
- package/dist/lib/api-client.js +10 -0
- package/package.json +1 -1
- package/src/api-client.ts +26 -0
- package/src/auto-session.ts +164 -51
- package/src/graph-expansion.ts +5 -7
- package/src/remote.ts +46 -1
- package/src/server.ts +45 -13
package/dist/cli.js
CHANGED
|
@@ -1456,6 +1456,12 @@ class HarmonyApiClient {
|
|
|
1456
1456
|
async getWorkspaceMembers(workspaceId) {
|
|
1457
1457
|
return this.request("GET", `/workspaces/${workspaceId}/members`);
|
|
1458
1458
|
}
|
|
1459
|
+
async listWorkspaceAgents(workspaceId) {
|
|
1460
|
+
return this.request("GET", `/workspaces/${workspaceId}/agents`);
|
|
1461
|
+
}
|
|
1462
|
+
async registerWorkspaceAgent(workspaceId, data) {
|
|
1463
|
+
return this.request("POST", `/workspaces/${workspaceId}/agents`, data);
|
|
1464
|
+
}
|
|
1459
1465
|
async listProjects(workspaceId) {
|
|
1460
1466
|
return this.request("GET", `/workspaces/${workspaceId}/projects`);
|
|
1461
1467
|
}
|
|
@@ -1720,6 +1726,10 @@ class HarmonyApiClient {
|
|
|
1720
1726
|
params.set("type", options.type);
|
|
1721
1727
|
if (options?.limit !== undefined)
|
|
1722
1728
|
params.set("limit", String(options.limit));
|
|
1729
|
+
for (const tag of options?.tags ?? [])
|
|
1730
|
+
params.append("tags", tag);
|
|
1731
|
+
if (options?.include_superseded)
|
|
1732
|
+
params.set("include_superseded", "true");
|
|
1723
1733
|
return this.request("GET", `/memory/search?${params.toString()}`);
|
|
1724
1734
|
}
|
|
1725
1735
|
async getVaultIndex(options) {
|
|
@@ -2043,43 +2053,56 @@ var AUTO_START_TRIGGERS = new Set([
|
|
|
2043
2053
|
]);
|
|
2044
2054
|
var INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
2045
2055
|
var CHECK_INTERVAL_MS = 60 * 1000;
|
|
2046
|
-
var
|
|
2056
|
+
var DEFAULT_SCOPE = "__default__";
|
|
2057
|
+
var scopes = new Map;
|
|
2047
2058
|
var inactivityTimer = null;
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2059
|
+
function getOrCreateScope(scopeId) {
|
|
2060
|
+
let scope = scopes.get(scopeId);
|
|
2061
|
+
if (!scope) {
|
|
2062
|
+
scope = {
|
|
2063
|
+
sessions: new Map,
|
|
2064
|
+
endCallback: null,
|
|
2065
|
+
clientGetter: null,
|
|
2066
|
+
clientInfoGetter: null
|
|
2067
|
+
};
|
|
2068
|
+
scopes.set(scopeId, scope);
|
|
2069
|
+
}
|
|
2070
|
+
return scope;
|
|
2071
|
+
}
|
|
2072
|
+
function initAutoSession(callback, getClient2, getClientInfo, scopeId = DEFAULT_SCOPE) {
|
|
2073
|
+
const scope = getOrCreateScope(scopeId);
|
|
2074
|
+
scope.endCallback = callback;
|
|
2075
|
+
scope.clientGetter = getClient2;
|
|
2076
|
+
scope.clientInfoGetter = getClientInfo ?? null;
|
|
2077
|
+
if (!inactivityTimer) {
|
|
2078
|
+
inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
|
|
2079
|
+
}
|
|
2058
2080
|
}
|
|
2059
2081
|
async function trackActivity(cardId, options) {
|
|
2082
|
+
const scope = getOrCreateScope(options?.scopeId ?? DEFAULT_SCOPE);
|
|
2060
2083
|
const now = Date.now();
|
|
2061
|
-
const existing =
|
|
2084
|
+
const existing = scope.sessions.get(cardId);
|
|
2062
2085
|
if (existing) {
|
|
2063
2086
|
existing.lastActivityAt = now;
|
|
2064
2087
|
return;
|
|
2065
2088
|
}
|
|
2066
2089
|
if (!options?.autoStart)
|
|
2067
2090
|
return;
|
|
2068
|
-
const client3 = options?.client ?? clientGetter?.();
|
|
2091
|
+
const client3 = options?.client ?? scope.clientGetter?.();
|
|
2069
2092
|
if (!client3)
|
|
2070
2093
|
return;
|
|
2071
|
-
const info = clientInfoGetter?.() ?? null;
|
|
2094
|
+
const info = options?.clientInfo ?? scope.clientInfoGetter?.() ?? null;
|
|
2072
2095
|
if (!info?.name)
|
|
2073
2096
|
return;
|
|
2074
2097
|
const { agentIdentifier, agentName } = resolveAgentIdentity(info);
|
|
2075
2098
|
const toEnd = [];
|
|
2076
|
-
for (const [otherCardId, session] of
|
|
2099
|
+
for (const [otherCardId, session] of scope.sessions) {
|
|
2077
2100
|
if (otherCardId !== cardId && !session.isExplicit) {
|
|
2078
2101
|
toEnd.push(otherCardId);
|
|
2079
2102
|
}
|
|
2080
2103
|
}
|
|
2081
2104
|
for (const otherCardId of toEnd) {
|
|
2082
|
-
await autoEndSession(client3, otherCardId, "completed");
|
|
2105
|
+
await autoEndSession(scope, client3, otherCardId, "completed");
|
|
2083
2106
|
}
|
|
2084
2107
|
try {
|
|
2085
2108
|
await client3.startAgentSession(cardId, {
|
|
@@ -2088,7 +2111,7 @@ async function trackActivity(cardId, options) {
|
|
|
2088
2111
|
status: "working"
|
|
2089
2112
|
});
|
|
2090
2113
|
} catch {}
|
|
2091
|
-
|
|
2114
|
+
scope.sessions.set(cardId, {
|
|
2092
2115
|
cardId,
|
|
2093
2116
|
startedAt: now,
|
|
2094
2117
|
lastActivityAt: now,
|
|
@@ -2098,7 +2121,8 @@ async function trackActivity(cardId, options) {
|
|
|
2098
2121
|
});
|
|
2099
2122
|
}
|
|
2100
2123
|
function markExplicit(cardId, options) {
|
|
2101
|
-
const
|
|
2124
|
+
const scope = getOrCreateScope(options?.scopeId ?? DEFAULT_SCOPE);
|
|
2125
|
+
const existing = scope.sessions.get(cardId);
|
|
2102
2126
|
if (existing) {
|
|
2103
2127
|
existing.isExplicit = true;
|
|
2104
2128
|
if (options?.agentIdentifier)
|
|
@@ -2106,7 +2130,7 @@ function markExplicit(cardId, options) {
|
|
|
2106
2130
|
if (options?.agentName)
|
|
2107
2131
|
existing.agentName = options.agentName;
|
|
2108
2132
|
} else {
|
|
2109
|
-
|
|
2133
|
+
scope.sessions.set(cardId, {
|
|
2110
2134
|
cardId,
|
|
2111
2135
|
startedAt: Date.now(),
|
|
2112
2136
|
lastActivityAt: Date.now(),
|
|
@@ -2116,15 +2140,23 @@ function markExplicit(cardId, options) {
|
|
|
2116
2140
|
});
|
|
2117
2141
|
}
|
|
2118
2142
|
}
|
|
2119
|
-
function untrack(cardId) {
|
|
2120
|
-
|
|
2143
|
+
function untrack(cardId, scopeId = DEFAULT_SCOPE) {
|
|
2144
|
+
scopes.get(scopeId)?.sessions.delete(cardId);
|
|
2121
2145
|
}
|
|
2122
|
-
async function shutdownAllSessions() {
|
|
2123
|
-
const
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2146
|
+
async function shutdownAllSessions(scopeId) {
|
|
2147
|
+
const targets = scopeId !== undefined ? scopes.has(scopeId) ? [scopeId] : [] : [...scopes.keys()];
|
|
2148
|
+
const promises = [];
|
|
2149
|
+
for (const sid of targets) {
|
|
2150
|
+
const scope = scopes.get(sid);
|
|
2151
|
+
if (!scope)
|
|
2152
|
+
continue;
|
|
2153
|
+
const client3 = scope.clientGetter?.();
|
|
2154
|
+
if (!client3)
|
|
2155
|
+
continue;
|
|
2156
|
+
for (const cardId of [...scope.sessions.keys()]) {
|
|
2157
|
+
promises.push(autoEndSession(scope, client3, cardId, "paused"));
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2128
2160
|
await Promise.allSettled(promises);
|
|
2129
2161
|
}
|
|
2130
2162
|
function destroyAutoSession() {
|
|
@@ -2132,32 +2164,32 @@ function destroyAutoSession() {
|
|
|
2132
2164
|
clearInterval(inactivityTimer);
|
|
2133
2165
|
inactivityTimer = null;
|
|
2134
2166
|
}
|
|
2135
|
-
|
|
2136
|
-
endCallback = null;
|
|
2137
|
-
clientGetter = null;
|
|
2138
|
-
clientInfoGetter = null;
|
|
2167
|
+
scopes.clear();
|
|
2139
2168
|
}
|
|
2140
2169
|
function checkInactivity() {
|
|
2141
2170
|
const now = Date.now();
|
|
2142
|
-
const
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
const entries = [...activeSessions.entries()];
|
|
2146
|
-
for (const [cardId, session] of entries) {
|
|
2147
|
-
if (session.isExplicit)
|
|
2171
|
+
for (const scope of scopes.values()) {
|
|
2172
|
+
const client3 = scope.clientGetter?.();
|
|
2173
|
+
if (!client3)
|
|
2148
2174
|
continue;
|
|
2149
|
-
|
|
2150
|
-
|
|
2175
|
+
const entries = [...scope.sessions.entries()];
|
|
2176
|
+
for (const [cardId, session] of entries) {
|
|
2177
|
+
if (session.isExplicit)
|
|
2178
|
+
continue;
|
|
2179
|
+
if (now - session.lastActivityAt > INACTIVITY_TIMEOUT_MS) {
|
|
2180
|
+
autoEndSession(scope, client3, cardId, "completed").catch(() => {});
|
|
2181
|
+
}
|
|
2151
2182
|
}
|
|
2152
2183
|
}
|
|
2153
2184
|
}
|
|
2154
|
-
async function autoEndSession(client3, cardId, status) {
|
|
2155
|
-
|
|
2185
|
+
async function autoEndSession(scope, client3, cardId, status) {
|
|
2186
|
+
if (!scope.sessions.delete(cardId))
|
|
2187
|
+
return;
|
|
2156
2188
|
try {
|
|
2157
2189
|
await client3.endAgentSession(cardId, { status });
|
|
2158
2190
|
} catch {}
|
|
2159
2191
|
try {
|
|
2160
|
-
await endCallback?.(client3, cardId, status);
|
|
2192
|
+
await scope.endCallback?.(client3, cardId, status);
|
|
2161
2193
|
} catch {}
|
|
2162
2194
|
}
|
|
2163
2195
|
|
|
@@ -4561,9 +4593,12 @@ function registerHandlers(server, deps) {
|
|
|
4561
4593
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4562
4594
|
if (cardIdArg && UUID_RE.test(cardIdArg) && deps.isConfigured()) {
|
|
4563
4595
|
const isAutoStartTrigger = AUTO_START_TRIGGERS.has(name);
|
|
4596
|
+
const cv = server.getClientVersion?.();
|
|
4564
4597
|
trackActivity(cardIdArg, {
|
|
4565
4598
|
autoStart: isAutoStartTrigger,
|
|
4566
|
-
client: deps.getClient()
|
|
4599
|
+
client: deps.getClient(),
|
|
4600
|
+
clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
|
|
4601
|
+
scopeId: deps.getScopeId?.()
|
|
4567
4602
|
}).catch(() => {});
|
|
4568
4603
|
}
|
|
4569
4604
|
try {
|
|
@@ -4573,9 +4608,12 @@ function registerHandlers(server, deps) {
|
|
|
4573
4608
|
const parsed = typeof result === "object" && result !== null ? result : {};
|
|
4574
4609
|
const resolvedCardId = parsed.cardId;
|
|
4575
4610
|
if (resolvedCardId && UUID_RE.test(resolvedCardId)) {
|
|
4611
|
+
const cv = server.getClientVersion?.();
|
|
4576
4612
|
trackActivity(resolvedCardId, {
|
|
4577
4613
|
autoStart: true,
|
|
4578
|
-
client: deps.getClient()
|
|
4614
|
+
client: deps.getClient(),
|
|
4615
|
+
clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
|
|
4616
|
+
scopeId: deps.getScopeId?.()
|
|
4579
4617
|
}).catch(() => {});
|
|
4580
4618
|
}
|
|
4581
4619
|
} catch {}
|
|
@@ -4732,7 +4770,7 @@ async function handleToolCall(name, args, deps) {
|
|
|
4732
4770
|
const { session } = await client3.getAgentSession(cardId);
|
|
4733
4771
|
if (session) {
|
|
4734
4772
|
await client3.endAgentSession(cardId, { status: "completed" });
|
|
4735
|
-
untrack(cardId);
|
|
4773
|
+
untrack(cardId, deps.getScopeId?.());
|
|
4736
4774
|
sessionEnded = true;
|
|
4737
4775
|
}
|
|
4738
4776
|
}
|
|
@@ -5090,7 +5128,11 @@ async function handleToolCall(name, args, deps) {
|
|
|
5090
5128
|
currentTask: args.currentTask,
|
|
5091
5129
|
estimatedMinutesRemaining: args.estimatedMinutesRemaining
|
|
5092
5130
|
});
|
|
5093
|
-
markExplicit(cardId, {
|
|
5131
|
+
markExplicit(cardId, {
|
|
5132
|
+
agentIdentifier,
|
|
5133
|
+
agentName,
|
|
5134
|
+
scopeId: deps.getScopeId?.()
|
|
5135
|
+
});
|
|
5094
5136
|
const agentSessionId = result.session?.id;
|
|
5095
5137
|
initMemorySession(cardId, agentIdentifier, agentName, agentSessionId);
|
|
5096
5138
|
return {
|
|
@@ -5152,7 +5194,7 @@ async function handleToolCall(name, args, deps) {
|
|
|
5152
5194
|
} catch (err) {
|
|
5153
5195
|
sessionEndError = err instanceof Error ? err.message : "Failed to end session";
|
|
5154
5196
|
}
|
|
5155
|
-
untrack(cardId);
|
|
5197
|
+
untrack(cardId, deps.getScopeId?.());
|
|
5156
5198
|
let movedTo = null;
|
|
5157
5199
|
try {
|
|
5158
5200
|
const { card } = await client3.getCard(cardId);
|
|
@@ -5329,23 +5371,21 @@ async function handleToolCall(name, args, deps) {
|
|
|
5329
5371
|
let entities;
|
|
5330
5372
|
let relevanceMap;
|
|
5331
5373
|
if (queryText) {
|
|
5374
|
+
const requestedTags = args.tags;
|
|
5332
5375
|
const searchResult = await client3.searchMemoryEntities(workspaceId, queryText, {
|
|
5333
5376
|
project_id: projectId,
|
|
5334
5377
|
type: args.type,
|
|
5335
|
-
limit: fetchLimit
|
|
5378
|
+
limit: fetchLimit,
|
|
5379
|
+
tags: requestedTags && requestedTags.length > 0 ? requestedTags : undefined,
|
|
5380
|
+
include_superseded: includeSuperseded
|
|
5336
5381
|
});
|
|
5337
5382
|
entities = searchResult.entities ?? [];
|
|
5338
|
-
const requestedTags = args.tags;
|
|
5339
5383
|
const minConfidence = args.minConfidence;
|
|
5340
5384
|
if (userScopeFilter) {
|
|
5341
5385
|
entities = entities.filter((e) => e?.scope === userScopeFilter);
|
|
5342
5386
|
} else if (excludeSessionFromLongTerm) {
|
|
5343
5387
|
entities = entities.filter((e) => !isSessionScope(e?.scope));
|
|
5344
5388
|
}
|
|
5345
|
-
if (requestedTags && requestedTags.length > 0) {
|
|
5346
|
-
const wanted = new Set(requestedTags);
|
|
5347
|
-
entities = entities.filter((e) => (e?.tags ?? []).some((t) => wanted.has(t)));
|
|
5348
|
-
}
|
|
5349
5389
|
entities = filterByMinConfidence(entities, minConfidence);
|
|
5350
5390
|
if (!includeSuperseded) {
|
|
5351
5391
|
entities = entities.filter((e) => !e?.superseded_at);
|
package/dist/index.js
CHANGED
|
@@ -1452,6 +1452,12 @@ class HarmonyApiClient {
|
|
|
1452
1452
|
async getWorkspaceMembers(workspaceId) {
|
|
1453
1453
|
return this.request("GET", `/workspaces/${workspaceId}/members`);
|
|
1454
1454
|
}
|
|
1455
|
+
async listWorkspaceAgents(workspaceId) {
|
|
1456
|
+
return this.request("GET", `/workspaces/${workspaceId}/agents`);
|
|
1457
|
+
}
|
|
1458
|
+
async registerWorkspaceAgent(workspaceId, data) {
|
|
1459
|
+
return this.request("POST", `/workspaces/${workspaceId}/agents`, data);
|
|
1460
|
+
}
|
|
1455
1461
|
async listProjects(workspaceId) {
|
|
1456
1462
|
return this.request("GET", `/workspaces/${workspaceId}/projects`);
|
|
1457
1463
|
}
|
|
@@ -1716,6 +1722,10 @@ class HarmonyApiClient {
|
|
|
1716
1722
|
params.set("type", options.type);
|
|
1717
1723
|
if (options?.limit !== undefined)
|
|
1718
1724
|
params.set("limit", String(options.limit));
|
|
1725
|
+
for (const tag of options?.tags ?? [])
|
|
1726
|
+
params.append("tags", tag);
|
|
1727
|
+
if (options?.include_superseded)
|
|
1728
|
+
params.set("include_superseded", "true");
|
|
1719
1729
|
return this.request("GET", `/memory/search?${params.toString()}`);
|
|
1720
1730
|
}
|
|
1721
1731
|
async getVaultIndex(options) {
|
|
@@ -2039,43 +2049,56 @@ var AUTO_START_TRIGGERS = new Set([
|
|
|
2039
2049
|
]);
|
|
2040
2050
|
var INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
2041
2051
|
var CHECK_INTERVAL_MS = 60 * 1000;
|
|
2042
|
-
var
|
|
2052
|
+
var DEFAULT_SCOPE = "__default__";
|
|
2053
|
+
var scopes = new Map;
|
|
2043
2054
|
var inactivityTimer = null;
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2055
|
+
function getOrCreateScope(scopeId) {
|
|
2056
|
+
let scope = scopes.get(scopeId);
|
|
2057
|
+
if (!scope) {
|
|
2058
|
+
scope = {
|
|
2059
|
+
sessions: new Map,
|
|
2060
|
+
endCallback: null,
|
|
2061
|
+
clientGetter: null,
|
|
2062
|
+
clientInfoGetter: null
|
|
2063
|
+
};
|
|
2064
|
+
scopes.set(scopeId, scope);
|
|
2065
|
+
}
|
|
2066
|
+
return scope;
|
|
2067
|
+
}
|
|
2068
|
+
function initAutoSession(callback, getClient2, getClientInfo, scopeId = DEFAULT_SCOPE) {
|
|
2069
|
+
const scope = getOrCreateScope(scopeId);
|
|
2070
|
+
scope.endCallback = callback;
|
|
2071
|
+
scope.clientGetter = getClient2;
|
|
2072
|
+
scope.clientInfoGetter = getClientInfo ?? null;
|
|
2073
|
+
if (!inactivityTimer) {
|
|
2074
|
+
inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
|
|
2075
|
+
}
|
|
2054
2076
|
}
|
|
2055
2077
|
async function trackActivity(cardId, options) {
|
|
2078
|
+
const scope = getOrCreateScope(options?.scopeId ?? DEFAULT_SCOPE);
|
|
2056
2079
|
const now = Date.now();
|
|
2057
|
-
const existing =
|
|
2080
|
+
const existing = scope.sessions.get(cardId);
|
|
2058
2081
|
if (existing) {
|
|
2059
2082
|
existing.lastActivityAt = now;
|
|
2060
2083
|
return;
|
|
2061
2084
|
}
|
|
2062
2085
|
if (!options?.autoStart)
|
|
2063
2086
|
return;
|
|
2064
|
-
const client3 = options?.client ?? clientGetter?.();
|
|
2087
|
+
const client3 = options?.client ?? scope.clientGetter?.();
|
|
2065
2088
|
if (!client3)
|
|
2066
2089
|
return;
|
|
2067
|
-
const info = clientInfoGetter?.() ?? null;
|
|
2090
|
+
const info = options?.clientInfo ?? scope.clientInfoGetter?.() ?? null;
|
|
2068
2091
|
if (!info?.name)
|
|
2069
2092
|
return;
|
|
2070
2093
|
const { agentIdentifier, agentName } = resolveAgentIdentity(info);
|
|
2071
2094
|
const toEnd = [];
|
|
2072
|
-
for (const [otherCardId, session] of
|
|
2095
|
+
for (const [otherCardId, session] of scope.sessions) {
|
|
2073
2096
|
if (otherCardId !== cardId && !session.isExplicit) {
|
|
2074
2097
|
toEnd.push(otherCardId);
|
|
2075
2098
|
}
|
|
2076
2099
|
}
|
|
2077
2100
|
for (const otherCardId of toEnd) {
|
|
2078
|
-
await autoEndSession(client3, otherCardId, "completed");
|
|
2101
|
+
await autoEndSession(scope, client3, otherCardId, "completed");
|
|
2079
2102
|
}
|
|
2080
2103
|
try {
|
|
2081
2104
|
await client3.startAgentSession(cardId, {
|
|
@@ -2084,7 +2107,7 @@ async function trackActivity(cardId, options) {
|
|
|
2084
2107
|
status: "working"
|
|
2085
2108
|
});
|
|
2086
2109
|
} catch {}
|
|
2087
|
-
|
|
2110
|
+
scope.sessions.set(cardId, {
|
|
2088
2111
|
cardId,
|
|
2089
2112
|
startedAt: now,
|
|
2090
2113
|
lastActivityAt: now,
|
|
@@ -2094,7 +2117,8 @@ async function trackActivity(cardId, options) {
|
|
|
2094
2117
|
});
|
|
2095
2118
|
}
|
|
2096
2119
|
function markExplicit(cardId, options) {
|
|
2097
|
-
const
|
|
2120
|
+
const scope = getOrCreateScope(options?.scopeId ?? DEFAULT_SCOPE);
|
|
2121
|
+
const existing = scope.sessions.get(cardId);
|
|
2098
2122
|
if (existing) {
|
|
2099
2123
|
existing.isExplicit = true;
|
|
2100
2124
|
if (options?.agentIdentifier)
|
|
@@ -2102,7 +2126,7 @@ function markExplicit(cardId, options) {
|
|
|
2102
2126
|
if (options?.agentName)
|
|
2103
2127
|
existing.agentName = options.agentName;
|
|
2104
2128
|
} else {
|
|
2105
|
-
|
|
2129
|
+
scope.sessions.set(cardId, {
|
|
2106
2130
|
cardId,
|
|
2107
2131
|
startedAt: Date.now(),
|
|
2108
2132
|
lastActivityAt: Date.now(),
|
|
@@ -2112,15 +2136,23 @@ function markExplicit(cardId, options) {
|
|
|
2112
2136
|
});
|
|
2113
2137
|
}
|
|
2114
2138
|
}
|
|
2115
|
-
function untrack(cardId) {
|
|
2116
|
-
|
|
2139
|
+
function untrack(cardId, scopeId = DEFAULT_SCOPE) {
|
|
2140
|
+
scopes.get(scopeId)?.sessions.delete(cardId);
|
|
2117
2141
|
}
|
|
2118
|
-
async function shutdownAllSessions() {
|
|
2119
|
-
const
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2142
|
+
async function shutdownAllSessions(scopeId) {
|
|
2143
|
+
const targets = scopeId !== undefined ? scopes.has(scopeId) ? [scopeId] : [] : [...scopes.keys()];
|
|
2144
|
+
const promises = [];
|
|
2145
|
+
for (const sid of targets) {
|
|
2146
|
+
const scope = scopes.get(sid);
|
|
2147
|
+
if (!scope)
|
|
2148
|
+
continue;
|
|
2149
|
+
const client3 = scope.clientGetter?.();
|
|
2150
|
+
if (!client3)
|
|
2151
|
+
continue;
|
|
2152
|
+
for (const cardId of [...scope.sessions.keys()]) {
|
|
2153
|
+
promises.push(autoEndSession(scope, client3, cardId, "paused"));
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2124
2156
|
await Promise.allSettled(promises);
|
|
2125
2157
|
}
|
|
2126
2158
|
function destroyAutoSession() {
|
|
@@ -2128,32 +2160,32 @@ function destroyAutoSession() {
|
|
|
2128
2160
|
clearInterval(inactivityTimer);
|
|
2129
2161
|
inactivityTimer = null;
|
|
2130
2162
|
}
|
|
2131
|
-
|
|
2132
|
-
endCallback = null;
|
|
2133
|
-
clientGetter = null;
|
|
2134
|
-
clientInfoGetter = null;
|
|
2163
|
+
scopes.clear();
|
|
2135
2164
|
}
|
|
2136
2165
|
function checkInactivity() {
|
|
2137
2166
|
const now = Date.now();
|
|
2138
|
-
const
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
const entries = [...activeSessions.entries()];
|
|
2142
|
-
for (const [cardId, session] of entries) {
|
|
2143
|
-
if (session.isExplicit)
|
|
2167
|
+
for (const scope of scopes.values()) {
|
|
2168
|
+
const client3 = scope.clientGetter?.();
|
|
2169
|
+
if (!client3)
|
|
2144
2170
|
continue;
|
|
2145
|
-
|
|
2146
|
-
|
|
2171
|
+
const entries = [...scope.sessions.entries()];
|
|
2172
|
+
for (const [cardId, session] of entries) {
|
|
2173
|
+
if (session.isExplicit)
|
|
2174
|
+
continue;
|
|
2175
|
+
if (now - session.lastActivityAt > INACTIVITY_TIMEOUT_MS) {
|
|
2176
|
+
autoEndSession(scope, client3, cardId, "completed").catch(() => {});
|
|
2177
|
+
}
|
|
2147
2178
|
}
|
|
2148
2179
|
}
|
|
2149
2180
|
}
|
|
2150
|
-
async function autoEndSession(client3, cardId, status) {
|
|
2151
|
-
|
|
2181
|
+
async function autoEndSession(scope, client3, cardId, status) {
|
|
2182
|
+
if (!scope.sessions.delete(cardId))
|
|
2183
|
+
return;
|
|
2152
2184
|
try {
|
|
2153
2185
|
await client3.endAgentSession(cardId, { status });
|
|
2154
2186
|
} catch {}
|
|
2155
2187
|
try {
|
|
2156
|
-
await endCallback?.(client3, cardId, status);
|
|
2188
|
+
await scope.endCallback?.(client3, cardId, status);
|
|
2157
2189
|
} catch {}
|
|
2158
2190
|
}
|
|
2159
2191
|
|
|
@@ -4557,9 +4589,12 @@ function registerHandlers(server, deps) {
|
|
|
4557
4589
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4558
4590
|
if (cardIdArg && UUID_RE.test(cardIdArg) && deps.isConfigured()) {
|
|
4559
4591
|
const isAutoStartTrigger = AUTO_START_TRIGGERS.has(name);
|
|
4592
|
+
const cv = server.getClientVersion?.();
|
|
4560
4593
|
trackActivity(cardIdArg, {
|
|
4561
4594
|
autoStart: isAutoStartTrigger,
|
|
4562
|
-
client: deps.getClient()
|
|
4595
|
+
client: deps.getClient(),
|
|
4596
|
+
clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
|
|
4597
|
+
scopeId: deps.getScopeId?.()
|
|
4563
4598
|
}).catch(() => {});
|
|
4564
4599
|
}
|
|
4565
4600
|
try {
|
|
@@ -4569,9 +4604,12 @@ function registerHandlers(server, deps) {
|
|
|
4569
4604
|
const parsed = typeof result === "object" && result !== null ? result : {};
|
|
4570
4605
|
const resolvedCardId = parsed.cardId;
|
|
4571
4606
|
if (resolvedCardId && UUID_RE.test(resolvedCardId)) {
|
|
4607
|
+
const cv = server.getClientVersion?.();
|
|
4572
4608
|
trackActivity(resolvedCardId, {
|
|
4573
4609
|
autoStart: true,
|
|
4574
|
-
client: deps.getClient()
|
|
4610
|
+
client: deps.getClient(),
|
|
4611
|
+
clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
|
|
4612
|
+
scopeId: deps.getScopeId?.()
|
|
4575
4613
|
}).catch(() => {});
|
|
4576
4614
|
}
|
|
4577
4615
|
} catch {}
|
|
@@ -4728,7 +4766,7 @@ async function handleToolCall(name, args, deps) {
|
|
|
4728
4766
|
const { session } = await client3.getAgentSession(cardId);
|
|
4729
4767
|
if (session) {
|
|
4730
4768
|
await client3.endAgentSession(cardId, { status: "completed" });
|
|
4731
|
-
untrack(cardId);
|
|
4769
|
+
untrack(cardId, deps.getScopeId?.());
|
|
4732
4770
|
sessionEnded = true;
|
|
4733
4771
|
}
|
|
4734
4772
|
}
|
|
@@ -5086,7 +5124,11 @@ async function handleToolCall(name, args, deps) {
|
|
|
5086
5124
|
currentTask: args.currentTask,
|
|
5087
5125
|
estimatedMinutesRemaining: args.estimatedMinutesRemaining
|
|
5088
5126
|
});
|
|
5089
|
-
markExplicit(cardId, {
|
|
5127
|
+
markExplicit(cardId, {
|
|
5128
|
+
agentIdentifier,
|
|
5129
|
+
agentName,
|
|
5130
|
+
scopeId: deps.getScopeId?.()
|
|
5131
|
+
});
|
|
5090
5132
|
const agentSessionId = result.session?.id;
|
|
5091
5133
|
initMemorySession(cardId, agentIdentifier, agentName, agentSessionId);
|
|
5092
5134
|
return {
|
|
@@ -5148,7 +5190,7 @@ async function handleToolCall(name, args, deps) {
|
|
|
5148
5190
|
} catch (err) {
|
|
5149
5191
|
sessionEndError = err instanceof Error ? err.message : "Failed to end session";
|
|
5150
5192
|
}
|
|
5151
|
-
untrack(cardId);
|
|
5193
|
+
untrack(cardId, deps.getScopeId?.());
|
|
5152
5194
|
let movedTo = null;
|
|
5153
5195
|
try {
|
|
5154
5196
|
const { card } = await client3.getCard(cardId);
|
|
@@ -5325,23 +5367,21 @@ async function handleToolCall(name, args, deps) {
|
|
|
5325
5367
|
let entities;
|
|
5326
5368
|
let relevanceMap;
|
|
5327
5369
|
if (queryText) {
|
|
5370
|
+
const requestedTags = args.tags;
|
|
5328
5371
|
const searchResult = await client3.searchMemoryEntities(workspaceId, queryText, {
|
|
5329
5372
|
project_id: projectId,
|
|
5330
5373
|
type: args.type,
|
|
5331
|
-
limit: fetchLimit
|
|
5374
|
+
limit: fetchLimit,
|
|
5375
|
+
tags: requestedTags && requestedTags.length > 0 ? requestedTags : undefined,
|
|
5376
|
+
include_superseded: includeSuperseded
|
|
5332
5377
|
});
|
|
5333
5378
|
entities = searchResult.entities ?? [];
|
|
5334
|
-
const requestedTags = args.tags;
|
|
5335
5379
|
const minConfidence = args.minConfidence;
|
|
5336
5380
|
if (userScopeFilter) {
|
|
5337
5381
|
entities = entities.filter((e) => e?.scope === userScopeFilter);
|
|
5338
5382
|
} else if (excludeSessionFromLongTerm) {
|
|
5339
5383
|
entities = entities.filter((e) => !isSessionScope(e?.scope));
|
|
5340
5384
|
}
|
|
5341
|
-
if (requestedTags && requestedTags.length > 0) {
|
|
5342
|
-
const wanted = new Set(requestedTags);
|
|
5343
|
-
entities = entities.filter((e) => (e?.tags ?? []).some((t) => wanted.has(t)));
|
|
5344
|
-
}
|
|
5345
5385
|
entities = filterByMinConfidence(entities, minConfidence);
|
|
5346
5386
|
if (!includeSuperseded) {
|
|
5347
5387
|
entities = entities.filter((e) => !e?.superseded_at);
|
package/dist/lib/api-client.js
CHANGED
|
@@ -1059,6 +1059,12 @@ class HarmonyApiClient {
|
|
|
1059
1059
|
async getWorkspaceMembers(workspaceId) {
|
|
1060
1060
|
return this.request("GET", `/workspaces/${workspaceId}/members`);
|
|
1061
1061
|
}
|
|
1062
|
+
async listWorkspaceAgents(workspaceId) {
|
|
1063
|
+
return this.request("GET", `/workspaces/${workspaceId}/agents`);
|
|
1064
|
+
}
|
|
1065
|
+
async registerWorkspaceAgent(workspaceId, data) {
|
|
1066
|
+
return this.request("POST", `/workspaces/${workspaceId}/agents`, data);
|
|
1067
|
+
}
|
|
1062
1068
|
async listProjects(workspaceId) {
|
|
1063
1069
|
return this.request("GET", `/workspaces/${workspaceId}/projects`);
|
|
1064
1070
|
}
|
|
@@ -1323,6 +1329,10 @@ class HarmonyApiClient {
|
|
|
1323
1329
|
params.set("type", options.type);
|
|
1324
1330
|
if (options?.limit !== undefined)
|
|
1325
1331
|
params.set("limit", String(options.limit));
|
|
1332
|
+
for (const tag of options?.tags ?? [])
|
|
1333
|
+
params.append("tags", tag);
|
|
1334
|
+
if (options?.include_superseded)
|
|
1335
|
+
params.set("include_superseded", "true");
|
|
1326
1336
|
return this.request("GET", `/memory/search?${params.toString()}`);
|
|
1327
1337
|
}
|
|
1328
1338
|
async getVaultIndex(options) {
|
package/package.json
CHANGED
package/src/api-client.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
type Comment,
|
|
3
3
|
getDisplayLinkType,
|
|
4
4
|
serializeCommentThread,
|
|
5
|
+
type WorkspaceAgent,
|
|
5
6
|
} from "@harmony/shared";
|
|
6
7
|
import { getApiKey, getApiUrl } from "./config.js";
|
|
7
8
|
|
|
@@ -396,6 +397,20 @@ export class HarmonyApiClient {
|
|
|
396
397
|
return this.request("GET", `/workspaces/${workspaceId}/members`);
|
|
397
398
|
}
|
|
398
399
|
|
|
400
|
+
async listWorkspaceAgents(
|
|
401
|
+
workspaceId: string,
|
|
402
|
+
): Promise<{ agents: WorkspaceAgent[] }> {
|
|
403
|
+
return this.request("GET", `/workspaces/${workspaceId}/agents`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Register/upsert this daemon's virtual agent. Idempotent by (workspace, identifier). */
|
|
407
|
+
async registerWorkspaceAgent(
|
|
408
|
+
workspaceId: string,
|
|
409
|
+
data: { identifier: string; name: string; color?: string },
|
|
410
|
+
): Promise<{ agent: WorkspaceAgent }> {
|
|
411
|
+
return this.request("POST", `/workspaces/${workspaceId}/agents`, data);
|
|
412
|
+
}
|
|
413
|
+
|
|
399
414
|
// ============ PROJECT OPERATIONS ============
|
|
400
415
|
|
|
401
416
|
async listProjects(workspaceId: string): Promise<{ projects: unknown[] }> {
|
|
@@ -676,6 +691,7 @@ export class HarmonyApiClient {
|
|
|
676
691
|
data: {
|
|
677
692
|
agentIdentifier: string;
|
|
678
693
|
agentName: string;
|
|
694
|
+
agentId?: string | null;
|
|
679
695
|
status?: "working" | "blocked" | "paused" | "completed";
|
|
680
696
|
progressPercent?: number;
|
|
681
697
|
currentTask?: string;
|
|
@@ -716,6 +732,8 @@ export class HarmonyApiClient {
|
|
|
716
732
|
| "review"
|
|
717
733
|
| "daemon_restart"
|
|
718
734
|
| "budget"
|
|
735
|
+
| "timeout"
|
|
736
|
+
| "stale"
|
|
719
737
|
| "other";
|
|
720
738
|
failureSummary?: string;
|
|
721
739
|
recoveryBranch?: string;
|
|
@@ -742,6 +760,8 @@ export class HarmonyApiClient {
|
|
|
742
760
|
| "review"
|
|
743
761
|
| "daemon_restart"
|
|
744
762
|
| "budget"
|
|
763
|
+
| "timeout"
|
|
764
|
+
| "stale"
|
|
745
765
|
| "other";
|
|
746
766
|
failureSummary?: string;
|
|
747
767
|
recoveryBranch?: string;
|
|
@@ -1003,6 +1023,8 @@ export class HarmonyApiClient {
|
|
|
1003
1023
|
project_id?: string;
|
|
1004
1024
|
type?: string;
|
|
1005
1025
|
limit?: number;
|
|
1026
|
+
tags?: string[];
|
|
1027
|
+
include_superseded?: boolean;
|
|
1006
1028
|
},
|
|
1007
1029
|
): Promise<{ entities: unknown[]; count: number }> {
|
|
1008
1030
|
const params = new URLSearchParams();
|
|
@@ -1012,6 +1034,10 @@ export class HarmonyApiClient {
|
|
|
1012
1034
|
if (options?.type) params.set("type", options.type);
|
|
1013
1035
|
if (options?.limit !== undefined)
|
|
1014
1036
|
params.set("limit", String(options.limit));
|
|
1037
|
+
// Repeated `tags` params — the search endpoint reads them via getAll and
|
|
1038
|
+
// matches against the canonical `tags_normalized` column (#299).
|
|
1039
|
+
for (const tag of options?.tags ?? []) params.append("tags", tag);
|
|
1040
|
+
if (options?.include_superseded) params.set("include_superseded", "true");
|
|
1015
1041
|
return this.request("GET", `/memory/search?${params.toString()}`);
|
|
1016
1042
|
}
|
|
1017
1043
|
|
package/src/auto-session.ts
CHANGED
|
@@ -8,6 +8,19 @@
|
|
|
8
8
|
* Agent identity is resolved from the MCP client's `initialize` handshake
|
|
9
9
|
* (clientInfo.name), so "Claude Code", "Cursor", "Codex", etc. are
|
|
10
10
|
* detected automatically — no hardcoded fallback needed.
|
|
11
|
+
*
|
|
12
|
+
* ## Multi-tenant scoping
|
|
13
|
+
*
|
|
14
|
+
* Bookkeeping is partitioned into per-scope state (`ScopeState`), keyed by a
|
|
15
|
+
* caller-supplied `scopeId`. The hosted/remote transport is multi-tenant — two
|
|
16
|
+
* different users acting on the *same card* must NOT collide on one shared
|
|
17
|
+
* entry. Remote scopes by `userId`; stdio (single user) uses `DEFAULT_SCOPE`.
|
|
18
|
+
*
|
|
19
|
+
* Each scope owns its own session map, end-of-session callback, API client
|
|
20
|
+
* getter, and client-identity getter, so per-connection `initAutoSession` calls
|
|
21
|
+
* no longer fight over a single global getter (the enabler for wiring the
|
|
22
|
+
* inactivity sweep + end pipeline on remote). A single process-wide timer
|
|
23
|
+
* sweeps every scope, ending idle sessions with that scope's own client.
|
|
11
24
|
*/
|
|
12
25
|
|
|
13
26
|
import type { HarmonyApiClient } from "./api-client.js";
|
|
@@ -88,29 +101,80 @@ export const AUTO_START_TRIGGERS = new Set([
|
|
|
88
101
|
export const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
89
102
|
const CHECK_INTERVAL_MS = 60 * 1000; // 60 seconds
|
|
90
103
|
|
|
91
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Scope used when no `scopeId` is supplied. stdio is single-user, so it shares
|
|
106
|
+
* one scope; the unit tests also exercise this default scope exclusively.
|
|
107
|
+
*/
|
|
108
|
+
const DEFAULT_SCOPE = "__default__";
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Per-tenant auto-session state. One per `scopeId` (e.g. one per remote user,
|
|
112
|
+
* or the single shared `DEFAULT_SCOPE` on stdio). Keying `sessions` by cardId
|
|
113
|
+
* *within* a scope means two users on the same card no longer collide.
|
|
114
|
+
*/
|
|
115
|
+
interface ScopeState {
|
|
116
|
+
/** cardId → tracked session */
|
|
117
|
+
sessions: Map<string, TrackedSession>;
|
|
118
|
+
endCallback: EndSessionCallback | null;
|
|
119
|
+
clientGetter: (() => HarmonyApiClient) | null;
|
|
120
|
+
clientInfoGetter: (() => ClientInfo | null) | null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** scopeId → per-tenant state */
|
|
124
|
+
const scopes = new Map<string, ScopeState>();
|
|
125
|
+
/**
|
|
126
|
+
* Single process-wide inactivity sweep. Started once; on each tick it walks
|
|
127
|
+
* every scope and ends that scope's idle sessions with the scope's own client.
|
|
128
|
+
* Keeping it global (rather than one timer per scope) is what lets the remote
|
|
129
|
+
* transport register many per-connection scopes without spawning a timer each.
|
|
130
|
+
*/
|
|
92
131
|
let inactivityTimer: ReturnType<typeof setInterval> | null = null;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
let
|
|
132
|
+
|
|
133
|
+
function getOrCreateScope(scopeId: string): ScopeState {
|
|
134
|
+
let scope = scopes.get(scopeId);
|
|
135
|
+
if (!scope) {
|
|
136
|
+
scope = {
|
|
137
|
+
sessions: new Map(),
|
|
138
|
+
endCallback: null,
|
|
139
|
+
clientGetter: null,
|
|
140
|
+
clientInfoGetter: null,
|
|
141
|
+
};
|
|
142
|
+
scopes.set(scopeId, scope);
|
|
143
|
+
}
|
|
144
|
+
return scope;
|
|
145
|
+
}
|
|
96
146
|
|
|
97
147
|
/**
|
|
98
|
-
* Initialize auto-session tracking.
|
|
148
|
+
* Initialize auto-session tracking for a scope.
|
|
149
|
+
*
|
|
150
|
+
* Safe to call once per scope (stdio: once for `DEFAULT_SCOPE`; remote: once per
|
|
151
|
+
* connection, keyed by the connection's userId). Re-calling a scope just updates
|
|
152
|
+
* its callbacks/getters — the latest connection's client wins, which is fine
|
|
153
|
+
* because all of a user's connections share equivalent API access. The shared
|
|
154
|
+
* inactivity timer is started on first init and left running.
|
|
155
|
+
*
|
|
99
156
|
* @param callback Called when an auto-session ends (runs the learning pipeline)
|
|
100
|
-
* @param getClient Function to get the current API client
|
|
157
|
+
* @param getClient Function to get the current API client for this scope
|
|
101
158
|
* @param getClientInfo Function to get MCP client identity from the initialize handshake
|
|
159
|
+
* @param scopeId Tenant scope (default: the shared single-user scope)
|
|
102
160
|
*/
|
|
103
161
|
export function initAutoSession(
|
|
104
162
|
callback: EndSessionCallback,
|
|
105
163
|
getClient: () => HarmonyApiClient,
|
|
106
164
|
getClientInfo?: () => ClientInfo | null,
|
|
165
|
+
scopeId: string = DEFAULT_SCOPE,
|
|
107
166
|
): void {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
167
|
+
const scope = getOrCreateScope(scopeId);
|
|
168
|
+
scope.endCallback = callback;
|
|
169
|
+
scope.clientGetter = getClient;
|
|
170
|
+
scope.clientInfoGetter = getClientInfo ?? null;
|
|
111
171
|
|
|
112
|
-
|
|
113
|
-
|
|
172
|
+
// Start the shared sweep once. Don't clear/restart on every (re-)init — on
|
|
173
|
+
// the remote transport, connections arrive faster than the 60s interval and a
|
|
174
|
+
// restart-each-time would keep deferring the sweep indefinitely (starvation).
|
|
175
|
+
if (!inactivityTimer) {
|
|
176
|
+
inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
|
|
177
|
+
}
|
|
114
178
|
}
|
|
115
179
|
|
|
116
180
|
/**
|
|
@@ -121,10 +185,23 @@ export async function trackActivity(
|
|
|
121
185
|
options?: {
|
|
122
186
|
autoStart?: boolean;
|
|
123
187
|
client?: HarmonyApiClient;
|
|
188
|
+
/**
|
|
189
|
+
* Per-request MCP client identity. Takes precedence over the scope's
|
|
190
|
+
* clientInfoGetter. The remote/HTTP transport resolves identity from the
|
|
191
|
+
* in-scope Server.getClientVersion() and passes it here, so auto-sessions
|
|
192
|
+
* attribute correctly on every transport (card #297).
|
|
193
|
+
*/
|
|
194
|
+
clientInfo?: ClientInfo;
|
|
195
|
+
/**
|
|
196
|
+
* Tenant scope. Remote passes the connection's userId so two users on the
|
|
197
|
+
* same card don't collide; stdio/tests omit it and share `DEFAULT_SCOPE`.
|
|
198
|
+
*/
|
|
199
|
+
scopeId?: string;
|
|
124
200
|
},
|
|
125
201
|
): Promise<void> {
|
|
202
|
+
const scope = getOrCreateScope(options?.scopeId ?? DEFAULT_SCOPE);
|
|
126
203
|
const now = Date.now();
|
|
127
|
-
const existing =
|
|
204
|
+
const existing = scope.sessions.get(cardId);
|
|
128
205
|
|
|
129
206
|
if (existing) {
|
|
130
207
|
// Update last activity timestamp
|
|
@@ -135,7 +212,7 @@ export async function trackActivity(
|
|
|
135
212
|
// Only auto-start if the tool is a trigger
|
|
136
213
|
if (!options?.autoStart) return;
|
|
137
214
|
|
|
138
|
-
const client = options?.client ?? clientGetter?.();
|
|
215
|
+
const client = options?.client ?? scope.clientGetter?.();
|
|
139
216
|
if (!client) return;
|
|
140
217
|
|
|
141
218
|
// Resolve agent identity from the MCP `initialize` handshake. Never auto-start
|
|
@@ -143,19 +220,22 @@ export async function trackActivity(
|
|
|
143
220
|
// phantom "Unknown Agent" session (card #295). Identified clients only — this
|
|
144
221
|
// bail happens BEFORE ending other sessions so an unidentified call can't tear
|
|
145
222
|
// down a legitimate tracked session.
|
|
146
|
-
const info = clientInfoGetter?.() ?? null;
|
|
223
|
+
const info = options?.clientInfo ?? scope.clientInfoGetter?.() ?? null;
|
|
147
224
|
if (!info?.name) return;
|
|
148
225
|
const { agentIdentifier, agentName } = resolveAgentIdentity(info);
|
|
149
226
|
|
|
150
|
-
// Collect auto-sessions on other cards to end (
|
|
227
|
+
// Collect this scope's auto-sessions on other cards to end (a card switch
|
|
228
|
+
// within one tenant ends the prior auto-session). Only this scope's sessions
|
|
229
|
+
// are swept — another user's work on a different card is untouched. Snapshot
|
|
230
|
+
// first to avoid mutating the map during iteration.
|
|
151
231
|
const toEnd: string[] = [];
|
|
152
|
-
for (const [otherCardId, session] of
|
|
232
|
+
for (const [otherCardId, session] of scope.sessions) {
|
|
153
233
|
if (otherCardId !== cardId && !session.isExplicit) {
|
|
154
234
|
toEnd.push(otherCardId);
|
|
155
235
|
}
|
|
156
236
|
}
|
|
157
237
|
for (const otherCardId of toEnd) {
|
|
158
|
-
await autoEndSession(client, otherCardId, "completed");
|
|
238
|
+
await autoEndSession(scope, client, otherCardId, "completed");
|
|
159
239
|
}
|
|
160
240
|
|
|
161
241
|
// Start a new auto-session
|
|
@@ -169,7 +249,7 @@ export async function trackActivity(
|
|
|
169
249
|
// Session start failed (might already have one), still track locally
|
|
170
250
|
}
|
|
171
251
|
|
|
172
|
-
|
|
252
|
+
scope.sessions.set(cardId, {
|
|
173
253
|
cardId,
|
|
174
254
|
startedAt: now,
|
|
175
255
|
lastActivityAt: now,
|
|
@@ -185,9 +265,10 @@ export async function trackActivity(
|
|
|
185
265
|
*/
|
|
186
266
|
export function markExplicit(
|
|
187
267
|
cardId: string,
|
|
188
|
-
options?: { agentIdentifier?: string; agentName?: string },
|
|
268
|
+
options?: { agentIdentifier?: string; agentName?: string; scopeId?: string },
|
|
189
269
|
): void {
|
|
190
|
-
const
|
|
270
|
+
const scope = getOrCreateScope(options?.scopeId ?? DEFAULT_SCOPE);
|
|
271
|
+
const existing = scope.sessions.get(cardId);
|
|
191
272
|
if (existing) {
|
|
192
273
|
existing.isExplicit = true;
|
|
193
274
|
if (options?.agentIdentifier)
|
|
@@ -195,7 +276,7 @@ export function markExplicit(
|
|
|
195
276
|
if (options?.agentName) existing.agentName = options.agentName;
|
|
196
277
|
} else {
|
|
197
278
|
// Track the explicit session even if we didn't auto-start it
|
|
198
|
-
|
|
279
|
+
scope.sessions.set(cardId, {
|
|
199
280
|
cardId,
|
|
200
281
|
startedAt: Date.now(),
|
|
201
282
|
lastActivityAt: Date.now(),
|
|
@@ -209,61 +290,88 @@ export function markExplicit(
|
|
|
209
290
|
/**
|
|
210
291
|
* Remove a session from tracking (called when session is explicitly ended).
|
|
211
292
|
*/
|
|
212
|
-
export function untrack(cardId: string): void {
|
|
213
|
-
|
|
293
|
+
export function untrack(cardId: string, scopeId: string = DEFAULT_SCOPE): void {
|
|
294
|
+
scopes.get(scopeId)?.sessions.delete(cardId);
|
|
214
295
|
}
|
|
215
296
|
|
|
216
297
|
/**
|
|
217
|
-
* End
|
|
298
|
+
* End active auto-sessions (called on process shutdown, or on a single tenant's
|
|
299
|
+
* disconnect). With a `scopeId`, only that scope's sessions are ended; without
|
|
300
|
+
* one, every scope is drained.
|
|
218
301
|
*/
|
|
219
|
-
export async function shutdownAllSessions(): Promise<void> {
|
|
220
|
-
const
|
|
221
|
-
|
|
302
|
+
export async function shutdownAllSessions(scopeId?: string): Promise<void> {
|
|
303
|
+
const targets =
|
|
304
|
+
scopeId !== undefined
|
|
305
|
+
? scopes.has(scopeId)
|
|
306
|
+
? [scopeId]
|
|
307
|
+
: []
|
|
308
|
+
: [...scopes.keys()];
|
|
222
309
|
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
310
|
+
const promises: Promise<void>[] = [];
|
|
311
|
+
for (const sid of targets) {
|
|
312
|
+
const scope = scopes.get(sid);
|
|
313
|
+
if (!scope) continue;
|
|
314
|
+
const client = scope.clientGetter?.();
|
|
315
|
+
if (!client) continue;
|
|
316
|
+
// Snapshot keys to avoid mutating map during iteration
|
|
317
|
+
for (const cardId of [...scope.sessions.keys()]) {
|
|
318
|
+
promises.push(autoEndSession(scope, client, cardId, "paused"));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
228
321
|
await Promise.allSettled(promises);
|
|
229
322
|
}
|
|
230
323
|
|
|
231
324
|
/**
|
|
232
|
-
*
|
|
325
|
+
* Drop a tenant scope entirely (remote: called when a user's last connection
|
|
326
|
+
* closes). Bounds the `scopes` map on a long-lived multi-tenant server so it
|
|
327
|
+
* doesn't retain one stale per-connection client/deps closure per distinct user
|
|
328
|
+
* forever. The shared sweep timer then only walks live scopes. End the scope's
|
|
329
|
+
* sessions FIRST (via shutdownAllSessions(scopeId)) so nothing dangles. Never
|
|
330
|
+
* drops DEFAULT_SCOPE — stdio's single scope lives for the whole process.
|
|
331
|
+
*/
|
|
332
|
+
export function dropScope(scopeId: string): void {
|
|
333
|
+
if (scopeId !== DEFAULT_SCOPE) scopes.delete(scopeId);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Clean up the interval timer and all scope state (for tests / full shutdown).
|
|
233
338
|
*/
|
|
234
339
|
export function destroyAutoSession(): void {
|
|
235
340
|
if (inactivityTimer) {
|
|
236
341
|
clearInterval(inactivityTimer);
|
|
237
342
|
inactivityTimer = null;
|
|
238
343
|
}
|
|
239
|
-
|
|
240
|
-
endCallback = null;
|
|
241
|
-
clientGetter = null;
|
|
242
|
-
clientInfoGetter = null;
|
|
344
|
+
scopes.clear();
|
|
243
345
|
}
|
|
244
346
|
|
|
245
347
|
/**
|
|
246
|
-
* Get a snapshot of active sessions (for testing/debugging).
|
|
348
|
+
* Get a snapshot of a scope's active sessions (for testing/debugging).
|
|
349
|
+
* Defaults to the shared single-user scope.
|
|
247
350
|
*/
|
|
248
|
-
export function getActiveSessions(
|
|
249
|
-
|
|
351
|
+
export function getActiveSessions(
|
|
352
|
+
scopeId: string = DEFAULT_SCOPE,
|
|
353
|
+
): Map<string, TrackedSession> {
|
|
354
|
+
return getOrCreateScope(scopeId).sessions;
|
|
250
355
|
}
|
|
251
356
|
|
|
252
357
|
/**
|
|
253
358
|
* Run inactivity check immediately (exported for testing).
|
|
254
|
-
* In production, called by the setInterval timer every 60s.
|
|
359
|
+
* In production, called by the setInterval timer every 60s. Sweeps every scope
|
|
360
|
+
* so the remote transport's per-user sessions auto-end just like stdio's.
|
|
255
361
|
*/
|
|
256
362
|
export function checkInactivity(): void {
|
|
257
363
|
const now = Date.now();
|
|
258
|
-
const
|
|
259
|
-
|
|
364
|
+
for (const scope of scopes.values()) {
|
|
365
|
+
const client = scope.clientGetter?.();
|
|
366
|
+
if (!client) continue;
|
|
260
367
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
368
|
+
// Snapshot entries to avoid mutating map during iteration
|
|
369
|
+
const entries = [...scope.sessions.entries()];
|
|
370
|
+
for (const [cardId, session] of entries) {
|
|
371
|
+
if (session.isExplicit) continue;
|
|
372
|
+
if (now - session.lastActivityAt > INACTIVITY_TIMEOUT_MS) {
|
|
373
|
+
autoEndSession(scope, client, cardId, "completed").catch(() => {});
|
|
374
|
+
}
|
|
267
375
|
}
|
|
268
376
|
}
|
|
269
377
|
}
|
|
@@ -271,18 +379,23 @@ export function checkInactivity(): void {
|
|
|
271
379
|
// --- Internal ---
|
|
272
380
|
|
|
273
381
|
async function autoEndSession(
|
|
382
|
+
scope: ScopeState,
|
|
274
383
|
client: HarmonyApiClient,
|
|
275
384
|
cardId: string,
|
|
276
385
|
status: "completed" | "paused",
|
|
277
386
|
): Promise<void> {
|
|
278
|
-
|
|
387
|
+
// Delete-and-claim: Map.delete returns false if the entry is already gone.
|
|
388
|
+
// The card-switch sweep and the inactivity timer can both target the same
|
|
389
|
+
// cardId concurrently; whichever claims it first runs the end + pipeline,
|
|
390
|
+
// the loser bails so endAgentSession / runEndSessionPipeline fire exactly once.
|
|
391
|
+
if (!scope.sessions.delete(cardId)) return;
|
|
279
392
|
try {
|
|
280
393
|
await client.endAgentSession(cardId, { status });
|
|
281
394
|
} catch {
|
|
282
395
|
// Best-effort end
|
|
283
396
|
}
|
|
284
397
|
try {
|
|
285
|
-
await endCallback?.(client, cardId, status);
|
|
398
|
+
await scope.endCallback?.(client, cardId, status);
|
|
286
399
|
} catch {
|
|
287
400
|
// Best-effort pipeline
|
|
288
401
|
}
|
package/src/graph-expansion.ts
CHANGED
|
@@ -264,13 +264,11 @@ export async function findSupersedeCandidates(
|
|
|
264
264
|
if (options?.scope && (e as { scope?: string }).scope !== undefined) {
|
|
265
265
|
if ((e as { scope?: string }).scope !== options.scope) return false;
|
|
266
266
|
}
|
|
267
|
-
// Skip already-superseded rows
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
// to supersede. A complete fix needs the RPC to return/filter the column
|
|
273
|
-
// (migration + deploy); tracked in docs/memory.md.
|
|
267
|
+
// Skip already-superseded rows. The hybrid-search RPC now both returns
|
|
268
|
+
// `superseded_at` and excludes tombstoned rows by default (#298), so
|
|
269
|
+
// retired rows no longer surface as candidates on the embedding path.
|
|
270
|
+
// Kept as belt-and-suspenders for the FTS fallback and any caller that
|
|
271
|
+
// opts into include_superseded.
|
|
274
272
|
if ((e as { superseded_at?: string | null }).superseded_at) {
|
|
275
273
|
return false;
|
|
276
274
|
}
|
package/src/remote.ts
CHANGED
|
@@ -21,7 +21,16 @@ import { serve } from "bun";
|
|
|
21
21
|
import { Hono } from "hono";
|
|
22
22
|
import { cors } from "hono/cors";
|
|
23
23
|
import { HarmonyApiClient } from "./api-client.js";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
dropScope,
|
|
26
|
+
initAutoSession,
|
|
27
|
+
shutdownAllSessions,
|
|
28
|
+
} from "./auto-session.js";
|
|
29
|
+
import {
|
|
30
|
+
registerHandlers,
|
|
31
|
+
runEndSessionPipeline,
|
|
32
|
+
type ToolDeps,
|
|
33
|
+
} from "./server.js";
|
|
25
34
|
|
|
26
35
|
// ---------------------------------------------------------------------------
|
|
27
36
|
// Config from env
|
|
@@ -258,16 +267,52 @@ function createSession(apiKey: string, keyInfo: TokenInfo): McpSession {
|
|
|
258
267
|
getUserEmail: () => null,
|
|
259
268
|
saveConfig: () => {}, // No-op in remote mode
|
|
260
269
|
resetClient: () => {}, // No-op in remote mode
|
|
270
|
+
// Partition auto-session bookkeeping by user. The hosted server is
|
|
271
|
+
// multi-tenant: keying by userId stops two users on the same card from
|
|
272
|
+
// colliding on one shared auto-session entry (card #301, Gap 2).
|
|
273
|
+
getScopeId: () => keyInfo.userId,
|
|
261
274
|
};
|
|
262
275
|
|
|
263
276
|
registerHandlers(server, deps);
|
|
264
277
|
|
|
278
|
+
// Wire the inactivity sweep + end-of-session pipeline for this user's scope.
|
|
279
|
+
// The remote transport historically skipped initAutoSession entirely, so
|
|
280
|
+
// hosted auto-sessions never auto-ended on inactivity and the end pipeline
|
|
281
|
+
// never fired — they dangled "working" indefinitely (card #301, Gap 1).
|
|
282
|
+
// Now each connection registers its userId scope; the shared process timer
|
|
283
|
+
// sweeps it like stdio. Identity is resolved per-request via options.clientInfo
|
|
284
|
+
// in the pre-hook (card #297), so no clientInfoGetter is supplied here. Re-init
|
|
285
|
+
// by a second connection of the same user just refreshes the scope's client —
|
|
286
|
+
// both are equivalent, and per-scope state means it can't clobber other users.
|
|
287
|
+
initAutoSession(
|
|
288
|
+
async (endClient, cardId, status) => {
|
|
289
|
+
await runEndSessionPipeline(endClient, deps, cardId, status);
|
|
290
|
+
},
|
|
291
|
+
() => client,
|
|
292
|
+
undefined,
|
|
293
|
+
keyInfo.userId,
|
|
294
|
+
);
|
|
295
|
+
|
|
265
296
|
// Single cleanup path: fires on explicit DELETE, our evictSession,
|
|
266
297
|
// and the stale-session GC. Keeping onsessioninitialized + this onclose
|
|
267
298
|
// (instead of also wiring onsessionclosed) avoids double-logging on DELETE.
|
|
268
299
|
transport.onclose = () => {
|
|
269
300
|
if (transport.sessionId) {
|
|
270
301
|
sessions.delete(transport.sessionId);
|
|
302
|
+
// Reap this user's auto-session scope once their LAST live connection is
|
|
303
|
+
// gone — otherwise `scopes` grows unbounded (one stale client/deps closure
|
|
304
|
+
// per distinct user) on a long-lived multi-tenant process. A user with
|
|
305
|
+
// another open connection keeps the scope (sessions.delete already ran, so
|
|
306
|
+
// the closing session isn't counted). Pause any in-flight auto-sessions so
|
|
307
|
+
// nothing dangles "working"; a reconnect + next tool call starts fresh.
|
|
308
|
+
const stillLive = [...sessions.values()].some(
|
|
309
|
+
(s) => s.userId === session.userId,
|
|
310
|
+
);
|
|
311
|
+
if (!stillLive) {
|
|
312
|
+
shutdownAllSessions(session.userId)
|
|
313
|
+
.catch(() => {})
|
|
314
|
+
.finally(() => dropScope(session.userId));
|
|
315
|
+
}
|
|
271
316
|
console.log(`[mcp] session=${transport.sessionId} closed`);
|
|
272
317
|
}
|
|
273
318
|
};
|
package/src/server.ts
CHANGED
|
@@ -81,6 +81,12 @@ export interface ToolDeps {
|
|
|
81
81
|
getUserEmail: () => string | null;
|
|
82
82
|
saveConfig: (config: { apiKey: string }) => void;
|
|
83
83
|
resetClient: () => void;
|
|
84
|
+
/**
|
|
85
|
+
* Tenant scope for auto-session bookkeeping. The remote/HTTP transport returns
|
|
86
|
+
* the connection's userId so two users on the same card don't collide on one
|
|
87
|
+
* shared auto-session entry. Omitted on stdio (single user → default scope).
|
|
88
|
+
*/
|
|
89
|
+
getScopeId?: () => string;
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
// --- Memory Session Tracking ---
|
|
@@ -1852,9 +1858,18 @@ export function registerHandlers(server: Server, deps: ToolDeps): void {
|
|
|
1852
1858
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1853
1859
|
if (cardIdArg && UUID_RE.test(cardIdArg) && deps.isConfigured()) {
|
|
1854
1860
|
const isAutoStartTrigger = AUTO_START_TRIGGERS.has(name);
|
|
1861
|
+
// Resolve MCP client identity per request from the in-scope Server. The
|
|
1862
|
+
// remote transport never calls initAutoSession (global getter is null),
|
|
1863
|
+
// so passing it here is what makes auto-sessions attribute correctly on
|
|
1864
|
+
// the hosted/OAuth path, not just stdio (card #297).
|
|
1865
|
+
// Optional-chained: the pre-hook is best-effort and must never throw into
|
|
1866
|
+
// tool dispatch if a transport/wrapper doesn't expose getClientVersion.
|
|
1867
|
+
const cv = server.getClientVersion?.();
|
|
1855
1868
|
trackActivity(cardIdArg, {
|
|
1856
1869
|
autoStart: isAutoStartTrigger,
|
|
1857
1870
|
client: deps.getClient(),
|
|
1871
|
+
clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
|
|
1872
|
+
scopeId: deps.getScopeId?.(),
|
|
1858
1873
|
}).catch(() => {}); // fire-and-forget
|
|
1859
1874
|
}
|
|
1860
1875
|
|
|
@@ -1874,9 +1889,16 @@ export function registerHandlers(server: Server, deps: ToolDeps): void {
|
|
|
1874
1889
|
| string
|
|
1875
1890
|
| undefined;
|
|
1876
1891
|
if (resolvedCardId && UUID_RE.test(resolvedCardId)) {
|
|
1892
|
+
// Optional-chained: the pre-hook is best-effort and must never throw into
|
|
1893
|
+
// tool dispatch if a transport/wrapper doesn't expose getClientVersion.
|
|
1894
|
+
const cv = server.getClientVersion?.();
|
|
1877
1895
|
trackActivity(resolvedCardId, {
|
|
1878
1896
|
autoStart: true,
|
|
1879
1897
|
client: deps.getClient(),
|
|
1898
|
+
clientInfo: cv
|
|
1899
|
+
? { name: cv.name, version: cv.version }
|
|
1900
|
+
: undefined,
|
|
1901
|
+
scopeId: deps.getScopeId?.(),
|
|
1880
1902
|
}).catch(() => {});
|
|
1881
1903
|
}
|
|
1882
1904
|
} catch {
|
|
@@ -2113,7 +2135,7 @@ async function handleToolCall(
|
|
|
2113
2135
|
const { session } = await client.getAgentSession(cardId);
|
|
2114
2136
|
if (session) {
|
|
2115
2137
|
await client.endAgentSession(cardId, { status: "completed" });
|
|
2116
|
-
untrack(cardId);
|
|
2138
|
+
untrack(cardId, deps.getScopeId?.());
|
|
2117
2139
|
sessionEnded = true;
|
|
2118
2140
|
}
|
|
2119
2141
|
}
|
|
@@ -2618,7 +2640,11 @@ async function handleToolCall(
|
|
|
2618
2640
|
});
|
|
2619
2641
|
|
|
2620
2642
|
// Mark as explicit so auto-session won't interfere
|
|
2621
|
-
markExplicit(cardId, {
|
|
2643
|
+
markExplicit(cardId, {
|
|
2644
|
+
agentIdentifier,
|
|
2645
|
+
agentName,
|
|
2646
|
+
scopeId: deps.getScopeId?.(),
|
|
2647
|
+
});
|
|
2622
2648
|
|
|
2623
2649
|
// Initialize memory session tracking for action visibility. Capture the
|
|
2624
2650
|
// backend session id so working-memory writes (`scope: 'session'`) bind
|
|
@@ -2721,7 +2747,7 @@ async function handleToolCall(
|
|
|
2721
2747
|
}
|
|
2722
2748
|
|
|
2723
2749
|
// Remove from auto-session tracking regardless
|
|
2724
|
-
untrack(cardId);
|
|
2750
|
+
untrack(cardId, deps.getScopeId?.());
|
|
2725
2751
|
|
|
2726
2752
|
let movedTo: string | null = null;
|
|
2727
2753
|
|
|
@@ -3080,6 +3106,12 @@ async function handleToolCall(
|
|
|
3080
3106
|
let relevanceMap: Map<string, number>;
|
|
3081
3107
|
|
|
3082
3108
|
if (queryText) {
|
|
3109
|
+
const requestedTags = args.tags as string[] | undefined;
|
|
3110
|
+
// Tag + superseded filtering is now authoritative in the hybrid_search
|
|
3111
|
+
// RPC (#298/#299): tags match the canonical `tags_normalized` column at
|
|
3112
|
+
// the DB level (no client-side fetch-then-filter completeness gap), and
|
|
3113
|
+
// tombstoned rows are excluded unless include_superseded is set. Tags
|
|
3114
|
+
// are normalized server-side; we pass them through verbatim.
|
|
3083
3115
|
const searchResult = await client.searchMemoryEntities(
|
|
3084
3116
|
workspaceId,
|
|
3085
3117
|
queryText,
|
|
@@ -3087,14 +3119,18 @@ async function handleToolCall(
|
|
|
3087
3119
|
project_id: projectId,
|
|
3088
3120
|
type: args.type as string | undefined,
|
|
3089
3121
|
limit: fetchLimit,
|
|
3122
|
+
tags:
|
|
3123
|
+
requestedTags && requestedTags.length > 0
|
|
3124
|
+
? requestedTags
|
|
3125
|
+
: undefined,
|
|
3126
|
+
include_superseded: includeSuperseded,
|
|
3090
3127
|
},
|
|
3091
3128
|
);
|
|
3092
3129
|
entities = (searchResult.entities ?? []) as any[];
|
|
3093
3130
|
|
|
3094
|
-
// Post-filter the
|
|
3095
|
-
//
|
|
3096
|
-
//
|
|
3097
|
-
const requestedTags = args.tags as string[] | undefined;
|
|
3131
|
+
// Post-filter the params the RPC does not handle. Scope + minConfidence
|
|
3132
|
+
// are applied here on the rank-ordered set. (Tags + include_superseded
|
|
3133
|
+
// are handled server-side above.)
|
|
3098
3134
|
const minConfidence = args.minConfidence as number | undefined;
|
|
3099
3135
|
if (userScopeFilter) {
|
|
3100
3136
|
entities = entities.filter((e) => e?.scope === userScopeFilter);
|
|
@@ -3103,13 +3139,9 @@ async function handleToolCall(
|
|
|
3103
3139
|
// out of the long-term mix so the same row never shows twice.
|
|
3104
3140
|
entities = entities.filter((e) => !isSessionScope(e?.scope));
|
|
3105
3141
|
}
|
|
3106
|
-
if (requestedTags && requestedTags.length > 0) {
|
|
3107
|
-
const wanted = new Set(requestedTags);
|
|
3108
|
-
entities = entities.filter((e) =>
|
|
3109
|
-
(e?.tags ?? []).some((t: string) => wanted.has(t)),
|
|
3110
|
-
);
|
|
3111
|
-
}
|
|
3112
3142
|
entities = filterByMinConfidence(entities, minConfidence);
|
|
3143
|
+
// Belt-and-suspenders: the RPC already excludes tombstoned rows; this
|
|
3144
|
+
// also drops any if a stale/FTS path slipped one through.
|
|
3113
3145
|
if (!includeSuperseded) {
|
|
3114
3146
|
entities = entities.filter((e) => !e?.superseded_at);
|
|
3115
3147
|
}
|