@agfpd/iapeer-memory-core 0.1.1
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/package.json +32 -0
- package/src/config.ts +257 -0
- package/src/context-render.ts +185 -0
- package/src/db.ts +550 -0
- package/src/embedding.ts +174 -0
- package/src/fm-update.ts +352 -0
- package/src/frontmatter-fill.ts +529 -0
- package/src/graph.ts +427 -0
- package/src/http-client.ts +129 -0
- package/src/human-edit-detect.ts +213 -0
- package/src/index-render.ts +876 -0
- package/src/index.ts +65 -0
- package/src/indexer.ts +323 -0
- package/src/log.ts +27 -0
- package/src/mcp-tools.ts +468 -0
- package/src/memoryd.ts +680 -0
- package/src/migrate-auto-memory.ts +289 -0
- package/src/parser.ts +269 -0
- package/src/permanent-detect.ts +110 -0
- package/src/render-doctrine.ts +113 -0
- package/src/reranker.ts +162 -0
- package/src/search.ts +806 -0
- package/src/smart-hash.ts +85 -0
- package/src/sqlite-loader.ts +151 -0
- package/src/tags-mirror.ts +47 -0
- package/src/taxonomy.ts +385 -0
- package/src/utils.ts +69 -0
- package/tsconfig.json +24 -0
package/src/graph.ts
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import type { CoreDb } from "./db.js";
|
|
2
|
+
import { getUnresolvedLinks } from "./db.js";
|
|
3
|
+
import type { CoreConfig } from "./config.js";
|
|
4
|
+
import { agentMemoryFolderMarker } from "./taxonomy.js";
|
|
5
|
+
|
|
6
|
+
export type Cluster = {
|
|
7
|
+
name: string;
|
|
8
|
+
nodes: string[];
|
|
9
|
+
hub: { path: string; title: string; degree: number } | null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type Hub = {
|
|
13
|
+
path: string;
|
|
14
|
+
title: string;
|
|
15
|
+
inDegree: number;
|
|
16
|
+
outDegree: number;
|
|
17
|
+
total: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type Bridge = {
|
|
21
|
+
path: string;
|
|
22
|
+
title: string;
|
|
23
|
+
connects: [string, string]; // cluster names
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type VaultMapData = {
|
|
27
|
+
generated: string;
|
|
28
|
+
stats: {
|
|
29
|
+
documents: number;
|
|
30
|
+
edges: number;
|
|
31
|
+
clusters: number;
|
|
32
|
+
hubs: number;
|
|
33
|
+
bridges: number;
|
|
34
|
+
orphans: number;
|
|
35
|
+
orphan_wikilinks: number;
|
|
36
|
+
};
|
|
37
|
+
clusters: Cluster[];
|
|
38
|
+
hubs: Hub[];
|
|
39
|
+
bridges: Bridge[];
|
|
40
|
+
orphans: string[];
|
|
41
|
+
orphanWikilinks: Array<{ source: string; target: string; reason: string }>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ── Graph loading ──
|
|
45
|
+
|
|
46
|
+
type AdjMap = Map<string, Set<string>>;
|
|
47
|
+
|
|
48
|
+
function loadGraph(db: CoreDb, agentMemoryMarker: string): { adj: AdjMap; directed: Map<string, Set<string>>; allPaths: Set<string> } {
|
|
49
|
+
const adj: AdjMap = new Map();
|
|
50
|
+
const directed = new Map<string, Set<string>>();
|
|
51
|
+
|
|
52
|
+
const edges = db.prepare("SELECT source_path, target_path FROM edges").all() as Array<{
|
|
53
|
+
source_path: string;
|
|
54
|
+
target_path: string;
|
|
55
|
+
}>;
|
|
56
|
+
|
|
57
|
+
// vault_map is the CANONICAL / shared topology — used by the Index nightly
|
|
58
|
+
// health-check (orphans, hubs, bridges). Agent operative notes are personal
|
|
59
|
+
// memory: excluding them here keeps that health picture honest (a
|
|
60
|
+
// canonically-isolated note must read as orphan even if some agent journaled
|
|
61
|
+
// about it). This is the uniform graph-layer half of the Audit #6 split —
|
|
62
|
+
// dropping operative notes from allPaths transitively drops every edge with
|
|
63
|
+
// an operative endpoint via the existing both-ends guard below.
|
|
64
|
+
//
|
|
65
|
+
// Deliberately NOT mirrored in search.ts (backlink-boost / graph-expand):
|
|
66
|
+
// there, "many agents reference this" is a legitimate cross-agent importance
|
|
67
|
+
// signal and operative→canon edges stay counted. Do not "unify" the two.
|
|
68
|
+
const allPaths = new Set<string>();
|
|
69
|
+
const docs = db.prepare("SELECT path FROM documents").all() as Array<{ path: string }>;
|
|
70
|
+
for (const d of docs) {
|
|
71
|
+
if (d.path.includes(agentMemoryMarker)) continue;
|
|
72
|
+
allPaths.add(d.path);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const e of edges) {
|
|
76
|
+
// Only include edges where both ends are indexed, non-operative documents
|
|
77
|
+
if (!allPaths.has(e.source_path) || !allPaths.has(e.target_path)) continue;
|
|
78
|
+
|
|
79
|
+
// Undirected for connected components
|
|
80
|
+
if (!adj.has(e.source_path)) adj.set(e.source_path, new Set());
|
|
81
|
+
if (!adj.has(e.target_path)) adj.set(e.target_path, new Set());
|
|
82
|
+
adj.get(e.source_path)!.add(e.target_path);
|
|
83
|
+
adj.get(e.target_path)!.add(e.source_path);
|
|
84
|
+
|
|
85
|
+
// Directed for degree counting
|
|
86
|
+
if (!directed.has(e.source_path)) directed.set(e.source_path, new Set());
|
|
87
|
+
directed.get(e.source_path)!.add(e.target_path);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { adj, directed, allPaths };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Connected components (BFS) ──
|
|
94
|
+
|
|
95
|
+
function findConnectedComponents(adj: AdjMap, allPaths: Set<string>): string[][] {
|
|
96
|
+
const visited = new Set<string>();
|
|
97
|
+
const components: string[][] = [];
|
|
98
|
+
|
|
99
|
+
for (const node of allPaths) {
|
|
100
|
+
if (visited.has(node)) continue;
|
|
101
|
+
|
|
102
|
+
const component: string[] = [];
|
|
103
|
+
const queue = [node];
|
|
104
|
+
visited.add(node);
|
|
105
|
+
|
|
106
|
+
while (queue.length > 0) {
|
|
107
|
+
const current = queue.shift()!;
|
|
108
|
+
component.push(current);
|
|
109
|
+
|
|
110
|
+
const neighbors = adj.get(current);
|
|
111
|
+
if (neighbors) {
|
|
112
|
+
for (const neighbor of neighbors) {
|
|
113
|
+
if (!visited.has(neighbor)) {
|
|
114
|
+
visited.add(neighbor);
|
|
115
|
+
queue.push(neighbor);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
components.push(component);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Sort by size descending
|
|
125
|
+
components.sort((a, b) => b.length - a.length);
|
|
126
|
+
return components;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Degree calculation ──
|
|
130
|
+
|
|
131
|
+
function calcDegrees(directed: Map<string, Set<string>>, allPaths: Set<string>): Map<string, { in: number; out: number }> {
|
|
132
|
+
const degrees = new Map<string, { in: number; out: number }>();
|
|
133
|
+
|
|
134
|
+
for (const p of allPaths) {
|
|
135
|
+
degrees.set(p, { in: 0, out: 0 });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const [src, targets] of directed) {
|
|
139
|
+
const d = degrees.get(src);
|
|
140
|
+
if (d) d.out = targets.size;
|
|
141
|
+
for (const tgt of targets) {
|
|
142
|
+
const td = degrees.get(tgt);
|
|
143
|
+
if (td) td.in++;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return degrees;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Articulation points (Tarjan) ──
|
|
151
|
+
|
|
152
|
+
function findArticulationPoints(adj: AdjMap, allPaths: Set<string>): Set<string> {
|
|
153
|
+
const disc = new Map<string, number>();
|
|
154
|
+
const low = new Map<string, number>();
|
|
155
|
+
const parent = new Map<string, string | null>();
|
|
156
|
+
const ap = new Set<string>();
|
|
157
|
+
let timer = 0;
|
|
158
|
+
|
|
159
|
+
function dfs(u: string): void {
|
|
160
|
+
let children = 0;
|
|
161
|
+
disc.set(u, timer);
|
|
162
|
+
low.set(u, timer);
|
|
163
|
+
timer++;
|
|
164
|
+
|
|
165
|
+
const neighbors = adj.get(u);
|
|
166
|
+
if (!neighbors) return;
|
|
167
|
+
|
|
168
|
+
for (const v of neighbors) {
|
|
169
|
+
if (!disc.has(v)) {
|
|
170
|
+
children++;
|
|
171
|
+
parent.set(v, u);
|
|
172
|
+
dfs(v);
|
|
173
|
+
|
|
174
|
+
const lowU = low.get(u)!;
|
|
175
|
+
const lowV = low.get(v)!;
|
|
176
|
+
low.set(u, Math.min(lowU, lowV));
|
|
177
|
+
|
|
178
|
+
// Root with 2+ children
|
|
179
|
+
if (parent.get(u) === null && children > 1) {
|
|
180
|
+
ap.add(u);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Non-root where low[v] >= disc[u]
|
|
184
|
+
if (parent.get(u) !== null && lowV >= disc.get(u)!) {
|
|
185
|
+
ap.add(u);
|
|
186
|
+
}
|
|
187
|
+
} else if (v !== parent.get(u)) {
|
|
188
|
+
const lowU = low.get(u)!;
|
|
189
|
+
const discV = disc.get(v)!;
|
|
190
|
+
low.set(u, Math.min(lowU, discV));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const node of allPaths) {
|
|
196
|
+
if (!disc.has(node)) {
|
|
197
|
+
parent.set(node, null);
|
|
198
|
+
dfs(node);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return ap;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Cluster naming ──
|
|
206
|
+
|
|
207
|
+
function getDocMeta(db: CoreDb, path: string): { title: string; tags: string[] } {
|
|
208
|
+
const row = db.prepare("SELECT title, tags FROM documents WHERE path = ?").get(path) as
|
|
209
|
+
| { title: string; tags: string | null }
|
|
210
|
+
| null;
|
|
211
|
+
if (!row) return { title: path.split("/").pop()?.replace(/\.md$/, "") ?? path, tags: [] };
|
|
212
|
+
|
|
213
|
+
let tags: string[] = [];
|
|
214
|
+
try {
|
|
215
|
+
tags = row.tags ? JSON.parse(row.tags) : [];
|
|
216
|
+
} catch {
|
|
217
|
+
tags = [];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { title: row.title || path, tags };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function nameCluster(
|
|
224
|
+
db: CoreDb,
|
|
225
|
+
nodes: string[],
|
|
226
|
+
degrees: Map<string, { in: number; out: number }>,
|
|
227
|
+
): { name: string; hub: { path: string; title: string; degree: number } | null } {
|
|
228
|
+
// Find hub: node with max total degree in this cluster
|
|
229
|
+
let hubPath: string | null = null;
|
|
230
|
+
let maxDeg = 0;
|
|
231
|
+
|
|
232
|
+
for (const n of nodes) {
|
|
233
|
+
const d = degrees.get(n);
|
|
234
|
+
const total = d ? d.in + d.out : 0;
|
|
235
|
+
if (total > maxDeg) {
|
|
236
|
+
maxDeg = total;
|
|
237
|
+
hubPath = n;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!hubPath || maxDeg === 0) {
|
|
242
|
+
// No connections — name by first node alphabetically
|
|
243
|
+
const sorted = [...nodes].sort();
|
|
244
|
+
const meta = getDocMeta(db, sorted[0]);
|
|
245
|
+
return { name: meta.title, hub: null };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const hubMeta = getDocMeta(db, hubPath);
|
|
249
|
+
const hub = { path: hubPath, title: hubMeta.title, degree: maxDeg };
|
|
250
|
+
|
|
251
|
+
// Cluster name = most frequent top-level domain tag across ALL nodes.
|
|
252
|
+
// Was `hubMeta.tags[0].split("/")[0]` — depended on the hub's YAML tag
|
|
253
|
+
// order, so reordering tags silently renamed the cluster (Audit #8).
|
|
254
|
+
// Counting top-level tags over the whole cluster is order-independent;
|
|
255
|
+
// ties break alphabetically for determinism.
|
|
256
|
+
const freq = new Map<string, number>();
|
|
257
|
+
for (const n of nodes) {
|
|
258
|
+
for (const t of getDocMeta(db, n).tags) {
|
|
259
|
+
const top = t.split("/")[0];
|
|
260
|
+
if (top) freq.set(top, (freq.get(top) ?? 0) + 1);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (freq.size > 0) {
|
|
264
|
+
const best = [...freq.entries()].sort(
|
|
265
|
+
(a, b) => b[1] - a[1] || a[0].localeCompare(b[0]),
|
|
266
|
+
)[0][0];
|
|
267
|
+
return { name: best, hub };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// No tags anywhere in the cluster — fall back to the hub title.
|
|
271
|
+
return { name: hubMeta.title, hub };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Which clusters does a bridge connect? ──
|
|
275
|
+
|
|
276
|
+
function bridgeConnects(
|
|
277
|
+
db: CoreDb,
|
|
278
|
+
bridgePath: string,
|
|
279
|
+
adj: AdjMap,
|
|
280
|
+
degrees: Map<string, { in: number; out: number }>,
|
|
281
|
+
): [string, string] | null {
|
|
282
|
+
const neighbors = adj.get(bridgePath);
|
|
283
|
+
if (!neighbors || neighbors.size < 2) return null;
|
|
284
|
+
|
|
285
|
+
// Components the graph splits into once the articulation point is removed.
|
|
286
|
+
const visited = new Set<string>([bridgePath]);
|
|
287
|
+
const groups: Set<string>[] = [];
|
|
288
|
+
|
|
289
|
+
for (const start of neighbors) {
|
|
290
|
+
if (visited.has(start)) continue;
|
|
291
|
+
const group = new Set<string>();
|
|
292
|
+
const queue = [start];
|
|
293
|
+
visited.add(start);
|
|
294
|
+
while (queue.length > 0) {
|
|
295
|
+
const cur = queue.shift()!;
|
|
296
|
+
group.add(cur);
|
|
297
|
+
const curNeighbors = adj.get(cur);
|
|
298
|
+
if (curNeighbors) {
|
|
299
|
+
for (const n of curNeighbors) {
|
|
300
|
+
if (n === bridgePath) continue;
|
|
301
|
+
if (!visited.has(n)) {
|
|
302
|
+
visited.add(n);
|
|
303
|
+
queue.push(n);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
groups.push(group);
|
|
309
|
+
if (groups.length >= 2) break;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (groups.length < 2) return null;
|
|
313
|
+
|
|
314
|
+
// Name each side by its OWN most-connected node + size. The old code named
|
|
315
|
+
// both sides by the original cluster (one name per connected component) —
|
|
316
|
+
// but an articulation point is by definition inside one component, so
|
|
317
|
+
// conn[0] === conn[1] always held and buildVaultMap dropped every bridge.
|
|
318
|
+
// The real signal is "what falls off if this node is removed".
|
|
319
|
+
const labelOf = (group: Set<string>): string => {
|
|
320
|
+
let repPath = "";
|
|
321
|
+
let repDeg = -1;
|
|
322
|
+
for (const p of group) {
|
|
323
|
+
const d = degrees.get(p);
|
|
324
|
+
const total = d ? d.in + d.out : 0;
|
|
325
|
+
if (total > repDeg) {
|
|
326
|
+
repDeg = total;
|
|
327
|
+
repPath = p;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (!repPath) repPath = [...group].sort()[0] ?? "";
|
|
331
|
+
const title = repPath ? getDocMeta(db, repPath).title : "?";
|
|
332
|
+
return `${title} (n=${group.size})`;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
return [labelOf(groups[0]), labelOf(groups[1])];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Main entry point ──
|
|
339
|
+
|
|
340
|
+
export function buildVaultMap(db: CoreDb, config: CoreConfig): VaultMapData {
|
|
341
|
+
const agentMemoryMarker = agentMemoryFolderMarker(config.taxonomy);
|
|
342
|
+
const { adj, directed, allPaths } = loadGraph(db, agentMemoryMarker);
|
|
343
|
+
const degrees = calcDegrees(directed, allPaths);
|
|
344
|
+
|
|
345
|
+
// Connected components
|
|
346
|
+
const rawComponents = findConnectedComponents(adj, allPaths);
|
|
347
|
+
|
|
348
|
+
// Orphans: single-node components with 0 edges
|
|
349
|
+
const orphans: string[] = [];
|
|
350
|
+
const clusterComponents: string[][] = [];
|
|
351
|
+
|
|
352
|
+
for (const comp of rawComponents) {
|
|
353
|
+
if (comp.length === 1) {
|
|
354
|
+
const d = degrees.get(comp[0]);
|
|
355
|
+
if (!d || (d.in + d.out === 0)) {
|
|
356
|
+
orphans.push(comp[0]);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
clusterComponents.push(comp);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Build clusters
|
|
364
|
+
const clusters: Cluster[] = [];
|
|
365
|
+
|
|
366
|
+
for (const comp of clusterComponents) {
|
|
367
|
+
const { name, hub } = nameCluster(db, comp, degrees);
|
|
368
|
+
clusters.push({ name, nodes: comp.sort(), hub });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Hubs: degree >= 5
|
|
372
|
+
const hubs: Hub[] = [];
|
|
373
|
+
for (const [path, d] of degrees) {
|
|
374
|
+
const total = d.in + d.out;
|
|
375
|
+
if (total >= 5) {
|
|
376
|
+
const meta = getDocMeta(db, path);
|
|
377
|
+
hubs.push({
|
|
378
|
+
path,
|
|
379
|
+
title: meta.title,
|
|
380
|
+
inDegree: d.in,
|
|
381
|
+
outDegree: d.out,
|
|
382
|
+
total,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
hubs.sort((a, b) => b.total - a.total);
|
|
387
|
+
|
|
388
|
+
// Bridges (articulation points)
|
|
389
|
+
const apSet = findArticulationPoints(adj, allPaths);
|
|
390
|
+
const bridges: Bridge[] = [];
|
|
391
|
+
for (const bp of apSet) {
|
|
392
|
+
const meta = getDocMeta(db, bp);
|
|
393
|
+
const conn = bridgeConnects(db, bp, adj, degrees);
|
|
394
|
+
if (conn) {
|
|
395
|
+
bridges.push({ path: bp, title: meta.title, connects: conn });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Edge count (unique directed edges)
|
|
400
|
+
let edgeCount = 0;
|
|
401
|
+
for (const targets of directed.values()) {
|
|
402
|
+
edgeCount += targets.size;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Unresolved wikilinks — broken (missing) or ambiguous (same basename in
|
|
406
|
+
// 2+ folders) links the resolver kept instead of silently dropping.
|
|
407
|
+
const orphanWikilinks = getUnresolvedLinks(db);
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
generated: new Date().toISOString(),
|
|
411
|
+
stats: {
|
|
412
|
+
documents: allPaths.size,
|
|
413
|
+
edges: edgeCount,
|
|
414
|
+
clusters: clusters.length,
|
|
415
|
+
hubs: hubs.length,
|
|
416
|
+
bridges: bridges.length,
|
|
417
|
+
orphans: orphans.length,
|
|
418
|
+
orphan_wikilinks: orphanWikilinks.length,
|
|
419
|
+
},
|
|
420
|
+
clusters,
|
|
421
|
+
hubs,
|
|
422
|
+
bridges,
|
|
423
|
+
orphans: orphans.sort(),
|
|
424
|
+
orphanWikilinks,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP client for embedding/reranker provider adapters (ADR-013).
|
|
3
|
+
*
|
|
4
|
+
* Owns the cross-provider mechanics so adapters stay pure request/response
|
|
5
|
+
* mappers: default timeout (a hung endpoint must not eat the ~30s fetch
|
|
6
|
+
* default on every search), failure classification and a circuit breaker
|
|
7
|
+
* (after N consecutive failures calls are skipped for a cooldown — search
|
|
8
|
+
* stays on its graceful BM25 fallback without paying the timeout each time).
|
|
9
|
+
*
|
|
10
|
+
* Each consumer creates its OWN breaker instance: embedding failures must
|
|
11
|
+
* not block reranking and vice versa (parity with the reference behaviour
|
|
12
|
+
* where each module kept its own module-level counters).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type ProviderCallStatus = "ok" | "timeout" | "error" | "circuit-open";
|
|
16
|
+
|
|
17
|
+
// One fetch to a local/LAN inference endpoint normally answers in <500ms.
|
|
18
|
+
// 3s is a generous threshold that catches an unreachable endpoint without
|
|
19
|
+
// cutting legitimate answers under load.
|
|
20
|
+
export const DEFAULT_TIMEOUT_MS = 3000;
|
|
21
|
+
const FAILURE_THRESHOLD = 2;
|
|
22
|
+
const COOLDOWN_MS = 60_000;
|
|
23
|
+
|
|
24
|
+
export type CircuitBreaker = {
|
|
25
|
+
/** True while in cooldown — the caller must skip the network call. */
|
|
26
|
+
isOpen(): boolean;
|
|
27
|
+
recordFailure(): void;
|
|
28
|
+
recordSuccess(): void;
|
|
29
|
+
/** Test-only: reset counters between test cases. */
|
|
30
|
+
_resetForTests(): void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function makeCircuitBreaker(
|
|
34
|
+
failureThreshold: number = FAILURE_THRESHOLD,
|
|
35
|
+
cooldownMs: number = COOLDOWN_MS,
|
|
36
|
+
): CircuitBreaker {
|
|
37
|
+
let consecutiveFailures = 0;
|
|
38
|
+
let cooldownUntil = 0;
|
|
39
|
+
return {
|
|
40
|
+
isOpen: () => Date.now() < cooldownUntil,
|
|
41
|
+
recordFailure: () => {
|
|
42
|
+
consecutiveFailures += 1;
|
|
43
|
+
if (consecutiveFailures >= failureThreshold) {
|
|
44
|
+
cooldownUntil = Date.now() + cooldownMs;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
recordSuccess: () => {
|
|
48
|
+
consecutiveFailures = 0;
|
|
49
|
+
cooldownUntil = 0;
|
|
50
|
+
},
|
|
51
|
+
_resetForTests: () => {
|
|
52
|
+
consecutiveFailures = 0;
|
|
53
|
+
cooldownUntil = 0;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function classifyFetchError(err: unknown): ProviderCallStatus {
|
|
59
|
+
if (err instanceof Error) {
|
|
60
|
+
if (err.name === "TimeoutError" || err.name === "AbortError") return "timeout";
|
|
61
|
+
}
|
|
62
|
+
return "error";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type PostJsonResult =
|
|
66
|
+
| { json: unknown; status: "ok" }
|
|
67
|
+
| { json: null; status: Exclude<ProviderCallStatus, "ok"> };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* POST a JSON body and parse a JSON response, with breaker/timeout/failure
|
|
71
|
+
* accounting. Returns `{json, status}`; `json === null` on any non-ok
|
|
72
|
+
* status. Success resets the breaker; timeout/non-2xx/parse failure record
|
|
73
|
+
* a failure. The caller-supplied signal is honoured as-is (the caller
|
|
74
|
+
* controls cancellation, e.g. batch indexing); otherwise the default
|
|
75
|
+
* timeout signal is attached.
|
|
76
|
+
*/
|
|
77
|
+
export async function postJson(opts: {
|
|
78
|
+
endpoint: string;
|
|
79
|
+
body: unknown;
|
|
80
|
+
apiKey?: string | null;
|
|
81
|
+
headers?: Record<string, string>;
|
|
82
|
+
signal?: AbortSignal;
|
|
83
|
+
timeoutMs?: number;
|
|
84
|
+
breaker: CircuitBreaker;
|
|
85
|
+
}): Promise<PostJsonResult> {
|
|
86
|
+
if (opts.breaker.isOpen()) {
|
|
87
|
+
return { json: null, status: "circuit-open" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const headers: Record<string, string> = {
|
|
91
|
+
"Content-Type": "application/json",
|
|
92
|
+
...(opts.headers ?? {}),
|
|
93
|
+
};
|
|
94
|
+
if (opts.apiKey) {
|
|
95
|
+
headers["Authorization"] = `Bearer ${opts.apiKey}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const effectiveSignal =
|
|
99
|
+
opts.signal ?? AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
100
|
+
|
|
101
|
+
let resp: Response;
|
|
102
|
+
try {
|
|
103
|
+
resp = await fetch(opts.endpoint, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers,
|
|
106
|
+
body: JSON.stringify(opts.body),
|
|
107
|
+
signal: effectiveSignal,
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
opts.breaker.recordFailure();
|
|
111
|
+
return { json: null, status: classifyFetchError(err) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!resp.ok) {
|
|
115
|
+
opts.breaker.recordFailure();
|
|
116
|
+
return { json: null, status: "error" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let json: unknown;
|
|
120
|
+
try {
|
|
121
|
+
json = await resp.json();
|
|
122
|
+
} catch {
|
|
123
|
+
opts.breaker.recordFailure();
|
|
124
|
+
return { json: null, status: "error" };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
opts.breaker.recordSuccess();
|
|
128
|
+
return { json, status: "ok" };
|
|
129
|
+
}
|