@gethmy/mcp 2.4.6 → 2.5.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/README.md +34 -1
- package/dist/cli.js +20867 -18386
- package/dist/index.js +20999 -18518
- package/dist/lib/api-client.js +130 -926
- package/dist/lib/config.js +5 -1
- package/package.json +2 -2
- package/src/__tests__/mcp-integration.test.ts +141 -0
- package/src/__tests__/memory-floor.test.ts +126 -0
- package/src/__tests__/memory-park.test.ts +213 -0
- package/src/__tests__/memory-session.test.ts +77 -0
- package/src/__tests__/prompt-builder.test.ts +234 -0
- package/src/__tests__/remote-routing.test.ts +285 -0
- package/src/__tests__/skills.test.ts +111 -0
- package/src/__tests__/tool-dispatch.test.ts +260 -0
- package/src/api-client.ts +133 -96
- package/src/memory-floor.ts +264 -0
- package/src/memory-park.ts +252 -0
- package/src/memory-session.ts +61 -0
- package/src/prompt-builder.ts +93 -0
- package/src/remote.ts +270 -77
- package/src/server.ts +351 -1467
- package/src/__tests__/active-learning.test.ts +0 -483
- package/src/__tests__/agent-performance-profiles.test.ts +0 -468
- package/src/__tests__/context-assembly.test.ts +0 -506
- package/src/__tests__/lifecycle-maintenance.test.ts +0 -238
- package/src/__tests__/memory-audit.test.ts +0 -528
- package/src/__tests__/pattern-detection.test.ts +0 -438
- package/src/active-learning.ts +0 -1165
- package/src/consolidation.ts +0 -383
- package/src/context-assembly.ts +0 -1175
- package/src/lifecycle-maintenance.ts +0 -120
- package/src/memory-audit.ts +0 -578
- package/src/memory-cleanup.ts +0 -902
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Automatic Memory Lifecycle Maintenance
|
|
3
|
-
*
|
|
4
|
-
* Runs at session end to enforce decay/archival rules:
|
|
5
|
-
* - Archive entities with confidence < 0.3
|
|
6
|
-
* - Delete stale drafts (>30 days old with low decay score)
|
|
7
|
-
* - Auto-promote eligible entities (draft→episode, episode→reference)
|
|
8
|
-
*
|
|
9
|
-
* All operations are non-fatal — failures are logged but never block session end.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { evaluateLifecycle } from "@harmony/memory";
|
|
13
|
-
import type { HarmonyApiClient } from "./api-client.js";
|
|
14
|
-
|
|
15
|
-
interface MemoryEntity {
|
|
16
|
-
id: string;
|
|
17
|
-
title: string;
|
|
18
|
-
memory_tier: "draft" | "episode" | "reference";
|
|
19
|
-
confidence: number;
|
|
20
|
-
access_count: number;
|
|
21
|
-
last_accessed_at: string | null;
|
|
22
|
-
created_at: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface MaintenanceResult {
|
|
26
|
-
archived: number;
|
|
27
|
-
pruned: number;
|
|
28
|
-
promoted: number;
|
|
29
|
-
reviewed: number;
|
|
30
|
-
errors: number;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Run lifecycle maintenance for a workspace.
|
|
35
|
-
* Called automatically at session end alongside consolidation.
|
|
36
|
-
*/
|
|
37
|
-
export async function runLifecycleMaintenance(
|
|
38
|
-
client: HarmonyApiClient,
|
|
39
|
-
workspaceId: string,
|
|
40
|
-
projectId?: string,
|
|
41
|
-
): Promise<MaintenanceResult> {
|
|
42
|
-
const result: MaintenanceResult = {
|
|
43
|
-
archived: 0,
|
|
44
|
-
pruned: 0,
|
|
45
|
-
promoted: 0,
|
|
46
|
-
reviewed: 0,
|
|
47
|
-
errors: 0,
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
let entities: MemoryEntity[];
|
|
51
|
-
try {
|
|
52
|
-
const listResult = await client.listMemoryEntities({
|
|
53
|
-
workspace_id: workspaceId,
|
|
54
|
-
project_id: projectId,
|
|
55
|
-
limit: 200,
|
|
56
|
-
});
|
|
57
|
-
entities = (listResult.entities || []) as MemoryEntity[];
|
|
58
|
-
} catch {
|
|
59
|
-
return result;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (entities.length === 0) return result;
|
|
63
|
-
|
|
64
|
-
const now = Date.now();
|
|
65
|
-
const STALE_DRAFT_MAX_AGE_DAYS = 30;
|
|
66
|
-
|
|
67
|
-
for (const entity of entities) {
|
|
68
|
-
try {
|
|
69
|
-
const lifecycle = evaluateLifecycle(entity);
|
|
70
|
-
|
|
71
|
-
// 1. Archive low-confidence entities (confidence < 0.3)
|
|
72
|
-
if (lifecycle.shouldArchive) {
|
|
73
|
-
await client.deleteMemoryEntity(entity.id);
|
|
74
|
-
result.archived++;
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// 2. Prune stale drafts (>30 days old with low decay score)
|
|
79
|
-
if (entity.memory_tier === "draft") {
|
|
80
|
-
const ageDays =
|
|
81
|
-
(now - new Date(entity.created_at).getTime()) / (1000 * 60 * 60 * 24);
|
|
82
|
-
if (ageDays > STALE_DRAFT_MAX_AGE_DAYS && lifecycle.decay.score < 0.3) {
|
|
83
|
-
await client.deleteMemoryEntity(entity.id);
|
|
84
|
-
result.pruned++;
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// 3. Auto-promote eligible entities
|
|
90
|
-
if (lifecycle.promotion.eligible && lifecycle.promotion.targetTier) {
|
|
91
|
-
await client.updateMemoryEntity(entity.id, {
|
|
92
|
-
memory_tier: lifecycle.promotion.targetTier,
|
|
93
|
-
metadata: {
|
|
94
|
-
promoted_at: new Date().toISOString(),
|
|
95
|
-
promotion_reason: lifecycle.promotion.reason,
|
|
96
|
-
promoted_from: entity.memory_tier,
|
|
97
|
-
},
|
|
98
|
-
});
|
|
99
|
-
result.promoted++;
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// 4. Flag stale entities for review (>90 days, <3 accesses)
|
|
104
|
-
if (lifecycle.shouldFlagForReview) {
|
|
105
|
-
await client.updateMemoryEntity(entity.id, {
|
|
106
|
-
metadata: {
|
|
107
|
-
needs_review: true,
|
|
108
|
-
review_reason: lifecycle.reviewReason,
|
|
109
|
-
flagged_at: new Date().toISOString(),
|
|
110
|
-
},
|
|
111
|
-
});
|
|
112
|
-
result.reviewed++;
|
|
113
|
-
}
|
|
114
|
-
} catch {
|
|
115
|
-
result.errors++;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return result;
|
|
120
|
-
}
|
package/src/memory-audit.ts
DELETED
|
@@ -1,578 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Memory Quality Audit
|
|
3
|
-
*
|
|
4
|
-
* Scores every memory entity against modern quality standards and buckets
|
|
5
|
-
* them into keep / review / archive / delete. Designed to catch legacy
|
|
6
|
-
* memories that pre-date tier/decay/embedding optimizations.
|
|
7
|
-
*
|
|
8
|
-
* Composite score (0-100): confidence (25) + decay (20) + structural (15) +
|
|
9
|
-
* content (15) + tier-age-fit (15) + access (10). Legacy signals (default
|
|
10
|
-
* confidence, missing embedding, stuck draft, no graph presence) are reported
|
|
11
|
-
* but don't change the score — they provide explanation.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { evaluateLifecycle } from "@harmony/memory";
|
|
15
|
-
import type { HarmonyApiClient } from "./api-client.js";
|
|
16
|
-
|
|
17
|
-
// Embeddings migration landed 2026-02-18. Entities older than this without
|
|
18
|
-
// embeddings are pre-vector and legacy by construction.
|
|
19
|
-
const EMBEDDINGS_MIGRATION_AT = Date.parse("2026-02-18T00:00:00Z");
|
|
20
|
-
const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
|
21
|
-
const BATCH_SIZE = 100;
|
|
22
|
-
const CONCURRENCY_LIMIT = 5;
|
|
23
|
-
|
|
24
|
-
interface AuditEntity {
|
|
25
|
-
id: string;
|
|
26
|
-
type: string;
|
|
27
|
-
title: string;
|
|
28
|
-
content: string;
|
|
29
|
-
confidence: number;
|
|
30
|
-
memory_tier: "draft" | "episode" | "reference";
|
|
31
|
-
access_count: number;
|
|
32
|
-
last_accessed_at: string | null;
|
|
33
|
-
created_at: string;
|
|
34
|
-
updated_at?: string;
|
|
35
|
-
tags?: string[];
|
|
36
|
-
metadata?: Record<string, unknown>;
|
|
37
|
-
embedding?: unknown;
|
|
38
|
-
promoted_from_id?: string | null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export type AuditBucket = "keep" | "review" | "archive" | "delete";
|
|
42
|
-
|
|
43
|
-
export interface AuditOptions {
|
|
44
|
-
dryRun?: boolean;
|
|
45
|
-
archiveBelow?: number;
|
|
46
|
-
deleteBelow?: number;
|
|
47
|
-
includeLegacyFlag?: boolean;
|
|
48
|
-
limit?: number;
|
|
49
|
-
/**
|
|
50
|
-
* Age threshold (days) for the stale-draft filter. A memory is flagged
|
|
51
|
-
* stale when `tier=draft AND access_count=0 AND age > staleDraftAgeDays`.
|
|
52
|
-
* Stale drafts are reported separately — they don't change scoring or
|
|
53
|
-
* bucketing, just surface promote-or-drop candidates the thresholds miss.
|
|
54
|
-
* Default: 7.
|
|
55
|
-
*/
|
|
56
|
-
staleDraftAgeDays?: number;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
interface EntityAudit {
|
|
60
|
-
id: string;
|
|
61
|
-
title: string;
|
|
62
|
-
type: string;
|
|
63
|
-
tier: string;
|
|
64
|
-
ageDays: number;
|
|
65
|
-
score: number;
|
|
66
|
-
bucket: AuditBucket;
|
|
67
|
-
reasons: string[];
|
|
68
|
-
legacy: boolean;
|
|
69
|
-
legacyReasons: string[];
|
|
70
|
-
staleDraft: boolean;
|
|
71
|
-
subScores: {
|
|
72
|
-
confidence: number;
|
|
73
|
-
decay: number;
|
|
74
|
-
structural: number;
|
|
75
|
-
content: number;
|
|
76
|
-
tierAgeFit: number;
|
|
77
|
-
access: number;
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export interface AuditReport {
|
|
82
|
-
success: boolean;
|
|
83
|
-
dryRun: boolean;
|
|
84
|
-
timestamp: string;
|
|
85
|
-
workspace: { id: string; projectId?: string };
|
|
86
|
-
summary: {
|
|
87
|
-
totalEntities: number;
|
|
88
|
-
scanned: number;
|
|
89
|
-
keep: number;
|
|
90
|
-
review: number;
|
|
91
|
-
archive: number;
|
|
92
|
-
delete: number;
|
|
93
|
-
legacyCount: number;
|
|
94
|
-
staleDraftCount: number;
|
|
95
|
-
};
|
|
96
|
-
actionsTaken: {
|
|
97
|
-
flaggedReview: number;
|
|
98
|
-
archived: number;
|
|
99
|
-
deleted: number;
|
|
100
|
-
};
|
|
101
|
-
distribution: {
|
|
102
|
-
"0-20": number;
|
|
103
|
-
"20-40": number;
|
|
104
|
-
"40-70": number;
|
|
105
|
-
"70-100": number;
|
|
106
|
-
};
|
|
107
|
-
legacyBreakdown: {
|
|
108
|
-
defaultConfidence: number;
|
|
109
|
-
missingEmbedding: number;
|
|
110
|
-
stuckDraft: number;
|
|
111
|
-
noGraphPresence: number;
|
|
112
|
-
};
|
|
113
|
-
lowest: EntityAudit[];
|
|
114
|
-
staleDrafts: EntityAudit[];
|
|
115
|
-
errors: Array<{ entityId?: string; step: string; message: string }>;
|
|
116
|
-
healthReport: string;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Patterns must stay in sync with:
|
|
120
|
-
// supabase/functions/_shared/memory-boilerplate.ts (edge function guard)
|
|
121
|
-
// supabase/migrations/*_harden_memory_cleanup.sql (cron sweeper)
|
|
122
|
-
//
|
|
123
|
-
// End-anchored where possible to avoid matching legitimate titles like
|
|
124
|
-
// "Placeholder pattern in React" or "Untitled.fig reference". The retired
|
|
125
|
-
// mid-session extractor's "Task transition: ..." prefix is the one open-ended
|
|
126
|
-
// pattern — it was never a user-chosen format.
|
|
127
|
-
const BOILERPLATE_PATTERNS = [
|
|
128
|
-
/^todo:?$/i,
|
|
129
|
-
/^placeholder(\s+\d+|:)?$/i,
|
|
130
|
-
/^\.\.\.$/,
|
|
131
|
-
/^untitled(\s+\d+|:)?$/i,
|
|
132
|
-
/^(note|memo|draft)\s+\d+$/i,
|
|
133
|
-
/^task transition:/i,
|
|
134
|
-
];
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Title-only check. Used by the audit override — should not delete an entry
|
|
138
|
-
* just because its content is empty (may be a draft the user hasn't finished).
|
|
139
|
-
*/
|
|
140
|
-
function isBoilerplateTitle(title: string): boolean {
|
|
141
|
-
const t = title.trim();
|
|
142
|
-
for (const pat of BOILERPLATE_PATTERNS) {
|
|
143
|
-
if (pat.test(t)) return true;
|
|
144
|
-
}
|
|
145
|
-
return false;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Stricter check used by the content-quality scoring band. Empty content is
|
|
150
|
-
* "boilerplate" for scoring — an empty memory contributes nothing regardless
|
|
151
|
-
* of title — but does not on its own trigger the delete-bucket override.
|
|
152
|
-
*/
|
|
153
|
-
function isBoilerplate(title: string, content: string): boolean {
|
|
154
|
-
if (content.trim().length === 0) return true;
|
|
155
|
-
return isBoilerplateTitle(title);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function scoreEntity(
|
|
159
|
-
entity: AuditEntity,
|
|
160
|
-
relationCount: number,
|
|
161
|
-
archiveBelow: number,
|
|
162
|
-
deleteBelow: number,
|
|
163
|
-
staleDraftAgeDays: number,
|
|
164
|
-
): EntityAudit {
|
|
165
|
-
const now = Date.now();
|
|
166
|
-
const ageDays = (now - Date.parse(entity.created_at)) / MS_PER_DAY;
|
|
167
|
-
// If an entity was never accessed, decay should start from creation time,
|
|
168
|
-
// not from "now" (which would falsely yield a fresh decay score of 1.0).
|
|
169
|
-
const effectiveLastAccess = entity.last_accessed_at ?? entity.created_at;
|
|
170
|
-
const lifecycle = evaluateLifecycle({
|
|
171
|
-
memory_tier: entity.memory_tier,
|
|
172
|
-
confidence: entity.confidence,
|
|
173
|
-
access_count: entity.access_count,
|
|
174
|
-
last_accessed_at: effectiveLastAccess,
|
|
175
|
-
created_at: entity.created_at,
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
const reasons: string[] = [];
|
|
179
|
-
const legacyReasons: string[] = [];
|
|
180
|
-
|
|
181
|
-
// Confidence (25)
|
|
182
|
-
const confidence = Math.max(0, Math.min(1, entity.confidence)) * 25;
|
|
183
|
-
|
|
184
|
-
// Decay (20)
|
|
185
|
-
const decay = Math.max(0, Math.min(1, lifecycle.decay.score)) * 20;
|
|
186
|
-
if (lifecycle.decay.score < 0.2)
|
|
187
|
-
reasons.push(`decay score ${lifecycle.decay.score.toFixed(2)}`);
|
|
188
|
-
|
|
189
|
-
// Structural completeness (15)
|
|
190
|
-
const hasEmbedding = entity.embedding != null;
|
|
191
|
-
const hasTags = (entity.tags?.length || 0) >= 1;
|
|
192
|
-
const hasRelations = relationCount > 0;
|
|
193
|
-
let structural = 0;
|
|
194
|
-
if (hasEmbedding) structural += 6;
|
|
195
|
-
if (hasTags) structural += 4;
|
|
196
|
-
if (hasRelations) structural += 5;
|
|
197
|
-
if (!hasEmbedding) reasons.push("no embedding");
|
|
198
|
-
if (!hasTags) reasons.push("no tags");
|
|
199
|
-
if (!hasRelations) reasons.push("no relations");
|
|
200
|
-
|
|
201
|
-
// Content quality (15) — boilerplate hard-zeroes the whole band. Auto-captured
|
|
202
|
-
// noise should never inherit the length/title bonuses it structurally earns.
|
|
203
|
-
let content = 0;
|
|
204
|
-
const contentLen = entity.content?.length || 0;
|
|
205
|
-
const boilerplate = isBoilerplate(entity.title, entity.content);
|
|
206
|
-
if (boilerplate) {
|
|
207
|
-
reasons.push("boilerplate title/content");
|
|
208
|
-
} else {
|
|
209
|
-
if (contentLen >= 80) content += 8;
|
|
210
|
-
const titleOk =
|
|
211
|
-
entity.title.trim().length >= 4 &&
|
|
212
|
-
!/^(untitled|draft|note)\b/i.test(entity.title.trim());
|
|
213
|
-
if (titleOk) content += 4;
|
|
214
|
-
content += 3;
|
|
215
|
-
if (contentLen < 80) reasons.push(`thin content (${contentLen} chars)`);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Tier-age fit (15)
|
|
219
|
-
let tierAgeFit = 15;
|
|
220
|
-
if (
|
|
221
|
-
entity.memory_tier === "draft" &&
|
|
222
|
-
ageDays > 60 &&
|
|
223
|
-
!entity.promoted_from_id
|
|
224
|
-
) {
|
|
225
|
-
tierAgeFit = 0;
|
|
226
|
-
reasons.push("stuck draft >60d never promoted");
|
|
227
|
-
} else if (
|
|
228
|
-
entity.memory_tier === "draft" &&
|
|
229
|
-
(entity.access_count || 0) === 0 &&
|
|
230
|
-
ageDays > 2
|
|
231
|
-
) {
|
|
232
|
-
// Young drafts get a 2-day grace window. After that, zero access means
|
|
233
|
-
// zero signal — strip the tier-age bonus so useless auto-captures fall to archive.
|
|
234
|
-
tierAgeFit = 5;
|
|
235
|
-
reasons.push("draft >2d with zero access");
|
|
236
|
-
}
|
|
237
|
-
if (entity.promoted_from_id) {
|
|
238
|
-
tierAgeFit = Math.min(15, tierAgeFit + 5);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// Access pattern (10)
|
|
242
|
-
const access = Math.min(10, Math.log10((entity.access_count || 0) + 1) * 5);
|
|
243
|
-
if (entity.access_count === 0 && ageDays > 14) reasons.push("never accessed");
|
|
244
|
-
|
|
245
|
-
const raw = confidence + decay + structural + content + tierAgeFit + access;
|
|
246
|
-
const score = Math.round(Math.max(0, Math.min(100, raw)));
|
|
247
|
-
|
|
248
|
-
// Legacy detection
|
|
249
|
-
let legacy = false;
|
|
250
|
-
if (entity.confidence === 1.0 && entity.access_count === 0 && ageDays > 30) {
|
|
251
|
-
legacy = true;
|
|
252
|
-
legacyReasons.push("default confidence never validated");
|
|
253
|
-
}
|
|
254
|
-
if (
|
|
255
|
-
!hasEmbedding &&
|
|
256
|
-
Date.parse(entity.created_at) < EMBEDDINGS_MIGRATION_AT
|
|
257
|
-
) {
|
|
258
|
-
legacy = true;
|
|
259
|
-
legacyReasons.push("pre-embeddings migration");
|
|
260
|
-
}
|
|
261
|
-
if (
|
|
262
|
-
entity.memory_tier === "draft" &&
|
|
263
|
-
ageDays > 60 &&
|
|
264
|
-
!entity.promoted_from_id
|
|
265
|
-
) {
|
|
266
|
-
legacy = true;
|
|
267
|
-
legacyReasons.push("stuck draft");
|
|
268
|
-
}
|
|
269
|
-
if (!hasTags && !hasRelations) {
|
|
270
|
-
legacy = true;
|
|
271
|
-
legacyReasons.push("no graph presence");
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Bucket — boilerplate TITLE is a one-way door to delete. High access
|
|
275
|
-
// counts on noise titles signal re-read churn (recall/dedup loops), not
|
|
276
|
-
// genuine reuse; letting confidence + tier + decay drag the composite
|
|
277
|
-
// score back into "keep" leaves promoted-to-reference junk untouched.
|
|
278
|
-
// Override scoring, except when deleteBelow=0 (the "no deletions" escape
|
|
279
|
-
// hatch). Title-only on purpose: an empty-content entry with a real title
|
|
280
|
-
// may be a draft; the user should see it in the audit, not lose it.
|
|
281
|
-
const boilerplateTitle = isBoilerplateTitle(entity.title);
|
|
282
|
-
let bucket: AuditBucket;
|
|
283
|
-
if (boilerplateTitle && deleteBelow > 0) {
|
|
284
|
-
bucket = "delete";
|
|
285
|
-
reasons.push("boilerplate title override");
|
|
286
|
-
} else if (score < deleteBelow) bucket = "delete";
|
|
287
|
-
else if (score < archiveBelow) bucket = "archive";
|
|
288
|
-
else if (score < 70) bucket = "review";
|
|
289
|
-
else bucket = "keep";
|
|
290
|
-
|
|
291
|
-
// Stale-draft filter — orthogonal to bucketing. A draft that's aged past
|
|
292
|
-
// the threshold without a single access is a promote-or-drop candidate
|
|
293
|
-
// regardless of its composite score.
|
|
294
|
-
const staleDraft =
|
|
295
|
-
entity.memory_tier === "draft" &&
|
|
296
|
-
(entity.access_count || 0) === 0 &&
|
|
297
|
-
ageDays > staleDraftAgeDays;
|
|
298
|
-
|
|
299
|
-
return {
|
|
300
|
-
id: entity.id,
|
|
301
|
-
title: entity.title,
|
|
302
|
-
type: entity.type,
|
|
303
|
-
tier: entity.memory_tier,
|
|
304
|
-
ageDays: Math.round(ageDays),
|
|
305
|
-
score,
|
|
306
|
-
bucket,
|
|
307
|
-
reasons,
|
|
308
|
-
legacy,
|
|
309
|
-
legacyReasons,
|
|
310
|
-
staleDraft,
|
|
311
|
-
subScores: {
|
|
312
|
-
confidence: Math.round(confidence),
|
|
313
|
-
decay: Math.round(decay),
|
|
314
|
-
structural,
|
|
315
|
-
content,
|
|
316
|
-
tierAgeFit,
|
|
317
|
-
access: Math.round(access),
|
|
318
|
-
},
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
export async function runMemoryAudit(
|
|
323
|
-
client: HarmonyApiClient,
|
|
324
|
-
workspaceId: string,
|
|
325
|
-
projectId?: string,
|
|
326
|
-
options?: AuditOptions,
|
|
327
|
-
): Promise<AuditReport> {
|
|
328
|
-
const dryRun = options?.dryRun !== false;
|
|
329
|
-
const archiveBelow = options?.archiveBelow ?? 40;
|
|
330
|
-
const deleteBelow = options?.deleteBelow ?? 20;
|
|
331
|
-
const limit = options?.limit ?? 500;
|
|
332
|
-
const staleDraftAgeDays = options?.staleDraftAgeDays ?? 7;
|
|
333
|
-
|
|
334
|
-
const report: AuditReport = {
|
|
335
|
-
success: true,
|
|
336
|
-
dryRun,
|
|
337
|
-
timestamp: new Date().toISOString(),
|
|
338
|
-
workspace: { id: workspaceId, projectId },
|
|
339
|
-
summary: {
|
|
340
|
-
totalEntities: 0,
|
|
341
|
-
scanned: 0,
|
|
342
|
-
keep: 0,
|
|
343
|
-
review: 0,
|
|
344
|
-
archive: 0,
|
|
345
|
-
delete: 0,
|
|
346
|
-
legacyCount: 0,
|
|
347
|
-
staleDraftCount: 0,
|
|
348
|
-
},
|
|
349
|
-
actionsTaken: { flaggedReview: 0, archived: 0, deleted: 0 },
|
|
350
|
-
distribution: { "0-20": 0, "20-40": 0, "40-70": 0, "70-100": 0 },
|
|
351
|
-
legacyBreakdown: {
|
|
352
|
-
defaultConfidence: 0,
|
|
353
|
-
missingEmbedding: 0,
|
|
354
|
-
stuckDraft: 0,
|
|
355
|
-
noGraphPresence: 0,
|
|
356
|
-
},
|
|
357
|
-
lowest: [],
|
|
358
|
-
staleDrafts: [],
|
|
359
|
-
errors: [],
|
|
360
|
-
healthReport: "",
|
|
361
|
-
};
|
|
362
|
-
|
|
363
|
-
// Paginate
|
|
364
|
-
const entities: AuditEntity[] = [];
|
|
365
|
-
let offset = 0;
|
|
366
|
-
try {
|
|
367
|
-
while (entities.length < limit) {
|
|
368
|
-
const pageSize = Math.min(BATCH_SIZE, limit - entities.length);
|
|
369
|
-
const result = await client.listMemoryEntities({
|
|
370
|
-
workspace_id: workspaceId,
|
|
371
|
-
project_id: projectId,
|
|
372
|
-
limit: pageSize,
|
|
373
|
-
offset,
|
|
374
|
-
});
|
|
375
|
-
const page = (result.entities || []) as AuditEntity[];
|
|
376
|
-
if (page.length === 0) break;
|
|
377
|
-
entities.push(...page);
|
|
378
|
-
if (page.length < pageSize) break;
|
|
379
|
-
offset += pageSize;
|
|
380
|
-
}
|
|
381
|
-
} catch (err) {
|
|
382
|
-
report.errors.push({
|
|
383
|
-
step: "fetch",
|
|
384
|
-
message: `Failed to fetch entities: ${(err as Error).message}`,
|
|
385
|
-
});
|
|
386
|
-
report.success = false;
|
|
387
|
-
report.healthReport = renderReport(report);
|
|
388
|
-
return report;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
report.summary.totalEntities = entities.length;
|
|
392
|
-
|
|
393
|
-
// Fetch relation counts concurrently
|
|
394
|
-
const relationCounts = new Map<string, number>();
|
|
395
|
-
for (let i = 0; i < entities.length; i += CONCURRENCY_LIMIT) {
|
|
396
|
-
const batch = entities.slice(i, i + CONCURRENCY_LIMIT);
|
|
397
|
-
const results = await Promise.allSettled(
|
|
398
|
-
batch.map(async (e) => {
|
|
399
|
-
const related = await client.getRelatedEntities(e.id);
|
|
400
|
-
const count =
|
|
401
|
-
(related.outgoing?.length || 0) + (related.incoming?.length || 0);
|
|
402
|
-
return { id: e.id, count };
|
|
403
|
-
}),
|
|
404
|
-
);
|
|
405
|
-
for (const r of results) {
|
|
406
|
-
if (r.status === "fulfilled") {
|
|
407
|
-
relationCounts.set(r.value.id, r.value.count);
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// Score each entity
|
|
413
|
-
const audits: EntityAudit[] = [];
|
|
414
|
-
for (const entity of entities) {
|
|
415
|
-
const relCount = relationCounts.get(entity.id) ?? 0;
|
|
416
|
-
const audit = scoreEntity(
|
|
417
|
-
entity,
|
|
418
|
-
relCount,
|
|
419
|
-
archiveBelow,
|
|
420
|
-
deleteBelow,
|
|
421
|
-
staleDraftAgeDays,
|
|
422
|
-
);
|
|
423
|
-
audits.push(audit);
|
|
424
|
-
report.summary.scanned++;
|
|
425
|
-
report.summary[audit.bucket]++;
|
|
426
|
-
if (audit.legacy) report.summary.legacyCount++;
|
|
427
|
-
if (audit.staleDraft) report.summary.staleDraftCount++;
|
|
428
|
-
|
|
429
|
-
// Distribution bin
|
|
430
|
-
if (audit.score < 20) report.distribution["0-20"]++;
|
|
431
|
-
else if (audit.score < 40) report.distribution["20-40"]++;
|
|
432
|
-
else if (audit.score < 70) report.distribution["40-70"]++;
|
|
433
|
-
else report.distribution["70-100"]++;
|
|
434
|
-
|
|
435
|
-
// Legacy breakdown
|
|
436
|
-
for (const reason of audit.legacyReasons) {
|
|
437
|
-
if (reason.startsWith("default confidence"))
|
|
438
|
-
report.legacyBreakdown.defaultConfidence++;
|
|
439
|
-
else if (reason.startsWith("pre-embeddings"))
|
|
440
|
-
report.legacyBreakdown.missingEmbedding++;
|
|
441
|
-
else if (reason.startsWith("stuck draft"))
|
|
442
|
-
report.legacyBreakdown.stuckDraft++;
|
|
443
|
-
else if (reason.startsWith("no graph"))
|
|
444
|
-
report.legacyBreakdown.noGraphPresence++;
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// Top 10 lowest-scoring
|
|
449
|
-
report.lowest = [...audits].sort((a, b) => a.score - b.score).slice(0, 10);
|
|
450
|
-
|
|
451
|
-
// All stale drafts — oldest first (most overdue for promote-or-drop)
|
|
452
|
-
report.staleDrafts = audits
|
|
453
|
-
.filter((a) => a.staleDraft)
|
|
454
|
-
.sort((a, b) => b.ageDays - a.ageDays);
|
|
455
|
-
|
|
456
|
-
// Execute actions
|
|
457
|
-
if (!dryRun) {
|
|
458
|
-
for (const audit of audits) {
|
|
459
|
-
try {
|
|
460
|
-
if (audit.bucket === "delete") {
|
|
461
|
-
await client.deleteMemoryEntity(audit.id);
|
|
462
|
-
report.actionsTaken.deleted++;
|
|
463
|
-
} else if (audit.bucket === "archive") {
|
|
464
|
-
await client.updateMemoryEntity(audit.id, {
|
|
465
|
-
confidence: 0.25,
|
|
466
|
-
metadata: {
|
|
467
|
-
audit_archived_at: new Date().toISOString(),
|
|
468
|
-
audit_score: audit.score,
|
|
469
|
-
audit_reasons: audit.reasons,
|
|
470
|
-
},
|
|
471
|
-
});
|
|
472
|
-
report.actionsTaken.archived++;
|
|
473
|
-
} else if (audit.bucket === "review") {
|
|
474
|
-
await client.updateMemoryEntity(audit.id, {
|
|
475
|
-
metadata: {
|
|
476
|
-
needs_review: true,
|
|
477
|
-
audit_score: audit.score,
|
|
478
|
-
audit_reasons: audit.reasons,
|
|
479
|
-
audit_at: new Date().toISOString(),
|
|
480
|
-
},
|
|
481
|
-
});
|
|
482
|
-
report.actionsTaken.flaggedReview++;
|
|
483
|
-
}
|
|
484
|
-
} catch (err) {
|
|
485
|
-
report.errors.push({
|
|
486
|
-
entityId: audit.id,
|
|
487
|
-
step: audit.bucket,
|
|
488
|
-
message: (err as Error).message,
|
|
489
|
-
});
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
report.healthReport = renderReport(report);
|
|
495
|
-
return report;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function renderReport(report: AuditReport): string {
|
|
499
|
-
const mode = report.dryRun ? "Dry Run (preview)" : "Executed";
|
|
500
|
-
const s = report.summary;
|
|
501
|
-
const lines: string[] = [
|
|
502
|
-
"# Memory Quality Audit\n",
|
|
503
|
-
`**Mode:** ${mode} | **Scanned:** ${s.scanned}/${s.totalEntities} | **Legacy:** ${s.legacyCount} | **Stale drafts:** ${s.staleDraftCount}`,
|
|
504
|
-
"",
|
|
505
|
-
"## Distribution",
|
|
506
|
-
`- 70-100 (keep): ${report.distribution["70-100"]}`,
|
|
507
|
-
`- 40-69 (review): ${report.distribution["40-70"]}`,
|
|
508
|
-
`- 20-39 (archive): ${report.distribution["20-40"]}`,
|
|
509
|
-
`- 0-19 (delete): ${report.distribution["0-20"]}`,
|
|
510
|
-
"",
|
|
511
|
-
"## Buckets",
|
|
512
|
-
`- **Keep:** ${s.keep}`,
|
|
513
|
-
`- **Review:** ${s.review}${!report.dryRun ? ` (flagged ${report.actionsTaken.flaggedReview})` : ""}`,
|
|
514
|
-
`- **Archive:** ${s.archive}${!report.dryRun ? ` (archived ${report.actionsTaken.archived})` : ""}`,
|
|
515
|
-
`- **Delete:** ${s.delete}${!report.dryRun ? ` (deleted ${report.actionsTaken.deleted})` : ""}`,
|
|
516
|
-
"",
|
|
517
|
-
];
|
|
518
|
-
|
|
519
|
-
const l = report.legacyBreakdown;
|
|
520
|
-
if (s.legacyCount > 0) {
|
|
521
|
-
lines.push("## Legacy Breakdown");
|
|
522
|
-
lines.push(`- Default confidence, never validated: ${l.defaultConfidence}`);
|
|
523
|
-
lines.push(`- Pre-embeddings migration: ${l.missingEmbedding}`);
|
|
524
|
-
lines.push(`- Stuck drafts (>60d, no promotion): ${l.stuckDraft}`);
|
|
525
|
-
lines.push(`- No tags + no relations: ${l.noGraphPresence}`);
|
|
526
|
-
lines.push("");
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
if (report.staleDrafts.length > 0) {
|
|
530
|
-
lines.push("## Stale Drafts (promote-or-drop candidates)");
|
|
531
|
-
lines.push("Filter: `tier=draft AND access=0 AND age>threshold`");
|
|
532
|
-
lines.push("| Age | Score | Title |");
|
|
533
|
-
lines.push("|-----|-------|-------|");
|
|
534
|
-
for (const a of report.staleDrafts.slice(0, 20)) {
|
|
535
|
-
const titleTrunc =
|
|
536
|
-
a.title.length > 50 ? `${a.title.slice(0, 47)}...` : a.title;
|
|
537
|
-
lines.push(`| ${a.ageDays}d | ${a.score} | ${titleTrunc} |`);
|
|
538
|
-
}
|
|
539
|
-
lines.push("");
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (report.lowest.length > 0) {
|
|
543
|
-
lines.push("## Lowest-Scoring (top 10)");
|
|
544
|
-
lines.push("| Score | Bucket | Tier | Age | Title | Reasons |");
|
|
545
|
-
lines.push("|-------|--------|------|-----|-------|---------|");
|
|
546
|
-
for (const a of report.lowest) {
|
|
547
|
-
const reasonStr = a.reasons.slice(0, 3).join(", ") || "—";
|
|
548
|
-
const titleTrunc =
|
|
549
|
-
a.title.length > 40 ? `${a.title.slice(0, 37)}...` : a.title;
|
|
550
|
-
lines.push(
|
|
551
|
-
`| ${a.score} | ${a.bucket} | ${a.tier} | ${a.ageDays}d | ${titleTrunc} | ${reasonStr} |`,
|
|
552
|
-
);
|
|
553
|
-
}
|
|
554
|
-
lines.push("");
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
if (report.errors.length > 0) {
|
|
558
|
-
lines.push("## Errors");
|
|
559
|
-
for (const e of report.errors.slice(0, 10)) {
|
|
560
|
-
lines.push(
|
|
561
|
-
`- **${e.step}${e.entityId ? ` ${e.entityId}` : ""}:** ${e.message}`,
|
|
562
|
-
);
|
|
563
|
-
}
|
|
564
|
-
lines.push("");
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
if (report.dryRun) {
|
|
568
|
-
lines.push("---");
|
|
569
|
-
lines.push(
|
|
570
|
-
"*Run with `dryRun: false` to flag review entries, archive low-quality memories, and delete worst offenders.*",
|
|
571
|
-
);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
return lines.join("\n");
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// Exposed for reuse from memory-cleanup.ts
|
|
578
|
-
export { scoreEntity };
|