@gethmy/mcp 2.4.0 → 2.4.2
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 +47 -16
- package/dist/index.js +47 -16
- package/package.json +1 -1
- package/src/__tests__/memory-audit.test.ts +90 -0
- package/src/memory-audit.ts +76 -11
- package/src/server.ts +19 -3
package/dist/cli.js
CHANGED
|
@@ -28074,7 +28074,8 @@ var BOILERPLATE_PATTERNS = [
|
|
|
28074
28074
|
/^placeholder/i,
|
|
28075
28075
|
/^\.\.\.$/,
|
|
28076
28076
|
/^untitled/i,
|
|
28077
|
-
/^(note|memo|draft)\s*\d*$/i
|
|
28077
|
+
/^(note|memo|draft)\s*\d*$/i,
|
|
28078
|
+
/^task transition:/i
|
|
28078
28079
|
];
|
|
28079
28080
|
function isBoilerplate(title, content) {
|
|
28080
28081
|
const t = title.trim();
|
|
@@ -28087,7 +28088,7 @@ function isBoilerplate(title, content) {
|
|
|
28087
28088
|
}
|
|
28088
28089
|
return false;
|
|
28089
28090
|
}
|
|
28090
|
-
function scoreEntity(entity, relationCount, archiveBelow, deleteBelow) {
|
|
28091
|
+
function scoreEntity(entity, relationCount, archiveBelow, deleteBelow, staleDraftAgeDays) {
|
|
28091
28092
|
const now = Date.now();
|
|
28092
28093
|
const ageDays = (now - Date.parse(entity.created_at)) / MS_PER_DAY;
|
|
28093
28094
|
const effectiveLastAccess = entity.last_accessed_at ?? entity.created_at;
|
|
@@ -28122,21 +28123,26 @@ function scoreEntity(entity, relationCount, archiveBelow, deleteBelow) {
|
|
|
28122
28123
|
reasons.push("no relations");
|
|
28123
28124
|
let content = 0;
|
|
28124
28125
|
const contentLen = entity.content?.length || 0;
|
|
28125
|
-
|
|
28126
|
-
|
|
28127
|
-
const titleOk = entity.title.trim().length >= 4 && !/^(untitled|draft|note)\b/i.test(entity.title.trim());
|
|
28128
|
-
if (titleOk)
|
|
28129
|
-
content += 4;
|
|
28130
|
-
if (!isBoilerplate(entity.title, entity.content))
|
|
28131
|
-
content += 3;
|
|
28132
|
-
if (contentLen < 80)
|
|
28133
|
-
reasons.push(`thin content (${contentLen} chars)`);
|
|
28134
|
-
if (isBoilerplate(entity.title, entity.content))
|
|
28126
|
+
const boilerplate = isBoilerplate(entity.title, entity.content);
|
|
28127
|
+
if (boilerplate) {
|
|
28135
28128
|
reasons.push("boilerplate title/content");
|
|
28129
|
+
} else {
|
|
28130
|
+
if (contentLen >= 80)
|
|
28131
|
+
content += 8;
|
|
28132
|
+
const titleOk = entity.title.trim().length >= 4 && !/^(untitled|draft|note)\b/i.test(entity.title.trim());
|
|
28133
|
+
if (titleOk)
|
|
28134
|
+
content += 4;
|
|
28135
|
+
content += 3;
|
|
28136
|
+
if (contentLen < 80)
|
|
28137
|
+
reasons.push(`thin content (${contentLen} chars)`);
|
|
28138
|
+
}
|
|
28136
28139
|
let tierAgeFit = 15;
|
|
28137
28140
|
if (entity.memory_tier === "draft" && ageDays > 60 && !entity.promoted_from_id) {
|
|
28138
28141
|
tierAgeFit = 0;
|
|
28139
28142
|
reasons.push("stuck draft >60d never promoted");
|
|
28143
|
+
} else if (entity.memory_tier === "draft" && (entity.access_count || 0) === 0 && ageDays > 2) {
|
|
28144
|
+
tierAgeFit = 5;
|
|
28145
|
+
reasons.push("draft >2d with zero access");
|
|
28140
28146
|
}
|
|
28141
28147
|
if (entity.promoted_from_id) {
|
|
28142
28148
|
tierAgeFit = Math.min(15, tierAgeFit + 5);
|
|
@@ -28172,6 +28178,7 @@ function scoreEntity(entity, relationCount, archiveBelow, deleteBelow) {
|
|
|
28172
28178
|
bucket = "review";
|
|
28173
28179
|
else
|
|
28174
28180
|
bucket = "keep";
|
|
28181
|
+
const staleDraft = entity.memory_tier === "draft" && (entity.access_count || 0) === 0 && ageDays > staleDraftAgeDays;
|
|
28175
28182
|
return {
|
|
28176
28183
|
id: entity.id,
|
|
28177
28184
|
title: entity.title,
|
|
@@ -28183,6 +28190,7 @@ function scoreEntity(entity, relationCount, archiveBelow, deleteBelow) {
|
|
|
28183
28190
|
reasons,
|
|
28184
28191
|
legacy,
|
|
28185
28192
|
legacyReasons,
|
|
28193
|
+
staleDraft,
|
|
28186
28194
|
subScores: {
|
|
28187
28195
|
confidence: Math.round(confidence),
|
|
28188
28196
|
decay: Math.round(decay),
|
|
@@ -28198,6 +28206,7 @@ async function runMemoryAudit(client3, workspaceId, projectId, options) {
|
|
|
28198
28206
|
const archiveBelow = options?.archiveBelow ?? 40;
|
|
28199
28207
|
const deleteBelow = options?.deleteBelow ?? 20;
|
|
28200
28208
|
const limit = options?.limit ?? 500;
|
|
28209
|
+
const staleDraftAgeDays = options?.staleDraftAgeDays ?? 7;
|
|
28201
28210
|
const report = {
|
|
28202
28211
|
success: true,
|
|
28203
28212
|
dryRun,
|
|
@@ -28210,7 +28219,8 @@ async function runMemoryAudit(client3, workspaceId, projectId, options) {
|
|
|
28210
28219
|
review: 0,
|
|
28211
28220
|
archive: 0,
|
|
28212
28221
|
delete: 0,
|
|
28213
|
-
legacyCount: 0
|
|
28222
|
+
legacyCount: 0,
|
|
28223
|
+
staleDraftCount: 0
|
|
28214
28224
|
},
|
|
28215
28225
|
actionsTaken: { flaggedReview: 0, archived: 0, deleted: 0 },
|
|
28216
28226
|
distribution: { "0-20": 0, "20-40": 0, "40-70": 0, "70-100": 0 },
|
|
@@ -28221,6 +28231,7 @@ async function runMemoryAudit(client3, workspaceId, projectId, options) {
|
|
|
28221
28231
|
noGraphPresence: 0
|
|
28222
28232
|
},
|
|
28223
28233
|
lowest: [],
|
|
28234
|
+
staleDrafts: [],
|
|
28224
28235
|
errors: [],
|
|
28225
28236
|
healthReport: ""
|
|
28226
28237
|
};
|
|
@@ -28270,12 +28281,14 @@ async function runMemoryAudit(client3, workspaceId, projectId, options) {
|
|
|
28270
28281
|
const audits = [];
|
|
28271
28282
|
for (const entity of entities) {
|
|
28272
28283
|
const relCount = relationCounts.get(entity.id) ?? 0;
|
|
28273
|
-
const audit = scoreEntity(entity, relCount, archiveBelow, deleteBelow);
|
|
28284
|
+
const audit = scoreEntity(entity, relCount, archiveBelow, deleteBelow, staleDraftAgeDays);
|
|
28274
28285
|
audits.push(audit);
|
|
28275
28286
|
report.summary.scanned++;
|
|
28276
28287
|
report.summary[audit.bucket]++;
|
|
28277
28288
|
if (audit.legacy)
|
|
28278
28289
|
report.summary.legacyCount++;
|
|
28290
|
+
if (audit.staleDraft)
|
|
28291
|
+
report.summary.staleDraftCount++;
|
|
28279
28292
|
if (audit.score < 20)
|
|
28280
28293
|
report.distribution["0-20"]++;
|
|
28281
28294
|
else if (audit.score < 40)
|
|
@@ -28296,6 +28309,7 @@ async function runMemoryAudit(client3, workspaceId, projectId, options) {
|
|
|
28296
28309
|
}
|
|
28297
28310
|
}
|
|
28298
28311
|
report.lowest = [...audits].sort((a, b) => a.score - b.score).slice(0, 10);
|
|
28312
|
+
report.staleDrafts = audits.filter((a) => a.staleDraft).sort((a, b) => b.ageDays - a.ageDays);
|
|
28299
28313
|
if (!dryRun) {
|
|
28300
28314
|
for (const audit of audits) {
|
|
28301
28315
|
try {
|
|
@@ -28341,7 +28355,7 @@ function renderReport(report) {
|
|
|
28341
28355
|
const lines = [
|
|
28342
28356
|
`# Memory Quality Audit
|
|
28343
28357
|
`,
|
|
28344
|
-
`**Mode:** ${mode} | **Scanned:** ${s.scanned}/${s.totalEntities} | **Legacy:** ${s.legacyCount}`,
|
|
28358
|
+
`**Mode:** ${mode} | **Scanned:** ${s.scanned}/${s.totalEntities} | **Legacy:** ${s.legacyCount} | **Stale drafts:** ${s.staleDraftCount}`,
|
|
28345
28359
|
"",
|
|
28346
28360
|
"## Distribution",
|
|
28347
28361
|
`- 70-100 (keep): ${report.distribution["70-100"]}`,
|
|
@@ -28365,6 +28379,17 @@ function renderReport(report) {
|
|
|
28365
28379
|
lines.push(`- No tags + no relations: ${l.noGraphPresence}`);
|
|
28366
28380
|
lines.push("");
|
|
28367
28381
|
}
|
|
28382
|
+
if (report.staleDrafts.length > 0) {
|
|
28383
|
+
lines.push("## Stale Drafts (promote-or-drop candidates)");
|
|
28384
|
+
lines.push("Filter: `tier=draft AND access=0 AND age>threshold`");
|
|
28385
|
+
lines.push("| Age | Score | Title |");
|
|
28386
|
+
lines.push("|-----|-------|-------|");
|
|
28387
|
+
for (const a of report.staleDrafts.slice(0, 20)) {
|
|
28388
|
+
const titleTrunc = a.title.length > 50 ? `${a.title.slice(0, 47)}...` : a.title;
|
|
28389
|
+
lines.push(`| ${a.ageDays}d | ${a.score} | ${titleTrunc} |`);
|
|
28390
|
+
}
|
|
28391
|
+
lines.push("");
|
|
28392
|
+
}
|
|
28368
28393
|
if (report.lowest.length > 0) {
|
|
28369
28394
|
lines.push("## Lowest-Scoring (top 10)");
|
|
28370
28395
|
lines.push("| Score | Bucket | Tier | Age | Title | Reasons |");
|
|
@@ -30459,6 +30484,10 @@ var TOOLS = {
|
|
|
30459
30484
|
limit: {
|
|
30460
30485
|
type: "number",
|
|
30461
30486
|
description: "Max number of entities to audit (default: 500). Paginated fetch."
|
|
30487
|
+
},
|
|
30488
|
+
staleDraftAgeDays: {
|
|
30489
|
+
type: "number",
|
|
30490
|
+
description: "Age threshold (days) for the stale-draft filter: flags drafts with 0 accesses older than this. Reported separately from bucket scoring — surfaces promote-or-drop candidates the thresholds miss. Default: 7."
|
|
30462
30491
|
}
|
|
30463
30492
|
},
|
|
30464
30493
|
required: []
|
|
@@ -32038,7 +32067,8 @@ async function handleToolCall(name, args, deps) {
|
|
|
32038
32067
|
dryRun: args.dryRun,
|
|
32039
32068
|
archiveBelow: args.archiveBelow,
|
|
32040
32069
|
deleteBelow: args.deleteBelow,
|
|
32041
|
-
limit: args.limit
|
|
32070
|
+
limit: args.limit,
|
|
32071
|
+
staleDraftAgeDays: args.staleDraftAgeDays
|
|
32042
32072
|
});
|
|
32043
32073
|
return {
|
|
32044
32074
|
success: report.success,
|
|
@@ -32048,6 +32078,7 @@ async function handleToolCall(name, args, deps) {
|
|
|
32048
32078
|
legacyBreakdown: report.legacyBreakdown,
|
|
32049
32079
|
actionsTaken: report.actionsTaken,
|
|
32050
32080
|
lowest: report.lowest,
|
|
32081
|
+
staleDrafts: report.staleDrafts,
|
|
32051
32082
|
errors: report.errors,
|
|
32052
32083
|
healthReport: report.healthReport
|
|
32053
32084
|
};
|
package/dist/index.js
CHANGED
|
@@ -25834,7 +25834,8 @@ var BOILERPLATE_PATTERNS = [
|
|
|
25834
25834
|
/^placeholder/i,
|
|
25835
25835
|
/^\.\.\.$/,
|
|
25836
25836
|
/^untitled/i,
|
|
25837
|
-
/^(note|memo|draft)\s*\d*$/i
|
|
25837
|
+
/^(note|memo|draft)\s*\d*$/i,
|
|
25838
|
+
/^task transition:/i
|
|
25838
25839
|
];
|
|
25839
25840
|
function isBoilerplate(title, content) {
|
|
25840
25841
|
const t = title.trim();
|
|
@@ -25847,7 +25848,7 @@ function isBoilerplate(title, content) {
|
|
|
25847
25848
|
}
|
|
25848
25849
|
return false;
|
|
25849
25850
|
}
|
|
25850
|
-
function scoreEntity(entity, relationCount, archiveBelow, deleteBelow) {
|
|
25851
|
+
function scoreEntity(entity, relationCount, archiveBelow, deleteBelow, staleDraftAgeDays) {
|
|
25851
25852
|
const now = Date.now();
|
|
25852
25853
|
const ageDays = (now - Date.parse(entity.created_at)) / MS_PER_DAY;
|
|
25853
25854
|
const effectiveLastAccess = entity.last_accessed_at ?? entity.created_at;
|
|
@@ -25882,21 +25883,26 @@ function scoreEntity(entity, relationCount, archiveBelow, deleteBelow) {
|
|
|
25882
25883
|
reasons.push("no relations");
|
|
25883
25884
|
let content = 0;
|
|
25884
25885
|
const contentLen = entity.content?.length || 0;
|
|
25885
|
-
|
|
25886
|
-
|
|
25887
|
-
const titleOk = entity.title.trim().length >= 4 && !/^(untitled|draft|note)\b/i.test(entity.title.trim());
|
|
25888
|
-
if (titleOk)
|
|
25889
|
-
content += 4;
|
|
25890
|
-
if (!isBoilerplate(entity.title, entity.content))
|
|
25891
|
-
content += 3;
|
|
25892
|
-
if (contentLen < 80)
|
|
25893
|
-
reasons.push(`thin content (${contentLen} chars)`);
|
|
25894
|
-
if (isBoilerplate(entity.title, entity.content))
|
|
25886
|
+
const boilerplate = isBoilerplate(entity.title, entity.content);
|
|
25887
|
+
if (boilerplate) {
|
|
25895
25888
|
reasons.push("boilerplate title/content");
|
|
25889
|
+
} else {
|
|
25890
|
+
if (contentLen >= 80)
|
|
25891
|
+
content += 8;
|
|
25892
|
+
const titleOk = entity.title.trim().length >= 4 && !/^(untitled|draft|note)\b/i.test(entity.title.trim());
|
|
25893
|
+
if (titleOk)
|
|
25894
|
+
content += 4;
|
|
25895
|
+
content += 3;
|
|
25896
|
+
if (contentLen < 80)
|
|
25897
|
+
reasons.push(`thin content (${contentLen} chars)`);
|
|
25898
|
+
}
|
|
25896
25899
|
let tierAgeFit = 15;
|
|
25897
25900
|
if (entity.memory_tier === "draft" && ageDays > 60 && !entity.promoted_from_id) {
|
|
25898
25901
|
tierAgeFit = 0;
|
|
25899
25902
|
reasons.push("stuck draft >60d never promoted");
|
|
25903
|
+
} else if (entity.memory_tier === "draft" && (entity.access_count || 0) === 0 && ageDays > 2) {
|
|
25904
|
+
tierAgeFit = 5;
|
|
25905
|
+
reasons.push("draft >2d with zero access");
|
|
25900
25906
|
}
|
|
25901
25907
|
if (entity.promoted_from_id) {
|
|
25902
25908
|
tierAgeFit = Math.min(15, tierAgeFit + 5);
|
|
@@ -25932,6 +25938,7 @@ function scoreEntity(entity, relationCount, archiveBelow, deleteBelow) {
|
|
|
25932
25938
|
bucket = "review";
|
|
25933
25939
|
else
|
|
25934
25940
|
bucket = "keep";
|
|
25941
|
+
const staleDraft = entity.memory_tier === "draft" && (entity.access_count || 0) === 0 && ageDays > staleDraftAgeDays;
|
|
25935
25942
|
return {
|
|
25936
25943
|
id: entity.id,
|
|
25937
25944
|
title: entity.title,
|
|
@@ -25943,6 +25950,7 @@ function scoreEntity(entity, relationCount, archiveBelow, deleteBelow) {
|
|
|
25943
25950
|
reasons,
|
|
25944
25951
|
legacy,
|
|
25945
25952
|
legacyReasons,
|
|
25953
|
+
staleDraft,
|
|
25946
25954
|
subScores: {
|
|
25947
25955
|
confidence: Math.round(confidence),
|
|
25948
25956
|
decay: Math.round(decay),
|
|
@@ -25958,6 +25966,7 @@ async function runMemoryAudit(client3, workspaceId, projectId, options) {
|
|
|
25958
25966
|
const archiveBelow = options?.archiveBelow ?? 40;
|
|
25959
25967
|
const deleteBelow = options?.deleteBelow ?? 20;
|
|
25960
25968
|
const limit = options?.limit ?? 500;
|
|
25969
|
+
const staleDraftAgeDays = options?.staleDraftAgeDays ?? 7;
|
|
25961
25970
|
const report = {
|
|
25962
25971
|
success: true,
|
|
25963
25972
|
dryRun,
|
|
@@ -25970,7 +25979,8 @@ async function runMemoryAudit(client3, workspaceId, projectId, options) {
|
|
|
25970
25979
|
review: 0,
|
|
25971
25980
|
archive: 0,
|
|
25972
25981
|
delete: 0,
|
|
25973
|
-
legacyCount: 0
|
|
25982
|
+
legacyCount: 0,
|
|
25983
|
+
staleDraftCount: 0
|
|
25974
25984
|
},
|
|
25975
25985
|
actionsTaken: { flaggedReview: 0, archived: 0, deleted: 0 },
|
|
25976
25986
|
distribution: { "0-20": 0, "20-40": 0, "40-70": 0, "70-100": 0 },
|
|
@@ -25981,6 +25991,7 @@ async function runMemoryAudit(client3, workspaceId, projectId, options) {
|
|
|
25981
25991
|
noGraphPresence: 0
|
|
25982
25992
|
},
|
|
25983
25993
|
lowest: [],
|
|
25994
|
+
staleDrafts: [],
|
|
25984
25995
|
errors: [],
|
|
25985
25996
|
healthReport: ""
|
|
25986
25997
|
};
|
|
@@ -26030,12 +26041,14 @@ async function runMemoryAudit(client3, workspaceId, projectId, options) {
|
|
|
26030
26041
|
const audits = [];
|
|
26031
26042
|
for (const entity of entities) {
|
|
26032
26043
|
const relCount = relationCounts.get(entity.id) ?? 0;
|
|
26033
|
-
const audit = scoreEntity(entity, relCount, archiveBelow, deleteBelow);
|
|
26044
|
+
const audit = scoreEntity(entity, relCount, archiveBelow, deleteBelow, staleDraftAgeDays);
|
|
26034
26045
|
audits.push(audit);
|
|
26035
26046
|
report.summary.scanned++;
|
|
26036
26047
|
report.summary[audit.bucket]++;
|
|
26037
26048
|
if (audit.legacy)
|
|
26038
26049
|
report.summary.legacyCount++;
|
|
26050
|
+
if (audit.staleDraft)
|
|
26051
|
+
report.summary.staleDraftCount++;
|
|
26039
26052
|
if (audit.score < 20)
|
|
26040
26053
|
report.distribution["0-20"]++;
|
|
26041
26054
|
else if (audit.score < 40)
|
|
@@ -26056,6 +26069,7 @@ async function runMemoryAudit(client3, workspaceId, projectId, options) {
|
|
|
26056
26069
|
}
|
|
26057
26070
|
}
|
|
26058
26071
|
report.lowest = [...audits].sort((a, b) => a.score - b.score).slice(0, 10);
|
|
26072
|
+
report.staleDrafts = audits.filter((a) => a.staleDraft).sort((a, b) => b.ageDays - a.ageDays);
|
|
26059
26073
|
if (!dryRun) {
|
|
26060
26074
|
for (const audit of audits) {
|
|
26061
26075
|
try {
|
|
@@ -26101,7 +26115,7 @@ function renderReport(report) {
|
|
|
26101
26115
|
const lines = [
|
|
26102
26116
|
`# Memory Quality Audit
|
|
26103
26117
|
`,
|
|
26104
|
-
`**Mode:** ${mode} | **Scanned:** ${s.scanned}/${s.totalEntities} | **Legacy:** ${s.legacyCount}`,
|
|
26118
|
+
`**Mode:** ${mode} | **Scanned:** ${s.scanned}/${s.totalEntities} | **Legacy:** ${s.legacyCount} | **Stale drafts:** ${s.staleDraftCount}`,
|
|
26105
26119
|
"",
|
|
26106
26120
|
"## Distribution",
|
|
26107
26121
|
`- 70-100 (keep): ${report.distribution["70-100"]}`,
|
|
@@ -26125,6 +26139,17 @@ function renderReport(report) {
|
|
|
26125
26139
|
lines.push(`- No tags + no relations: ${l.noGraphPresence}`);
|
|
26126
26140
|
lines.push("");
|
|
26127
26141
|
}
|
|
26142
|
+
if (report.staleDrafts.length > 0) {
|
|
26143
|
+
lines.push("## Stale Drafts (promote-or-drop candidates)");
|
|
26144
|
+
lines.push("Filter: `tier=draft AND access=0 AND age>threshold`");
|
|
26145
|
+
lines.push("| Age | Score | Title |");
|
|
26146
|
+
lines.push("|-----|-------|-------|");
|
|
26147
|
+
for (const a of report.staleDrafts.slice(0, 20)) {
|
|
26148
|
+
const titleTrunc = a.title.length > 50 ? `${a.title.slice(0, 47)}...` : a.title;
|
|
26149
|
+
lines.push(`| ${a.ageDays}d | ${a.score} | ${titleTrunc} |`);
|
|
26150
|
+
}
|
|
26151
|
+
lines.push("");
|
|
26152
|
+
}
|
|
26128
26153
|
if (report.lowest.length > 0) {
|
|
26129
26154
|
lines.push("## Lowest-Scoring (top 10)");
|
|
26130
26155
|
lines.push("| Score | Bucket | Tier | Age | Title | Reasons |");
|
|
@@ -28219,6 +28244,10 @@ var TOOLS = {
|
|
|
28219
28244
|
limit: {
|
|
28220
28245
|
type: "number",
|
|
28221
28246
|
description: "Max number of entities to audit (default: 500). Paginated fetch."
|
|
28247
|
+
},
|
|
28248
|
+
staleDraftAgeDays: {
|
|
28249
|
+
type: "number",
|
|
28250
|
+
description: "Age threshold (days) for the stale-draft filter: flags drafts with 0 accesses older than this. Reported separately from bucket scoring — surfaces promote-or-drop candidates the thresholds miss. Default: 7."
|
|
28222
28251
|
}
|
|
28223
28252
|
},
|
|
28224
28253
|
required: []
|
|
@@ -29798,7 +29827,8 @@ async function handleToolCall(name, args, deps) {
|
|
|
29798
29827
|
dryRun: args.dryRun,
|
|
29799
29828
|
archiveBelow: args.archiveBelow,
|
|
29800
29829
|
deleteBelow: args.deleteBelow,
|
|
29801
|
-
limit: args.limit
|
|
29830
|
+
limit: args.limit,
|
|
29831
|
+
staleDraftAgeDays: args.staleDraftAgeDays
|
|
29802
29832
|
});
|
|
29803
29833
|
return {
|
|
29804
29834
|
success: report.success,
|
|
@@ -29808,6 +29838,7 @@ async function handleToolCall(name, args, deps) {
|
|
|
29808
29838
|
legacyBreakdown: report.legacyBreakdown,
|
|
29809
29839
|
actionsTaken: report.actionsTaken,
|
|
29810
29840
|
lowest: report.lowest,
|
|
29841
|
+
staleDrafts: report.staleDrafts,
|
|
29811
29842
|
errors: report.errors,
|
|
29812
29843
|
healthReport: report.healthReport
|
|
29813
29844
|
};
|
package/package.json
CHANGED
|
@@ -282,6 +282,96 @@ describe("runMemoryAudit", () => {
|
|
|
282
282
|
).toBe(0.25);
|
|
283
283
|
});
|
|
284
284
|
|
|
285
|
+
test("stale-draft filter flags draft+0access+age>threshold separately from bucket", async () => {
|
|
286
|
+
const { client } = makeMockClient(
|
|
287
|
+
[
|
|
288
|
+
// Stale draft — should be flagged by the filter, but otherwise healthy
|
|
289
|
+
// enough to bucket as "review" (not archive).
|
|
290
|
+
{
|
|
291
|
+
id: "stale-draft",
|
|
292
|
+
type: "context",
|
|
293
|
+
title:
|
|
294
|
+
"Task transition: feature work started but never touched again",
|
|
295
|
+
content:
|
|
296
|
+
"This draft has enough content and tags to score reasonably, " +
|
|
297
|
+
"but nobody ever accessed it after creation — classic promote-or-drop candidate.",
|
|
298
|
+
confidence: 0.4,
|
|
299
|
+
memory_tier: "draft",
|
|
300
|
+
access_count: 0,
|
|
301
|
+
last_accessed_at: null,
|
|
302
|
+
created_at: daysAgo(10),
|
|
303
|
+
tags: ["task"],
|
|
304
|
+
embedding: [0.1],
|
|
305
|
+
},
|
|
306
|
+
// Fresh draft — same shape but under the age threshold, must NOT flag.
|
|
307
|
+
{
|
|
308
|
+
id: "fresh-draft",
|
|
309
|
+
type: "context",
|
|
310
|
+
title: "Task transition: a fresh draft still within the grace window",
|
|
311
|
+
content:
|
|
312
|
+
"Content long enough to not be thin at all, really properly sized.",
|
|
313
|
+
confidence: 0.4,
|
|
314
|
+
memory_tier: "draft",
|
|
315
|
+
access_count: 0,
|
|
316
|
+
last_accessed_at: null,
|
|
317
|
+
created_at: daysAgo(3),
|
|
318
|
+
tags: ["task"],
|
|
319
|
+
embedding: [0.1],
|
|
320
|
+
},
|
|
321
|
+
// Non-draft old zero-access — must NOT flag (filter is draft-only).
|
|
322
|
+
{
|
|
323
|
+
id: "old-episode",
|
|
324
|
+
type: "pattern",
|
|
325
|
+
title: "Episode entity that is old and unaccessed but not a draft",
|
|
326
|
+
content:
|
|
327
|
+
"Sometimes reference/episode tier entities sit unaccessed; " +
|
|
328
|
+
"they're not draft-promotion candidates so the filter should skip them.",
|
|
329
|
+
confidence: 0.8,
|
|
330
|
+
memory_tier: "episode",
|
|
331
|
+
access_count: 0,
|
|
332
|
+
last_accessed_at: null,
|
|
333
|
+
created_at: daysAgo(30),
|
|
334
|
+
tags: ["pat"],
|
|
335
|
+
embedding: [0.1],
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
{ "stale-draft": 1, "fresh-draft": 1, "old-episode": 2 },
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const report = await runMemoryAudit(client, "ws-1");
|
|
342
|
+
expect(report.summary.staleDraftCount).toBe(1);
|
|
343
|
+
expect(report.staleDrafts).toHaveLength(1);
|
|
344
|
+
expect(report.staleDrafts[0].id).toBe("stale-draft");
|
|
345
|
+
expect(report.healthReport).toContain("Stale Drafts");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("staleDraftAgeDays option tunes the filter threshold", async () => {
|
|
349
|
+
const { client } = makeMockClient([
|
|
350
|
+
{
|
|
351
|
+
id: "draft-5d",
|
|
352
|
+
type: "context",
|
|
353
|
+
title: "Five-day-old untouched draft",
|
|
354
|
+
content:
|
|
355
|
+
"Content long enough to pass the thin-content check, absolutely.",
|
|
356
|
+
confidence: 0.4,
|
|
357
|
+
memory_tier: "draft",
|
|
358
|
+
access_count: 0,
|
|
359
|
+
last_accessed_at: null,
|
|
360
|
+
created_at: daysAgo(5),
|
|
361
|
+
tags: ["x"],
|
|
362
|
+
embedding: [0.1],
|
|
363
|
+
},
|
|
364
|
+
]);
|
|
365
|
+
|
|
366
|
+
const defaultRun = await runMemoryAudit(client, "ws-1");
|
|
367
|
+
expect(defaultRun.summary.staleDraftCount).toBe(0);
|
|
368
|
+
|
|
369
|
+
const tightRun = await runMemoryAudit(client, "ws-1", undefined, {
|
|
370
|
+
staleDraftAgeDays: 3,
|
|
371
|
+
});
|
|
372
|
+
expect(tightRun.summary.staleDraftCount).toBe(1);
|
|
373
|
+
});
|
|
374
|
+
|
|
285
375
|
test("fetch error surfaces as report.success=false", async () => {
|
|
286
376
|
const client = {
|
|
287
377
|
listMemoryEntities: mock(async () => {
|
package/src/memory-audit.ts
CHANGED
|
@@ -46,6 +46,14 @@ export interface AuditOptions {
|
|
|
46
46
|
deleteBelow?: number;
|
|
47
47
|
includeLegacyFlag?: boolean;
|
|
48
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;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
interface EntityAudit {
|
|
@@ -59,6 +67,7 @@ interface EntityAudit {
|
|
|
59
67
|
reasons: string[];
|
|
60
68
|
legacy: boolean;
|
|
61
69
|
legacyReasons: string[];
|
|
70
|
+
staleDraft: boolean;
|
|
62
71
|
subScores: {
|
|
63
72
|
confidence: number;
|
|
64
73
|
decay: number;
|
|
@@ -82,6 +91,7 @@ export interface AuditReport {
|
|
|
82
91
|
archive: number;
|
|
83
92
|
delete: number;
|
|
84
93
|
legacyCount: number;
|
|
94
|
+
staleDraftCount: number;
|
|
85
95
|
};
|
|
86
96
|
actionsTaken: {
|
|
87
97
|
flaggedReview: number;
|
|
@@ -101,6 +111,7 @@ export interface AuditReport {
|
|
|
101
111
|
noGraphPresence: number;
|
|
102
112
|
};
|
|
103
113
|
lowest: EntityAudit[];
|
|
114
|
+
staleDrafts: EntityAudit[];
|
|
104
115
|
errors: Array<{ entityId?: string; step: string; message: string }>;
|
|
105
116
|
healthReport: string;
|
|
106
117
|
}
|
|
@@ -111,6 +122,9 @@ const BOILERPLATE_PATTERNS = [
|
|
|
111
122
|
/^\.\.\.$/,
|
|
112
123
|
/^untitled/i,
|
|
113
124
|
/^(note|memo|draft)\s*\d*$/i,
|
|
125
|
+
// Auto-captured task-transition snapshots from a retired active-learning rule.
|
|
126
|
+
// No user intent, no access pattern — treat as boilerplate so scoring archives them.
|
|
127
|
+
/^task transition:/i,
|
|
114
128
|
];
|
|
115
129
|
|
|
116
130
|
function isBoilerplate(title: string, content: string): boolean {
|
|
@@ -128,6 +142,7 @@ function scoreEntity(
|
|
|
128
142
|
relationCount: number,
|
|
129
143
|
archiveBelow: number,
|
|
130
144
|
deleteBelow: number,
|
|
145
|
+
staleDraftAgeDays: number,
|
|
131
146
|
): EntityAudit {
|
|
132
147
|
const now = Date.now();
|
|
133
148
|
const ageDays = (now - Date.parse(entity.created_at)) / MS_PER_DAY;
|
|
@@ -165,18 +180,22 @@ function scoreEntity(
|
|
|
165
180
|
if (!hasTags) reasons.push("no tags");
|
|
166
181
|
if (!hasRelations) reasons.push("no relations");
|
|
167
182
|
|
|
168
|
-
// Content quality (15)
|
|
183
|
+
// Content quality (15) — boilerplate hard-zeroes the whole band. Auto-captured
|
|
184
|
+
// noise should never inherit the length/title bonuses it structurally earns.
|
|
169
185
|
let content = 0;
|
|
170
186
|
const contentLen = entity.content?.length || 0;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
entity.title.trim().length >= 4 &&
|
|
174
|
-
!/^(untitled|draft|note)\b/i.test(entity.title.trim());
|
|
175
|
-
if (titleOk) content += 4;
|
|
176
|
-
if (!isBoilerplate(entity.title, entity.content)) content += 3;
|
|
177
|
-
if (contentLen < 80) reasons.push(`thin content (${contentLen} chars)`);
|
|
178
|
-
if (isBoilerplate(entity.title, entity.content))
|
|
187
|
+
const boilerplate = isBoilerplate(entity.title, entity.content);
|
|
188
|
+
if (boilerplate) {
|
|
179
189
|
reasons.push("boilerplate title/content");
|
|
190
|
+
} else {
|
|
191
|
+
if (contentLen >= 80) content += 8;
|
|
192
|
+
const titleOk =
|
|
193
|
+
entity.title.trim().length >= 4 &&
|
|
194
|
+
!/^(untitled|draft|note)\b/i.test(entity.title.trim());
|
|
195
|
+
if (titleOk) content += 4;
|
|
196
|
+
content += 3;
|
|
197
|
+
if (contentLen < 80) reasons.push(`thin content (${contentLen} chars)`);
|
|
198
|
+
}
|
|
180
199
|
|
|
181
200
|
// Tier-age fit (15)
|
|
182
201
|
let tierAgeFit = 15;
|
|
@@ -187,6 +206,15 @@ function scoreEntity(
|
|
|
187
206
|
) {
|
|
188
207
|
tierAgeFit = 0;
|
|
189
208
|
reasons.push("stuck draft >60d never promoted");
|
|
209
|
+
} else if (
|
|
210
|
+
entity.memory_tier === "draft" &&
|
|
211
|
+
(entity.access_count || 0) === 0 &&
|
|
212
|
+
ageDays > 2
|
|
213
|
+
) {
|
|
214
|
+
// Young drafts get a 2-day grace window. After that, zero access means
|
|
215
|
+
// zero signal — strip the tier-age bonus so useless auto-captures fall to archive.
|
|
216
|
+
tierAgeFit = 5;
|
|
217
|
+
reasons.push("draft >2d with zero access");
|
|
190
218
|
}
|
|
191
219
|
if (entity.promoted_from_id) {
|
|
192
220
|
tierAgeFit = Math.min(15, tierAgeFit + 5);
|
|
@@ -232,6 +260,14 @@ function scoreEntity(
|
|
|
232
260
|
else if (score < 70) bucket = "review";
|
|
233
261
|
else bucket = "keep";
|
|
234
262
|
|
|
263
|
+
// Stale-draft filter — orthogonal to bucketing. A draft that's aged past
|
|
264
|
+
// the threshold without a single access is a promote-or-drop candidate
|
|
265
|
+
// regardless of its composite score.
|
|
266
|
+
const staleDraft =
|
|
267
|
+
entity.memory_tier === "draft" &&
|
|
268
|
+
(entity.access_count || 0) === 0 &&
|
|
269
|
+
ageDays > staleDraftAgeDays;
|
|
270
|
+
|
|
235
271
|
return {
|
|
236
272
|
id: entity.id,
|
|
237
273
|
title: entity.title,
|
|
@@ -243,6 +279,7 @@ function scoreEntity(
|
|
|
243
279
|
reasons,
|
|
244
280
|
legacy,
|
|
245
281
|
legacyReasons,
|
|
282
|
+
staleDraft,
|
|
246
283
|
subScores: {
|
|
247
284
|
confidence: Math.round(confidence),
|
|
248
285
|
decay: Math.round(decay),
|
|
@@ -264,6 +301,7 @@ export async function runMemoryAudit(
|
|
|
264
301
|
const archiveBelow = options?.archiveBelow ?? 40;
|
|
265
302
|
const deleteBelow = options?.deleteBelow ?? 20;
|
|
266
303
|
const limit = options?.limit ?? 500;
|
|
304
|
+
const staleDraftAgeDays = options?.staleDraftAgeDays ?? 7;
|
|
267
305
|
|
|
268
306
|
const report: AuditReport = {
|
|
269
307
|
success: true,
|
|
@@ -278,6 +316,7 @@ export async function runMemoryAudit(
|
|
|
278
316
|
archive: 0,
|
|
279
317
|
delete: 0,
|
|
280
318
|
legacyCount: 0,
|
|
319
|
+
staleDraftCount: 0,
|
|
281
320
|
},
|
|
282
321
|
actionsTaken: { flaggedReview: 0, archived: 0, deleted: 0 },
|
|
283
322
|
distribution: { "0-20": 0, "20-40": 0, "40-70": 0, "70-100": 0 },
|
|
@@ -288,6 +327,7 @@ export async function runMemoryAudit(
|
|
|
288
327
|
noGraphPresence: 0,
|
|
289
328
|
},
|
|
290
329
|
lowest: [],
|
|
330
|
+
staleDrafts: [],
|
|
291
331
|
errors: [],
|
|
292
332
|
healthReport: "",
|
|
293
333
|
};
|
|
@@ -345,11 +385,18 @@ export async function runMemoryAudit(
|
|
|
345
385
|
const audits: EntityAudit[] = [];
|
|
346
386
|
for (const entity of entities) {
|
|
347
387
|
const relCount = relationCounts.get(entity.id) ?? 0;
|
|
348
|
-
const audit = scoreEntity(
|
|
388
|
+
const audit = scoreEntity(
|
|
389
|
+
entity,
|
|
390
|
+
relCount,
|
|
391
|
+
archiveBelow,
|
|
392
|
+
deleteBelow,
|
|
393
|
+
staleDraftAgeDays,
|
|
394
|
+
);
|
|
349
395
|
audits.push(audit);
|
|
350
396
|
report.summary.scanned++;
|
|
351
397
|
report.summary[audit.bucket]++;
|
|
352
398
|
if (audit.legacy) report.summary.legacyCount++;
|
|
399
|
+
if (audit.staleDraft) report.summary.staleDraftCount++;
|
|
353
400
|
|
|
354
401
|
// Distribution bin
|
|
355
402
|
if (audit.score < 20) report.distribution["0-20"]++;
|
|
@@ -373,6 +420,11 @@ export async function runMemoryAudit(
|
|
|
373
420
|
// Top 10 lowest-scoring
|
|
374
421
|
report.lowest = [...audits].sort((a, b) => a.score - b.score).slice(0, 10);
|
|
375
422
|
|
|
423
|
+
// All stale drafts — oldest first (most overdue for promote-or-drop)
|
|
424
|
+
report.staleDrafts = audits
|
|
425
|
+
.filter((a) => a.staleDraft)
|
|
426
|
+
.sort((a, b) => b.ageDays - a.ageDays);
|
|
427
|
+
|
|
376
428
|
// Execute actions
|
|
377
429
|
if (!dryRun) {
|
|
378
430
|
for (const audit of audits) {
|
|
@@ -420,7 +472,7 @@ function renderReport(report: AuditReport): string {
|
|
|
420
472
|
const s = report.summary;
|
|
421
473
|
const lines: string[] = [
|
|
422
474
|
"# Memory Quality Audit\n",
|
|
423
|
-
`**Mode:** ${mode} | **Scanned:** ${s.scanned}/${s.totalEntities} | **Legacy:** ${s.legacyCount}`,
|
|
475
|
+
`**Mode:** ${mode} | **Scanned:** ${s.scanned}/${s.totalEntities} | **Legacy:** ${s.legacyCount} | **Stale drafts:** ${s.staleDraftCount}`,
|
|
424
476
|
"",
|
|
425
477
|
"## Distribution",
|
|
426
478
|
`- 70-100 (keep): ${report.distribution["70-100"]}`,
|
|
@@ -446,6 +498,19 @@ function renderReport(report: AuditReport): string {
|
|
|
446
498
|
lines.push("");
|
|
447
499
|
}
|
|
448
500
|
|
|
501
|
+
if (report.staleDrafts.length > 0) {
|
|
502
|
+
lines.push("## Stale Drafts (promote-or-drop candidates)");
|
|
503
|
+
lines.push("Filter: `tier=draft AND access=0 AND age>threshold`");
|
|
504
|
+
lines.push("| Age | Score | Title |");
|
|
505
|
+
lines.push("|-----|-------|-------|");
|
|
506
|
+
for (const a of report.staleDrafts.slice(0, 20)) {
|
|
507
|
+
const titleTrunc =
|
|
508
|
+
a.title.length > 50 ? `${a.title.slice(0, 47)}...` : a.title;
|
|
509
|
+
lines.push(`| ${a.ageDays}d | ${a.score} | ${titleTrunc} |`);
|
|
510
|
+
}
|
|
511
|
+
lines.push("");
|
|
512
|
+
}
|
|
513
|
+
|
|
449
514
|
if (report.lowest.length > 0) {
|
|
450
515
|
lines.push("## Lowest-Scoring (top 10)");
|
|
451
516
|
lines.push("| Score | Bucket | Tier | Age | Title | Reasons |");
|
package/src/server.ts
CHANGED
|
@@ -1709,11 +1709,13 @@ const TOOLS = {
|
|
|
1709
1709
|
},
|
|
1710
1710
|
auditArchiveBelow: {
|
|
1711
1711
|
type: "number",
|
|
1712
|
-
description:
|
|
1712
|
+
description:
|
|
1713
|
+
"Audit: archive entities scoring below this (default: 40)",
|
|
1713
1714
|
},
|
|
1714
1715
|
auditDeleteBelow: {
|
|
1715
1716
|
type: "number",
|
|
1716
|
-
description:
|
|
1717
|
+
description:
|
|
1718
|
+
"Audit: delete entities scoring below this (default: 20)",
|
|
1717
1719
|
},
|
|
1718
1720
|
},
|
|
1719
1721
|
required: [],
|
|
@@ -1753,6 +1755,11 @@ const TOOLS = {
|
|
|
1753
1755
|
description:
|
|
1754
1756
|
"Max number of entities to audit (default: 500). Paginated fetch.",
|
|
1755
1757
|
},
|
|
1758
|
+
staleDraftAgeDays: {
|
|
1759
|
+
type: "number",
|
|
1760
|
+
description:
|
|
1761
|
+
"Age threshold (days) for the stale-draft filter: flags drafts with 0 accesses older than this. Reported separately from bucket scoring — surfaces promote-or-drop candidates the thresholds miss. Default: 7.",
|
|
1762
|
+
},
|
|
1756
1763
|
},
|
|
1757
1764
|
required: [],
|
|
1758
1765
|
},
|
|
@@ -4165,6 +4172,7 @@ async function handleToolCall(
|
|
|
4165
4172
|
archiveBelow: args.archiveBelow as number | undefined,
|
|
4166
4173
|
deleteBelow: args.deleteBelow as number | undefined,
|
|
4167
4174
|
limit: args.limit as number | undefined,
|
|
4175
|
+
staleDraftAgeDays: args.staleDraftAgeDays as number | undefined,
|
|
4168
4176
|
});
|
|
4169
4177
|
|
|
4170
4178
|
return {
|
|
@@ -4175,6 +4183,7 @@ async function handleToolCall(
|
|
|
4175
4183
|
legacyBreakdown: report.legacyBreakdown,
|
|
4176
4184
|
actionsTaken: report.actionsTaken,
|
|
4177
4185
|
lowest: report.lowest,
|
|
4186
|
+
staleDrafts: report.staleDrafts,
|
|
4178
4187
|
errors: report.errors,
|
|
4179
4188
|
healthReport: report.healthReport,
|
|
4180
4189
|
};
|
|
@@ -4210,7 +4219,14 @@ async function handleToolCall(
|
|
|
4210
4219
|
const report = await runMemoryCleanup(client, workspaceId, projectId, {
|
|
4211
4220
|
dryRun: args.dryRun as boolean | undefined,
|
|
4212
4221
|
steps: steps as
|
|
4213
|
-
| (
|
|
4222
|
+
| (
|
|
4223
|
+
| "prune"
|
|
4224
|
+
| "consolidate"
|
|
4225
|
+
| "orphans"
|
|
4226
|
+
| "duplicates"
|
|
4227
|
+
| "backfill"
|
|
4228
|
+
| "audit"
|
|
4229
|
+
)[]
|
|
4214
4230
|
| undefined,
|
|
4215
4231
|
maxAgeDays: args.maxAgeDays as number | undefined,
|
|
4216
4232
|
minClusterSize: args.minClusterSize as number | undefined,
|