@gethmy/mcp 2.8.4 → 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 +430 -33
- package/dist/index.js +430 -33
- package/dist/lib/api-client.js +14 -4
- package/package.json +1 -1
- package/src/auto-session.ts +18 -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 +320 -43
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory tag normalization + lint (card #274).
|
|
3
|
+
*
|
|
4
|
+
* Memory tags were inconsistent and broke tag-based recall: card references
|
|
5
|
+
* appeared as both `card:259` (colon) and `card-175` / `card 229` (dash/space),
|
|
6
|
+
* so `harmony_recall({tags:["card:259"]})` missed the variants.
|
|
7
|
+
*
|
|
8
|
+
* Canonical card-ref form is `card:<n>` (colon) — this aligns with the active
|
|
9
|
+
* programmatic write path: the agent daemon's `episode-writer.ts` already tags
|
|
10
|
+
* episodes with `` `card:${short_id}` ``. We normalize toward that, not away.
|
|
11
|
+
*
|
|
12
|
+
* Rules are intentionally small and deterministic (no LLM, no taxonomy):
|
|
13
|
+
* - trim surrounding whitespace
|
|
14
|
+
* - lowercase
|
|
15
|
+
* - collapse internal whitespace runs to a single space
|
|
16
|
+
* - canonicalize card refs: `card-<n>` / `card <n>` / `card#<n>` → `card:<n>`
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Matches a card ref written with any common separator, e.g. `card-12`,
|
|
20
|
+
* `card 12`, `card#12`, `card:12`. Captures the numeric short id. */
|
|
21
|
+
const CARD_REF = /^card[\s:#-]+(\d+)$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Normalize a single tag to its canonical form.
|
|
25
|
+
* Pure: trims, lowercases, collapses internal whitespace, canonicalizes card refs.
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeTag(tag: string): string {
|
|
28
|
+
const cleaned = tag.trim().toLowerCase().replace(/\s+/g, " ");
|
|
29
|
+
const cardMatch = cleaned.match(CARD_REF);
|
|
30
|
+
if (cardMatch) {
|
|
31
|
+
return `card:${cardMatch[1]}`;
|
|
32
|
+
}
|
|
33
|
+
return cleaned;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Normalize a list of tags: map each through {@link normalizeTag}, drop empties,
|
|
38
|
+
* and dedupe while preserving first-seen order.
|
|
39
|
+
*/
|
|
40
|
+
export function normalizeTags(tags: string[]): string[] {
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
const out: string[] = [];
|
|
43
|
+
for (const raw of tags) {
|
|
44
|
+
const norm = normalizeTag(raw);
|
|
45
|
+
if (!norm || seen.has(norm)) continue;
|
|
46
|
+
seen.add(norm);
|
|
47
|
+
out.push(norm);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A single advisory tag-lint suggestion. Non-fatal. */
|
|
53
|
+
export interface TagSuggestion {
|
|
54
|
+
/** The original tag as written by the caller. */
|
|
55
|
+
tag: string;
|
|
56
|
+
/** The canonical form we'd prefer. */
|
|
57
|
+
suggestion: string;
|
|
58
|
+
/** Short human-readable reason. */
|
|
59
|
+
reason: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Advisory tag lint (card #274). Returns non-fatal suggestions for tags that
|
|
64
|
+
* differ from their canonical form (case variants, dash/space/hash card refs,
|
|
65
|
+
* surrounding/internal whitespace). NEVER rejects — purely informational.
|
|
66
|
+
*
|
|
67
|
+
* Clean tags (already canonical) produce no suggestions, so a fully-normalized
|
|
68
|
+
* input list returns `[]`.
|
|
69
|
+
*/
|
|
70
|
+
export function lintTags(tags: string[]): TagSuggestion[] {
|
|
71
|
+
const suggestions: TagSuggestion[] = [];
|
|
72
|
+
for (const tag of tags) {
|
|
73
|
+
const norm = normalizeTag(tag);
|
|
74
|
+
if (norm === tag) continue;
|
|
75
|
+
let reason: string;
|
|
76
|
+
if (CARD_REF.test(tag.trim().toLowerCase()) && norm.startsWith("card:")) {
|
|
77
|
+
reason = "non-canonical card ref; use 'card:<n>'";
|
|
78
|
+
} else if (tag !== tag.trim() || /\s{2,}/.test(tag)) {
|
|
79
|
+
reason = "whitespace; trim and collapse";
|
|
80
|
+
} else if (tag !== tag.toLowerCase()) {
|
|
81
|
+
reason = "case variant; use lowercase";
|
|
82
|
+
} else {
|
|
83
|
+
reason = "normalize to canonical form";
|
|
84
|
+
}
|
|
85
|
+
suggestions.push({ tag, suggestion: norm, reason });
|
|
86
|
+
}
|
|
87
|
+
return suggestions;
|
|
88
|
+
}
|
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
|
|
|
@@ -1101,6 +1116,15 @@ export const TOOLS = {
|
|
|
1101
1116
|
type: "string",
|
|
1102
1117
|
description: "Project ID (optional, required if scope is 'project')",
|
|
1103
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
|
+
},
|
|
1104
1128
|
},
|
|
1105
1129
|
required: ["title", "content"],
|
|
1106
1130
|
},
|
|
@@ -1282,6 +1306,52 @@ export const TOOLS = {
|
|
|
1282
1306
|
required: ["sourceId", "targetId", "relationType"],
|
|
1283
1307
|
},
|
|
1284
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
|
+
},
|
|
1285
1355
|
harmony_memory_search: {
|
|
1286
1356
|
description:
|
|
1287
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.",
|
|
@@ -2773,7 +2843,13 @@ async function handleToolCall(
|
|
|
2773
2843
|
);
|
|
2774
2844
|
}
|
|
2775
2845
|
const entityType = (args.type as string) || "context";
|
|
2776
|
-
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);
|
|
2777
2853
|
|
|
2778
2854
|
// Working-memory binding (plan §12 Phase 1, §13.1 D3). The caller may
|
|
2779
2855
|
// pass `scope: 'session'` as an alias; resolve it to the active
|
|
@@ -2804,35 +2880,116 @@ async function handleToolCall(
|
|
|
2804
2880
|
);
|
|
2805
2881
|
}
|
|
2806
2882
|
|
|
2807
|
-
//
|
|
2808
|
-
//
|
|
2809
|
-
//
|
|
2810
|
-
//
|
|
2811
|
-
|
|
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 =
|
|
2812
2925
|
args.importance !== undefined
|
|
2813
2926
|
? z.number().int().min(1).max(10).parse(args.importance)
|
|
2814
|
-
:
|
|
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
|
+
}
|
|
2815
2950
|
|
|
2816
|
-
// Use session's agent identifier if available, otherwise null
|
|
2817
2951
|
const result = await client.createMemoryEntity({
|
|
2818
2952
|
workspace_id: workspaceId,
|
|
2819
|
-
project_id:
|
|
2820
|
-
(args.projectId as string) || deps.getActiveProjectId() || undefined,
|
|
2953
|
+
project_id: projectIdForWrite,
|
|
2821
2954
|
type: entityType,
|
|
2822
2955
|
scope: entityScope,
|
|
2823
2956
|
memory_tier: (args.tier as string) || undefined,
|
|
2824
2957
|
title,
|
|
2825
2958
|
content,
|
|
2826
|
-
metadata:
|
|
2827
|
-
confidence
|
|
2828
|
-
|
|
2829
|
-
? z.number().min(0).max(1).parse(args.confidence)
|
|
2830
|
-
: undefined,
|
|
2831
|
-
importance: callerImportance,
|
|
2959
|
+
metadata: metadataWithOrigin,
|
|
2960
|
+
confidence,
|
|
2961
|
+
importance,
|
|
2832
2962
|
tags: entityTags.length > 0 ? entityTags : undefined,
|
|
2833
2963
|
agent_identifier: activeMemSession?.agentIdentifier || undefined,
|
|
2834
2964
|
});
|
|
2835
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
|
+
|
|
2836
2993
|
// Fire-and-forget graph expansion: link new entity to semantically similar ones
|
|
2837
2994
|
const newEntityIdForGraph = (result.entity as any)?.id;
|
|
2838
2995
|
if (newEntityIdForGraph) {
|
|
@@ -2843,7 +3000,7 @@ async function handleToolCall(
|
|
|
2843
3000
|
content,
|
|
2844
3001
|
entityTags,
|
|
2845
3002
|
workspaceId,
|
|
2846
|
-
|
|
3003
|
+
projectIdForWrite,
|
|
2847
3004
|
).catch(() => {});
|
|
2848
3005
|
}
|
|
2849
3006
|
|
|
@@ -2859,6 +3016,15 @@ async function handleToolCall(
|
|
|
2859
3016
|
return {
|
|
2860
3017
|
success: true,
|
|
2861
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 } : {}),
|
|
2862
3028
|
};
|
|
2863
3029
|
}
|
|
2864
3030
|
|
|
@@ -2943,13 +3109,7 @@ async function handleToolCall(
|
|
|
2943
3109
|
(e?.tags ?? []).some((t: string) => wanted.has(t)),
|
|
2944
3110
|
);
|
|
2945
3111
|
}
|
|
2946
|
-
|
|
2947
|
-
entities = entities.filter(
|
|
2948
|
-
(e) =>
|
|
2949
|
-
typeof e?.confidence === "number" &&
|
|
2950
|
-
e.confidence >= minConfidence,
|
|
2951
|
-
);
|
|
2952
|
-
}
|
|
3112
|
+
entities = filterByMinConfidence(entities, minConfidence);
|
|
2953
3113
|
if (!includeSuperseded) {
|
|
2954
3114
|
entities = entities.filter((e) => !e?.superseded_at);
|
|
2955
3115
|
}
|
|
@@ -2992,23 +3152,20 @@ async function handleToolCall(
|
|
|
2992
3152
|
trimmed = fitToBudget(trimmed, budgetTokens);
|
|
2993
3153
|
}
|
|
2994
3154
|
|
|
2995
|
-
// Touch access
|
|
2996
|
-
//
|
|
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.
|
|
2997
3162
|
if (trimmed.length > 0) {
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
_last_recall: new Date().toISOString(),
|
|
3005
|
-
},
|
|
3006
|
-
});
|
|
3007
|
-
} catch (_) {
|
|
3008
|
-
// Non-critical
|
|
3009
|
-
}
|
|
3010
|
-
}),
|
|
3011
|
-
).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
|
+
}
|
|
3012
3169
|
}
|
|
3013
3170
|
|
|
3014
3171
|
// Working-memory prepend (plan §12 Phase 1). When a session is active
|
|
@@ -3086,7 +3243,15 @@ async function handleToolCall(
|
|
|
3086
3243
|
updates.content = z.string().max(50000).parse(args.content);
|
|
3087
3244
|
if (args.type !== undefined) updates.type = args.type;
|
|
3088
3245
|
if (args.scope !== undefined) updates.scope = args.scope;
|
|
3089
|
-
|
|
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
|
+
}
|
|
3090
3255
|
if (args.confidence !== undefined)
|
|
3091
3256
|
updates.confidence = z.number().min(0).max(1).parse(args.confidence);
|
|
3092
3257
|
if (args.metadata !== undefined) updates.metadata = args.metadata;
|
|
@@ -3103,7 +3268,13 @@ async function handleToolCall(
|
|
|
3103
3268
|
flushMemoryActions(client, updateMemSession.cardId).catch(() => {});
|
|
3104
3269
|
}
|
|
3105
3270
|
|
|
3106
|
-
return {
|
|
3271
|
+
return {
|
|
3272
|
+
success: true,
|
|
3273
|
+
...result,
|
|
3274
|
+
...(updateTagSuggestions.length > 0
|
|
3275
|
+
? { tagSuggestions: updateTagSuggestions }
|
|
3276
|
+
: {}),
|
|
3277
|
+
};
|
|
3107
3278
|
}
|
|
3108
3279
|
|
|
3109
3280
|
case "harmony_forget": {
|
|
@@ -3173,6 +3344,112 @@ async function handleToolCall(
|
|
|
3173
3344
|
return { success: true, ...result };
|
|
3174
3345
|
}
|
|
3175
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
|
+
|
|
3176
3453
|
case "harmony_memory_search": {
|
|
3177
3454
|
const query = z.string().min(1).max(500).parse(args.query);
|
|
3178
3455
|
const workspaceId =
|