@gethmy/mcp 2.8.4 → 2.8.6
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 +438 -39
- package/dist/index.js +438 -39
- package/dist/lib/api-client.js +18 -4
- package/package.json +1 -1
- package/src/api-client.ts +6 -0
- package/src/auto-session.ts +18 -9
- package/src/graph-expansion.ts +151 -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 +336 -53
package/dist/lib/api-client.js
CHANGED
|
@@ -726,10 +726,14 @@ function getDisplayLinkType(linkType, direction) {
|
|
|
726
726
|
}
|
|
727
727
|
// ../harmony-shared/dist/commentSerializer.js
|
|
728
728
|
var CONFLICT_INSTRUCTION = "When two comments conflict, prefer the latest created_at, UNLESS a later " + "comment explicitly confirms or restates the earlier finding. Evaluate " + "substance, not just recency. Cite the comment id(s) you relied on.";
|
|
729
|
+
function sanitizeHeaderField(value) {
|
|
730
|
+
return value.replace(/[\]\r\n|<>]/g, " ").trim() || "—";
|
|
731
|
+
}
|
|
729
732
|
function authorLabel(c) {
|
|
730
733
|
if (c.author_type === "agent")
|
|
731
734
|
return "AI agent";
|
|
732
|
-
|
|
735
|
+
const raw = c.author?.full_name || "teammate";
|
|
736
|
+
return sanitizeHeaderField(raw);
|
|
733
737
|
}
|
|
734
738
|
function criticalIds(comments) {
|
|
735
739
|
const keep = new Set;
|
|
@@ -786,9 +790,15 @@ function serializeCommentThread(comments, options = {}) {
|
|
|
786
790
|
if (c.resolved_at)
|
|
787
791
|
tags.push("resolved");
|
|
788
792
|
const tagStr = tags.length ? ` | ${tags.join(" | ")}` : "";
|
|
789
|
-
const header = `[${ref(c.id)} | ${c.author_type} | ${authorLabel(c)} | ${c.comment_type} | ${c.created_at}${tagStr}]`;
|
|
790
|
-
|
|
791
|
-
|
|
793
|
+
const header = `[${sanitizeHeaderField(ref(c.id))} | ${sanitizeHeaderField(c.author_type)} | ${authorLabel(c)} | ${sanitizeHeaderField(c.comment_type)} | ${sanitizeHeaderField(c.created_at)}${tagStr}]`;
|
|
794
|
+
const fencedBody = c.body.trim().replaceAll("<", "<").replaceAll(">", ">");
|
|
795
|
+
lines.push({
|
|
796
|
+
at: c.created_at,
|
|
797
|
+
text: `${header}
|
|
798
|
+
<comment-body>
|
|
799
|
+
${fencedBody}
|
|
800
|
+
</comment-body>`
|
|
801
|
+
});
|
|
792
802
|
}
|
|
793
803
|
for (const a of activity) {
|
|
794
804
|
const actor = a.actor ? `${a.actor} ` : "";
|
|
@@ -1313,6 +1323,10 @@ class HarmonyApiClient {
|
|
|
1313
1323
|
params.set("type", options.type);
|
|
1314
1324
|
if (options?.limit !== undefined)
|
|
1315
1325
|
params.set("limit", String(options.limit));
|
|
1326
|
+
for (const tag of options?.tags ?? [])
|
|
1327
|
+
params.append("tags", tag);
|
|
1328
|
+
if (options?.include_superseded)
|
|
1329
|
+
params.set("include_superseded", "true");
|
|
1316
1330
|
return this.request("GET", `/memory/search?${params.toString()}`);
|
|
1317
1331
|
}
|
|
1318
1332
|
async getVaultIndex(options) {
|
package/package.json
CHANGED
package/src/api-client.ts
CHANGED
|
@@ -1003,6 +1003,8 @@ export class HarmonyApiClient {
|
|
|
1003
1003
|
project_id?: string;
|
|
1004
1004
|
type?: string;
|
|
1005
1005
|
limit?: number;
|
|
1006
|
+
tags?: string[];
|
|
1007
|
+
include_superseded?: boolean;
|
|
1006
1008
|
},
|
|
1007
1009
|
): Promise<{ entities: unknown[]; count: number }> {
|
|
1008
1010
|
const params = new URLSearchParams();
|
|
@@ -1012,6 +1014,10 @@ export class HarmonyApiClient {
|
|
|
1012
1014
|
if (options?.type) params.set("type", options.type);
|
|
1013
1015
|
if (options?.limit !== undefined)
|
|
1014
1016
|
params.set("limit", String(options.limit));
|
|
1017
|
+
// Repeated `tags` params — the search endpoint reads them via getAll and
|
|
1018
|
+
// matches against the canonical `tags_normalized` column (#299).
|
|
1019
|
+
for (const tag of options?.tags ?? []) params.append("tags", tag);
|
|
1020
|
+
if (options?.include_superseded) params.set("include_superseded", "true");
|
|
1015
1021
|
return this.request("GET", `/memory/search?${params.toString()}`);
|
|
1016
1022
|
}
|
|
1017
1023
|
|
package/src/auto-session.ts
CHANGED
|
@@ -68,14 +68,18 @@ export function resolveAgentIdentity(info: ClientInfo | null): {
|
|
|
68
68
|
/**
|
|
69
69
|
* Tools that trigger auto-start of a session.
|
|
70
70
|
*
|
|
71
|
-
* Restricted to tools that signal real work on a card.
|
|
72
|
-
*
|
|
73
|
-
* and would create false-positive sessions whose side effect
|
|
74
|
-
* `agent` label on the card) confuses both UI and humans.
|
|
71
|
+
* Restricted to tools that signal real work on a card. Triage/board-management
|
|
72
|
+
* ops are excluded — they're routinely used for sorting and card creation, not
|
|
73
|
+
* implementation, and would create false-positive sessions whose side effect
|
|
74
|
+
* (the auto-added `agent` label on the card) confuses both UI and humans.
|
|
75
|
+
*
|
|
76
|
+
* `harmony_update_card` is deliberately NOT a trigger: editing a card's
|
|
77
|
+
* title/description/priority is metadata editing (used during `/hmy` create and
|
|
78
|
+
* triage), not work. Including it spawned phantom sessions on freshly-created
|
|
79
|
+
* cards (card #295), the same reason move/label ops are excluded.
|
|
75
80
|
*/
|
|
76
81
|
export const AUTO_START_TRIGGERS = new Set([
|
|
77
82
|
"harmony_generate_prompt",
|
|
78
|
-
"harmony_update_card",
|
|
79
83
|
"harmony_create_subtask",
|
|
80
84
|
"harmony_toggle_subtask",
|
|
81
85
|
"harmony_update_subtask",
|
|
@@ -134,6 +138,15 @@ export async function trackActivity(
|
|
|
134
138
|
const client = options?.client ?? clientGetter?.();
|
|
135
139
|
if (!client) return;
|
|
136
140
|
|
|
141
|
+
// Resolve agent identity from the MCP `initialize` handshake. Never auto-start
|
|
142
|
+
// an anonymous session: if we can't say WHO is working, we don't fabricate a
|
|
143
|
+
// phantom "Unknown Agent" session (card #295). Identified clients only — this
|
|
144
|
+
// bail happens BEFORE ending other sessions so an unidentified call can't tear
|
|
145
|
+
// down a legitimate tracked session.
|
|
146
|
+
const info = clientInfoGetter?.() ?? null;
|
|
147
|
+
if (!info?.name) return;
|
|
148
|
+
const { agentIdentifier, agentName } = resolveAgentIdentity(info);
|
|
149
|
+
|
|
137
150
|
// Collect auto-sessions on other cards to end (avoid mutating map during iteration)
|
|
138
151
|
const toEnd: string[] = [];
|
|
139
152
|
for (const [otherCardId, session] of activeSessions) {
|
|
@@ -145,10 +158,6 @@ export async function trackActivity(
|
|
|
145
158
|
await autoEndSession(client, otherCardId, "completed");
|
|
146
159
|
}
|
|
147
160
|
|
|
148
|
-
// Resolve agent identity from MCP client info
|
|
149
|
-
const info = clientInfoGetter?.() ?? null;
|
|
150
|
-
const { agentIdentifier, agentName } = resolveAgentIdentity(info);
|
|
151
|
-
|
|
152
161
|
// Start a new auto-session
|
|
153
162
|
try {
|
|
154
163
|
await client.startAgentSession(cardId, {
|
package/src/graph-expansion.ts
CHANGED
|
@@ -138,6 +138,157 @@ export async function findSimilarEntities(
|
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
// ============ WRITE-TIME SEMANTIC DEDUP (card #275) ============
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* RRF-score floor for treating a hybrid-search hit as a *supersede candidate*
|
|
145
|
+
* at write time. The hybrid_search RPC fuses FTS + vector ranks via Reciprocal
|
|
146
|
+
* Rank Fusion: score = 1/(k+fts_rank) + 1/(k+semantic_rank), k=50. A row that
|
|
147
|
+
* ranks #1 in BOTH lists tops out near 2/51 ≈ 0.039; #1 in a single list is
|
|
148
|
+
* ≈ 0.0196. RRF rank is NOT cosine similarity, so this threshold alone is a
|
|
149
|
+
* weak signal — it is paired with a lexical title-overlap guard below so we
|
|
150
|
+
* only ever surface genuinely near-duplicate titles. Deliberately
|
|
151
|
+
* conservative: dedup must never produce false "this already exists" noise.
|
|
152
|
+
*
|
|
153
|
+
* Tuning note: the cross-type causal linker (linkCrossTypeNeighbors) uses
|
|
154
|
+
* minRrfScore 0.04 for a comparable "strongly related" bar; we sit just under
|
|
155
|
+
* it because dedup probes the SAME type and wants the top fused hit.
|
|
156
|
+
*/
|
|
157
|
+
export const SUPERSEDE_RRF_THRESHOLD = 0.029;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Minimum Jaccard overlap of significant title tokens required, in addition to
|
|
161
|
+
* the RRF floor, before a hit counts as a supersede candidate. Guards against
|
|
162
|
+
* semantic-only matches (e.g. two different patterns about "BoardContext")
|
|
163
|
+
* being flagged as duplicates. 0.5 = at least half the significant tokens are
|
|
164
|
+
* shared.
|
|
165
|
+
*/
|
|
166
|
+
export const SUPERSEDE_TITLE_OVERLAP = 0.5;
|
|
167
|
+
|
|
168
|
+
const TITLE_STOPWORDS = new Set([
|
|
169
|
+
"a",
|
|
170
|
+
"an",
|
|
171
|
+
"the",
|
|
172
|
+
"and",
|
|
173
|
+
"or",
|
|
174
|
+
"of",
|
|
175
|
+
"to",
|
|
176
|
+
"in",
|
|
177
|
+
"on",
|
|
178
|
+
"for",
|
|
179
|
+
"with",
|
|
180
|
+
"is",
|
|
181
|
+
"are",
|
|
182
|
+
"be",
|
|
183
|
+
"by",
|
|
184
|
+
"at",
|
|
185
|
+
"as",
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
function significantTitleTokens(title: string): Set<string> {
|
|
189
|
+
return new Set(
|
|
190
|
+
title
|
|
191
|
+
.toLowerCase()
|
|
192
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
193
|
+
.split(/\s+/)
|
|
194
|
+
.filter((t) => t.length > 2 && !TITLE_STOPWORDS.has(t)),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Jaccard similarity of two token sets. Returns 0 when either set is empty. */
|
|
199
|
+
function jaccard(a: Set<string>, b: Set<string>): number {
|
|
200
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
201
|
+
let intersection = 0;
|
|
202
|
+
for (const t of a) if (b.has(t)) intersection++;
|
|
203
|
+
return intersection / (a.size + b.size - intersection);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface SupersedeCandidate {
|
|
207
|
+
id: string;
|
|
208
|
+
title: string;
|
|
209
|
+
/** RRF score from the hybrid search (higher = more relevant). */
|
|
210
|
+
score: number;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Write-time dedup probe (card #275). BEFORE inserting a new memory, find
|
|
215
|
+
* existing, non-superseded entities of the SAME type + scope that look like
|
|
216
|
+
* near-duplicates of the candidate title+content.
|
|
217
|
+
*
|
|
218
|
+
* Reuses the existing hybrid-search path (searchMemoryEntities → the
|
|
219
|
+
* hybrid_search_knowledge_entities RPC) — no new embedding pipeline. A hit must
|
|
220
|
+
* clear BOTH the RRF floor AND a lexical title-overlap guard, so this is a
|
|
221
|
+
* conservative "these are probably the same memory" signal, never a silent
|
|
222
|
+
* merge.
|
|
223
|
+
*
|
|
224
|
+
* Non-fatal and non-blocking: any failure returns [] so the write still
|
|
225
|
+
* proceeds. The caller ALWAYS inserts; this only surfaces candidates for the
|
|
226
|
+
* caller (agent / assistant / human) to optionally supersede.
|
|
227
|
+
*/
|
|
228
|
+
export async function findSupersedeCandidates(
|
|
229
|
+
client: HarmonyApiClient,
|
|
230
|
+
title: string,
|
|
231
|
+
content: string,
|
|
232
|
+
type: string,
|
|
233
|
+
workspaceId: string,
|
|
234
|
+
options?: {
|
|
235
|
+
projectId?: string;
|
|
236
|
+
scope?: string;
|
|
237
|
+
limit?: number;
|
|
238
|
+
rrfThreshold?: number;
|
|
239
|
+
titleOverlap?: number;
|
|
240
|
+
},
|
|
241
|
+
): Promise<SupersedeCandidate[]> {
|
|
242
|
+
const rrfThreshold = options?.rrfThreshold ?? SUPERSEDE_RRF_THRESHOLD;
|
|
243
|
+
const titleOverlap = options?.titleOverlap ?? SUPERSEDE_TITLE_OVERLAP;
|
|
244
|
+
const candidateTokens = significantTitleTokens(title);
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const hits = await findSimilarEntities(
|
|
248
|
+
client,
|
|
249
|
+
title,
|
|
250
|
+
content,
|
|
251
|
+
workspaceId,
|
|
252
|
+
{
|
|
253
|
+
projectId: options?.projectId,
|
|
254
|
+
// Filter to the same type server-side — dedup only applies within a type.
|
|
255
|
+
type,
|
|
256
|
+
limit: options?.limit ?? 10,
|
|
257
|
+
minRrfScore: rrfThreshold,
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
return hits
|
|
262
|
+
.filter((e) => {
|
|
263
|
+
// Same scope only — a project memory shouldn't supersede a global one.
|
|
264
|
+
if (options?.scope && (e as { scope?: string }).scope !== undefined) {
|
|
265
|
+
if ((e as { scope?: string }).scope !== options.scope) return false;
|
|
266
|
+
}
|
|
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.
|
|
272
|
+
if ((e as { superseded_at?: string | null }).superseded_at) {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
// Lexical guard: require real title-token overlap on top of RRF.
|
|
276
|
+
return (
|
|
277
|
+
jaccard(candidateTokens, significantTitleTokens(e.title)) >=
|
|
278
|
+
titleOverlap
|
|
279
|
+
);
|
|
280
|
+
})
|
|
281
|
+
.map((e) => ({
|
|
282
|
+
id: e.id,
|
|
283
|
+
title: e.title,
|
|
284
|
+
score: e.rrf_score ?? 0,
|
|
285
|
+
}));
|
|
286
|
+
} catch {
|
|
287
|
+
// Never block a write because the dedup probe failed.
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
141
292
|
/**
|
|
142
293
|
* Causal lookup table: maps an entity type to the target types it should
|
|
143
294
|
* be linked to, along with the relation type and direction.
|
package/src/memory-park.ts
CHANGED
|
@@ -11,9 +11,18 @@
|
|
|
11
11
|
* baseline so recency + importance still differentiate.
|
|
12
12
|
* recency_decay — exp(-Δt_seconds / τ_type) clamped to [0, 1].
|
|
13
13
|
* τ depends on memory type per plan §4.
|
|
14
|
-
* importance_norm —
|
|
14
|
+
* importance_norm — effective_importance / 10, clamped to [0, 1], where
|
|
15
|
+
* effective_importance folds in two bounded, deterministic
|
|
16
|
+
* signals on top of the stored importance (card #279):
|
|
17
|
+
* + usage bump — proven-useful memories (recalled
|
|
18
|
+
* often) rank above never-recalled ones
|
|
19
|
+
* + feedback bump — 👍/👎 stored in metadata.feedback
|
|
20
|
+
* See `effectiveImportance` below. This is RANKING ONLY:
|
|
21
|
+
* nothing is stored, deleted, or mutated.
|
|
15
22
|
*
|
|
16
|
-
* defaults: α=0.55, β=0.25, γ=0.20 (sum to 1.0).
|
|
23
|
+
* defaults: α=0.55, β=0.25, γ=0.20 (sum to 1.0). Weights are NOT re-tuned by
|
|
24
|
+
* #279 — usage + feedback fold into the existing γ·importance term so the
|
|
25
|
+
* formula stays a stable 3-weight model.
|
|
17
26
|
*
|
|
18
27
|
* The function is pure. Hot-path-safe — no LLM calls, no DB reads.
|
|
19
28
|
*/
|
|
@@ -28,6 +37,37 @@ export const DEFAULT_WEIGHTS = {
|
|
|
28
37
|
importance: 0.2,
|
|
29
38
|
} as const;
|
|
30
39
|
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Usage + feedback bumps (card #279) — fold into effective importance.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Usage bump (card #279, task 2). A bounded, log-scaled lift to importance for
|
|
46
|
+
* memories that have actually been recalled. Proven-useful memories outrank
|
|
47
|
+
* never-recalled ones at equal relevance/recency; never-used ones get +0 and
|
|
48
|
+
* gently sink relative to their used peers.
|
|
49
|
+
*
|
|
50
|
+
* bump = USAGE_BUMP_SCALE · ln(1 + access_count), capped at USAGE_BUMP_MAX
|
|
51
|
+
*
|
|
52
|
+
* Log scaling keeps the lift gentle and diminishing: the jump from 0→1 recall
|
|
53
|
+
* matters most, runaway counts can't dominate. Capped so usage never swamps the
|
|
54
|
+
* stored importance signal. Pure + deterministic — no LLM, no storage change.
|
|
55
|
+
*/
|
|
56
|
+
export const USAGE_BUMP_SCALE = 0.6;
|
|
57
|
+
export const USAGE_BUMP_MAX = 2;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Feedback bump (card #279, task 3). Net 👍/👎 stored non-destructively in
|
|
61
|
+
* `metadata.feedback = { up, down }` shifts importance up (positive net) or
|
|
62
|
+
* down (negative net), bounded and symmetric. Feedback affects RANKING ONLY —
|
|
63
|
+
* it never deletes or supersedes a memory.
|
|
64
|
+
*
|
|
65
|
+
* bump = FEEDBACK_BUMP_SCALE · sign(net) · ln(1 + |net|),
|
|
66
|
+
* clamped to ±FEEDBACK_BUMP_MAX
|
|
67
|
+
*/
|
|
68
|
+
export const FEEDBACK_BUMP_SCALE = 0.8;
|
|
69
|
+
export const FEEDBACK_BUMP_MAX = 2;
|
|
70
|
+
|
|
31
71
|
// Per-type recency time constant τ in seconds.
|
|
32
72
|
// `Infinity` = never decays (preferences shouldn't fade with disuse).
|
|
33
73
|
export const TYPE_TAU_SECONDS: Record<string, number> = {
|
|
@@ -71,17 +111,28 @@ export const TYPE_IMPORTANCE_DEFAULT: Record<string, number> = {
|
|
|
71
111
|
// Types
|
|
72
112
|
// ---------------------------------------------------------------------------
|
|
73
113
|
|
|
114
|
+
/** 👍/👎 counters stored non-destructively at `metadata.feedback`. */
|
|
115
|
+
export interface MemoryFeedback {
|
|
116
|
+
up?: number;
|
|
117
|
+
down?: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
74
120
|
export interface ParkInput {
|
|
75
121
|
type: string;
|
|
76
122
|
importance?: number | null;
|
|
77
123
|
last_accessed_at?: string | null;
|
|
78
124
|
created_at?: string | null;
|
|
125
|
+
/** Recall counter maintained by batch_touch_knowledge_entities (#273). */
|
|
126
|
+
access_count?: number | null;
|
|
127
|
+
/** Carries `metadata.feedback` (#279). Other metadata keys are ignored. */
|
|
128
|
+
metadata?: { feedback?: MemoryFeedback | null } | null;
|
|
79
129
|
}
|
|
80
130
|
|
|
81
131
|
export interface ParkScored<T extends ParkInput> {
|
|
82
132
|
entity: T;
|
|
83
133
|
relevance: number;
|
|
84
134
|
recency: number;
|
|
135
|
+
/** Effective importance term, normalised to [0,1] (post usage + feedback). */
|
|
85
136
|
importance: number;
|
|
86
137
|
score: number;
|
|
87
138
|
}
|
|
@@ -124,10 +175,59 @@ function recencyDecay(
|
|
|
124
175
|
return clamp01(Math.exp(-dtSec / tau));
|
|
125
176
|
}
|
|
126
177
|
|
|
127
|
-
|
|
178
|
+
/** Resolve the stored (base) importance, clamped to [1,10]. */
|
|
179
|
+
function baseImportance(raw: number | null | undefined, type: string): number {
|
|
128
180
|
let v = typeof raw === "number" ? raw : (TYPE_IMPORTANCE_DEFAULT[type] ?? 5);
|
|
129
181
|
if (v < 1) v = 1;
|
|
130
182
|
if (v > 10) v = 10;
|
|
183
|
+
return v;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Bounded, log-scaled usage lift (card #279, task 2). +0 for never-recalled.
|
|
188
|
+
* Pure; reads only `access_count`.
|
|
189
|
+
*/
|
|
190
|
+
export function usageBump(accessCount: number | null | undefined): number {
|
|
191
|
+
const n =
|
|
192
|
+
typeof accessCount === "number" && accessCount > 0 ? accessCount : 0;
|
|
193
|
+
if (n === 0) return 0;
|
|
194
|
+
return Math.min(USAGE_BUMP_MAX, USAGE_BUMP_SCALE * Math.log(1 + n));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Bounded, symmetric feedback shift (card #279, task 3). Positive net 👍 lifts,
|
|
199
|
+
* negative net 👎 demotes. Pure; reads only `metadata.feedback`.
|
|
200
|
+
*/
|
|
201
|
+
export function feedbackBump(
|
|
202
|
+
feedback: MemoryFeedback | null | undefined,
|
|
203
|
+
): number {
|
|
204
|
+
const up = typeof feedback?.up === "number" ? feedback.up : 0;
|
|
205
|
+
const down = typeof feedback?.down === "number" ? feedback.down : 0;
|
|
206
|
+
const net = up - down;
|
|
207
|
+
if (net === 0) return 0;
|
|
208
|
+
const raw =
|
|
209
|
+
FEEDBACK_BUMP_SCALE * Math.sign(net) * Math.log(1 + Math.abs(net));
|
|
210
|
+
if (raw > FEEDBACK_BUMP_MAX) return FEEDBACK_BUMP_MAX;
|
|
211
|
+
if (raw < -FEEDBACK_BUMP_MAX) return -FEEDBACK_BUMP_MAX;
|
|
212
|
+
return raw;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Effective importance = base importance + usage bump + feedback bump, clamped
|
|
217
|
+
* to [1,10], then normalised to [0,1] (card #279, task 2 + 3).
|
|
218
|
+
*
|
|
219
|
+
* Folding usage + feedback into the existing γ·importance term — instead of
|
|
220
|
+
* adding new weights — keeps the Park formula a stable 3-weight model. This is
|
|
221
|
+
* a RANKING-ONLY transform: it reads `access_count` and `metadata.feedback`
|
|
222
|
+
* but never writes, deletes, or supersedes anything.
|
|
223
|
+
*/
|
|
224
|
+
export function effectiveImportance(entity: ParkInput): number {
|
|
225
|
+
const base = baseImportance(entity.importance, entity.type);
|
|
226
|
+
const bump =
|
|
227
|
+
usageBump(entity.access_count) + feedbackBump(entity.metadata?.feedback);
|
|
228
|
+
let v = base + bump;
|
|
229
|
+
if (v < 1) v = 1;
|
|
230
|
+
if (v > 10) v = 10;
|
|
131
231
|
return v / 10;
|
|
132
232
|
}
|
|
133
233
|
|
|
@@ -160,7 +260,7 @@ export function rescore<T extends ParkInput & { id?: string }>(
|
|
|
160
260
|
entity.type,
|
|
161
261
|
now,
|
|
162
262
|
);
|
|
163
|
-
const importance =
|
|
263
|
+
const importance = effectiveImportance(entity);
|
|
164
264
|
const score =
|
|
165
265
|
w.relevance * relevance + w.recency * recency + w.importance * importance;
|
|
166
266
|
return { entity, relevance, recency, importance, score };
|
|
@@ -176,6 +276,28 @@ export function rescore<T extends ParkInput & { id?: string }>(
|
|
|
176
276
|
return scored;
|
|
177
277
|
}
|
|
178
278
|
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// minConfidence filter (#273)
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Keep only entities whose confidence meets the threshold. Entities with a
|
|
285
|
+
* non-numeric confidence are dropped (we can't prove they clear the bar).
|
|
286
|
+
*
|
|
287
|
+
* Pure + exported so the recall path's `minConfidence` semantics are
|
|
288
|
+
* unit-testable. Once writes set non-uniform confidence (#273), a low
|
|
289
|
+
* threshold yields a strictly smaller set than passing no threshold.
|
|
290
|
+
*/
|
|
291
|
+
export function filterByMinConfidence<T extends { confidence?: number | null }>(
|
|
292
|
+
entities: T[],
|
|
293
|
+
minConfidence: number | undefined,
|
|
294
|
+
): T[] {
|
|
295
|
+
if (typeof minConfidence !== "number") return entities;
|
|
296
|
+
return entities.filter(
|
|
297
|
+
(e) => typeof e.confidence === "number" && e.confidence >= minConfidence,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
179
301
|
// ---------------------------------------------------------------------------
|
|
180
302
|
// Rank-to-relevance helper (Phase 1 hybrid retrieval bridge)
|
|
181
303
|
// ---------------------------------------------------------------------------
|
|
@@ -250,3 +372,60 @@ export function fitToBudget<
|
|
|
250
372
|
}
|
|
251
373
|
return out;
|
|
252
374
|
}
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// Stale / never-recalled signal (card #279, task 4)
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
export interface StaleUnusedInput {
|
|
381
|
+
access_count?: number | null;
|
|
382
|
+
last_accessed_at?: string | null;
|
|
383
|
+
created_at?: string | null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Returns true when a memory has NEVER been recalled (access_count 0/absent)
|
|
388
|
+
* AND has existed longer than `thresholdDays`. SIGNAL ONLY — card #280's
|
|
389
|
+
* prune-suggestion digest consumes this to surface candidates for human
|
|
390
|
+
* review. It deletes/modifies NOTHING; non-destructive by construction.
|
|
391
|
+
*
|
|
392
|
+
* Age is measured from `created_at` (a never-recalled memory has no meaningful
|
|
393
|
+
* `last_accessed_at`; #273 only stamps it on recall). A memory that has been
|
|
394
|
+
* recalled even once is never stale-unused, regardless of age.
|
|
395
|
+
*/
|
|
396
|
+
export function isStaleUnused(
|
|
397
|
+
entity: StaleUnusedInput,
|
|
398
|
+
now: Date,
|
|
399
|
+
thresholdDays: number,
|
|
400
|
+
): boolean {
|
|
401
|
+
const recalled =
|
|
402
|
+
typeof entity.access_count === "number" && entity.access_count > 0;
|
|
403
|
+
if (recalled) return false;
|
|
404
|
+
const createdRaw = entity.created_at ?? null;
|
|
405
|
+
if (!createdRaw) return false; // Unknown age: don't flag.
|
|
406
|
+
const created = Date.parse(createdRaw);
|
|
407
|
+
if (Number.isNaN(created)) return false;
|
|
408
|
+
const ageDays = (now.getTime() - created) / (1000 * 60 * 60 * 24);
|
|
409
|
+
return ageDays > thresholdDays;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// Feedback merge (card #279, task 3) — non-destructive counter increment
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Merge a single 👍/👎 vote into an existing feedback counter, returning a NEW
|
|
418
|
+
* object (input is never mutated). Used by the recall-feedback record path to
|
|
419
|
+
* compute the `metadata.feedback` patch before persisting. Pure + bounded to
|
|
420
|
+
* non-negative integers.
|
|
421
|
+
*/
|
|
422
|
+
export function mergeFeedback(
|
|
423
|
+
existing: MemoryFeedback | null | undefined,
|
|
424
|
+
vote: "up" | "down",
|
|
425
|
+
): MemoryFeedback {
|
|
426
|
+
const up =
|
|
427
|
+
typeof existing?.up === "number" && existing.up > 0 ? existing.up : 0;
|
|
428
|
+
const down =
|
|
429
|
+
typeof existing?.down === "number" && existing.down > 0 ? existing.down : 0;
|
|
430
|
+
return vote === "up" ? { up: up + 1, down } : { up, down: down + 1 };
|
|
431
|
+
}
|