@oscharko-dev/keiko-memory-consolidation 0.2.0
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/.tsbuildinfo +1 -0
- package/dist/_constants.d.ts +8 -0
- package/dist/_constants.d.ts.map +1 -0
- package/dist/_constants.js +22 -0
- package/dist/_ordering.d.ts +8 -0
- package/dist/_ordering.d.ts.map +1 -0
- package/dist/_ordering.js +62 -0
- package/dist/conflicts.d.ts +12 -0
- package/dist/conflicts.d.ts.map +1 -0
- package/dist/conflicts.js +212 -0
- package/dist/consolidate.d.ts +4 -0
- package/dist/consolidate.d.ts.map +1 -0
- package/dist/consolidate.js +254 -0
- package/dist/dedupe.d.ts +15 -0
- package/dist/dedupe.d.ts.map +1 -0
- package/dist/dedupe.js +101 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/job.d.ts +11 -0
- package/dist/job.d.ts.map +1 -0
- package/dist/job.js +82 -0
- package/dist/similarity.d.ts +10 -0
- package/dist/similarity.d.ts.map +1 -0
- package/dist/similarity.js +65 -0
- package/dist/stale.d.ts +9 -0
- package/dist/stale.d.ts.map +1 -0
- package/dist/stale.js +54 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +19 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +1 -0
- package/package.json +31 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// runConsolidation: the engine entry point. Pure function; no IO; no clock reads; no
|
|
2
|
+
// randomness. Every impurity is injected via ConsolidationOptions.
|
|
3
|
+
//
|
|
4
|
+
// Design choice pinned here so future contributors do not "fix" it:
|
|
5
|
+
// updatesProposed is ALWAYS the empty array in v1. Every merge / supersession is routed
|
|
6
|
+
// through a ReviewItem carrying a ProposedAction; the caller (#211 MemoriaViva UI or a
|
|
7
|
+
// workflow) materializes the actual MemorySupersession envelope after explicit review. The
|
|
8
|
+
// updatesProposed slot is reserved for a future model-assisted body-summarisation pass
|
|
9
|
+
// (issue #212 or follow-up). This preserves the Epic #204 invariant: "consolidation never
|
|
10
|
+
// mutates accepted memories without preserving provenance and audit history".
|
|
11
|
+
//
|
|
12
|
+
// Cancellation: cancellationSignal is polled BEFORE each cluster is inspected (once per
|
|
13
|
+
// cluster), so the cost is bounded by the cluster count and partial results survive the
|
|
14
|
+
// cancel. The signal is polled once at the very start as well, so a caller that already knows
|
|
15
|
+
// it wants to abort can short-circuit without inspecting any cluster.
|
|
16
|
+
import { validateMemoryRecord, } from "@oscharko-dev/keiko-contracts/memory";
|
|
17
|
+
import { JACCARD_DEFAULT, MAX_AGE_MS_DEFAULT, MAX_CLUSTERS_PER_RUN_DEFAULT, MAX_CLUSTERS_PER_RUN_HARD_LIMIT, MAX_RECORDS_PER_RUN_DEFAULT, MAX_RECORDS_PER_RUN_HARD_LIMIT, STALE_CONFIDENCE_DEFAULT, } from "./_constants.js";
|
|
18
|
+
import { compareEdges, compareRecordsByAge, compareReviewItems } from "./_ordering.js";
|
|
19
|
+
import { CONFLICT_OVERLAP_THRESHOLD, detectConflicts, findConflictPairs } from "./conflicts.js";
|
|
20
|
+
import { scanDuplicateClusters } from "./dedupe.js";
|
|
21
|
+
import { findStaleMemories } from "./stale.js";
|
|
22
|
+
const ELIGIBLE_STATUS = "accepted";
|
|
23
|
+
function isFiniteInRange(n, lo, hi) {
|
|
24
|
+
return Number.isFinite(n) && n >= lo && n <= hi;
|
|
25
|
+
}
|
|
26
|
+
function isFiniteNonNegative(n) {
|
|
27
|
+
return Number.isFinite(n) && n >= 0;
|
|
28
|
+
}
|
|
29
|
+
function neverCancel() {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
function validateNumericKnobs(knobs) {
|
|
33
|
+
if (!isFiniteInRange(knobs.jaccardThreshold, 0, 1))
|
|
34
|
+
return false;
|
|
35
|
+
if (!isFiniteInRange(knobs.staleConfidenceThreshold, 0, 1))
|
|
36
|
+
return false;
|
|
37
|
+
if (!isFiniteNonNegative(knobs.maxAgeMs))
|
|
38
|
+
return false;
|
|
39
|
+
if (!isFiniteInRange(knobs.maxClustersPerRun, 0, MAX_CLUSTERS_PER_RUN_HARD_LIMIT)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (!isFiniteInRange(knobs.maxRecordsPerRun, 0, MAX_RECORDS_PER_RUN_HARD_LIMIT)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return Number.isInteger(knobs.maxClustersPerRun) && Number.isInteger(knobs.maxRecordsPerRun);
|
|
46
|
+
}
|
|
47
|
+
function resolveOptions(options) {
|
|
48
|
+
const knobs = {
|
|
49
|
+
jaccardThreshold: options.jaccardThreshold ?? JACCARD_DEFAULT,
|
|
50
|
+
staleConfidenceThreshold: options.staleConfidenceThreshold ?? STALE_CONFIDENCE_DEFAULT,
|
|
51
|
+
maxAgeMs: options.maxAgeMs ?? MAX_AGE_MS_DEFAULT,
|
|
52
|
+
maxClustersPerRun: options.maxClustersPerRun ?? MAX_CLUSTERS_PER_RUN_DEFAULT,
|
|
53
|
+
maxRecordsPerRun: options.maxRecordsPerRun ?? MAX_RECORDS_PER_RUN_DEFAULT,
|
|
54
|
+
};
|
|
55
|
+
if (!validateNumericKnobs(knobs))
|
|
56
|
+
return null;
|
|
57
|
+
return {
|
|
58
|
+
nowMs: options.nowMs,
|
|
59
|
+
newEdgeId: options.newEdgeId,
|
|
60
|
+
newReviewItemId: options.newReviewItemId,
|
|
61
|
+
...knobs,
|
|
62
|
+
cancellationSignal: options.cancellationSignal ?? neverCancel,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function emptyResult(state, recordsInspected = 0, truncated = false) {
|
|
66
|
+
return {
|
|
67
|
+
state,
|
|
68
|
+
edgesProposed: [],
|
|
69
|
+
updatesProposed: [],
|
|
70
|
+
staleFlags: [],
|
|
71
|
+
reviewItems: [],
|
|
72
|
+
clustersInspected: 0,
|
|
73
|
+
conflictPairsDetected: 0,
|
|
74
|
+
recordsInspected,
|
|
75
|
+
truncated,
|
|
76
|
+
elapsedMs: 0,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function eligibleMemories(memories) {
|
|
80
|
+
const accepted = [];
|
|
81
|
+
for (const memory of memories) {
|
|
82
|
+
const validated = validateMemoryRecord(memory);
|
|
83
|
+
if (!validated.ok)
|
|
84
|
+
return null;
|
|
85
|
+
if (validated.value.status === ELIGIBLE_STATUS) {
|
|
86
|
+
accepted.push(validated.value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return accepted;
|
|
90
|
+
}
|
|
91
|
+
function boundedEligibleMemories(memories, resolved) {
|
|
92
|
+
const sorted = [...memories].sort(compareRecordsByAge);
|
|
93
|
+
return {
|
|
94
|
+
records: sorted.slice(0, resolved.maxRecordsPerRun),
|
|
95
|
+
truncated: sorted.length > resolved.maxRecordsPerRun,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function buildEdge(fromMemoryId, toMemoryId, kind, resolved, provenanceSummary) {
|
|
99
|
+
return {
|
|
100
|
+
id: resolved.newEdgeId(),
|
|
101
|
+
schemaVersion: "1",
|
|
102
|
+
fromMemoryId,
|
|
103
|
+
toMemoryId,
|
|
104
|
+
kind,
|
|
105
|
+
createdAt: resolved.nowMs,
|
|
106
|
+
provenanceSummary,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function buildDuplicateEdges(older, newer, resolved) {
|
|
110
|
+
return [
|
|
111
|
+
buildEdge(older.id, newer.id, "derived-from", resolved, "consolidation: near-duplicate"),
|
|
112
|
+
buildEdge(older.id, newer.id, "related", resolved, "consolidation: related duplicate"),
|
|
113
|
+
buildEdge(older.id, newer.id, "temporal-precedes", resolved, "consolidation: temporal link"),
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
function buildSupersedeReviewEdges(older, newer, resolved) {
|
|
117
|
+
return [
|
|
118
|
+
buildEdge(older, newer, "conflicts-with", resolved, "consolidation: proposed conflict"),
|
|
119
|
+
buildEdge(newer, older, "corrects", resolved, "consolidation: proposed correction"),
|
|
120
|
+
buildEdge(older, newer, "supersedes", resolved, "consolidation: proposed supersession"),
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
function buildMergeReviewEdges(winner, losers, resolved) {
|
|
124
|
+
const edges = [];
|
|
125
|
+
for (const loser of losers) {
|
|
126
|
+
edges.push(buildEdge(loser, winner, "derived-from", resolved, "consolidation: proposed merge lineage"), buildEdge(loser, winner, "related", resolved, "consolidation: proposed merge relationship"), buildEdge(loser, winner, "supersedes", resolved, "consolidation: proposed merge supersession"));
|
|
127
|
+
}
|
|
128
|
+
return edges;
|
|
129
|
+
}
|
|
130
|
+
function withProposedReviewEdges(item, resolved) {
|
|
131
|
+
const action = item.proposedAction;
|
|
132
|
+
if (action === undefined)
|
|
133
|
+
return item;
|
|
134
|
+
const proposedEdges = action.kind === "supersede"
|
|
135
|
+
? buildSupersedeReviewEdges(action.older, action.newer, resolved)
|
|
136
|
+
: buildMergeReviewEdges(action.winner, action.losers, resolved);
|
|
137
|
+
return proposedEdges.length === 0 ? item : { ...item, proposedEdges };
|
|
138
|
+
}
|
|
139
|
+
function processTwoMemberCluster(cluster, resolved) {
|
|
140
|
+
const conflictItems = detectConflicts([cluster], {
|
|
141
|
+
nowMs: resolved.nowMs,
|
|
142
|
+
newReviewItemId: resolved.newReviewItemId,
|
|
143
|
+
});
|
|
144
|
+
if (conflictItems.length > 0) {
|
|
145
|
+
const item = conflictItems[0];
|
|
146
|
+
return {
|
|
147
|
+
edges: [],
|
|
148
|
+
reviewItem: item === undefined ? null : withProposedReviewEdges(item, resolved),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const older = cluster.members[0];
|
|
152
|
+
const newer = cluster.members[1];
|
|
153
|
+
if (older === undefined || newer === undefined) {
|
|
154
|
+
return { edges: [], reviewItem: null };
|
|
155
|
+
}
|
|
156
|
+
return { edges: buildDuplicateEdges(older, newer, resolved), reviewItem: null };
|
|
157
|
+
}
|
|
158
|
+
function processMultiWayCluster(cluster, resolved) {
|
|
159
|
+
const items = detectConflicts([cluster], {
|
|
160
|
+
nowMs: resolved.nowMs,
|
|
161
|
+
newReviewItemId: resolved.newReviewItemId,
|
|
162
|
+
});
|
|
163
|
+
const item = items[0];
|
|
164
|
+
return {
|
|
165
|
+
edges: [],
|
|
166
|
+
reviewItem: item === undefined ? null : withProposedReviewEdges(item, resolved),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function processCluster(cluster, resolved) {
|
|
170
|
+
if (cluster.members.length > 2)
|
|
171
|
+
return processMultiWayCluster(cluster, resolved);
|
|
172
|
+
if (cluster.members.length === 2)
|
|
173
|
+
return processTwoMemberCluster(cluster, resolved);
|
|
174
|
+
return { edges: [], reviewItem: null };
|
|
175
|
+
}
|
|
176
|
+
function consumeClusters(clusters, resolved) {
|
|
177
|
+
const edges = [];
|
|
178
|
+
const reviewItems = [];
|
|
179
|
+
let clustersInspected = 0;
|
|
180
|
+
const limit = Math.min(clusters.length, resolved.maxClustersPerRun);
|
|
181
|
+
for (let i = 0; i < limit; i += 1) {
|
|
182
|
+
if (resolved.cancellationSignal()) {
|
|
183
|
+
return { state: "canceled", edges, reviewItems, clustersInspected };
|
|
184
|
+
}
|
|
185
|
+
const cluster = clusters[i];
|
|
186
|
+
if (cluster === undefined)
|
|
187
|
+
continue;
|
|
188
|
+
const effects = processCluster(cluster, resolved);
|
|
189
|
+
for (const edge of effects.edges)
|
|
190
|
+
edges.push(edge);
|
|
191
|
+
if (effects.reviewItem !== null)
|
|
192
|
+
reviewItems.push(effects.reviewItem);
|
|
193
|
+
clustersInspected += 1;
|
|
194
|
+
}
|
|
195
|
+
return { state: "completed", edges, reviewItems, clustersInspected };
|
|
196
|
+
}
|
|
197
|
+
function collectStaleFlags(records, resolved) {
|
|
198
|
+
return findStaleMemories(records, {
|
|
199
|
+
nowMs: resolved.nowMs,
|
|
200
|
+
staleConfidenceThreshold: resolved.staleConfidenceThreshold,
|
|
201
|
+
maxAgeMs: resolved.maxAgeMs,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function collectConflictPairs(records, clusters, resolved, consumedState, clusterScanCanceled) {
|
|
205
|
+
// If the cluster sweep was canceled, do NOT extend work into the conflict-pair sweep —
|
|
206
|
+
// honour the cancellation boundary so partial results stay partial.
|
|
207
|
+
if (consumedState === "canceled" || clusterScanCanceled)
|
|
208
|
+
return [];
|
|
209
|
+
return findConflictPairs(records, clusters, CONFLICT_OVERLAP_THRESHOLD, {
|
|
210
|
+
nowMs: resolved.nowMs,
|
|
211
|
+
newReviewItemId: resolved.newReviewItemId,
|
|
212
|
+
cancellationSignal: resolved.cancellationSignal,
|
|
213
|
+
}).map((item) => withProposedReviewEdges(item, resolved));
|
|
214
|
+
}
|
|
215
|
+
// Public entry. Same input + same options => byte-identical result.
|
|
216
|
+
export function runConsolidation(memories, options) {
|
|
217
|
+
const resolved = resolveOptions(options);
|
|
218
|
+
if (resolved === null)
|
|
219
|
+
return emptyResult("failed");
|
|
220
|
+
if (resolved.cancellationSignal())
|
|
221
|
+
return emptyResult("canceled");
|
|
222
|
+
const eligible = eligibleMemories(memories);
|
|
223
|
+
if (eligible === null)
|
|
224
|
+
return emptyResult("failed");
|
|
225
|
+
if (eligible.length === 0)
|
|
226
|
+
return emptyResult("skipped");
|
|
227
|
+
const bounded = boundedEligibleMemories(eligible, resolved);
|
|
228
|
+
if (bounded.records.length === 0 || resolved.maxClustersPerRun === 0) {
|
|
229
|
+
return emptyResult("skipped", bounded.records.length, bounded.truncated);
|
|
230
|
+
}
|
|
231
|
+
const scanned = scanDuplicateClusters(bounded.records, resolved.jaccardThreshold, {
|
|
232
|
+
cancellationSignal: resolved.cancellationSignal,
|
|
233
|
+
});
|
|
234
|
+
if (scanned.canceled && scanned.clusters.length === 0) {
|
|
235
|
+
return emptyResult("canceled", bounded.records.length, bounded.truncated);
|
|
236
|
+
}
|
|
237
|
+
const clusters = scanned.clusters;
|
|
238
|
+
const consumed = consumeClusters(clusters, resolved);
|
|
239
|
+
const conflictPairs = collectConflictPairs(bounded.records, clusters, resolved, consumed.state, scanned.canceled);
|
|
240
|
+
const staleFlags = collectStaleFlags(bounded.records, resolved);
|
|
241
|
+
const mergedReviewItems = [...consumed.reviewItems, ...conflictPairs].sort(compareReviewItems);
|
|
242
|
+
return {
|
|
243
|
+
state: scanned.canceled ? "canceled" : consumed.state,
|
|
244
|
+
edgesProposed: [...consumed.edges].sort(compareEdges),
|
|
245
|
+
updatesProposed: [],
|
|
246
|
+
staleFlags,
|
|
247
|
+
reviewItems: mergedReviewItems,
|
|
248
|
+
clustersInspected: consumed.clustersInspected,
|
|
249
|
+
conflictPairsDetected: conflictPairs.length,
|
|
250
|
+
recordsInspected: bounded.records.length,
|
|
251
|
+
truncated: bounded.truncated,
|
|
252
|
+
elapsedMs: 0,
|
|
253
|
+
};
|
|
254
|
+
}
|
package/dist/dedupe.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MemoryRecord } from "@oscharko-dev/keiko-contracts/memory";
|
|
2
|
+
export interface DuplicateCluster {
|
|
3
|
+
readonly canonicalId: string;
|
|
4
|
+
readonly members: readonly MemoryRecord[];
|
|
5
|
+
}
|
|
6
|
+
export interface DuplicateClusterScanOptions {
|
|
7
|
+
readonly cancellationSignal?: () => boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface DuplicateClusterScanResult {
|
|
10
|
+
readonly clusters: readonly DuplicateCluster[];
|
|
11
|
+
readonly canceled: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function scanDuplicateClusters(records: readonly MemoryRecord[], jaccardThreshold: number, options?: DuplicateClusterScanOptions): DuplicateClusterScanResult;
|
|
14
|
+
export declare function findDuplicateClusters(records: readonly MemoryRecord[], jaccardThreshold: number): readonly DuplicateCluster[];
|
|
15
|
+
//# sourceMappingURL=dedupe.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dedupe.d.ts","sourceRoot":"","sources":["../src/dedupe.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sCAAsC,CAAC;AAKzE,MAAM,WAAW,gBAAgB;IAG/B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,OAAO,EAAE,SAAS,YAAY,EAAE,CAAC;CAC3C;AAQD,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,OAAO,CAAC;CAC7C;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,QAAQ,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAC/C,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;CAC5B;AAoED,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,SAAS,YAAY,EAAE,EAChC,gBAAgB,EAAE,MAAM,EACxB,OAAO,GAAE,2BAAgC,GACxC,0BAA0B,CA8B5B;AAED,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,SAAS,YAAY,EAAE,EAChC,gBAAgB,EAAE,MAAM,GACvB,SAAS,gBAAgB,EAAE,CAE7B"}
|
package/dist/dedupe.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Near-duplicate clustering. Pure function; deterministic; never mutates input.
|
|
2
|
+
//
|
|
3
|
+
// Algorithm:
|
|
4
|
+
// 1. Partition input by (scopeCoordinateKey, type). Cross-scope and cross-type records
|
|
5
|
+
// are never merged — this is the load-bearing visibility invariant from #205.
|
|
6
|
+
// 2. Within each partition, build clusters using a union-find-by-iteration sweep:
|
|
7
|
+
// for each record, find the first existing cluster whose CANONICAL member matches by
|
|
8
|
+
// one of (exact body, normalized body, bigram-Jaccard at-or-above threshold). If found,
|
|
9
|
+
// add to that cluster; otherwise open a new singleton cluster. Comparing to the canonical
|
|
10
|
+
// (oldest) member only — not pairwise to every member — is a cost compromise that holds
|
|
11
|
+
// because clusters are small in practice and the canonical member is the representative
|
|
12
|
+
// most likely to share the dominant body shape.
|
|
13
|
+
// 3. Drop singleton clusters (consolidation needs ≥ 2 members to act).
|
|
14
|
+
// 4. Sort members oldest-first (createdAt ASC, id ASC). Sort clusters by canonical (oldest)
|
|
15
|
+
// member id so the output is byte-stable across input shuffles.
|
|
16
|
+
import { compareRecordsByAge, scopeCoordinateKey } from "./_ordering.js";
|
|
17
|
+
import { jaccardSimilarityPrepared, prepareBody } from "./similarity.js";
|
|
18
|
+
function partitionKey(record) {
|
|
19
|
+
return `${scopeCoordinateKey(record.scope)}|type:${record.type}`;
|
|
20
|
+
}
|
|
21
|
+
// Returns true when `candidate` should join the existing `cluster`. Compared only to the
|
|
22
|
+
// canonical (oldest) member to keep cluster-add at O(1) per candidate per cluster.
|
|
23
|
+
function clusterAccepts(cluster, candidateBody, jaccardThreshold) {
|
|
24
|
+
if (candidateBody.normalized === cluster.canonicalBody.normalized)
|
|
25
|
+
return true;
|
|
26
|
+
const score = jaccardSimilarityPrepared(candidateBody, cluster.canonicalBody);
|
|
27
|
+
return score >= jaccardThreshold;
|
|
28
|
+
}
|
|
29
|
+
function tryJoinExistingCluster(partition, candidate, candidateBody, jaccardThreshold) {
|
|
30
|
+
for (const cluster of partition) {
|
|
31
|
+
if (clusterAccepts(cluster, candidateBody, jaccardThreshold)) {
|
|
32
|
+
cluster.members.push(candidate);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
function clusterPartition(records, jaccardThreshold, cancellationSignal) {
|
|
39
|
+
// Order inputs so the OLDEST record in any group of similar records becomes the canonical
|
|
40
|
+
// member (cluster.canonical). Without this, the canonical would depend on input order — a
|
|
41
|
+
// determinism break.
|
|
42
|
+
const ordered = [...records].sort(compareRecordsByAge);
|
|
43
|
+
const partition = [];
|
|
44
|
+
for (const record of ordered) {
|
|
45
|
+
if (cancellationSignal?.() === true) {
|
|
46
|
+
return { clusters: partition, canceled: true };
|
|
47
|
+
}
|
|
48
|
+
const prepared = prepareBody(record.body);
|
|
49
|
+
if (tryJoinExistingCluster(partition, record, prepared, jaccardThreshold))
|
|
50
|
+
continue;
|
|
51
|
+
partition.push({
|
|
52
|
+
canonical: record,
|
|
53
|
+
canonicalBody: prepared,
|
|
54
|
+
members: [record],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return { clusters: partition, canceled: false };
|
|
58
|
+
}
|
|
59
|
+
function finalizeCluster(cluster) {
|
|
60
|
+
const sortedMembers = [...cluster.members].sort(compareRecordsByAge);
|
|
61
|
+
return {
|
|
62
|
+
canonicalId: cluster.canonical.id,
|
|
63
|
+
members: sortedMembers,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// Public entry point. Returns clusters of size >= 2 only; singletons are filtered out.
|
|
67
|
+
// Output is deterministic for any permutation of `records`.
|
|
68
|
+
export function scanDuplicateClusters(records, jaccardThreshold, options = {}) {
|
|
69
|
+
const partitions = new Map();
|
|
70
|
+
for (const record of records) {
|
|
71
|
+
const key = partitionKey(record);
|
|
72
|
+
const bucket = partitions.get(key);
|
|
73
|
+
if (bucket === undefined) {
|
|
74
|
+
partitions.set(key, [record]);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
bucket.push(record);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const clusters = [];
|
|
81
|
+
let canceled = false;
|
|
82
|
+
for (const bucket of partitions.values()) {
|
|
83
|
+
const built = clusterPartition(bucket, jaccardThreshold, options.cancellationSignal);
|
|
84
|
+
canceled ||= built.canceled;
|
|
85
|
+
for (const cluster of built.clusters) {
|
|
86
|
+
if (cluster.members.length >= 2) {
|
|
87
|
+
clusters.push(finalizeCluster(cluster));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (canceled)
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
// Stable cluster ordering: by canonical id (the oldest member's id is unique per cluster).
|
|
94
|
+
return {
|
|
95
|
+
clusters: clusters.sort((a, b) => a.canonicalId < b.canonicalId ? -1 : a.canonicalId > b.canonicalId ? 1 : 0),
|
|
96
|
+
canceled,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export function findDuplicateClusters(records, jaccardThreshold) {
|
|
100
|
+
return scanDuplicateClusters(records, jaccardThreshold).clusters;
|
|
101
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { KEIKO_MEMORY_CONSOLIDATION_VERSION } from "./version.js";
|
|
2
|
+
export type { ConsolidationJob, ConsolidationJobState, ConsolidationOptions, ConsolidationResult, ProposedAction, ReviewItem, ReviewReason, StaleFlag, StaleReason, } from "./types.js";
|
|
3
|
+
export { runConsolidation } from "./consolidate.js";
|
|
4
|
+
export { buildConsolidationJob, ConsolidationJobError, transitionJob, type ConsolidationJobErrorCode, } from "./job.js";
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,kCAAkC,EAAE,MAAM,cAAc,CAAC;AAClE,YAAY,EACV,gBAAgB,EAChB,qBAAqB,EACrB,oBAAoB,EACpB,mBAAmB,EACnB,cAAc,EACd,UAAU,EACV,YAAY,EACZ,SAAS,EACT,WAAW,GACZ,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,aAAa,EACb,KAAK,yBAAyB,GAC/B,MAAM,UAAU,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Public surface of @oscharko-dev/keiko-memory-consolidation (Epic #204 child #208).
|
|
2
|
+
// Keeping this file the SOLE entry point prevents downstream packages from reaching into
|
|
3
|
+
// private modules (ADR-0019 trust rule 7). Internal modules (dedupe, stale, conflicts,
|
|
4
|
+
// similarity, _ordering, _constants) are package-private.
|
|
5
|
+
export { KEIKO_MEMORY_CONSOLIDATION_VERSION } from "./version.js";
|
|
6
|
+
export { runConsolidation } from "./consolidate.js";
|
|
7
|
+
export { buildConsolidationJob, ConsolidationJobError, transitionJob, } from "./job.js";
|
package/dist/job.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ConsolidationJob, ConsolidationJobState } from "./types.js";
|
|
2
|
+
export type ConsolidationJobErrorCode = "invalid-transition";
|
|
3
|
+
export declare class ConsolidationJobError extends Error {
|
|
4
|
+
readonly code: ConsolidationJobErrorCode;
|
|
5
|
+
readonly from: ConsolidationJobState;
|
|
6
|
+
readonly to: ConsolidationJobState;
|
|
7
|
+
constructor(code: ConsolidationJobErrorCode, from: ConsolidationJobState, to: ConsolidationJobState);
|
|
8
|
+
}
|
|
9
|
+
export declare function buildConsolidationJob(id: string, startedAtMs: number): ConsolidationJob;
|
|
10
|
+
export declare function transitionJob(job: ConsolidationJob, to: ConsolidationJobState, patch?: Partial<Pick<ConsolidationJob, "result" | "completedAt" | "error">>): ConsolidationJob;
|
|
11
|
+
//# sourceMappingURL=job.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"job.d.ts","sourceRoot":"","sources":["../src/job.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,gBAAgB,EAAE,qBAAqB,EAAuB,MAAM,YAAY,CAAC;AAE/F,MAAM,MAAM,yBAAyB,GAAG,oBAAoB,CAAC;AAE7D,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,SAAgB,IAAI,EAAE,yBAAyB,CAAC;IAChD,SAAgB,IAAI,EAAE,qBAAqB,CAAC;IAC5C,SAAgB,EAAE,EAAE,qBAAqB,CAAC;gBAExC,IAAI,EAAE,yBAAyB,EAC/B,IAAI,EAAE,qBAAqB,EAC3B,EAAE,EAAE,qBAAqB;CAQ5B;AAqBD,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,gBAAgB,CAEvF;AAKD,wBAAgB,aAAa,CAC3B,GAAG,EAAE,gBAAgB,EACrB,EAAE,EAAE,qBAAqB,EACzB,KAAK,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,gBAAgB,EAAE,QAAQ,GAAG,aAAa,GAAG,OAAO,CAAC,CAAC,GAC1E,gBAAgB,CAMlB"}
|
package/dist/job.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// ConsolidationJob value-object lifecycle. NOT a process. The package does not spawn jobs,
|
|
2
|
+
// schedule them, or persist them; callers (a scheduler / UI button / workflow orchestrator)
|
|
3
|
+
// drive the state machine via `buildConsolidationJob` + `transitionJob`. This keeps the layer
|
|
4
|
+
// purely deterministic — id allocation, clock reads, and persistence stay outside.
|
|
5
|
+
//
|
|
6
|
+
// State machine (see ConsolidationJobState in types.ts):
|
|
7
|
+
//
|
|
8
|
+
// queued --> running (start)
|
|
9
|
+
// queued --> canceled (operator cancels before start)
|
|
10
|
+
// queued --> skipped (engine returned state "skipped" before any cluster inspection)
|
|
11
|
+
// running --> completed (engine returned "completed")
|
|
12
|
+
// running --> failed (engine returned "failed")
|
|
13
|
+
// running --> canceled (engine returned "canceled" mid-run)
|
|
14
|
+
//
|
|
15
|
+
// All terminal states (completed, failed, canceled, skipped) are absorbing: any transition
|
|
16
|
+
// out of them throws `ConsolidationJobError("invalid-transition")`. There is no transition
|
|
17
|
+
// queued -> completed: the engine must observe at least the "running" state for the lifecycle
|
|
18
|
+
// to remain auditable.
|
|
19
|
+
export class ConsolidationJobError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
from;
|
|
22
|
+
to;
|
|
23
|
+
constructor(code, from, to) {
|
|
24
|
+
super(`ConsolidationJobError(${code}): ${from} -> ${to}`);
|
|
25
|
+
this.name = "ConsolidationJobError";
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.from = from;
|
|
28
|
+
this.to = to;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Static transition matrix. Encoded as a Readonly<Record<...>> so adding a new state is a
|
|
32
|
+
// compile-time error (every key must be supplied).
|
|
33
|
+
const ALLOWED_TRANSITIONS = {
|
|
34
|
+
queued: ["running", "canceled", "skipped"],
|
|
35
|
+
running: ["completed", "failed", "canceled"],
|
|
36
|
+
completed: [],
|
|
37
|
+
failed: [],
|
|
38
|
+
canceled: [],
|
|
39
|
+
skipped: [],
|
|
40
|
+
};
|
|
41
|
+
function isLegalTransition(from, to) {
|
|
42
|
+
return ALLOWED_TRANSITIONS[from].includes(to);
|
|
43
|
+
}
|
|
44
|
+
// Constructs a fresh queued job. `startedAt` is recorded eagerly so the caller can compute
|
|
45
|
+
// elapsedMs at the terminal-transition site without a separate clock read in this layer.
|
|
46
|
+
export function buildConsolidationJob(id, startedAtMs) {
|
|
47
|
+
return { id, state: "queued", startedAt: startedAtMs };
|
|
48
|
+
}
|
|
49
|
+
// Transitions a job to a new state, optionally merging in a result, completedAt, or error.
|
|
50
|
+
// Throws ConsolidationJobError when the transition is illegal. The input job is never
|
|
51
|
+
// mutated — every successful call returns a NEW object.
|
|
52
|
+
export function transitionJob(job, to, patch) {
|
|
53
|
+
if (!isLegalTransition(job.state, to)) {
|
|
54
|
+
throw new ConsolidationJobError("invalid-transition", job.state, to);
|
|
55
|
+
}
|
|
56
|
+
const next = { ...job, state: to };
|
|
57
|
+
return applyPatch(next, patch);
|
|
58
|
+
}
|
|
59
|
+
function applyPatch(job, patch) {
|
|
60
|
+
if (patch === undefined)
|
|
61
|
+
return job;
|
|
62
|
+
const merged = { ...job };
|
|
63
|
+
if (patch.result !== undefined) {
|
|
64
|
+
return mergeResult(merged, patch.result, patch.completedAt, patch.error);
|
|
65
|
+
}
|
|
66
|
+
if (patch.completedAt !== undefined) {
|
|
67
|
+
return {
|
|
68
|
+
...merged,
|
|
69
|
+
completedAt: patch.completedAt,
|
|
70
|
+
...(patch.error !== undefined ? { error: patch.error } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (patch.error !== undefined) {
|
|
74
|
+
return { ...merged, error: patch.error };
|
|
75
|
+
}
|
|
76
|
+
return merged;
|
|
77
|
+
}
|
|
78
|
+
function mergeResult(job, result, completedAt, error) {
|
|
79
|
+
const withResult = { ...job, result };
|
|
80
|
+
const withCompletedAt = completedAt !== undefined ? { ...withResult, completedAt } : withResult;
|
|
81
|
+
return error !== undefined ? { ...withCompletedAt, error } : withCompletedAt;
|
|
82
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function normalizeBody(body: string): string;
|
|
2
|
+
export declare function bigramTokens(normalized: string): Set<string>;
|
|
3
|
+
export interface PreparedBody {
|
|
4
|
+
readonly normalized: string;
|
|
5
|
+
readonly tokens: ReadonlySet<string>;
|
|
6
|
+
}
|
|
7
|
+
export declare function prepareBody(body: string): PreparedBody;
|
|
8
|
+
export declare function jaccardSimilarityPrepared(a: PreparedBody, b: PreparedBody): number;
|
|
9
|
+
export declare function jaccardSimilarity(a: string, b: string): number;
|
|
10
|
+
//# sourceMappingURL=similarity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"similarity.d.ts","sourceRoot":"","sources":["../src/similarity.ts"],"names":[],"mappings":"AA0BA,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKlD;AAKD,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAM5D;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CACtC;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAMtD;AAED,wBAAgB,yBAAyB,CAAC,CAAC,EAAE,YAAY,EAAE,CAAC,EAAE,YAAY,GAAG,MAAM,CASlF;AAKD,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAE9D"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// String-similarity primitives used by dedupe and conflict detection. Pure functions, bounded
|
|
2
|
+
// work, no unbounded regex backtracking — `normalizeBody` walks the input once character-by-
|
|
3
|
+
// character so an adversarial input cannot push it beyond O(n).
|
|
4
|
+
//
|
|
5
|
+
// Design choices:
|
|
6
|
+
// • Character bigrams (not word bigrams) — robust to whitespace drift and short utterances
|
|
7
|
+
// ("yes" vs "yep" share "ye"). The cost is sensitivity to spelling variants, which is
|
|
8
|
+
// acceptable for v1 (the Jaccard threshold backs off recall in exchange for precision).
|
|
9
|
+
// • Set-based Jaccard (not multiset) — collapses repeated bigrams. Same precision intuition.
|
|
10
|
+
// • Punctuation stripping is char-class based (Unicode-aware via `RegExp` `\p{...}` escapes
|
|
11
|
+
// in a NON-greedy single-char form so there is no backtracking; the `g` flag walks the
|
|
12
|
+
// string linearly). Lowercase is locale-independent (`String.prototype.toLowerCase` uses
|
|
13
|
+
// case-folding without locale; we deliberately do NOT call `toLocaleLowerCase` because the
|
|
14
|
+
// output must be byte-stable across hosts).
|
|
15
|
+
// Single-character matcher: anything that is NOT a Unicode letter, digit, or whitespace.
|
|
16
|
+
// Strips ASCII punctuation, NUL bytes, control chars, and emoji-adjacent symbols. Uses the
|
|
17
|
+
// `u` flag (Unicode-aware character classes) and a SINGLE quantifier (no nesting) — no ReDoS.
|
|
18
|
+
const PUNCT_OR_CONTROL = /[^\p{L}\p{N}\s]/gu;
|
|
19
|
+
// Whitespace run matcher. `\s+` over a finite input has linear time; no nesting.
|
|
20
|
+
const WHITESPACE_RUN = /\s+/g;
|
|
21
|
+
// Normalizes a memory body for similarity comparison. Output is lowercase, NUL-free, with
|
|
22
|
+
// internal whitespace collapsed to single spaces, leading/trailing whitespace stripped, and
|
|
23
|
+
// ASCII/Unicode punctuation removed. Returns `""` when no normalizable characters survive.
|
|
24
|
+
export function normalizeBody(body) {
|
|
25
|
+
const lowered = body.toLowerCase();
|
|
26
|
+
const stripped = lowered.replace(PUNCT_OR_CONTROL, "");
|
|
27
|
+
const collapsed = stripped.replace(WHITESPACE_RUN, " ");
|
|
28
|
+
return collapsed.trim();
|
|
29
|
+
}
|
|
30
|
+
// Builds the set of character bigrams of a normalized string. Returns an empty Set for inputs
|
|
31
|
+
// shorter than 2 characters (no bigrams to form). Uses a Set (not an array) so the caller can
|
|
32
|
+
// take set-intersection in O(min(|a|, |b|)) for Jaccard.
|
|
33
|
+
export function bigramTokens(normalized) {
|
|
34
|
+
const tokens = new Set();
|
|
35
|
+
for (let i = 0; i + 1 < normalized.length; i += 1) {
|
|
36
|
+
tokens.add(normalized.slice(i, i + 2));
|
|
37
|
+
}
|
|
38
|
+
return tokens;
|
|
39
|
+
}
|
|
40
|
+
export function prepareBody(body) {
|
|
41
|
+
const normalized = normalizeBody(body);
|
|
42
|
+
return {
|
|
43
|
+
normalized,
|
|
44
|
+
tokens: bigramTokens(normalized),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function jaccardSimilarityPrepared(a, b) {
|
|
48
|
+
if (a.tokens.size === 0 && b.tokens.size === 0)
|
|
49
|
+
return 1;
|
|
50
|
+
if (a.tokens.size === 0 || b.tokens.size === 0)
|
|
51
|
+
return 0;
|
|
52
|
+
let intersectionSize = 0;
|
|
53
|
+
for (const token of a.tokens) {
|
|
54
|
+
if (b.tokens.has(token))
|
|
55
|
+
intersectionSize += 1;
|
|
56
|
+
}
|
|
57
|
+
const unionSize = a.tokens.size + b.tokens.size - intersectionSize;
|
|
58
|
+
return intersectionSize / unionSize;
|
|
59
|
+
}
|
|
60
|
+
// Jaccard similarity of two strings over their normalized character-bigram sets. Returns 1 for
|
|
61
|
+
// two empty inputs (vacuously similar — they map to the same equivalence class) and 0 when one
|
|
62
|
+
// side is empty but the other is not (no overlap is possible). Output is in [0, 1].
|
|
63
|
+
export function jaccardSimilarity(a, b) {
|
|
64
|
+
return jaccardSimilarityPrepared(prepareBody(a), prepareBody(b));
|
|
65
|
+
}
|
package/dist/stale.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MemoryRecord } from "@oscharko-dev/keiko-contracts/memory";
|
|
2
|
+
import type { StaleFlag } from "./types.js";
|
|
3
|
+
export interface StaleOptions {
|
|
4
|
+
readonly nowMs: number;
|
|
5
|
+
readonly staleConfidenceThreshold: number;
|
|
6
|
+
readonly maxAgeMs: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function findStaleMemories(records: readonly MemoryRecord[], options: StaleOptions): readonly StaleFlag[];
|
|
9
|
+
//# sourceMappingURL=stale.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stale.d.ts","sourceRoot":"","sources":["../src/stale.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,YAAY,EAAgB,MAAM,sCAAsC,CAAC;AAGvF,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,YAAY,CAAC;AAEzD,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,wBAAwB,EAAE,MAAM,CAAC;IAC1C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AA6BD,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,SAAS,YAAY,EAAE,EAChC,OAAO,EAAE,YAAY,GACpB,SAAS,SAAS,EAAE,CAUtB"}
|
package/dist/stale.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Stale-memory detection. Pure function; deterministic; never mutates input.
|
|
2
|
+
//
|
|
3
|
+
// Three independent reasons may fire per record (a record can be both "expired" AND
|
|
4
|
+
// "low-confidence" simultaneously — each produces its own StaleFlag). The full set per record
|
|
5
|
+
// is enumerated so the caller can route each reason to a distinct review surface.
|
|
6
|
+
//
|
|
7
|
+
// Pinned-exemption invariant: a pinned record NEVER produces a StaleFlag, regardless of
|
|
8
|
+
// validity, confidence, or age. This is an Epic #204 hard rule — pinning is the user's
|
|
9
|
+
// explicit "never auto-degrade this" signal.
|
|
10
|
+
//
|
|
11
|
+
// Terminal-status skip: records in `rejected` or `forgotten` are skipped entirely — they have
|
|
12
|
+
// no active lifecycle for consolidation to touch. `archived` and `superseded` records are NOT
|
|
13
|
+
// skipped here: archival is reversible (status transitions allow `archived → accepted`), and a
|
|
14
|
+
// stale flag on a superseded record may still inform the audit trail. Trading false-positives
|
|
15
|
+
// for absent signal is the safer default; the caller filters by status if needed.
|
|
16
|
+
import { compareStaleFlags } from "./_ordering.js";
|
|
17
|
+
// Records in these statuses are skipped entirely: they have no consolidation work to do.
|
|
18
|
+
// `conflicted` and `proposed` are included because they are pending human review — emitting
|
|
19
|
+
// stale flags for records the reviewer hasn't accepted yet would produce false signal.
|
|
20
|
+
const SKIPPED_STATUSES = new Set(["rejected", "forgotten", "conflicted", "proposed"]);
|
|
21
|
+
function isExpired(record, nowMs) {
|
|
22
|
+
const { validUntil } = record.validity;
|
|
23
|
+
return validUntil !== undefined && validUntil <= nowMs;
|
|
24
|
+
}
|
|
25
|
+
function isLowConfidence(record, threshold) {
|
|
26
|
+
return record.provenance.confidence <= threshold;
|
|
27
|
+
}
|
|
28
|
+
function isAgedOut(record, nowMs, maxAgeMs) {
|
|
29
|
+
return record.updatedAt + maxAgeMs <= nowMs;
|
|
30
|
+
}
|
|
31
|
+
function collectReasonsFor(record, options) {
|
|
32
|
+
const reasons = [];
|
|
33
|
+
if (isExpired(record, options.nowMs))
|
|
34
|
+
reasons.push("expired");
|
|
35
|
+
if (isLowConfidence(record, options.staleConfidenceThreshold))
|
|
36
|
+
reasons.push("low-confidence");
|
|
37
|
+
if (isAgedOut(record, options.nowMs, options.maxAgeMs))
|
|
38
|
+
reasons.push("aged-out");
|
|
39
|
+
return reasons;
|
|
40
|
+
}
|
|
41
|
+
// Public entry. Returns flags sorted by (memoryId ASC, reason ASC) for byte-stable output.
|
|
42
|
+
export function findStaleMemories(records, options) {
|
|
43
|
+
const flags = [];
|
|
44
|
+
for (const record of records) {
|
|
45
|
+
if (record.pinned)
|
|
46
|
+
continue;
|
|
47
|
+
if (SKIPPED_STATUSES.has(record.status))
|
|
48
|
+
continue;
|
|
49
|
+
for (const reason of collectReasonsFor(record, options)) {
|
|
50
|
+
flags.push({ memoryId: record.id, reason, detectedAt: options.nowMs });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return flags.sort(compareStaleFlags);
|
|
54
|
+
}
|