crewly 1.20.398 → 1.20.400
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/config/constants.ts +22 -0
- package/config/skills/agent/core/standing-update/SKILL.md +6 -2
- package/dist/backend/backend/src/constants.d.ts +17 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +12 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/services/memory/agent-memory.service.d.ts +9 -4
- package/dist/backend/backend/src/services/memory/agent-memory.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/memory/agent-memory.service.js +47 -23
- package/dist/backend/backend/src/services/memory/agent-memory.service.js.map +1 -1
- package/dist/backend/backend/src/services/memory/knowledge-similarity.d.ts +206 -0
- package/dist/backend/backend/src/services/memory/knowledge-similarity.d.ts.map +1 -0
- package/dist/backend/backend/src/services/memory/knowledge-similarity.js +319 -0
- package/dist/backend/backend/src/services/memory/knowledge-similarity.js.map +1 -0
- package/dist/backend/backend/src/services/memory/standing-answers.service.d.ts +49 -1
- package/dist/backend/backend/src/services/memory/standing-answers.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/memory/standing-answers.service.js +123 -4
- package/dist/backend/backend/src/services/memory/standing-answers.service.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/backend/config/constants.d.ts +21 -0
- package/dist/backend/config/constants.d.ts.map +1 -1
- package/dist/backend/config/constants.js +21 -0
- package/dist/backend/config/constants.js.map +1 -1
- package/dist/backend/config/index.d.ts +5 -0
- package/dist/backend/config/index.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.d.ts +17 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +12 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/memory/agent-memory.service.d.ts +9 -4
- package/dist/cli/backend/src/services/memory/agent-memory.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/memory/agent-memory.service.js +47 -23
- package/dist/cli/backend/src/services/memory/agent-memory.service.js.map +1 -1
- package/dist/cli/backend/src/services/memory/knowledge-similarity.d.ts +206 -0
- package/dist/cli/backend/src/services/memory/knowledge-similarity.d.ts.map +1 -0
- package/dist/cli/backend/src/services/memory/knowledge-similarity.js +319 -0
- package/dist/cli/backend/src/services/memory/knowledge-similarity.js.map +1 -0
- package/dist/cli/backend/src/services/memory/standing-answers.service.d.ts +49 -1
- package/dist/cli/backend/src/services/memory/standing-answers.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/memory/standing-answers.service.js +123 -4
- package/dist/cli/backend/src/services/memory/standing-answers.service.js.map +1 -1
- package/dist/cli/config/constants.d.ts +21 -0
- package/dist/cli/config/constants.d.ts.map +1 -1
- package/dist/cli/config/constants.js +21 -0
- package/dist/cli/config/constants.js.map +1 -1
- package/dist/cli/config/index.d.ts +5 -0
- package/dist/cli/config/index.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Near-duplicate detection and evidence merge for role knowledge (#914 P1).
|
|
3
|
+
*
|
|
4
|
+
* `addRoleKnowledge` used to dedupe by exact match or substring containment
|
|
5
|
+
* only, so the same fact written twice with a different id, count or date
|
|
6
|
+
* in it ("Refresh standing answer … (1 newer)" / "… (6 newer)") became two
|
|
7
|
+
* entries, and recall surfaced both. This module replaces that check with
|
|
8
|
+
* a cheap nearest-neighbour over token sets:
|
|
9
|
+
*
|
|
10
|
+
* - `knowledgeTokens` lowercases, splits on non-alphanumerics and drops the
|
|
11
|
+
* tokens that vary between restatements of one fact (uuids, hex ids,
|
|
12
|
+
* long digit runs, single letters);
|
|
13
|
+
* - `jaccard` is the set overlap of two token sets;
|
|
14
|
+
* - `numbersCompatible` keeps two texts apart when they disagree on a
|
|
15
|
+
* number (ticket, count, version): the frame may be identical, the fact
|
|
16
|
+
* is not;
|
|
17
|
+
* - `findNearestKnowledge` returns the best candidate in the same scope
|
|
18
|
+
* (same category; overlapping tags when both sides have tags), an exact
|
|
19
|
+
* or substring match first, else the highest Jaccard at or above the
|
|
20
|
+
* threshold among number-compatible entries. Superseded and expired
|
|
21
|
+
* entries are never candidates, so a new write cannot reinforce a dead
|
|
22
|
+
* fact. Short texts (fewer than `MIN_TOKENS_FOR_NEAR` tokens) match
|
|
23
|
+
* exactly or not at all.
|
|
24
|
+
* - `mergeKnowledgeEvidence` folds the incoming entry's provenance into the
|
|
25
|
+
* existing one (exact match): evidence union, importance max,
|
|
26
|
+
* verification timestamp. The existing content stays.
|
|
27
|
+
* - `restateKnowledge` handles a near match: the newest wording becomes a
|
|
28
|
+
* new entry with the old provenance and reinforcement, the old entry is
|
|
29
|
+
* marked superseded by it and kept for audit. The LLM adjudication the
|
|
30
|
+
* issue describes (keep / modify / supersede by judgment) is a later,
|
|
31
|
+
* possibly Pro, step; this is the deterministic rule.
|
|
32
|
+
* - `evictionIndex` picks what a full store drops: superseded first.
|
|
33
|
+
*
|
|
34
|
+
* Pure functions only; the threshold was set by replaying the owner's
|
|
35
|
+
* 3.7k real entries (see spec `2026-10-25-knowledge-evidence-merge.md`).
|
|
36
|
+
*
|
|
37
|
+
* @module services/memory/knowledge-similarity
|
|
38
|
+
*/
|
|
39
|
+
import { MEMORY_CONSTANTS } from '../../constants.js';
|
|
40
|
+
import { isHiddenFromDefaultRecall } from './role-knowledge-eligibility.js';
|
|
41
|
+
/** A uuid anywhere in the text (work item, claim and task ids). */
|
|
42
|
+
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
|
|
43
|
+
/**
|
|
44
|
+
* Hex runs of four or more characters with at least one digit: partial ids
|
|
45
|
+
* (`WI d3416d29`), commit shas, hashes. Plain words that happen to be hex
|
|
46
|
+
* letters (`face`, `bead`) have no digit and stay.
|
|
47
|
+
*/
|
|
48
|
+
const HEX_ID = /^(?=.*\d)[0-9a-f]{4,}$/;
|
|
49
|
+
/** Digit runs of six or more: epoch values, compact dates, random suffixes. */
|
|
50
|
+
const LONG_NUMBER = /^\d{6,}$/;
|
|
51
|
+
/** A number that survived: ticket numbers, counts, versions, years. */
|
|
52
|
+
const NUMBER = /^\d+$/;
|
|
53
|
+
/** Anything that is not a letter or digit splits tokens. */
|
|
54
|
+
const NON_ALNUM = /[^\p{L}\p{N}]+/u;
|
|
55
|
+
/**
|
|
56
|
+
* Tokens of a knowledge text that survive a restatement: lowercase words
|
|
57
|
+
* and numbers up to five digits, without uuids, hex ids, long digit runs or
|
|
58
|
+
* single letters. Single digits stay: "entry 0" and "entry 1" are two
|
|
59
|
+
* facts.
|
|
60
|
+
*
|
|
61
|
+
* @param text - Entry content
|
|
62
|
+
* @returns Distinct tokens
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* knowledgeTokens('Claimed WorkItem X (id: 51851ddb-3e5f-48b4-b73a-fccbc6a79abb) at 2026-10-25, 3 newer')
|
|
67
|
+
* // Set { 'claimed', 'workitem', 'id', 'at', '2026', '10', '25', '3', 'newer' }
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function knowledgeTokens(text) {
|
|
71
|
+
const out = new Set();
|
|
72
|
+
for (const raw of text.toLowerCase().replace(UUID, ' ').split(NON_ALNUM)) {
|
|
73
|
+
if (raw.length === 0)
|
|
74
|
+
continue;
|
|
75
|
+
if (NUMBER.test(raw)) {
|
|
76
|
+
if (!LONG_NUMBER.test(raw))
|
|
77
|
+
out.add(raw);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (raw.length === 1 || HEX_ID.test(raw))
|
|
81
|
+
continue;
|
|
82
|
+
out.add(raw);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Whether two token sets agree on their numbers: one side's numbers must
|
|
88
|
+
* all appear on the other. "verify CE-140" and "verify CE-196" share every
|
|
89
|
+
* word and differ in one number — two facts, however alike the frame.
|
|
90
|
+
* "snapshot 10/13 03:40Z" and "snapshot 10/13" are one fact, the second
|
|
91
|
+
* just shorter. Disagreeing counts ("1 newer" / "6 newer") are kept apart
|
|
92
|
+
* on purpose: a false merge loses knowledge, a missed one only leaves a
|
|
93
|
+
* duplicate.
|
|
94
|
+
*
|
|
95
|
+
* @param a - First token set
|
|
96
|
+
* @param b - Second token set
|
|
97
|
+
* @returns true when the numbers of one set are a subset of the other's
|
|
98
|
+
*/
|
|
99
|
+
export function numbersCompatible(a, b) {
|
|
100
|
+
const na = [...a].filter((t) => NUMBER.test(t));
|
|
101
|
+
const nb = [...b].filter((t) => NUMBER.test(t));
|
|
102
|
+
return na.every((t) => b.has(t)) || nb.every((t) => a.has(t));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Jaccard overlap of two token sets: |A ∩ B| / |A ∪ B|.
|
|
106
|
+
*
|
|
107
|
+
* @param a - First set
|
|
108
|
+
* @param b - Second set
|
|
109
|
+
* @returns 0 when either set is empty, else the overlap in [0, 1]
|
|
110
|
+
*/
|
|
111
|
+
export function jaccard(a, b) {
|
|
112
|
+
if (a.size === 0 || b.size === 0)
|
|
113
|
+
return 0;
|
|
114
|
+
let shared = 0;
|
|
115
|
+
for (const t of a)
|
|
116
|
+
if (b.has(t))
|
|
117
|
+
shared++;
|
|
118
|
+
return shared / (a.size + b.size - shared);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Whether an existing entry is in the incoming entry's scope: the same
|
|
122
|
+
* category when the incoming one names a category, and at least one shared
|
|
123
|
+
* tag when both carry tags.
|
|
124
|
+
*
|
|
125
|
+
* @param existing - Existing entry
|
|
126
|
+
* @param incoming - Incoming entry
|
|
127
|
+
* @returns true when the two may be compared
|
|
128
|
+
*/
|
|
129
|
+
export function sameScope(existing, incoming) {
|
|
130
|
+
if (incoming.category && existing.category && incoming.category !== existing.category)
|
|
131
|
+
return false;
|
|
132
|
+
const a = incoming.tags ?? [];
|
|
133
|
+
const b = existing.tags ?? [];
|
|
134
|
+
if (a.length > 0 && b.length > 0 && !a.some((t) => b.includes(t)))
|
|
135
|
+
return false;
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Equal after trim and lowercase.
|
|
140
|
+
*
|
|
141
|
+
* @param existing - Existing content
|
|
142
|
+
* @param incoming - Incoming content
|
|
143
|
+
* @returns true when the two texts are the same
|
|
144
|
+
*/
|
|
145
|
+
export function isSameText(existing, incoming) {
|
|
146
|
+
return existing.toLowerCase().trim() === incoming.toLowerCase().trim();
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* One text contains the other, when the incoming text is longer than the
|
|
150
|
+
* substring floor (the legacy containment rule).
|
|
151
|
+
*
|
|
152
|
+
* @param existing - Existing content
|
|
153
|
+
* @param incoming - Incoming content
|
|
154
|
+
* @returns true for a containment match
|
|
155
|
+
*/
|
|
156
|
+
export function containsText(existing, incoming) {
|
|
157
|
+
const a = existing.toLowerCase().trim();
|
|
158
|
+
const b = incoming.toLowerCase().trim();
|
|
159
|
+
return b.length > MEMORY_CONSTANTS.DEDUP.SUBSTRING_MIN_CHARS && (a.includes(b) || b.includes(a));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The legacy rule: {@link isSameText} or {@link containsText}.
|
|
163
|
+
*
|
|
164
|
+
* @param existing - Existing content
|
|
165
|
+
* @param incoming - Incoming content
|
|
166
|
+
* @returns true for an exact or containment match
|
|
167
|
+
*/
|
|
168
|
+
export function isExactMatch(existing, incoming) {
|
|
169
|
+
return isSameText(existing, incoming) || containsText(existing, incoming);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The existing entry the incoming one duplicates, if any: an exact match
|
|
173
|
+
* first, else the highest token overlap at or above the threshold, among
|
|
174
|
+
* entries in scope that recall still shows.
|
|
175
|
+
*
|
|
176
|
+
* @param knowledge - Existing entries
|
|
177
|
+
* @param incoming - Entry about to be written
|
|
178
|
+
* @param options - Threshold and reference time
|
|
179
|
+
* @returns The nearest match, or null when nothing is close enough
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* ```typescript
|
|
183
|
+
* const hit = findNearestKnowledge(memory.roleKnowledge, { content, category });
|
|
184
|
+
* if (hit) merge(hit.entry); else append(content);
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
export function findNearestKnowledge(knowledge, incoming, options = {}) {
|
|
188
|
+
const threshold = options.threshold ?? MEMORY_CONSTANTS.DEDUP.NEAR_DUPLICATE_JACCARD;
|
|
189
|
+
const now = options.now ?? new Date();
|
|
190
|
+
const incomingTokens = knowledgeTokens(incoming.content);
|
|
191
|
+
const nearAllowed = incomingTokens.size >= MEMORY_CONSTANTS.DEDUP.MIN_TOKENS_FOR_NEAR;
|
|
192
|
+
// Same text wins outright; a containment match beats any near match; among
|
|
193
|
+
// near matches the highest overlap wins.
|
|
194
|
+
let contained = null;
|
|
195
|
+
let near = null;
|
|
196
|
+
for (const entry of knowledge) {
|
|
197
|
+
if (isHiddenFromDefaultRecall(entry, now))
|
|
198
|
+
continue;
|
|
199
|
+
if (!sameScope(entry, incoming))
|
|
200
|
+
continue;
|
|
201
|
+
if (isSameText(entry.content, incoming.content))
|
|
202
|
+
return { entry, score: 1, kind: 'exact' };
|
|
203
|
+
if (contained === null && containsText(entry.content, incoming.content)) {
|
|
204
|
+
contained = { entry, score: 1, kind: 'exact' };
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (!nearAllowed)
|
|
208
|
+
continue;
|
|
209
|
+
const entryTokens = knowledgeTokens(entry.content);
|
|
210
|
+
if (!numbersCompatible(entryTokens, incomingTokens))
|
|
211
|
+
continue;
|
|
212
|
+
const score = jaccard(entryTokens, incomingTokens);
|
|
213
|
+
if (score >= threshold && (near === null || score > near.score))
|
|
214
|
+
near = { entry, score, kind: 'near' };
|
|
215
|
+
}
|
|
216
|
+
return contained ?? near;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Folds an incoming duplicate's provenance into the existing entry, in
|
|
220
|
+
* place: evidence becomes the union (legacy `learnedFrom` and
|
|
221
|
+
* `sourceTaskId` count as evidence), importance the max, and the entry is
|
|
222
|
+
* marked verified and used now. Content, confidence and ids are left to the
|
|
223
|
+
* caller.
|
|
224
|
+
*
|
|
225
|
+
* @param existing - Entry that stays (mutated)
|
|
226
|
+
* @param incoming - Duplicate that is not written
|
|
227
|
+
* @param now - Timestamp for `lastVerifiedAt` / `lastUsed`
|
|
228
|
+
* @returns What changed
|
|
229
|
+
*/
|
|
230
|
+
export function mergeKnowledgeEvidence(existing, incoming, now = new Date()) {
|
|
231
|
+
const evidence = [...(existing.evidence ?? (existing.learnedFrom ? [existing.learnedFrom] : []))];
|
|
232
|
+
const addedEvidence = [];
|
|
233
|
+
for (const ref of [...(incoming.evidence ?? []), incoming.learnedFrom, incoming.sourceTaskId]) {
|
|
234
|
+
if (!ref || evidence.includes(ref))
|
|
235
|
+
continue;
|
|
236
|
+
evidence.push(ref);
|
|
237
|
+
addedEvidence.push(ref);
|
|
238
|
+
}
|
|
239
|
+
if (evidence.length > 0)
|
|
240
|
+
existing.evidence = evidence;
|
|
241
|
+
let importanceRaised = false;
|
|
242
|
+
if (incoming.importance !== undefined && incoming.importance > (existing.importance ?? 0)) {
|
|
243
|
+
existing.importance = incoming.importance;
|
|
244
|
+
importanceRaised = true;
|
|
245
|
+
}
|
|
246
|
+
const iso = now.toISOString();
|
|
247
|
+
existing.lastVerifiedAt = iso;
|
|
248
|
+
existing.lastUsed = iso;
|
|
249
|
+
return { addedEvidence, importanceRaised };
|
|
250
|
+
}
|
|
251
|
+
/** Prefix of the evidence ref a superseded entry gets when a restatement replaced it. */
|
|
252
|
+
export const RESTATEMENT_REASON_PREFIX = 'supersession-reason:restated';
|
|
253
|
+
/**
|
|
254
|
+
* A near-duplicate is the same fact said again, often with a correction
|
|
255
|
+
* ("I asserted X is a working symlink" → "X is a DANGLING symlink"), so the
|
|
256
|
+
* newest wording wins: the incoming entry becomes a new entry carrying the
|
|
257
|
+
* old one's provenance and reinforcement, and the old entry is marked
|
|
258
|
+
* superseded by it — hidden from recall, kept for audit, the same way
|
|
259
|
+
* `MemorySupersessionService.supersede` records an explicit supersession.
|
|
260
|
+
*
|
|
261
|
+
* @param old - Existing near-duplicate (mutated: superseded, evidence reason appended)
|
|
262
|
+
* @param incoming - Entry being written
|
|
263
|
+
* @param ids - The new entry's id and timestamp, and the match score for the audit ref
|
|
264
|
+
* @returns The new entry to store
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* ```typescript
|
|
268
|
+
* const fresh = restateKnowledge(match.entry, entry, { id: uuidv4(), now: new Date(), score: match.score });
|
|
269
|
+
* memory.roleKnowledge.push(fresh);
|
|
270
|
+
* ```
|
|
271
|
+
*/
|
|
272
|
+
export function restateKnowledge(old, incoming, ids) {
|
|
273
|
+
const iso = ids.now.toISOString();
|
|
274
|
+
const fresh = {
|
|
275
|
+
...incoming,
|
|
276
|
+
id: ids.id,
|
|
277
|
+
createdAt: iso,
|
|
278
|
+
confidence: Math.min(Math.max(old.confidence, incoming.confidence ?? MEMORY_CONSTANTS.DEFAULTS.INITIAL_CONFIDENCE) + MEMORY_CONSTANTS.DEFAULTS.CONFIDENCE_REINFORCEMENT, MEMORY_CONSTANTS.DEFAULTS.MAX_CONFIDENCE),
|
|
279
|
+
lastVerifiedAt: iso,
|
|
280
|
+
};
|
|
281
|
+
// The new entry inherits the old provenance, then its own.
|
|
282
|
+
const inherited = { ...fresh, evidence: [...(old.evidence ?? (old.learnedFrom ? [old.learnedFrom] : []))], importance: old.importance };
|
|
283
|
+
mergeKnowledgeEvidence(inherited, incoming, ids.now);
|
|
284
|
+
if (inherited.evidence && inherited.evidence.length > 0)
|
|
285
|
+
fresh.evidence = inherited.evidence;
|
|
286
|
+
if (inherited.importance !== undefined)
|
|
287
|
+
fresh.importance = inherited.importance;
|
|
288
|
+
old.superseded = true;
|
|
289
|
+
old.supersededBy = fresh.id;
|
|
290
|
+
old.evidence = [...(old.evidence ?? (old.learnedFrom ? [old.learnedFrom] : [])), `${RESTATEMENT_REASON_PREFIX} (jaccard ${ids.score.toFixed(2)})`];
|
|
291
|
+
return fresh;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Which entry to evict when a store is full: the oldest superseded entry
|
|
295
|
+
* first (recall no longer shows it), else the lowest-confidence live one.
|
|
296
|
+
*
|
|
297
|
+
* @param knowledge - Entries
|
|
298
|
+
* @returns Index to remove, or -1 for an empty list
|
|
299
|
+
*/
|
|
300
|
+
export function evictionIndex(knowledge) {
|
|
301
|
+
if (knowledge.length === 0)
|
|
302
|
+
return -1;
|
|
303
|
+
let pick = -1;
|
|
304
|
+
for (let i = 0; i < knowledge.length; i++) {
|
|
305
|
+
const e = knowledge[i];
|
|
306
|
+
if (!(e.superseded === true || e.supersededBy))
|
|
307
|
+
continue;
|
|
308
|
+
if (pick === -1 || e.createdAt < knowledge[pick].createdAt)
|
|
309
|
+
pick = i;
|
|
310
|
+
}
|
|
311
|
+
if (pick !== -1)
|
|
312
|
+
return pick;
|
|
313
|
+
for (let i = 0; i < knowledge.length; i++) {
|
|
314
|
+
if (pick === -1 || knowledge[i].confidence < knowledge[pick].confidence)
|
|
315
|
+
pick = i;
|
|
316
|
+
}
|
|
317
|
+
return pick;
|
|
318
|
+
}
|
|
319
|
+
//# sourceMappingURL=knowledge-similarity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"knowledge-similarity.js","sourceRoot":"","sources":["../../../../../../backend/src/services/memory/knowledge-similarity.ts"],"names":[],"mappings":"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"}
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
* is no longer in force (superseded, expired, resolved). The watermark
|
|
34
34
|
* does not move for these, so they are checked on every read
|
|
35
35
|
* ({@link findInvalidatedSections}) and also make the page need a refresh.
|
|
36
|
+
* - Edits are per section (keep / modify / remove / add), so a refresh never
|
|
37
|
+
* rewrites what it did not touch. Before a content change the page as it
|
|
38
|
+
* was is snapshotted and the write is logged (`changes.log`), so every
|
|
39
|
+
* change is auditable (#914 P1).
|
|
36
40
|
* - No LLM runs here. The only writer is an agent working a refresh
|
|
37
41
|
* WorkItem (see standing-refresh.service.ts), raised only when the
|
|
38
42
|
* watermark moves.
|
|
@@ -128,8 +132,19 @@ export interface WriteSectionInput extends StandingLocation {
|
|
|
128
132
|
/** Citations (`dec:<id>` …); at least one unless removing. */
|
|
129
133
|
cites: string[];
|
|
130
134
|
}
|
|
135
|
+
/** What a section write did to the page. */
|
|
136
|
+
export type SectionWriteOp = 'added' | 'modified' | 'removed' | 'unchanged';
|
|
131
137
|
/** Result of a section write. */
|
|
132
138
|
export interface WriteSectionResult {
|
|
139
|
+
/**
|
|
140
|
+
* What changed: `added` (new heading), `modified` (body or citations
|
|
141
|
+
* differ), `removed` (empty body on an existing section) or `unchanged`
|
|
142
|
+
* (the same body and citations again — only `last_refreshed` and the
|
|
143
|
+
* watermark move; use it to confirm a section after a refresh).
|
|
144
|
+
*/
|
|
145
|
+
op: SectionWriteOp;
|
|
146
|
+
/** Snapshot of the page as it was before this write, null when nothing changed or the page is new. */
|
|
147
|
+
snapshot: string | null;
|
|
133
148
|
filePath: string;
|
|
134
149
|
watermark: string | null;
|
|
135
150
|
removed: boolean;
|
|
@@ -244,9 +259,31 @@ export declare class StandingAnswersService {
|
|
|
244
259
|
/**
|
|
245
260
|
* @param options - `crewlyHome` overrides {@link getCrewlyHomePath} (tests)
|
|
246
261
|
*/
|
|
262
|
+
private readonly historyKeep;
|
|
263
|
+
/**
|
|
264
|
+
* @param options - `crewlyHome` overrides {@link getCrewlyHomePath} (tests);
|
|
265
|
+
* `historyKeep` overrides {@link STANDING_ANSWERS_CONSTANTS.HISTORY_KEEP}
|
|
266
|
+
*/
|
|
247
267
|
constructor(options?: {
|
|
248
268
|
crewlyHome?: string;
|
|
269
|
+
historyKeep?: number;
|
|
249
270
|
});
|
|
271
|
+
/**
|
|
272
|
+
* Directory holding a page's snapshots and `changes.log`.
|
|
273
|
+
*
|
|
274
|
+
* @param def - Page definition
|
|
275
|
+
* @param loc - Location
|
|
276
|
+
* @returns Absolute path, or null when the location lacks what the scope needs
|
|
277
|
+
*/
|
|
278
|
+
historyDir(def: StandingPageDef, loc: StandingLocation): string | null;
|
|
279
|
+
/**
|
|
280
|
+
* Snapshots of a page, oldest first (absolute paths).
|
|
281
|
+
*
|
|
282
|
+
* @param def - Page definition
|
|
283
|
+
* @param loc - Location
|
|
284
|
+
* @returns Snapshot files; empty when there is no history
|
|
285
|
+
*/
|
|
286
|
+
listSnapshots(def: StandingPageDef, loc: StandingLocation): Promise<string[]>;
|
|
250
287
|
/**
|
|
251
288
|
* File path of a page for a location.
|
|
252
289
|
*
|
|
@@ -281,7 +318,10 @@ export declare class StandingAnswersService {
|
|
|
281
318
|
*/
|
|
282
319
|
listPageStatuses(loc: StandingLocation): Promise<StandingPageStatus[]>;
|
|
283
320
|
/**
|
|
284
|
-
* Write one section of a page (section-level edit)
|
|
321
|
+
* Write one section of a page (section-level edit): keep the rest, replace
|
|
322
|
+
* or add this heading, or remove it (empty body). Before a content change
|
|
323
|
+
* the previous page is snapshotted under {@link historyDir} and every write
|
|
324
|
+
* appends a `changes.log` line, so each change is auditable (#914 P1).
|
|
285
325
|
*
|
|
286
326
|
* Validates the heading and body, requires every citation to name an
|
|
287
327
|
* entry in the page's sources, masks secrets, then stamps
|
|
@@ -306,6 +346,14 @@ export declare class StandingAnswersService {
|
|
|
306
346
|
*/
|
|
307
347
|
buildRefreshBrief(status: StandingPageStatus, loc: StandingLocation, agentSkillsPath: string): Promise<string>;
|
|
308
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* What writing `section` would do to `page`.
|
|
351
|
+
*
|
|
352
|
+
* @param page - Page as it stands
|
|
353
|
+
* @param section - Incoming section (empty body = remove)
|
|
354
|
+
* @returns The op
|
|
355
|
+
*/
|
|
356
|
+
export declare function sectionWriteOp(page: StandingPage, section: StandingSection): SectionWriteOp;
|
|
309
357
|
/**
|
|
310
358
|
* Pairs of in-force entries from the same UTC day that share a topic: the
|
|
311
359
|
* later one may replace the earlier one without a supersede link (#884).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"standing-answers.service.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/memory/standing-answers.service.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"standing-answers.service.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/memory/standing-answers.service.ts"],"names":[],"mappings":"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"}
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
* is no longer in force (superseded, expired, resolved). The watermark
|
|
34
34
|
* does not move for these, so they are checked on every read
|
|
35
35
|
* ({@link findInvalidatedSections}) and also make the page need a refresh.
|
|
36
|
+
* - Edits are per section (keep / modify / remove / add), so a refresh never
|
|
37
|
+
* rewrites what it did not touch. Before a content change the page as it
|
|
38
|
+
* was is snapshotted and the write is logged (`changes.log`), so every
|
|
39
|
+
* change is auditable (#914 P1).
|
|
36
40
|
* - No LLM runs here. The only writer is an agent working a refresh
|
|
37
41
|
* WorkItem (see standing-refresh.service.ts), raised only when the
|
|
38
42
|
* watermark moves.
|
|
@@ -322,8 +326,50 @@ export class StandingAnswersService {
|
|
|
322
326
|
/**
|
|
323
327
|
* @param options - `crewlyHome` overrides {@link getCrewlyHomePath} (tests)
|
|
324
328
|
*/
|
|
329
|
+
historyKeep;
|
|
330
|
+
/**
|
|
331
|
+
* @param options - `crewlyHome` overrides {@link getCrewlyHomePath} (tests);
|
|
332
|
+
* `historyKeep` overrides {@link STANDING_ANSWERS_CONSTANTS.HISTORY_KEEP}
|
|
333
|
+
*/
|
|
325
334
|
constructor(options = {}) {
|
|
326
335
|
this.crewlyHome = options.crewlyHome ?? getCrewlyHomePath();
|
|
336
|
+
this.historyKeep = options.historyKeep ?? STANDING_ANSWERS_CONSTANTS.HISTORY_KEEP;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Directory holding a page's snapshots and `changes.log`.
|
|
340
|
+
*
|
|
341
|
+
* @param def - Page definition
|
|
342
|
+
* @param loc - Location
|
|
343
|
+
* @returns Absolute path, or null when the location lacks what the scope needs
|
|
344
|
+
*/
|
|
345
|
+
historyDir(def, loc) {
|
|
346
|
+
if (def.scope === 'project') {
|
|
347
|
+
if (!loc.projectPath)
|
|
348
|
+
return null;
|
|
349
|
+
return path.join(resolveProjectDataDir(loc.projectPath), STANDING_ANSWERS_CONSTANTS.HISTORY_DIR, def.id);
|
|
350
|
+
}
|
|
351
|
+
if (!loc.sessionName || !isSafeSegment(loc.sessionName))
|
|
352
|
+
return null;
|
|
353
|
+
return path.join(this.crewlyHome, MEMORY_CONSTANTS.PATHS.AGENTS_DIR, loc.sessionName, STANDING_ANSWERS_CONSTANTS.HISTORY_DIR);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Snapshots of a page, oldest first (absolute paths).
|
|
357
|
+
*
|
|
358
|
+
* @param def - Page definition
|
|
359
|
+
* @param loc - Location
|
|
360
|
+
* @returns Snapshot files; empty when there is no history
|
|
361
|
+
*/
|
|
362
|
+
async listSnapshots(def, loc) {
|
|
363
|
+
const dir = this.historyDir(def, loc);
|
|
364
|
+
if (!dir)
|
|
365
|
+
return [];
|
|
366
|
+
try {
|
|
367
|
+
const names = await fs.readdir(dir);
|
|
368
|
+
return names.filter((n) => n.endsWith('.md')).sort().map((n) => path.join(dir, n));
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
327
373
|
}
|
|
328
374
|
/**
|
|
329
375
|
* File path of a page for a location.
|
|
@@ -439,7 +485,10 @@ export class StandingAnswersService {
|
|
|
439
485
|
return all.filter((s) => s !== null);
|
|
440
486
|
}
|
|
441
487
|
/**
|
|
442
|
-
* Write one section of a page (section-level edit)
|
|
488
|
+
* Write one section of a page (section-level edit): keep the rest, replace
|
|
489
|
+
* or add this heading, or remove it (empty body). Before a content change
|
|
490
|
+
* the previous page is snapshotted under {@link historyDir} and every write
|
|
491
|
+
* appends a `changes.log` line, so each change is auditable (#914 P1).
|
|
443
492
|
*
|
|
444
493
|
* Validates the heading and body, requires every citation to name an
|
|
445
494
|
* entry in the page's sources, masks secrets, then stamps
|
|
@@ -491,17 +540,36 @@ export class StandingAnswersService {
|
|
|
491
540
|
const maskedHeading = redactSensitive(heading);
|
|
492
541
|
const raw = await readText(filePath);
|
|
493
542
|
const existing = raw === null ? { sections: [] } : parseStandingPage(raw);
|
|
494
|
-
const
|
|
543
|
+
const section = { heading: maskedHeading, body: maskedBody, cites: body ? cites : [] };
|
|
544
|
+
const op = sectionWriteOp(existing, section);
|
|
545
|
+
const { page, removed } = upsertSection(existing, section);
|
|
495
546
|
const watermark = computeWatermark(entries);
|
|
547
|
+
const now = new Date().toISOString();
|
|
496
548
|
const next = {
|
|
497
549
|
...page,
|
|
498
550
|
question: existing.question ?? def.question,
|
|
499
|
-
lastRefreshed:
|
|
551
|
+
lastRefreshed: now,
|
|
500
552
|
watermark: watermark ?? undefined,
|
|
501
553
|
};
|
|
554
|
+
const historyDir = this.historyDir(def, loc);
|
|
555
|
+
let snapshot = null;
|
|
556
|
+
if (historyDir) {
|
|
557
|
+
try {
|
|
558
|
+
await ensureDir(historyDir);
|
|
559
|
+
if (op !== 'unchanged' && raw !== null) {
|
|
560
|
+
snapshot = await writeSnapshot(historyDir, now, raw);
|
|
561
|
+
await pruneSnapshots(historyDir, this.historyKeep);
|
|
562
|
+
}
|
|
563
|
+
await appendChangeLog(historyDir, `${now}\t${op}\t${maskedHeading}\t${section.cites.length} cite${section.cites.length === 1 ? '' : 's'}\t${snapshot ? path.basename(snapshot) : '-'}`);
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
// History is an audit aid; a failure there must not block the page write.
|
|
567
|
+
snapshot = null;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
502
570
|
await ensureDir(path.dirname(filePath));
|
|
503
571
|
await atomicWriteFile(filePath, serializeStandingPage(next));
|
|
504
|
-
return { filePath, watermark, removed, masked: maskedBody !== body || maskedHeading !== heading };
|
|
572
|
+
return { filePath, watermark, op, snapshot, removed, masked: maskedBody !== body || maskedHeading !== heading };
|
|
505
573
|
}
|
|
506
574
|
/**
|
|
507
575
|
* The brief for a refresh WorkItem: the question, the current sections,
|
|
@@ -538,6 +606,7 @@ export class StandingAnswersService {
|
|
|
538
606
|
...possibleSupersedeLines(shown),
|
|
539
607
|
'',
|
|
540
608
|
'## Write each changed section (do not edit the file by hand — the skill checks citations and masks secrets)',
|
|
609
|
+
'Sections you do not write are kept as they are. A section that is still right: write it again unchanged so the watermark moves (the skill reports `unchanged`; nothing is rewritten). Each change is snapshotted and logged.',
|
|
541
610
|
'```bash',
|
|
542
611
|
`bash ${agentSkillsPath}/core/standing-update/execute.sh --page ${status.def.id} ${where} --heading "<section heading>" --body-file <file> --cites "<cite>,<cite>"`,
|
|
543
612
|
'```',
|
|
@@ -546,9 +615,59 @@ export class StandingAnswersService {
|
|
|
546
615
|
return lines.join('\n');
|
|
547
616
|
}
|
|
548
617
|
}
|
|
618
|
+
/**
|
|
619
|
+
* What writing `section` would do to `page`.
|
|
620
|
+
*
|
|
621
|
+
* @param page - Page as it stands
|
|
622
|
+
* @param section - Incoming section (empty body = remove)
|
|
623
|
+
* @returns The op
|
|
624
|
+
*/
|
|
625
|
+
export function sectionWriteOp(page, section) {
|
|
626
|
+
const key = section.heading.trim().toLowerCase();
|
|
627
|
+
const prior = page.sections.find((s) => s.heading.trim().toLowerCase() === key);
|
|
628
|
+
if (!section.body.trim())
|
|
629
|
+
return prior ? 'removed' : 'unchanged';
|
|
630
|
+
if (!prior)
|
|
631
|
+
return 'added';
|
|
632
|
+
const sameCites = prior.cites.length === section.cites.length && prior.cites.every((c, i) => c === section.cites[i]);
|
|
633
|
+
return prior.body === section.body && sameCites ? 'unchanged' : 'modified';
|
|
634
|
+
}
|
|
549
635
|
// ---------------------------------------------------------------------------
|
|
550
636
|
// File helpers
|
|
551
637
|
// ---------------------------------------------------------------------------
|
|
638
|
+
/**
|
|
639
|
+
* Save the previous page under `<dir>/<timestamp>.md` (a numeric suffix
|
|
640
|
+
* keeps two snapshots in the same millisecond apart).
|
|
641
|
+
*
|
|
642
|
+
* @returns The snapshot path
|
|
643
|
+
*/
|
|
644
|
+
async function writeSnapshot(dir, now, raw) {
|
|
645
|
+
const stem = now.replace(/[:.]/g, '-');
|
|
646
|
+
for (let n = 0;; n++) {
|
|
647
|
+
const file = path.join(dir, `${stem}${n ? `-${n}` : ''}.md`);
|
|
648
|
+
try {
|
|
649
|
+
await fs.writeFile(file, raw, { encoding: 'utf8', flag: 'wx' });
|
|
650
|
+
return file;
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
if (error.code !== 'EEXIST')
|
|
654
|
+
throw error;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/** Delete the oldest snapshots beyond `keep`. */
|
|
659
|
+
async function pruneSnapshots(dir, keep) {
|
|
660
|
+
const names = (await fs.readdir(dir)).filter((n) => n.endsWith('.md')).sort();
|
|
661
|
+
const excess = names.slice(0, Math.max(0, names.length - keep));
|
|
662
|
+
await Promise.all(excess.map((n) => fs.rm(path.join(dir, n), { force: true })));
|
|
663
|
+
}
|
|
664
|
+
/** Append one line to `changes.log`, keeping the last HISTORY_LOG_KEEP lines. */
|
|
665
|
+
async function appendChangeLog(dir, line) {
|
|
666
|
+
const file = path.join(dir, 'changes.log');
|
|
667
|
+
const prior = (await readText(file))?.split('\n').filter(Boolean) ?? [];
|
|
668
|
+
const lines = [...prior, line].slice(-STANDING_ANSWERS_CONSTANTS.HISTORY_LOG_KEEP);
|
|
669
|
+
await atomicWriteFile(file, `${lines.join('\n')}\n`);
|
|
670
|
+
}
|
|
552
671
|
/** Words too common to signal a shared topic. */
|
|
553
672
|
const TOPIC_STOPWORDS = new Set(['that', 'this', 'with', 'from', 'have', 'will', 'were', 'been', 'into', 'than', 'then', 'they', 'them', 'their', 'there', 'what', 'when', 'which', 'would', 'should', 'could', 'about', 'after', 'before', 'still', 'only', 'also', 'more', 'some', 'does', 'done', 'just', 'over', 'each', 'very']);
|
|
554
673
|
/** Lower-case topic words (4+ letters, not stopwords) of an entry. */
|