@gethmy/mcp 2.4.4 → 2.4.7
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 +56 -12
- package/dist/index.js +56 -12
- package/dist/lib/api-client.js +29 -1
- package/dist/lib/config.js +5 -1
- package/package.json +1 -1
- package/src/__tests__/memory-audit.test.ts +83 -1
- package/src/__tests__/remote-routing.test.ts +285 -0
- package/src/api-client.ts +40 -1
- package/src/memory-audit.ts +36 -16
- package/src/remote.ts +318 -56
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing/auth tests for the remote MCP server (`src/remote.ts`).
|
|
3
|
+
*
|
|
4
|
+
* Covers the four session-routing branches mandated by the MCP Streamable
|
|
5
|
+
* HTTP spec — the regression that surfaced as "Harmony MCP not responding"
|
|
6
|
+
* after a server restart, OAuth refresh, or stale-session GC was a missing
|
|
7
|
+
* 404 branch for unknown `Mcp-Session-Id` values.
|
|
8
|
+
*
|
|
9
|
+
* These tests stub `globalThis.fetch` so we don't depend on harmony-api
|
|
10
|
+
* being reachable.
|
|
11
|
+
*
|
|
12
|
+
* Run with: bun test packages/mcp-server/src/__tests__/remote-routing.test.ts
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
afterAll,
|
|
17
|
+
afterEach,
|
|
18
|
+
beforeAll,
|
|
19
|
+
beforeEach,
|
|
20
|
+
describe,
|
|
21
|
+
expect,
|
|
22
|
+
test,
|
|
23
|
+
} from "bun:test";
|
|
24
|
+
|
|
25
|
+
const ORIGINAL_FETCH = globalThis.fetch;
|
|
26
|
+
|
|
27
|
+
// Fixed values used across tests
|
|
28
|
+
const TEST_USER = "user-alpha";
|
|
29
|
+
const OTHER_USER = "user-beta";
|
|
30
|
+
const TEST_WORKSPACE = "ws-1";
|
|
31
|
+
const VALID_TOKEN = "hmy_at_alpha_valid";
|
|
32
|
+
const REFRESHED_TOKEN = "hmy_at_alpha_refreshed";
|
|
33
|
+
const OTHER_USER_TOKEN = "hmy_at_beta_valid";
|
|
34
|
+
const REVOKED_TOKEN = "hmy_at_revoked";
|
|
35
|
+
|
|
36
|
+
function makeFetchStub() {
|
|
37
|
+
return async (
|
|
38
|
+
input: string | URL | Request,
|
|
39
|
+
init?: RequestInit,
|
|
40
|
+
): Promise<Response> => {
|
|
41
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
42
|
+
const apiKey =
|
|
43
|
+
(init?.headers as Record<string, string> | undefined)?.["X-API-Key"] ??
|
|
44
|
+
"";
|
|
45
|
+
|
|
46
|
+
if (url.endsWith("/v1/auth/context")) {
|
|
47
|
+
const map: Record<
|
|
48
|
+
string,
|
|
49
|
+
{ userId: string; workspaceId: string | null; source: string } | null
|
|
50
|
+
> = {
|
|
51
|
+
[VALID_TOKEN]: {
|
|
52
|
+
userId: TEST_USER,
|
|
53
|
+
workspaceId: TEST_WORKSPACE,
|
|
54
|
+
source: "oauth",
|
|
55
|
+
},
|
|
56
|
+
[REFRESHED_TOKEN]: {
|
|
57
|
+
userId: TEST_USER,
|
|
58
|
+
workspaceId: TEST_WORKSPACE,
|
|
59
|
+
source: "oauth",
|
|
60
|
+
},
|
|
61
|
+
[OTHER_USER_TOKEN]: {
|
|
62
|
+
userId: OTHER_USER,
|
|
63
|
+
workspaceId: "ws-2",
|
|
64
|
+
source: "oauth",
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
const ctx = map[apiKey];
|
|
68
|
+
if (!ctx) {
|
|
69
|
+
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
70
|
+
status: 401,
|
|
71
|
+
headers: { "Content-Type": "application/json" },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return new Response(JSON.stringify(ctx), {
|
|
75
|
+
status: 200,
|
|
76
|
+
headers: { "Content-Type": "application/json" },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Anything else — return a benign 404 so unrelated lookups don't blow up.
|
|
81
|
+
return new Response("{}", {
|
|
82
|
+
status: 404,
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let fetchHandler: (req: Request) => Promise<Response>;
|
|
89
|
+
let _sessionsForTests: Map<string, unknown>;
|
|
90
|
+
|
|
91
|
+
beforeAll(async () => {
|
|
92
|
+
globalThis.fetch = makeFetchStub() as unknown as typeof fetch;
|
|
93
|
+
// Import after the stub is in place (module init is synchronous-only.)
|
|
94
|
+
const mod = await import("../remote.js");
|
|
95
|
+
fetchHandler = mod.fetchHandler as (req: Request) => Promise<Response>;
|
|
96
|
+
_sessionsForTests = mod._sessionsForTests as Map<string, unknown>;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterAll(() => {
|
|
100
|
+
globalThis.fetch = ORIGINAL_FETCH;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
// Refresh the fetch stub each test (preserves across the cache TTL).
|
|
105
|
+
globalThis.fetch = makeFetchStub() as unknown as typeof fetch;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
// Wipe sessions between tests so state doesn't leak.
|
|
110
|
+
_sessionsForTests.clear();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const INIT_BODY = {
|
|
114
|
+
jsonrpc: "2.0",
|
|
115
|
+
id: 1,
|
|
116
|
+
method: "initialize",
|
|
117
|
+
params: {
|
|
118
|
+
protocolVersion: "2025-06-18",
|
|
119
|
+
capabilities: {},
|
|
120
|
+
clientInfo: { name: "test", version: "0.0.1" },
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const TOOLS_LIST_BODY = {
|
|
125
|
+
jsonrpc: "2.0",
|
|
126
|
+
id: 2,
|
|
127
|
+
method: "tools/list",
|
|
128
|
+
params: {},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function makePost(
|
|
132
|
+
body: unknown,
|
|
133
|
+
opts: { token?: string; sessionId?: string } = {},
|
|
134
|
+
): Request {
|
|
135
|
+
const headers: Record<string, string> = {
|
|
136
|
+
"Content-Type": "application/json",
|
|
137
|
+
Accept: "application/json, text/event-stream",
|
|
138
|
+
};
|
|
139
|
+
if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
|
|
140
|
+
if (opts.sessionId) headers["Mcp-Session-Id"] = opts.sessionId;
|
|
141
|
+
return new Request("http://localhost/mcp", {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers,
|
|
144
|
+
body: JSON.stringify(body),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
describe("remote MCP routing", () => {
|
|
149
|
+
test("no Authorization header → 401 + WWW-Authenticate", async () => {
|
|
150
|
+
const res = await fetchHandler(
|
|
151
|
+
new Request("http://localhost/mcp", { method: "POST" }),
|
|
152
|
+
);
|
|
153
|
+
expect(res.status).toBe(401);
|
|
154
|
+
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
|
|
155
|
+
expect(wwwAuth).toContain("Bearer");
|
|
156
|
+
expect(wwwAuth).toContain("resource_metadata=");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("invalid bearer on initialize → 401 invalid_token", async () => {
|
|
160
|
+
const res = await fetchHandler(
|
|
161
|
+
makePost(INIT_BODY, { token: REVOKED_TOKEN }),
|
|
162
|
+
);
|
|
163
|
+
expect(res.status).toBe(401);
|
|
164
|
+
expect(res.headers.get("WWW-Authenticate") ?? "").toContain(
|
|
165
|
+
"invalid_token",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("POST without session id and not initialize → 400", async () => {
|
|
170
|
+
const res = await fetchHandler(
|
|
171
|
+
makePost(TOOLS_LIST_BODY, { token: VALID_TOKEN }),
|
|
172
|
+
);
|
|
173
|
+
expect(res.status).toBe(400);
|
|
174
|
+
const body = (await res.json()) as { error: { message: string } };
|
|
175
|
+
expect(body.error.message).toMatch(/Mcp-Session-Id header required/i);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("POST with unknown session id → 404 (forces client re-init)", async () => {
|
|
179
|
+
const res = await fetchHandler(
|
|
180
|
+
makePost(TOOLS_LIST_BODY, {
|
|
181
|
+
token: VALID_TOKEN,
|
|
182
|
+
sessionId: "ghost-session-id",
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
// This is the spec-mandated behavior that fixes "Harmony MCP not responding".
|
|
186
|
+
expect(res.status).toBe(404);
|
|
187
|
+
const body = (await res.json()) as { error: { code: number } };
|
|
188
|
+
expect(body.error.code).toBe(-32001);
|
|
189
|
+
expect(res.headers.get("Mcp-Session-Id")).toBe("ghost-session-id");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("initialize succeeds and registers a session", async () => {
|
|
193
|
+
const res = await fetchHandler(makePost(INIT_BODY, { token: VALID_TOKEN }));
|
|
194
|
+
expect(res.status).toBe(200);
|
|
195
|
+
const sid = res.headers.get("mcp-session-id");
|
|
196
|
+
expect(sid).toBeTruthy();
|
|
197
|
+
expect(_sessionsForTests.has(sid!)).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("rotated token (same user) is accepted on hot-swap", async () => {
|
|
201
|
+
// Bootstrap a session.
|
|
202
|
+
const initRes = await fetchHandler(
|
|
203
|
+
makePost(INIT_BODY, { token: VALID_TOKEN }),
|
|
204
|
+
);
|
|
205
|
+
const sid = initRes.headers.get("mcp-session-id")!;
|
|
206
|
+
|
|
207
|
+
// Send a follow-up with the *new* token + same session id.
|
|
208
|
+
// We're not asserting on the body — just that the rotation didn't
|
|
209
|
+
// produce a 401/404, which it would if hot-swap were broken.
|
|
210
|
+
const follow = await fetchHandler(
|
|
211
|
+
makePost(TOOLS_LIST_BODY, {
|
|
212
|
+
token: REFRESHED_TOKEN,
|
|
213
|
+
sessionId: sid,
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
expect(follow.status).not.toBe(401);
|
|
217
|
+
expect(follow.status).not.toBe(404);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("token from a different user is REJECTED on hot-swap", async () => {
|
|
221
|
+
const initRes = await fetchHandler(
|
|
222
|
+
makePost(INIT_BODY, { token: VALID_TOKEN }),
|
|
223
|
+
);
|
|
224
|
+
const sid = initRes.headers.get("mcp-session-id")!;
|
|
225
|
+
|
|
226
|
+
// Attempt to ride the session with another user's bearer.
|
|
227
|
+
const hijack = await fetchHandler(
|
|
228
|
+
makePost(TOOLS_LIST_BODY, {
|
|
229
|
+
token: OTHER_USER_TOKEN,
|
|
230
|
+
sessionId: sid,
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
expect(hijack.status).toBe(401);
|
|
234
|
+
const wwwAuth = hijack.headers.get("WWW-Authenticate") ?? "";
|
|
235
|
+
expect(wwwAuth).toContain("invalid_token");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("GET without valid session id → 400", async () => {
|
|
239
|
+
const res = await fetchHandler(
|
|
240
|
+
new Request("http://localhost/mcp", {
|
|
241
|
+
method: "GET",
|
|
242
|
+
headers: {
|
|
243
|
+
Authorization: `Bearer ${VALID_TOKEN}`,
|
|
244
|
+
Accept: "text/event-stream",
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
// GET without a session id falls into "session required" — 400.
|
|
249
|
+
expect(res.status).toBe(400);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("GET with unknown session id → 404", async () => {
|
|
253
|
+
const res = await fetchHandler(
|
|
254
|
+
new Request("http://localhost/mcp", {
|
|
255
|
+
method: "GET",
|
|
256
|
+
headers: {
|
|
257
|
+
Authorization: `Bearer ${VALID_TOKEN}`,
|
|
258
|
+
Accept: "text/event-stream",
|
|
259
|
+
"Mcp-Session-Id": "ghost",
|
|
260
|
+
},
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
expect(res.status).toBe(404);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("/.well-known/oauth-protected-resource is unauthenticated", async () => {
|
|
267
|
+
const res = await fetchHandler(
|
|
268
|
+
new Request("http://localhost/.well-known/oauth-protected-resource"),
|
|
269
|
+
);
|
|
270
|
+
expect(res.status).toBe(200);
|
|
271
|
+
const body = (await res.json()) as {
|
|
272
|
+
authorization_servers: string[];
|
|
273
|
+
bearer_methods_supported: string[];
|
|
274
|
+
};
|
|
275
|
+
expect(body.authorization_servers.length).toBeGreaterThan(0);
|
|
276
|
+
expect(body.bearer_methods_supported).toContain("header");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("/health is unauthenticated", async () => {
|
|
280
|
+
const res = await fetchHandler(new Request("http://localhost/health"));
|
|
281
|
+
expect(res.status).toBe(200);
|
|
282
|
+
const body = (await res.json()) as { status: string };
|
|
283
|
+
expect(body.status).toBe("ok");
|
|
284
|
+
});
|
|
285
|
+
});
|
package/src/api-client.ts
CHANGED
|
@@ -112,19 +112,47 @@ 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
|
+
|
|
152
|
+
getApiKey(): string {
|
|
153
|
+
return this.apiKey;
|
|
154
|
+
}
|
|
155
|
+
|
|
128
156
|
private async request<T>(
|
|
129
157
|
method: string,
|
|
130
158
|
path: string,
|
|
@@ -197,6 +225,13 @@ export class HarmonyApiClient {
|
|
|
197
225
|
? null
|
|
198
226
|
: `API error: ${response.status} (non-JSON response)`) ||
|
|
199
227
|
`API error: ${response.status}`;
|
|
228
|
+
// 401: token rejected by harmony-api. Don't retry — surface a typed
|
|
229
|
+
// error so the MCP transport layer can issue an HTTP 401 challenge
|
|
230
|
+
// and trigger the client's OAuth refresh flow.
|
|
231
|
+
if (response.status === 401) {
|
|
232
|
+
this.onUnauthorized?.();
|
|
233
|
+
throw new HarmonyUnauthorizedError(errorMsg);
|
|
234
|
+
}
|
|
200
235
|
if (!isRetryableError(null, response.status)) {
|
|
201
236
|
throw new Error(errorMsg);
|
|
202
237
|
}
|
|
@@ -259,6 +294,10 @@ export class HarmonyApiClient {
|
|
|
259
294
|
} catch {
|
|
260
295
|
errorMsg = text || `API error: ${response.status}`;
|
|
261
296
|
}
|
|
297
|
+
if (response.status === 401) {
|
|
298
|
+
this.onUnauthorized?.();
|
|
299
|
+
throw new HarmonyUnauthorizedError(errorMsg);
|
|
300
|
+
}
|
|
262
301
|
if (!isRetryableError(null, response.status)) {
|
|
263
302
|
throw new Error(errorMsg);
|
|
264
303
|
}
|
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";
|