@gethmy/mcp 2.8.3 → 2.8.5
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 +481 -36
- package/dist/index.js +481 -36
- package/dist/lib/api-client.js +17 -4
- package/package.json +1 -1
- package/src/api-client.ts +8 -0
- package/src/auto-session.ts +19 -9
- package/src/graph-expansion.ts +153 -0
- package/src/memory-park.ts +183 -4
- package/src/memory-provenance.ts +177 -0
- package/src/memory-tags.ts +88 -0
- package/src/server.ts +379 -43
package/src/server.ts
CHANGED
|
@@ -34,19 +34,34 @@ import {
|
|
|
34
34
|
setActiveProject,
|
|
35
35
|
setActiveWorkspace,
|
|
36
36
|
} from "./config.js";
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
autoExpandGraph,
|
|
39
|
+
findSimilarEntities,
|
|
40
|
+
findSupersedeCandidates,
|
|
41
|
+
type SupersedeCandidate,
|
|
42
|
+
} from "./graph-expansion.js";
|
|
38
43
|
import { validateMemoryQuality } from "./memory-floor.js";
|
|
39
44
|
import {
|
|
45
|
+
filterByMinConfidence,
|
|
40
46
|
fitToBudget,
|
|
47
|
+
type MemoryFeedback,
|
|
48
|
+
mergeFeedback,
|
|
41
49
|
type ParkInput,
|
|
42
50
|
relevanceFromRank,
|
|
43
51
|
rescore,
|
|
44
52
|
} from "./memory-park.js";
|
|
53
|
+
import {
|
|
54
|
+
buildOrigin,
|
|
55
|
+
defaultConfidenceForSource,
|
|
56
|
+
importanceWithSignalBump,
|
|
57
|
+
type MemorySource,
|
|
58
|
+
} from "./memory-provenance.js";
|
|
45
59
|
import {
|
|
46
60
|
isSessionScope,
|
|
47
61
|
resolveSessionScope,
|
|
48
62
|
sessionScopeFor,
|
|
49
63
|
} from "./memory-session.js";
|
|
64
|
+
import { lintTags, normalizeTags } from "./memory-tags.js";
|
|
50
65
|
import { onboardNewUser } from "./onboard.js";
|
|
51
66
|
import { stripSkillPreamble } from "./skills.js";
|
|
52
67
|
|
|
@@ -308,6 +323,12 @@ export const TOOLS = {
|
|
|
308
323
|
assigneeId: { type: "string", nullable: true },
|
|
309
324
|
dueDate: { type: "string", nullable: true },
|
|
310
325
|
done: { type: "boolean", description: "Mark card as done or not done" },
|
|
326
|
+
planId: {
|
|
327
|
+
type: "string",
|
|
328
|
+
nullable: true,
|
|
329
|
+
description:
|
|
330
|
+
"Plan ID to link this card to, or null to unlink. Sets cards.plan_id. The plan must exist and belong to the same project.",
|
|
331
|
+
},
|
|
311
332
|
},
|
|
312
333
|
required: ["cardId"],
|
|
313
334
|
},
|
|
@@ -625,6 +646,26 @@ export const TOOLS = {
|
|
|
625
646
|
required: ["subtaskId"],
|
|
626
647
|
},
|
|
627
648
|
},
|
|
649
|
+
harmony_update_subtask: {
|
|
650
|
+
description:
|
|
651
|
+
"Update a subtask: rename its title, set an explicit completion state, and/or reorder it. Unlike harmony_toggle_subtask (which flips completion), `completed` here sets an explicit value — safe to call idempotently. At least one of title/completed/position is required.",
|
|
652
|
+
inputSchema: {
|
|
653
|
+
type: "object",
|
|
654
|
+
properties: {
|
|
655
|
+
subtaskId: { type: "string" },
|
|
656
|
+
title: { type: "string", description: "New title (1–500 chars)" },
|
|
657
|
+
completed: {
|
|
658
|
+
type: "boolean",
|
|
659
|
+
description: "Explicit completion state (set, not toggle)",
|
|
660
|
+
},
|
|
661
|
+
position: {
|
|
662
|
+
type: "number",
|
|
663
|
+
description: "New position (0-based ordering within the card)",
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
required: ["subtaskId"],
|
|
667
|
+
},
|
|
668
|
+
},
|
|
628
669
|
harmony_delete_subtask: {
|
|
629
670
|
description: "Delete a subtask",
|
|
630
671
|
inputSchema: {
|
|
@@ -1075,6 +1116,15 @@ export const TOOLS = {
|
|
|
1075
1116
|
type: "string",
|
|
1076
1117
|
description: "Project ID (optional, required if scope is 'project')",
|
|
1077
1118
|
},
|
|
1119
|
+
supersedeId: {
|
|
1120
|
+
type: "string",
|
|
1121
|
+
description:
|
|
1122
|
+
"Optional. ID of an existing memory entity to soft-supersede with " +
|
|
1123
|
+
"this new one (sets its superseded_by/superseded_at). Use after a " +
|
|
1124
|
+
"previous harmony_remember returned a `similar` candidate you want " +
|
|
1125
|
+
"to replace. Reversible; never deletes or merges content. The new " +
|
|
1126
|
+
"entity is always created regardless.",
|
|
1127
|
+
},
|
|
1078
1128
|
},
|
|
1079
1129
|
required: ["title", "content"],
|
|
1080
1130
|
},
|
|
@@ -1256,6 +1306,52 @@ export const TOOLS = {
|
|
|
1256
1306
|
required: ["sourceId", "targetId", "relationType"],
|
|
1257
1307
|
},
|
|
1258
1308
|
},
|
|
1309
|
+
harmony_suggest_relations: {
|
|
1310
|
+
description:
|
|
1311
|
+
"Suggest existing memory entities that look related to a given entity, using the same hybrid vector+FTS similarity as recall (card #281). Read-only: it NEVER creates relations — it only proposes candidates for a caller (human/agent) to relate explicitly via harmony_relate. Use to surface 'these look related — link?' candidates that would activate graph-walk retrieval.",
|
|
1312
|
+
inputSchema: {
|
|
1313
|
+
type: "object",
|
|
1314
|
+
properties: {
|
|
1315
|
+
entityId: {
|
|
1316
|
+
type: "string",
|
|
1317
|
+
description: "The entity UUID to find related candidates for",
|
|
1318
|
+
},
|
|
1319
|
+
limit: {
|
|
1320
|
+
type: "number",
|
|
1321
|
+
description: "Max suggestions to return (default: 5)",
|
|
1322
|
+
},
|
|
1323
|
+
workspaceId: {
|
|
1324
|
+
type: "string",
|
|
1325
|
+
description: "Workspace ID (optional if context set)",
|
|
1326
|
+
},
|
|
1327
|
+
projectId: {
|
|
1328
|
+
type: "string",
|
|
1329
|
+
description: "Project ID (optional)",
|
|
1330
|
+
},
|
|
1331
|
+
},
|
|
1332
|
+
required: ["entityId"],
|
|
1333
|
+
},
|
|
1334
|
+
},
|
|
1335
|
+
harmony_recall_feedback: {
|
|
1336
|
+
description:
|
|
1337
|
+
"Record a 👍/👎 on a recalled memory so future recalls can rank it better or worse. Non-destructive: stores a counter at metadata.feedback={up,down} and only affects ranking — it never deletes, hides, or supersedes the memory. Call after a recall when a memory proved useful (vote 'up') or misleading/irrelevant (vote 'down').",
|
|
1338
|
+
inputSchema: {
|
|
1339
|
+
type: "object",
|
|
1340
|
+
properties: {
|
|
1341
|
+
entityId: {
|
|
1342
|
+
type: "string",
|
|
1343
|
+
description: "Memory entity UUID that was recalled",
|
|
1344
|
+
},
|
|
1345
|
+
vote: {
|
|
1346
|
+
type: "string",
|
|
1347
|
+
enum: ["up", "down"],
|
|
1348
|
+
description:
|
|
1349
|
+
"'up' if the memory was useful, 'down' if it was misleading or irrelevant",
|
|
1350
|
+
},
|
|
1351
|
+
},
|
|
1352
|
+
required: ["entityId", "vote"],
|
|
1353
|
+
},
|
|
1354
|
+
},
|
|
1259
1355
|
harmony_memory_search: {
|
|
1260
1356
|
description:
|
|
1261
1357
|
"Full-text and semantic search across the knowledge base. Uses hybrid vector+FTS search (when embeddings are available) for best results. Returns entities ranked by relevance.",
|
|
@@ -1944,6 +2040,13 @@ async function handleToolCall(
|
|
|
1944
2040
|
|
|
1945
2041
|
case "harmony_update_card": {
|
|
1946
2042
|
const cardId = z.string().uuid().parse(args.cardId);
|
|
2043
|
+
// null = unlink, undefined = leave untouched, otherwise must be a UUID.
|
|
2044
|
+
const planId =
|
|
2045
|
+
args.planId === undefined
|
|
2046
|
+
? undefined
|
|
2047
|
+
: args.planId === null
|
|
2048
|
+
? null
|
|
2049
|
+
: z.string().uuid().parse(args.planId);
|
|
1947
2050
|
const result = await client.updateCard(cardId, {
|
|
1948
2051
|
title: args.title as string | undefined,
|
|
1949
2052
|
description: args.description as string | undefined,
|
|
@@ -1951,6 +2054,7 @@ async function handleToolCall(
|
|
|
1951
2054
|
assigneeId: args.assigneeId as string | null | undefined,
|
|
1952
2055
|
dueDate: args.dueDate as string | null | undefined,
|
|
1953
2056
|
done: args.done as boolean | undefined,
|
|
2057
|
+
planId,
|
|
1954
2058
|
});
|
|
1955
2059
|
return { success: true, ...result };
|
|
1956
2060
|
}
|
|
@@ -2237,6 +2341,31 @@ async function handleToolCall(
|
|
|
2237
2341
|
return { success: true, ...result };
|
|
2238
2342
|
}
|
|
2239
2343
|
|
|
2344
|
+
case "harmony_update_subtask": {
|
|
2345
|
+
const subtaskId = z.string().uuid().parse(args.subtaskId);
|
|
2346
|
+
const updates: {
|
|
2347
|
+
title?: string;
|
|
2348
|
+
completed?: boolean;
|
|
2349
|
+
position?: number;
|
|
2350
|
+
} = {};
|
|
2351
|
+
if (args.title !== undefined) {
|
|
2352
|
+
updates.title = z.string().min(1).max(500).parse(args.title);
|
|
2353
|
+
}
|
|
2354
|
+
if (args.completed !== undefined) {
|
|
2355
|
+
updates.completed = z.boolean().parse(args.completed);
|
|
2356
|
+
}
|
|
2357
|
+
if (args.position !== undefined) {
|
|
2358
|
+
updates.position = z.number().int().min(0).parse(args.position);
|
|
2359
|
+
}
|
|
2360
|
+
if (Object.keys(updates).length === 0) {
|
|
2361
|
+
throw new Error(
|
|
2362
|
+
"harmony_update_subtask requires at least one of: title, completed, position",
|
|
2363
|
+
);
|
|
2364
|
+
}
|
|
2365
|
+
const result = await client.updateSubtask(subtaskId, updates);
|
|
2366
|
+
return { success: true, ...result };
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2240
2369
|
case "harmony_delete_subtask": {
|
|
2241
2370
|
const subtaskId = z.string().uuid().parse(args.subtaskId);
|
|
2242
2371
|
await client.deleteSubtask(subtaskId);
|
|
@@ -2714,7 +2843,13 @@ async function handleToolCall(
|
|
|
2714
2843
|
);
|
|
2715
2844
|
}
|
|
2716
2845
|
const entityType = (args.type as string) || "context";
|
|
2717
|
-
const
|
|
2846
|
+
const rawTags = (args.tags as string[]) || [];
|
|
2847
|
+
// Tag taxonomy normalization (#274): canonicalize before any downstream
|
|
2848
|
+
// use so the Floor, the write, and graph expansion all see the same
|
|
2849
|
+
// canonical card refs / casing. Lint is advisory — surfaced in the
|
|
2850
|
+
// response, never rejecting.
|
|
2851
|
+
const entityTags = normalizeTags(rawTags);
|
|
2852
|
+
const tagSuggestions = lintTags(rawTags);
|
|
2718
2853
|
|
|
2719
2854
|
// Working-memory binding (plan §12 Phase 1, §13.1 D3). The caller may
|
|
2720
2855
|
// pass `scope: 'session'` as an alias; resolve it to the active
|
|
@@ -2745,35 +2880,116 @@ async function handleToolCall(
|
|
|
2745
2880
|
);
|
|
2746
2881
|
}
|
|
2747
2882
|
|
|
2748
|
-
//
|
|
2749
|
-
//
|
|
2750
|
-
//
|
|
2751
|
-
//
|
|
2752
|
-
|
|
2883
|
+
// Provenance (#273, task 2). Infer the source from context: an active
|
|
2884
|
+
// memory session means the write came from an agent run (or the board
|
|
2885
|
+
// assistant proxying for the user). With no session it's a manual,
|
|
2886
|
+
// human-curated capture. The origin record rides inside metadata.origin
|
|
2887
|
+
// — there is no dedicated provenance column.
|
|
2888
|
+
const sourceTrust = args.source_trust as string | undefined;
|
|
2889
|
+
const source: MemorySource = activeMemSession
|
|
2890
|
+
? activeMemSession.agentIdentifier === "assistant"
|
|
2891
|
+
? "assistant"
|
|
2892
|
+
: "agent-run"
|
|
2893
|
+
: "manual";
|
|
2894
|
+
const origin = buildOrigin({
|
|
2895
|
+
source,
|
|
2896
|
+
source_card_id: activeMemSession?.cardId,
|
|
2897
|
+
source_session_id: activeMemSession?.agentSessionId,
|
|
2898
|
+
author: activeMemSession?.agentIdentifier ?? "user",
|
|
2899
|
+
source_trust: sourceTrust,
|
|
2900
|
+
});
|
|
2901
|
+
const callerMetadata = args.metadata as
|
|
2902
|
+
| Record<string, unknown>
|
|
2903
|
+
| undefined;
|
|
2904
|
+
const metadataWithOrigin: Record<string, unknown> = {
|
|
2905
|
+
...(callerMetadata ?? {}),
|
|
2906
|
+
origin,
|
|
2907
|
+
};
|
|
2908
|
+
|
|
2909
|
+
// Confidence (#273, task 5). An explicit confidence always wins. When
|
|
2910
|
+
// omitted, derive a non-uniform default from source/trust so
|
|
2911
|
+
// minConfidence filtering on recall actually bites: manual/curated stays
|
|
2912
|
+
// high, agent auto-extract / low-trust starts moderate. (Was: edge fn
|
|
2913
|
+
// defaulted everything to 1.0, making the whole vault uniform.)
|
|
2914
|
+
const confidence =
|
|
2915
|
+
args.confidence !== undefined
|
|
2916
|
+
? z.number().min(0).max(1).parse(args.confidence)
|
|
2917
|
+
: defaultConfidenceForSource({ source, source_trust: sourceTrust });
|
|
2918
|
+
|
|
2919
|
+
// Importance (#273, task 6). Caller-provided wins; auto_score_importance
|
|
2920
|
+
// (LLM rating) stays deferred per the 2026-05-08 decision (the AGP
|
|
2921
|
+
// auto-scorer was reverted in 43782a4 for poisoning the corpus). When
|
|
2922
|
+
// omitted, use the per-type default plus a bounded, deterministic
|
|
2923
|
+
// signal-density bump so the Park rescore has a non-flat importance term.
|
|
2924
|
+
const importance =
|
|
2753
2925
|
args.importance !== undefined
|
|
2754
2926
|
? z.number().int().min(1).max(10).parse(args.importance)
|
|
2755
|
-
:
|
|
2927
|
+
: importanceWithSignalBump(entityType, content);
|
|
2928
|
+
|
|
2929
|
+
const projectIdForWrite =
|
|
2930
|
+
(args.projectId as string) || deps.getActiveProjectId() || undefined;
|
|
2931
|
+
|
|
2932
|
+
// Write-time semantic dedup probe (card #275). BEFORE inserting, look for
|
|
2933
|
+
// existing non-superseded entities of the same type + scope that look
|
|
2934
|
+
// like near-duplicates, reusing the hybrid-search path (no new embedding
|
|
2935
|
+
// pipeline). This NEVER blocks the write — the result is surfaced in the
|
|
2936
|
+
// `similar` response field so the caller can choose to supersede later
|
|
2937
|
+
// via the `supersedeId` param. Session-scoped (ephemeral) writes skip the
|
|
2938
|
+
// probe entirely: working memory is short-lived by design.
|
|
2939
|
+
let similar: SupersedeCandidate[] = [];
|
|
2940
|
+
if (!isSessionScope(entityScope)) {
|
|
2941
|
+
similar = await findSupersedeCandidates(
|
|
2942
|
+
client,
|
|
2943
|
+
title,
|
|
2944
|
+
content,
|
|
2945
|
+
entityType,
|
|
2946
|
+
workspaceId,
|
|
2947
|
+
{ projectId: projectIdForWrite, scope: entityScope },
|
|
2948
|
+
);
|
|
2949
|
+
}
|
|
2756
2950
|
|
|
2757
|
-
// Use session's agent identifier if available, otherwise null
|
|
2758
2951
|
const result = await client.createMemoryEntity({
|
|
2759
2952
|
workspace_id: workspaceId,
|
|
2760
|
-
project_id:
|
|
2761
|
-
(args.projectId as string) || deps.getActiveProjectId() || undefined,
|
|
2953
|
+
project_id: projectIdForWrite,
|
|
2762
2954
|
type: entityType,
|
|
2763
2955
|
scope: entityScope,
|
|
2764
2956
|
memory_tier: (args.tier as string) || undefined,
|
|
2765
2957
|
title,
|
|
2766
2958
|
content,
|
|
2767
|
-
metadata:
|
|
2768
|
-
confidence
|
|
2769
|
-
|
|
2770
|
-
? z.number().min(0).max(1).parse(args.confidence)
|
|
2771
|
-
: undefined,
|
|
2772
|
-
importance: callerImportance,
|
|
2959
|
+
metadata: metadataWithOrigin,
|
|
2960
|
+
confidence,
|
|
2961
|
+
importance,
|
|
2773
2962
|
tags: entityTags.length > 0 ? entityTags : undefined,
|
|
2774
2963
|
agent_identifier: activeMemSession?.agentIdentifier || undefined,
|
|
2775
2964
|
});
|
|
2776
2965
|
|
|
2966
|
+
// Explicit supersede branch (card #275). When the caller passes
|
|
2967
|
+
// `supersedeId`, soft-tombstone that existing entity by pointing its
|
|
2968
|
+
// superseded_by at the row we just created. This is the ONLY mutation of
|
|
2969
|
+
// an existing memory and it is always caller-driven — never automatic.
|
|
2970
|
+
// Soft + reversible (clearing superseded_by restores it); no hard delete,
|
|
2971
|
+
// no content merge. The AGP auto-consolidation layer was reverted
|
|
2972
|
+
// (43782a4) for autonomously merging — this keeps the decision explicit.
|
|
2973
|
+
const supersedeId = args.supersedeId as string | undefined;
|
|
2974
|
+
const newEntityId = (result.entity as { id?: string } | null)?.id;
|
|
2975
|
+
let superseded: string | undefined;
|
|
2976
|
+
if (supersedeId && newEntityId) {
|
|
2977
|
+
try {
|
|
2978
|
+
await client.updateMemoryEntity(supersedeId, {
|
|
2979
|
+
superseded_by: newEntityId,
|
|
2980
|
+
superseded_at: new Date().toISOString(),
|
|
2981
|
+
});
|
|
2982
|
+
superseded = supersedeId;
|
|
2983
|
+
} catch (err) {
|
|
2984
|
+
// A failed supersede must not fail the write — the new entity already
|
|
2985
|
+
// exists. Surface the failure in logs; caller can retry the tombstone.
|
|
2986
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2987
|
+
console.debug(
|
|
2988
|
+
`[harmony_remember] supersede ${supersedeId} failed: ${msg}`,
|
|
2989
|
+
);
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2777
2993
|
// Fire-and-forget graph expansion: link new entity to semantically similar ones
|
|
2778
2994
|
const newEntityIdForGraph = (result.entity as any)?.id;
|
|
2779
2995
|
if (newEntityIdForGraph) {
|
|
@@ -2784,7 +3000,7 @@ async function handleToolCall(
|
|
|
2784
3000
|
content,
|
|
2785
3001
|
entityTags,
|
|
2786
3002
|
workspaceId,
|
|
2787
|
-
|
|
3003
|
+
projectIdForWrite,
|
|
2788
3004
|
).catch(() => {});
|
|
2789
3005
|
}
|
|
2790
3006
|
|
|
@@ -2800,6 +3016,15 @@ async function handleToolCall(
|
|
|
2800
3016
|
return {
|
|
2801
3017
|
success: true,
|
|
2802
3018
|
...result,
|
|
3019
|
+
// Near-duplicate existing entities (same type + scope) above threshold.
|
|
3020
|
+
// Always present; empty when nothing similar was found. Non-breaking:
|
|
3021
|
+
// existing callers ignore it. Callers may re-call harmony_remember with
|
|
3022
|
+
// `supersedeId` set to one of these ids to tombstone the older row.
|
|
3023
|
+
...(similar.length > 0 ? { similar } : {}),
|
|
3024
|
+
...(superseded ? { superseded } : {}),
|
|
3025
|
+
// Advisory tag-lint hints (#274). Present only when the caller's tags
|
|
3026
|
+
// were non-canonical; the tags were still normalized and written.
|
|
3027
|
+
...(tagSuggestions.length > 0 ? { tagSuggestions } : {}),
|
|
2803
3028
|
};
|
|
2804
3029
|
}
|
|
2805
3030
|
|
|
@@ -2884,13 +3109,7 @@ async function handleToolCall(
|
|
|
2884
3109
|
(e?.tags ?? []).some((t: string) => wanted.has(t)),
|
|
2885
3110
|
);
|
|
2886
3111
|
}
|
|
2887
|
-
|
|
2888
|
-
entities = entities.filter(
|
|
2889
|
-
(e) =>
|
|
2890
|
-
typeof e?.confidence === "number" &&
|
|
2891
|
-
e.confidence >= minConfidence,
|
|
2892
|
-
);
|
|
2893
|
-
}
|
|
3112
|
+
entities = filterByMinConfidence(entities, minConfidence);
|
|
2894
3113
|
if (!includeSuperseded) {
|
|
2895
3114
|
entities = entities.filter((e) => !e?.superseded_at);
|
|
2896
3115
|
}
|
|
@@ -2933,23 +3152,20 @@ async function handleToolCall(
|
|
|
2933
3152
|
trimmed = fitToBudget(trimmed, budgetTokens);
|
|
2934
3153
|
}
|
|
2935
3154
|
|
|
2936
|
-
// Touch access
|
|
2937
|
-
//
|
|
3155
|
+
// Touch access counters for the entities we returned (#273, task 4).
|
|
3156
|
+
// Previously this wrote `metadata._last_recall` via updateMemoryEntity,
|
|
3157
|
+
// which left `access_count` at 0 forever and never updated
|
|
3158
|
+
// `last_accessed_at` — the column the Park recency term actually reads.
|
|
3159
|
+
// Route through batch-touch, which calls the atomic
|
|
3160
|
+
// `batch_touch_knowledge_entities` RPC (access_count += 1, last_accessed_at
|
|
3161
|
+
// = now) in a single round-trip. Non-blocking; best-effort.
|
|
2938
3162
|
if (trimmed.length > 0) {
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
_last_recall: new Date().toISOString(),
|
|
2946
|
-
},
|
|
2947
|
-
});
|
|
2948
|
-
} catch (_) {
|
|
2949
|
-
// Non-critical
|
|
2950
|
-
}
|
|
2951
|
-
}),
|
|
2952
|
-
).catch(() => {});
|
|
3163
|
+
const touchIds = trimmed
|
|
3164
|
+
.map(({ entity }: { entity: any }) => entity?.id)
|
|
3165
|
+
.filter((id: unknown): id is string => typeof id === "string");
|
|
3166
|
+
if (touchIds.length > 0) {
|
|
3167
|
+
client.batchTouchMemoryEntities(touchIds).catch(() => {});
|
|
3168
|
+
}
|
|
2953
3169
|
}
|
|
2954
3170
|
|
|
2955
3171
|
// Working-memory prepend (plan §12 Phase 1). When a session is active
|
|
@@ -3027,7 +3243,15 @@ async function handleToolCall(
|
|
|
3027
3243
|
updates.content = z.string().max(50000).parse(args.content);
|
|
3028
3244
|
if (args.type !== undefined) updates.type = args.type;
|
|
3029
3245
|
if (args.scope !== undefined) updates.scope = args.scope;
|
|
3030
|
-
|
|
3246
|
+
// Tag taxonomy normalization (#274): same canonicalization + advisory
|
|
3247
|
+
// lint as harmony_remember. Normalized tags are persisted; suggestions
|
|
3248
|
+
// are non-fatal and surfaced in the response.
|
|
3249
|
+
let updateTagSuggestions: ReturnType<typeof lintTags> = [];
|
|
3250
|
+
if (args.tags !== undefined) {
|
|
3251
|
+
const updateRawTags = args.tags as string[];
|
|
3252
|
+
updates.tags = normalizeTags(updateRawTags);
|
|
3253
|
+
updateTagSuggestions = lintTags(updateRawTags);
|
|
3254
|
+
}
|
|
3031
3255
|
if (args.confidence !== undefined)
|
|
3032
3256
|
updates.confidence = z.number().min(0).max(1).parse(args.confidence);
|
|
3033
3257
|
if (args.metadata !== undefined) updates.metadata = args.metadata;
|
|
@@ -3044,7 +3268,13 @@ async function handleToolCall(
|
|
|
3044
3268
|
flushMemoryActions(client, updateMemSession.cardId).catch(() => {});
|
|
3045
3269
|
}
|
|
3046
3270
|
|
|
3047
|
-
return {
|
|
3271
|
+
return {
|
|
3272
|
+
success: true,
|
|
3273
|
+
...result,
|
|
3274
|
+
...(updateTagSuggestions.length > 0
|
|
3275
|
+
? { tagSuggestions: updateTagSuggestions }
|
|
3276
|
+
: {}),
|
|
3277
|
+
};
|
|
3048
3278
|
}
|
|
3049
3279
|
|
|
3050
3280
|
case "harmony_forget": {
|
|
@@ -3114,6 +3344,112 @@ async function handleToolCall(
|
|
|
3114
3344
|
return { success: true, ...result };
|
|
3115
3345
|
}
|
|
3116
3346
|
|
|
3347
|
+
case "harmony_suggest_relations": {
|
|
3348
|
+
// Relation suggestions (card #281, task 3). Read-only: reuses the
|
|
3349
|
+
// existing hybrid similarity helper (findSimilarEntities, card #275) to
|
|
3350
|
+
// propose candidates the caller may explicitly relate via harmony_relate.
|
|
3351
|
+
// It NEVER writes a relation — surfacing only. Already-related entities
|
|
3352
|
+
// and the entity itself are excluded so suggestions are net-new edges.
|
|
3353
|
+
const entityId = z.string().uuid().parse(args.entityId);
|
|
3354
|
+
const workspaceId =
|
|
3355
|
+
(args.workspaceId as string) || deps.getActiveWorkspaceId();
|
|
3356
|
+
if (!workspaceId) {
|
|
3357
|
+
throw new Error(
|
|
3358
|
+
"No workspace specified. Use harmony_set_workspace_context or provide workspaceId.",
|
|
3359
|
+
);
|
|
3360
|
+
}
|
|
3361
|
+
const projectId =
|
|
3362
|
+
(args.projectId as string) || deps.getActiveProjectId() || undefined;
|
|
3363
|
+
const limit = (args.limit as number | undefined) ?? 5;
|
|
3364
|
+
|
|
3365
|
+
const { entity } = await client.getMemoryEntity(entityId);
|
|
3366
|
+
const src = entity as {
|
|
3367
|
+
title?: string;
|
|
3368
|
+
content?: string;
|
|
3369
|
+
} | null;
|
|
3370
|
+
if (!src) {
|
|
3371
|
+
throw new Error(`Entity not found: ${entityId}`);
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
// Exclude the entity itself plus anything it is already related to, so
|
|
3375
|
+
// suggestions only ever propose new edges.
|
|
3376
|
+
const related = await client.getRelatedEntities(entityId);
|
|
3377
|
+
const excludeIds = new Set<string>([entityId]);
|
|
3378
|
+
for (const raw of [
|
|
3379
|
+
...(related.outgoing || []),
|
|
3380
|
+
...(related.incoming || []),
|
|
3381
|
+
]) {
|
|
3382
|
+
const rel = raw as Record<string, unknown>;
|
|
3383
|
+
const target = rel.target as Record<string, unknown> | undefined;
|
|
3384
|
+
const source = rel.source as Record<string, unknown> | undefined;
|
|
3385
|
+
const otherId =
|
|
3386
|
+
(target?.id as string) ??
|
|
3387
|
+
(rel.target_id as string) ??
|
|
3388
|
+
(source?.id as string) ??
|
|
3389
|
+
(rel.source_id as string);
|
|
3390
|
+
if (otherId) excludeIds.add(otherId);
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
const suggestions = await findSimilarEntities(
|
|
3394
|
+
client,
|
|
3395
|
+
src.title ?? "",
|
|
3396
|
+
src.content ?? "",
|
|
3397
|
+
workspaceId,
|
|
3398
|
+
{
|
|
3399
|
+
projectId,
|
|
3400
|
+
limit: limit + excludeIds.size,
|
|
3401
|
+
excludeIds: [...excludeIds],
|
|
3402
|
+
},
|
|
3403
|
+
);
|
|
3404
|
+
|
|
3405
|
+
return {
|
|
3406
|
+
success: true,
|
|
3407
|
+
suggestions: suggestions.slice(0, limit).map((s) => ({
|
|
3408
|
+
id: s.id,
|
|
3409
|
+
title: s.title,
|
|
3410
|
+
type: s.type,
|
|
3411
|
+
rrf_score: s.rrf_score,
|
|
3412
|
+
})),
|
|
3413
|
+
};
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
case "harmony_recall_feedback": {
|
|
3417
|
+
// Record-success feedback loop (card #279, task 3). Non-destructive:
|
|
3418
|
+
// increments a counter in metadata.feedback and writes it back via the
|
|
3419
|
+
// standard metadata-merge update path. Affects RANKING ONLY (the Park
|
|
3420
|
+
// rescore reads metadata.feedback into effective importance); the memory
|
|
3421
|
+
// is never deleted, hidden, or superseded.
|
|
3422
|
+
const entityId = z.string().uuid().parse(args.entityId);
|
|
3423
|
+
const vote = z.enum(["up", "down"]).parse(args.vote);
|
|
3424
|
+
|
|
3425
|
+
const { entity } = await client.getMemoryEntity(entityId);
|
|
3426
|
+
const existingMeta =
|
|
3427
|
+
(entity as { metadata?: Record<string, unknown> } | null)?.metadata ??
|
|
3428
|
+
{};
|
|
3429
|
+
const existingFeedback = (existingMeta as { feedback?: MemoryFeedback })
|
|
3430
|
+
.feedback;
|
|
3431
|
+
const feedback = mergeFeedback(existingFeedback, vote);
|
|
3432
|
+
|
|
3433
|
+
// The harmony-api update REPLACES the metadata column (no server-side
|
|
3434
|
+
// merge), so we must send the full object: existing metadata (incl.
|
|
3435
|
+
// metadata.origin provenance from #273) plus the updated feedback delta.
|
|
3436
|
+
// Sending only { feedback } would wipe provenance and everything else.
|
|
3437
|
+
const result = await client.updateMemoryEntity(entityId, {
|
|
3438
|
+
metadata: { ...existingMeta, feedback },
|
|
3439
|
+
});
|
|
3440
|
+
|
|
3441
|
+
const fbMemSession = getActiveMemorySession();
|
|
3442
|
+
if (fbMemSession) {
|
|
3443
|
+
appendMemoryAction(
|
|
3444
|
+
fbMemSession.cardId,
|
|
3445
|
+
`Memory feedback: ${vote === "up" ? "👍" : "👎"} ${entityId.slice(0, 8)}`,
|
|
3446
|
+
);
|
|
3447
|
+
flushMemoryActions(client, fbMemSession.cardId).catch(() => {});
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
return { success: true, feedback, ...result };
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3117
3453
|
case "harmony_memory_search": {
|
|
3118
3454
|
const query = z.string().min(1).max(500).parse(args.query);
|
|
3119
3455
|
const workspaceId =
|