@gethmy/mcp 2.4.4 → 2.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +32 -9
- package/dist/index.js +32 -9
- package/dist/lib/api-client.js +21 -0
- package/package.json +1 -1
- package/src/__tests__/memory-audit.test.ts +83 -1
- package/src/api-client.ts +36 -1
- package/src/memory-audit.ts +36 -16
- package/src/remote.ts +76 -7
package/dist/cli.js
CHANGED
|
@@ -27052,16 +27052,28 @@ async function requestWithBearer(apiUrl, bearerToken, method, path, body) {
|
|
|
27052
27052
|
return result;
|
|
27053
27053
|
}
|
|
27054
27054
|
|
|
27055
|
+
class HarmonyUnauthorizedError extends Error {
|
|
27056
|
+
constructor(message = "Invalid or expired credentials") {
|
|
27057
|
+
super(message);
|
|
27058
|
+
this.name = "HarmonyUnauthorizedError";
|
|
27059
|
+
}
|
|
27060
|
+
}
|
|
27061
|
+
|
|
27055
27062
|
class HarmonyApiClient {
|
|
27056
27063
|
apiKey;
|
|
27057
27064
|
apiUrl;
|
|
27065
|
+
onUnauthorized;
|
|
27058
27066
|
constructor(options) {
|
|
27059
27067
|
this.apiKey = options?.apiKey ?? getApiKey();
|
|
27060
27068
|
this.apiUrl = options?.apiUrl ?? getApiUrl();
|
|
27069
|
+
this.onUnauthorized = options?.onUnauthorized;
|
|
27061
27070
|
}
|
|
27062
27071
|
getApiUrl() {
|
|
27063
27072
|
return this.apiUrl;
|
|
27064
27073
|
}
|
|
27074
|
+
setApiKey(apiKey) {
|
|
27075
|
+
this.apiKey = apiKey;
|
|
27076
|
+
}
|
|
27065
27077
|
async request(method, path, body, options) {
|
|
27066
27078
|
await requestSemaphore.acquire();
|
|
27067
27079
|
try {
|
|
@@ -27108,6 +27120,10 @@ class HarmonyApiClient {
|
|
|
27108
27120
|
}
|
|
27109
27121
|
if (!response.ok) {
|
|
27110
27122
|
const errorMsg = data?.error || (looksLikeJson ? null : `API error: ${response.status} (non-JSON response)`) || `API error: ${response.status}`;
|
|
27123
|
+
if (response.status === 401) {
|
|
27124
|
+
this.onUnauthorized?.();
|
|
27125
|
+
throw new HarmonyUnauthorizedError(errorMsg);
|
|
27126
|
+
}
|
|
27111
27127
|
if (!isRetryableError(null, response.status)) {
|
|
27112
27128
|
throw new Error(errorMsg);
|
|
27113
27129
|
}
|
|
@@ -27157,6 +27173,10 @@ class HarmonyApiClient {
|
|
|
27157
27173
|
} catch {
|
|
27158
27174
|
errorMsg = text || `API error: ${response.status}`;
|
|
27159
27175
|
}
|
|
27176
|
+
if (response.status === 401) {
|
|
27177
|
+
this.onUnauthorized?.();
|
|
27178
|
+
throw new HarmonyUnauthorizedError(errorMsg);
|
|
27179
|
+
}
|
|
27160
27180
|
if (!isRetryableError(null, response.status)) {
|
|
27161
27181
|
throw new Error(errorMsg);
|
|
27162
27182
|
}
|
|
@@ -28088,23 +28108,25 @@ var BATCH_SIZE = 100;
|
|
|
28088
28108
|
var CONCURRENCY_LIMIT = 5;
|
|
28089
28109
|
var BOILERPLATE_PATTERNS = [
|
|
28090
28110
|
/^todo:?$/i,
|
|
28091
|
-
/^placeholder
|
|
28111
|
+
/^placeholder(\s+\d+|:)?$/i,
|
|
28092
28112
|
/^\.\.\.$/,
|
|
28093
|
-
/^untitled
|
|
28094
|
-
/^(note|memo|draft)\s
|
|
28113
|
+
/^untitled(\s+\d+|:)?$/i,
|
|
28114
|
+
/^(note|memo|draft)\s+\d+$/i,
|
|
28095
28115
|
/^task transition:/i
|
|
28096
28116
|
];
|
|
28097
|
-
function
|
|
28117
|
+
function isBoilerplateTitle(title) {
|
|
28098
28118
|
const t = title.trim();
|
|
28099
|
-
const c = content.trim();
|
|
28100
|
-
if (c.length === 0)
|
|
28101
|
-
return true;
|
|
28102
28119
|
for (const pat of BOILERPLATE_PATTERNS) {
|
|
28103
28120
|
if (pat.test(t))
|
|
28104
28121
|
return true;
|
|
28105
28122
|
}
|
|
28106
28123
|
return false;
|
|
28107
28124
|
}
|
|
28125
|
+
function isBoilerplate(title, content) {
|
|
28126
|
+
if (content.trim().length === 0)
|
|
28127
|
+
return true;
|
|
28128
|
+
return isBoilerplateTitle(title);
|
|
28129
|
+
}
|
|
28108
28130
|
function scoreEntity(entity, relationCount, archiveBelow, deleteBelow, staleDraftAgeDays) {
|
|
28109
28131
|
const now = Date.now();
|
|
28110
28132
|
const ageDays = (now - Date.parse(entity.created_at)) / MS_PER_DAY;
|
|
@@ -28186,10 +28208,11 @@ function scoreEntity(entity, relationCount, archiveBelow, deleteBelow, staleDraf
|
|
|
28186
28208
|
legacy = true;
|
|
28187
28209
|
legacyReasons.push("no graph presence");
|
|
28188
28210
|
}
|
|
28211
|
+
const boilerplateTitle = isBoilerplateTitle(entity.title);
|
|
28189
28212
|
let bucket;
|
|
28190
|
-
if (
|
|
28213
|
+
if (boilerplateTitle && deleteBelow > 0) {
|
|
28191
28214
|
bucket = "delete";
|
|
28192
|
-
reasons.push("boilerplate override");
|
|
28215
|
+
reasons.push("boilerplate title override");
|
|
28193
28216
|
} else if (score < deleteBelow)
|
|
28194
28217
|
bucket = "delete";
|
|
28195
28218
|
else if (score < archiveBelow)
|
package/dist/index.js
CHANGED
|
@@ -24812,16 +24812,28 @@ async function requestWithBearer(apiUrl, bearerToken, method, path, body) {
|
|
|
24812
24812
|
return result;
|
|
24813
24813
|
}
|
|
24814
24814
|
|
|
24815
|
+
class HarmonyUnauthorizedError extends Error {
|
|
24816
|
+
constructor(message = "Invalid or expired credentials") {
|
|
24817
|
+
super(message);
|
|
24818
|
+
this.name = "HarmonyUnauthorizedError";
|
|
24819
|
+
}
|
|
24820
|
+
}
|
|
24821
|
+
|
|
24815
24822
|
class HarmonyApiClient {
|
|
24816
24823
|
apiKey;
|
|
24817
24824
|
apiUrl;
|
|
24825
|
+
onUnauthorized;
|
|
24818
24826
|
constructor(options) {
|
|
24819
24827
|
this.apiKey = options?.apiKey ?? getApiKey();
|
|
24820
24828
|
this.apiUrl = options?.apiUrl ?? getApiUrl();
|
|
24829
|
+
this.onUnauthorized = options?.onUnauthorized;
|
|
24821
24830
|
}
|
|
24822
24831
|
getApiUrl() {
|
|
24823
24832
|
return this.apiUrl;
|
|
24824
24833
|
}
|
|
24834
|
+
setApiKey(apiKey) {
|
|
24835
|
+
this.apiKey = apiKey;
|
|
24836
|
+
}
|
|
24825
24837
|
async request(method, path, body, options) {
|
|
24826
24838
|
await requestSemaphore.acquire();
|
|
24827
24839
|
try {
|
|
@@ -24868,6 +24880,10 @@ class HarmonyApiClient {
|
|
|
24868
24880
|
}
|
|
24869
24881
|
if (!response.ok) {
|
|
24870
24882
|
const errorMsg = data?.error || (looksLikeJson ? null : `API error: ${response.status} (non-JSON response)`) || `API error: ${response.status}`;
|
|
24883
|
+
if (response.status === 401) {
|
|
24884
|
+
this.onUnauthorized?.();
|
|
24885
|
+
throw new HarmonyUnauthorizedError(errorMsg);
|
|
24886
|
+
}
|
|
24871
24887
|
if (!isRetryableError(null, response.status)) {
|
|
24872
24888
|
throw new Error(errorMsg);
|
|
24873
24889
|
}
|
|
@@ -24917,6 +24933,10 @@ class HarmonyApiClient {
|
|
|
24917
24933
|
} catch {
|
|
24918
24934
|
errorMsg = text || `API error: ${response.status}`;
|
|
24919
24935
|
}
|
|
24936
|
+
if (response.status === 401) {
|
|
24937
|
+
this.onUnauthorized?.();
|
|
24938
|
+
throw new HarmonyUnauthorizedError(errorMsg);
|
|
24939
|
+
}
|
|
24920
24940
|
if (!isRetryableError(null, response.status)) {
|
|
24921
24941
|
throw new Error(errorMsg);
|
|
24922
24942
|
}
|
|
@@ -25848,23 +25868,25 @@ var BATCH_SIZE = 100;
|
|
|
25848
25868
|
var CONCURRENCY_LIMIT = 5;
|
|
25849
25869
|
var BOILERPLATE_PATTERNS = [
|
|
25850
25870
|
/^todo:?$/i,
|
|
25851
|
-
/^placeholder
|
|
25871
|
+
/^placeholder(\s+\d+|:)?$/i,
|
|
25852
25872
|
/^\.\.\.$/,
|
|
25853
|
-
/^untitled
|
|
25854
|
-
/^(note|memo|draft)\s
|
|
25873
|
+
/^untitled(\s+\d+|:)?$/i,
|
|
25874
|
+
/^(note|memo|draft)\s+\d+$/i,
|
|
25855
25875
|
/^task transition:/i
|
|
25856
25876
|
];
|
|
25857
|
-
function
|
|
25877
|
+
function isBoilerplateTitle(title) {
|
|
25858
25878
|
const t = title.trim();
|
|
25859
|
-
const c = content.trim();
|
|
25860
|
-
if (c.length === 0)
|
|
25861
|
-
return true;
|
|
25862
25879
|
for (const pat of BOILERPLATE_PATTERNS) {
|
|
25863
25880
|
if (pat.test(t))
|
|
25864
25881
|
return true;
|
|
25865
25882
|
}
|
|
25866
25883
|
return false;
|
|
25867
25884
|
}
|
|
25885
|
+
function isBoilerplate(title, content) {
|
|
25886
|
+
if (content.trim().length === 0)
|
|
25887
|
+
return true;
|
|
25888
|
+
return isBoilerplateTitle(title);
|
|
25889
|
+
}
|
|
25868
25890
|
function scoreEntity(entity, relationCount, archiveBelow, deleteBelow, staleDraftAgeDays) {
|
|
25869
25891
|
const now = Date.now();
|
|
25870
25892
|
const ageDays = (now - Date.parse(entity.created_at)) / MS_PER_DAY;
|
|
@@ -25946,10 +25968,11 @@ function scoreEntity(entity, relationCount, archiveBelow, deleteBelow, staleDraf
|
|
|
25946
25968
|
legacy = true;
|
|
25947
25969
|
legacyReasons.push("no graph presence");
|
|
25948
25970
|
}
|
|
25971
|
+
const boilerplateTitle = isBoilerplateTitle(entity.title);
|
|
25949
25972
|
let bucket;
|
|
25950
|
-
if (
|
|
25973
|
+
if (boilerplateTitle && deleteBelow > 0) {
|
|
25951
25974
|
bucket = "delete";
|
|
25952
|
-
reasons.push("boilerplate override");
|
|
25975
|
+
reasons.push("boilerplate title override");
|
|
25953
25976
|
} else if (score < deleteBelow)
|
|
25954
25977
|
bucket = "delete";
|
|
25955
25978
|
else if (score < archiveBelow)
|
package/dist/lib/api-client.js
CHANGED
|
@@ -1570,16 +1570,28 @@ async function requestWithBearer(apiUrl, bearerToken, method, path, body) {
|
|
|
1570
1570
|
return result;
|
|
1571
1571
|
}
|
|
1572
1572
|
|
|
1573
|
+
class HarmonyUnauthorizedError extends Error {
|
|
1574
|
+
constructor(message = "Invalid or expired credentials") {
|
|
1575
|
+
super(message);
|
|
1576
|
+
this.name = "HarmonyUnauthorizedError";
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1573
1580
|
class HarmonyApiClient {
|
|
1574
1581
|
apiKey;
|
|
1575
1582
|
apiUrl;
|
|
1583
|
+
onUnauthorized;
|
|
1576
1584
|
constructor(options) {
|
|
1577
1585
|
this.apiKey = options?.apiKey ?? getApiKey();
|
|
1578
1586
|
this.apiUrl = options?.apiUrl ?? getApiUrl();
|
|
1587
|
+
this.onUnauthorized = options?.onUnauthorized;
|
|
1579
1588
|
}
|
|
1580
1589
|
getApiUrl() {
|
|
1581
1590
|
return this.apiUrl;
|
|
1582
1591
|
}
|
|
1592
|
+
setApiKey(apiKey) {
|
|
1593
|
+
this.apiKey = apiKey;
|
|
1594
|
+
}
|
|
1583
1595
|
async request(method, path, body, options) {
|
|
1584
1596
|
await requestSemaphore.acquire();
|
|
1585
1597
|
try {
|
|
@@ -1626,6 +1638,10 @@ class HarmonyApiClient {
|
|
|
1626
1638
|
}
|
|
1627
1639
|
if (!response.ok) {
|
|
1628
1640
|
const errorMsg = data?.error || (looksLikeJson ? null : `API error: ${response.status} (non-JSON response)`) || `API error: ${response.status}`;
|
|
1641
|
+
if (response.status === 401) {
|
|
1642
|
+
this.onUnauthorized?.();
|
|
1643
|
+
throw new HarmonyUnauthorizedError(errorMsg);
|
|
1644
|
+
}
|
|
1629
1645
|
if (!isRetryableError(null, response.status)) {
|
|
1630
1646
|
throw new Error(errorMsg);
|
|
1631
1647
|
}
|
|
@@ -1675,6 +1691,10 @@ class HarmonyApiClient {
|
|
|
1675
1691
|
} catch {
|
|
1676
1692
|
errorMsg = text || `API error: ${response.status}`;
|
|
1677
1693
|
}
|
|
1694
|
+
if (response.status === 401) {
|
|
1695
|
+
this.onUnauthorized?.();
|
|
1696
|
+
throw new HarmonyUnauthorizedError(errorMsg);
|
|
1697
|
+
}
|
|
1678
1698
|
if (!isRetryableError(null, response.status)) {
|
|
1679
1699
|
throw new Error(errorMsg);
|
|
1680
1700
|
}
|
|
@@ -2136,5 +2156,6 @@ export {
|
|
|
2136
2156
|
resetClient,
|
|
2137
2157
|
requestWithBearer,
|
|
2138
2158
|
getClient,
|
|
2159
|
+
HarmonyUnauthorizedError,
|
|
2139
2160
|
HarmonyApiClient
|
|
2140
2161
|
};
|
package/package.json
CHANGED
|
@@ -312,7 +312,89 @@ describe("runMemoryAudit", () => {
|
|
|
312
312
|
});
|
|
313
313
|
expect(report.summary.delete).toBe(1);
|
|
314
314
|
expect(deletedIds).toContain("promoted-junk");
|
|
315
|
-
expect(report.lowest[0].reasons).toContain("boilerplate override");
|
|
315
|
+
expect(report.lowest[0].reasons).toContain("boilerplate title override");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("legitimate titles starting with boilerplate-prefix words are NOT deleted", async () => {
|
|
319
|
+
// Regression test for the over-broad regex bug. Pre-fix patterns matched
|
|
320
|
+
// any title starting with "Placeholder", "Untitled", "Note", etc. After
|
|
321
|
+
// tightening, only exact boilerplate forms (with optional digit suffix
|
|
322
|
+
// or colon) match — real titles survive.
|
|
323
|
+
const { client, deletedIds } = makeMockClient(
|
|
324
|
+
[
|
|
325
|
+
{
|
|
326
|
+
id: "legit-placeholder",
|
|
327
|
+
type: "pattern",
|
|
328
|
+
title: "Placeholder pattern in React Suspense",
|
|
329
|
+
content:
|
|
330
|
+
"Use React.Suspense with a fallback component as the placeholder pattern for streaming SSR.",
|
|
331
|
+
confidence: 0.9,
|
|
332
|
+
memory_tier: "reference",
|
|
333
|
+
access_count: 12,
|
|
334
|
+
last_accessed_at: daysAgo(1),
|
|
335
|
+
created_at: daysAgo(60),
|
|
336
|
+
tags: ["react", "ssr"],
|
|
337
|
+
embedding: [0.1],
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
id: "legit-untitled",
|
|
341
|
+
type: "context",
|
|
342
|
+
title: "UntitledMaster.fig — design source for the homepage",
|
|
343
|
+
content:
|
|
344
|
+
"Reference Figma file containing master components for landing page assets.",
|
|
345
|
+
confidence: 0.85,
|
|
346
|
+
memory_tier: "reference",
|
|
347
|
+
access_count: 8,
|
|
348
|
+
last_accessed_at: daysAgo(2),
|
|
349
|
+
created_at: daysAgo(45),
|
|
350
|
+
tags: ["design"],
|
|
351
|
+
embedding: [0.1],
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
id: "legit-note",
|
|
355
|
+
type: "context",
|
|
356
|
+
title: "Note: schema migration order matters",
|
|
357
|
+
content: "Always run 0042 before 0043 because of FK dependency.",
|
|
358
|
+
confidence: 0.8,
|
|
359
|
+
memory_tier: "reference",
|
|
360
|
+
access_count: 5,
|
|
361
|
+
last_accessed_at: daysAgo(3),
|
|
362
|
+
created_at: daysAgo(30),
|
|
363
|
+
tags: ["db"],
|
|
364
|
+
embedding: [0.1],
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
{ "legit-placeholder": 3, "legit-untitled": 2, "legit-note": 1 },
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const report = await runMemoryAudit(client, "ws-1", undefined, {
|
|
371
|
+
dryRun: false,
|
|
372
|
+
});
|
|
373
|
+
expect(deletedIds).toHaveLength(0);
|
|
374
|
+
expect(report.summary.delete).toBe(0);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("empty-content draft with real title is NOT delete-bucketed", async () => {
|
|
378
|
+
// Users sometimes save a draft with title only and fill content later.
|
|
379
|
+
// The override is title-only, so empty content alone must not delete.
|
|
380
|
+
const { client, deletedIds } = makeMockClient([
|
|
381
|
+
{
|
|
382
|
+
id: "draft-empty-body",
|
|
383
|
+
type: "decision",
|
|
384
|
+
title: "Decision: skip Q3 launch",
|
|
385
|
+
content: "",
|
|
386
|
+
confidence: 0.7,
|
|
387
|
+
memory_tier: "draft",
|
|
388
|
+
access_count: 1,
|
|
389
|
+
last_accessed_at: daysAgo(1),
|
|
390
|
+
created_at: daysAgo(2),
|
|
391
|
+
tags: ["q3"],
|
|
392
|
+
embedding: null,
|
|
393
|
+
},
|
|
394
|
+
]);
|
|
395
|
+
|
|
396
|
+
await runMemoryAudit(client, "ws-1", undefined, { dryRun: false });
|
|
397
|
+
expect(deletedIds).not.toContain("draft-empty-body");
|
|
316
398
|
});
|
|
317
399
|
|
|
318
400
|
test("boilerplate override respects deleteBelow=0 escape hatch", async () => {
|
package/src/api-client.ts
CHANGED
|
@@ -112,19 +112,43 @@ export async function requestWithBearer<T = unknown>(
|
|
|
112
112
|
return result as T;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// Sentinel thrown when the API rejects the bearer/api-key with HTTP 401.
|
|
116
|
+
// Lets the MCP transport layer turn it into an HTTP 401 + WWW-Authenticate
|
|
117
|
+
// challenge so OAuth clients can refresh, instead of burying it inside a
|
|
118
|
+
// JSON-RPC tool error envelope.
|
|
119
|
+
export class HarmonyUnauthorizedError extends Error {
|
|
120
|
+
constructor(message = "Invalid or expired credentials") {
|
|
121
|
+
super(message);
|
|
122
|
+
this.name = "HarmonyUnauthorizedError";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
115
126
|
export class HarmonyApiClient {
|
|
116
127
|
private apiKey: string;
|
|
117
128
|
private apiUrl: string;
|
|
129
|
+
private onUnauthorized?: () => void;
|
|
118
130
|
|
|
119
|
-
constructor(options?: {
|
|
131
|
+
constructor(options?: {
|
|
132
|
+
apiKey?: string;
|
|
133
|
+
apiUrl?: string;
|
|
134
|
+
onUnauthorized?: () => void;
|
|
135
|
+
}) {
|
|
120
136
|
this.apiKey = options?.apiKey ?? getApiKey();
|
|
121
137
|
this.apiUrl = options?.apiUrl ?? getApiUrl();
|
|
138
|
+
this.onUnauthorized = options?.onUnauthorized;
|
|
122
139
|
}
|
|
123
140
|
|
|
124
141
|
getApiUrl(): string {
|
|
125
142
|
return this.apiUrl;
|
|
126
143
|
}
|
|
127
144
|
|
|
145
|
+
// Lets the MCP session swap in a freshly refreshed OAuth access token
|
|
146
|
+
// without recreating the client. Called from remote.ts when the incoming
|
|
147
|
+
// Bearer header differs from the cached token.
|
|
148
|
+
setApiKey(apiKey: string): void {
|
|
149
|
+
this.apiKey = apiKey;
|
|
150
|
+
}
|
|
151
|
+
|
|
128
152
|
private async request<T>(
|
|
129
153
|
method: string,
|
|
130
154
|
path: string,
|
|
@@ -197,6 +221,13 @@ export class HarmonyApiClient {
|
|
|
197
221
|
? null
|
|
198
222
|
: `API error: ${response.status} (non-JSON response)`) ||
|
|
199
223
|
`API error: ${response.status}`;
|
|
224
|
+
// 401: token rejected by harmony-api. Don't retry — surface a typed
|
|
225
|
+
// error so the MCP transport layer can issue an HTTP 401 challenge
|
|
226
|
+
// and trigger the client's OAuth refresh flow.
|
|
227
|
+
if (response.status === 401) {
|
|
228
|
+
this.onUnauthorized?.();
|
|
229
|
+
throw new HarmonyUnauthorizedError(errorMsg);
|
|
230
|
+
}
|
|
200
231
|
if (!isRetryableError(null, response.status)) {
|
|
201
232
|
throw new Error(errorMsg);
|
|
202
233
|
}
|
|
@@ -259,6 +290,10 @@ export class HarmonyApiClient {
|
|
|
259
290
|
} catch {
|
|
260
291
|
errorMsg = text || `API error: ${response.status}`;
|
|
261
292
|
}
|
|
293
|
+
if (response.status === 401) {
|
|
294
|
+
this.onUnauthorized?.();
|
|
295
|
+
throw new HarmonyUnauthorizedError(errorMsg);
|
|
296
|
+
}
|
|
262
297
|
if (!isRetryableError(null, response.status)) {
|
|
263
298
|
throw new Error(errorMsg);
|
|
264
299
|
}
|
package/src/memory-audit.ts
CHANGED
|
@@ -116,27 +116,45 @@ export interface AuditReport {
|
|
|
116
116
|
healthReport: string;
|
|
117
117
|
}
|
|
118
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.
|
|
119
127
|
const BOILERPLATE_PATTERNS = [
|
|
120
128
|
/^todo:?$/i,
|
|
121
|
-
/^placeholder
|
|
129
|
+
/^placeholder(\s+\d+|:)?$/i,
|
|
122
130
|
/^\.\.\.$/,
|
|
123
|
-
/^untitled
|
|
124
|
-
/^(note|memo|draft)\s
|
|
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.
|
|
131
|
+
/^untitled(\s+\d+|:)?$/i,
|
|
132
|
+
/^(note|memo|draft)\s+\d+$/i,
|
|
127
133
|
/^task transition:/i,
|
|
128
134
|
];
|
|
129
135
|
|
|
130
|
-
|
|
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 {
|
|
131
141
|
const t = title.trim();
|
|
132
|
-
const c = content.trim();
|
|
133
|
-
if (c.length === 0) return true;
|
|
134
142
|
for (const pat of BOILERPLATE_PATTERNS) {
|
|
135
143
|
if (pat.test(t)) return true;
|
|
136
144
|
}
|
|
137
145
|
return false;
|
|
138
146
|
}
|
|
139
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
|
+
|
|
140
158
|
function scoreEntity(
|
|
141
159
|
entity: AuditEntity,
|
|
142
160
|
relationCount: number,
|
|
@@ -253,16 +271,18 @@ function scoreEntity(
|
|
|
253
271
|
legacyReasons.push("no graph presence");
|
|
254
272
|
}
|
|
255
273
|
|
|
256
|
-
// Bucket — boilerplate is a one-way door to delete. High access
|
|
257
|
-
// noise titles signal re-read churn (recall/dedup loops), not
|
|
258
|
-
// letting confidence + tier + decay drag the composite
|
|
259
|
-
// "keep" leaves promoted-to-reference junk untouched.
|
|
260
|
-
// except when deleteBelow=0 (the "no deletions" escape
|
|
261
|
-
//
|
|
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);
|
|
262
282
|
let bucket: AuditBucket;
|
|
263
|
-
if (
|
|
283
|
+
if (boilerplateTitle && deleteBelow > 0) {
|
|
264
284
|
bucket = "delete";
|
|
265
|
-
reasons.push("boilerplate override");
|
|
285
|
+
reasons.push("boilerplate title override");
|
|
266
286
|
} else if (score < deleteBelow) bucket = "delete";
|
|
267
287
|
else if (score < archiveBelow) bucket = "archive";
|
|
268
288
|
else if (score < 70) bucket = "review";
|
package/src/remote.ts
CHANGED
|
@@ -90,10 +90,16 @@ async function resolveWorkspaceForLegacyKey(
|
|
|
90
90
|
interface McpSession {
|
|
91
91
|
transport: WebStandardStreamableHTTPServerTransport;
|
|
92
92
|
server: Server;
|
|
93
|
+
client: HarmonyApiClient;
|
|
93
94
|
apiKey: string;
|
|
94
95
|
activeWorkspaceId: string | null;
|
|
95
96
|
activeProjectId: string | null;
|
|
96
97
|
createdAt: number;
|
|
98
|
+
// Set by HarmonyApiClient.onUnauthorized when the API rejects the cached
|
|
99
|
+
// token mid-session. The HTTP layer reads this after transport.handleRequest
|
|
100
|
+
// returns and converts the response to an HTTP 401 challenge so the OAuth
|
|
101
|
+
// client refreshes instead of caching a JSON-RPC error forever.
|
|
102
|
+
unauthorized: boolean;
|
|
97
103
|
}
|
|
98
104
|
|
|
99
105
|
const sessions = new Map<string, McpSession>();
|
|
@@ -124,18 +130,38 @@ function createSession(apiKey: string, keyInfo: TokenInfo): McpSession {
|
|
|
124
130
|
{ capabilities: { tools: {}, resources: {} } },
|
|
125
131
|
);
|
|
126
132
|
|
|
133
|
+
// unauthorized flag lives on a mutable holder so the HarmonyApiClient
|
|
134
|
+
// callback can flip it without a circular reference between the client and
|
|
135
|
+
// the session struct it lives on.
|
|
136
|
+
const authState = { unauthorized: false };
|
|
137
|
+
|
|
138
|
+
// Create per-session API client. onUnauthorized fires when harmony-api
|
|
139
|
+
// returns 401 — we mark the session so the HTTP layer can surface a real
|
|
140
|
+
// 401 + WWW-Authenticate challenge to the OAuth client.
|
|
141
|
+
const client = new HarmonyApiClient({
|
|
142
|
+
apiKey,
|
|
143
|
+
apiUrl: HARMONY_API_URL,
|
|
144
|
+
onUnauthorized: () => {
|
|
145
|
+
authState.unauthorized = true;
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
127
149
|
const session: McpSession = {
|
|
128
150
|
transport,
|
|
129
151
|
server,
|
|
152
|
+
client,
|
|
130
153
|
apiKey,
|
|
131
154
|
activeWorkspaceId: keyInfo.workspaceId,
|
|
132
155
|
activeProjectId: null,
|
|
133
156
|
createdAt: Date.now(),
|
|
157
|
+
get unauthorized() {
|
|
158
|
+
return authState.unauthorized;
|
|
159
|
+
},
|
|
160
|
+
set unauthorized(v: boolean) {
|
|
161
|
+
authState.unauthorized = v;
|
|
162
|
+
},
|
|
134
163
|
};
|
|
135
164
|
|
|
136
|
-
// Create per-session deps
|
|
137
|
-
const client = new HarmonyApiClient({ apiKey, apiUrl: HARMONY_API_URL });
|
|
138
|
-
|
|
139
165
|
const deps: ToolDeps = {
|
|
140
166
|
getClient: () => client,
|
|
141
167
|
isConfigured: () => true,
|
|
@@ -224,6 +250,16 @@ function unauthenticatedResponse(): Response {
|
|
|
224
250
|
);
|
|
225
251
|
}
|
|
226
252
|
|
|
253
|
+
// Evict a session and tear down its transport. Used when an OAuth token
|
|
254
|
+
// rotates or is revoked mid-session — we don't want to keep a zombie session
|
|
255
|
+
// around with a stale cached api key.
|
|
256
|
+
function evictSession(sessionId: string): void {
|
|
257
|
+
const session = sessions.get(sessionId);
|
|
258
|
+
if (!session) return;
|
|
259
|
+
sessions.delete(sessionId);
|
|
260
|
+
session.transport.close().catch(() => {});
|
|
261
|
+
}
|
|
262
|
+
|
|
227
263
|
// MCP endpoint - handles POST (JSON-RPC), GET (SSE), DELETE (session close).
|
|
228
264
|
// Mounted on both `/mcp` and `/` so clients that registered the bare host as
|
|
229
265
|
// their server URL still reach the OAuth challenge instead of a 404.
|
|
@@ -243,9 +279,33 @@ const handleMcpRequest = async (c: import("hono").Context) => {
|
|
|
243
279
|
const sessionId = c.req.header("Mcp-Session-Id");
|
|
244
280
|
|
|
245
281
|
if (sessionId && sessions.has(sessionId)) {
|
|
246
|
-
// Existing session - forward request
|
|
247
282
|
const session = sessions.get(sessionId)!;
|
|
248
|
-
|
|
283
|
+
|
|
284
|
+
// Hot-swap the cached token if the OAuth client just refreshed. Without
|
|
285
|
+
// this the session would keep using the stale access token forever and
|
|
286
|
+
// every tool call after refresh would 401 — the bug that motivated this
|
|
287
|
+
// patch.
|
|
288
|
+
if (session.apiKey !== apiKey) {
|
|
289
|
+
session.apiKey = apiKey;
|
|
290
|
+
session.client.setApiKey(apiKey);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Reset the per-request 401 latch before handing off to the transport.
|
|
294
|
+
session.unauthorized = false;
|
|
295
|
+
|
|
296
|
+
const response = await session.transport.handleRequest(c.req.raw);
|
|
297
|
+
|
|
298
|
+
// If a tool call hit 401 against harmony-api, the api-client tripped the
|
|
299
|
+
// unauthorized flag. Evict the session and return an HTTP 401 +
|
|
300
|
+
// WWW-Authenticate so the client triggers a refresh — instead of burying
|
|
301
|
+
// the auth failure inside a JSON-RPC error envelope the client can't act
|
|
302
|
+
// on.
|
|
303
|
+
if (session.unauthorized) {
|
|
304
|
+
evictSession(sessionId);
|
|
305
|
+
return unauthenticatedResponse();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return response;
|
|
249
309
|
}
|
|
250
310
|
|
|
251
311
|
if (method === "POST") {
|
|
@@ -272,8 +332,17 @@ const handleMcpRequest = async (c: import("hono").Context) => {
|
|
|
272
332
|
origOnSessionInitialized?.(sid);
|
|
273
333
|
};
|
|
274
334
|
|
|
275
|
-
|
|
276
|
-
|
|
335
|
+
const response = await session.transport.handleRequest(c.req.raw);
|
|
336
|
+
|
|
337
|
+
// Same 401-latch check as the existing-session branch — covers the case
|
|
338
|
+
// where the *initialize* call itself triggers an API request that 401s
|
|
339
|
+
// (e.g., revoked-during-handshake).
|
|
340
|
+
if (session.unauthorized && session.transport.sessionId) {
|
|
341
|
+
evictSession(session.transport.sessionId);
|
|
342
|
+
return unauthenticatedResponse();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return response;
|
|
277
346
|
}
|
|
278
347
|
|
|
279
348
|
// GET or DELETE without a valid session
|