@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 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
- if (contentLen >= 80)
28126
- content += 8;
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
- if (contentLen >= 80)
25886
- content += 8;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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 () => {
@@ -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
- if (contentLen >= 80) content += 8;
172
- const titleOk =
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(entity, relCount, archiveBelow, deleteBelow);
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: "Audit: archive entities scoring below this (default: 40)",
1712
+ description:
1713
+ "Audit: archive entities scoring below this (default: 40)",
1713
1714
  },
1714
1715
  auditDeleteBelow: {
1715
1716
  type: "number",
1716
- description: "Audit: delete entities scoring below this (default: 20)",
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
- | ("prune" | "consolidate" | "orphans" | "duplicates" | "backfill" | "audit")[]
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,