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.
Files changed (48) hide show
  1. package/config/constants.ts +22 -0
  2. package/config/skills/agent/core/standing-update/SKILL.md +6 -2
  3. package/dist/backend/backend/src/constants.d.ts +17 -0
  4. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  5. package/dist/backend/backend/src/constants.js +12 -0
  6. package/dist/backend/backend/src/constants.js.map +1 -1
  7. package/dist/backend/backend/src/services/memory/agent-memory.service.d.ts +9 -4
  8. package/dist/backend/backend/src/services/memory/agent-memory.service.d.ts.map +1 -1
  9. package/dist/backend/backend/src/services/memory/agent-memory.service.js +47 -23
  10. package/dist/backend/backend/src/services/memory/agent-memory.service.js.map +1 -1
  11. package/dist/backend/backend/src/services/memory/knowledge-similarity.d.ts +206 -0
  12. package/dist/backend/backend/src/services/memory/knowledge-similarity.d.ts.map +1 -0
  13. package/dist/backend/backend/src/services/memory/knowledge-similarity.js +319 -0
  14. package/dist/backend/backend/src/services/memory/knowledge-similarity.js.map +1 -0
  15. package/dist/backend/backend/src/services/memory/standing-answers.service.d.ts +49 -1
  16. package/dist/backend/backend/src/services/memory/standing-answers.service.d.ts.map +1 -1
  17. package/dist/backend/backend/src/services/memory/standing-answers.service.js +123 -4
  18. package/dist/backend/backend/src/services/memory/standing-answers.service.js.map +1 -1
  19. package/dist/backend/build-info.json +2 -2
  20. package/dist/backend/config/constants.d.ts +21 -0
  21. package/dist/backend/config/constants.d.ts.map +1 -1
  22. package/dist/backend/config/constants.js +21 -0
  23. package/dist/backend/config/constants.js.map +1 -1
  24. package/dist/backend/config/index.d.ts +5 -0
  25. package/dist/backend/config/index.d.ts.map +1 -1
  26. package/dist/cli/backend/src/constants.d.ts +17 -0
  27. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  28. package/dist/cli/backend/src/constants.js +12 -0
  29. package/dist/cli/backend/src/constants.js.map +1 -1
  30. package/dist/cli/backend/src/services/memory/agent-memory.service.d.ts +9 -4
  31. package/dist/cli/backend/src/services/memory/agent-memory.service.d.ts.map +1 -1
  32. package/dist/cli/backend/src/services/memory/agent-memory.service.js +47 -23
  33. package/dist/cli/backend/src/services/memory/agent-memory.service.js.map +1 -1
  34. package/dist/cli/backend/src/services/memory/knowledge-similarity.d.ts +206 -0
  35. package/dist/cli/backend/src/services/memory/knowledge-similarity.d.ts.map +1 -0
  36. package/dist/cli/backend/src/services/memory/knowledge-similarity.js +319 -0
  37. package/dist/cli/backend/src/services/memory/knowledge-similarity.js.map +1 -0
  38. package/dist/cli/backend/src/services/memory/standing-answers.service.d.ts +49 -1
  39. package/dist/cli/backend/src/services/memory/standing-answers.service.d.ts.map +1 -1
  40. package/dist/cli/backend/src/services/memory/standing-answers.service.js +123 -4
  41. package/dist/cli/backend/src/services/memory/standing-answers.service.js.map +1 -1
  42. package/dist/cli/config/constants.d.ts +21 -0
  43. package/dist/cli/config/constants.d.ts.map +1 -1
  44. package/dist/cli/config/constants.js +21 -0
  45. package/dist/cli/config/constants.js.map +1 -1
  46. package/dist/cli/config/index.d.ts +5 -0
  47. package/dist/cli/config/index.d.ts.map +1 -1
  48. 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":"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"}
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 { page, removed } = upsertSection(existing, { heading: maskedHeading, body: maskedBody, cites: body ? cites : [] });
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: new Date().toISOString(),
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. */