@wirecat/cli-core 0.366.0 → 0.367.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/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.367.0 — 20.10.2026
11
+
12
+ ### Added
13
+
14
+ - **A document hit in project gathering carries the paragraphs around it.** `around` holds, in document order, up
15
+ to `around.before` and `around.after` paragraphs next to the first one naming a searched word, plus that one
16
+ marked `found`. The bytes count as `usage.aroundBytes`, as a message's neighbours do.
17
+
18
+ ### Fixed
19
+
20
+ - **An evidence findings review accepts the findings whose own evidence is unchanged.** Before, one changed text
21
+ anywhere in a batch refused accepting any of it, so the agent had to stage the whole batch again. Now only the
22
+ accepted findings' project, assignee and cited texts are rechecked; the rest can be rejected in the same review.
23
+ Batches keep one hash per checked record for this; a batch staged earlier is still checked as a whole.
24
+
25
+ - **Project gathering moves past large documents instead of stalling.** A page of evidence now holds the records
26
+ that fit its byte budget together, rather than failing when they do not. A record larger than the page is read
27
+ alone, up to 256 KiB. One larger still is listed in the new `oversized` result field by reference and skipped,
28
+ so a resume no longer retries the same page and charges its budget each time. The context reader's page gains
29
+ the same optional `oversized`. Meeting preparation reports such a record as `read-limited` and continues past it.
30
+
10
31
  ## 0.366.0 — 20.10.2026
11
32
 
12
33
  ### Added
@@ -34,6 +34,14 @@ export const validateContextEvidencePage = (page, request) => {
34
34
  fail();
35
35
  if (page.next)
36
36
  contextEvidenceRequest({ ...request, cursor: page.next });
37
+ const big = page.oversized;
38
+ if (big !== undefined &&
39
+ (!big ||
40
+ page.items.length ||
41
+ typeof big.ref !== "string" ||
42
+ !Number.isSafeInteger(big.bytes) ||
43
+ big.bytes <= maxReadBytes))
44
+ fail();
37
45
  let actual = 0;
38
46
  for (const item of page.items) {
39
47
  if (!item ||
@@ -1,3 +1,4 @@
1
+ export declare const CONTEXT_MAX_READ_BYTES = 262144;
1
2
  export declare const CONTEXT_SOURCE_KINDS: readonly ["message", "mail", "document", "meeting"];
2
3
  export interface ContextSource {
3
4
  kind: (typeof CONTEXT_SOURCE_KINDS)[number];
@@ -40,6 +41,11 @@ export interface ContextEvidencePage {
40
41
  scope: string;
41
42
  } | null;
42
43
  readBytes: number;
44
+ /** The next record alone is larger than maxReadBytes: it was not read, and next moves past it. */
45
+ oversized?: {
46
+ ref: string;
47
+ bytes: number;
48
+ };
43
49
  coverage: {
44
50
  available: boolean;
45
51
  index: "ready" | "pending" | "missing" | "unknown";
@@ -65,6 +71,11 @@ export interface ContextIndex {
65
71
  }
66
72
  /** Changes whenever the cited text, its title or its revision does; a staged finding citing an older one is stale. */
67
73
  export declare const contextEvidenceFingerprint: (item: Pick<ContextEvidenceItem, "ref" | "title" | "text" | "revision">) => string;
74
+ /** How many rows, in reading order, fit the allowance together; none when the first alone is larger. */
75
+ export declare const fittingRows: (sizes: readonly number[], limit: number, maxReadBytes: number) => {
76
+ count: number;
77
+ bytes: number;
78
+ };
68
79
  /** One stored message, email or document as the context reader returns it, by its reference. */
69
80
  export interface ContextRecordReader {
70
81
  read(ref: string): Promise<Pick<ContextEvidenceItem, "ref" | "title" | "text" | "revision"> | null>;
@@ -1,10 +1,22 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { CliError } from "@wirecat/cli-base";
3
+ export const CONTEXT_MAX_READ_BYTES = 262144;
3
4
  export const CONTEXT_SOURCE_KINDS = ["message", "mail", "document", "meeting"];
4
5
  /** Changes whenever the cited text, its title or its revision does; a staged finding citing an older one is stale. */
5
6
  export const contextEvidenceFingerprint = (item) => createHash("sha256")
6
7
  .update(JSON.stringify(["context-evidence-v1", item.ref, item.title, item.text, item.revision]))
7
8
  .digest("hex");
9
+ /** How many rows, in reading order, fit the allowance together; none when the first alone is larger. */
10
+ export const fittingRows = (sizes, limit, maxReadBytes) => {
11
+ let count = 0, bytes = 0;
12
+ for (const size of sizes.slice(0, limit)) {
13
+ if (!(bytes + size <= maxReadBytes))
14
+ break;
15
+ bytes += size;
16
+ count++;
17
+ }
18
+ return { count, bytes };
19
+ };
8
20
  export const contextCursorScope = (request) => createHash("sha256")
9
21
  .update(JSON.stringify([
10
22
  "context-evidence-v1",
@@ -56,7 +68,7 @@ export const contextEvidenceRequest = (input) => {
56
68
  if (!words.length || words.length > 16)
57
69
  throw new CliError("validation_error", "query needs 1–16 literal words");
58
70
  const limit = integer(input.limit ?? 10, "limit", 1, 20);
59
- const maxReadBytes = integer(input.maxReadBytes ?? 65536, "maxReadBytes", 1, 262144);
71
+ const maxReadBytes = integer(input.maxReadBytes ?? 65536, "maxReadBytes", 1, CONTEXT_MAX_READ_BYTES);
60
72
  if (input.cursor !== undefined && (!input.cursor || typeof input.cursor !== "object"))
61
73
  throw new CliError("validation_error", "cursor needs at, id and scope");
62
74
  if (input.cursor)
@@ -41,6 +41,10 @@ export const readPreparationCommunications = async (plan, signal) => {
41
41
  coverage.state = "unavailable";
42
42
  coverage.issue = "not_found";
43
43
  }
44
+ else if (page.oversized) {
45
+ coverage.state = "read-limited";
46
+ coverage.issue = "validation_error";
47
+ }
44
48
  else if (page.coverage.index !== "ready")
45
49
  coverage.issue = "configuration_error";
46
50
  const refs = new Set();
@@ -20,6 +20,14 @@ export interface ProjectNeighbourReader {
20
20
  }[]>;
21
21
  }
22
22
  export declare const storedMessageNeighbours: (messages: Pick<MessagesStore, "around">) => ProjectNeighbourReader;
23
+ /** The paragraphs around the first one that names the query, in document order; the hit's own is marked found. */
24
+ export declare const documentPassage: (text: string, query: string, window: {
25
+ before: number;
26
+ after: number;
27
+ }) => {
28
+ text: string;
29
+ found: boolean;
30
+ }[];
23
31
  /**
24
32
  * Neighbours are context for reading a hit, not matches: they never join `items`, and their text counts against
25
33
  * the run's read bytes.
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { formatLocator, parseLocator } from "../domain/locator.js";
3
+ import { fold } from "../store/normalize.js";
3
4
  import { excerpt } from "./excerpt.js";
4
5
  export const storedMessageNeighbours = (messages) => ({
5
6
  async around(ref, window) {
@@ -17,6 +18,23 @@ export const storedMessageNeighbours = (messages) => ({
17
18
  }));
18
19
  },
19
20
  });
21
+ const wordsOf = (text) => (text.match(/[\p{L}\p{N}\p{M}]+/gu) ?? []).map(fold);
22
+ /** The paragraphs around the first one that names the query, in document order; the hit's own is marked found. */
23
+ export const documentPassage = (text, query, window) => {
24
+ const paragraphs = text
25
+ .split(/\n\s*\n/)
26
+ .map((part) => part.replace(/\s+/g, " ").trim())
27
+ .filter(Boolean);
28
+ const words = new Set(wordsOf(query));
29
+ const hit = paragraphs.findIndex((part) => wordsOf(part).some((word) => words.has(word)));
30
+ if (hit < 0 || paragraphs.length < 2)
31
+ return [];
32
+ const from = Math.max(0, hit - window.before);
33
+ return paragraphs.slice(from, hit + window.after + 1).map((part, index) => ({
34
+ text: excerpt(part, query, 200, { literal: true }),
35
+ found: from + index === hit,
36
+ }));
37
+ };
20
38
  /**
21
39
  * Neighbours are context for reading a hit, not matches: they never join `items`, and their text counts against
22
40
  * the run's read bytes.
@@ -79,6 +79,12 @@ export declare const gatherProjectEvidence: (ports: ProjectGatheringPorts, reque
79
79
  issue: string | null;
80
80
  }[];
81
81
  items: ProjectEvidenceItem[];
82
+ oversized: {
83
+ ref: string;
84
+ lanes: string[];
85
+ source: number;
86
+ bytes: number;
87
+ }[];
82
88
  matchedAgain: {
83
89
  ref: string;
84
90
  lanes: string[];
@@ -1,8 +1,8 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { contextEvidenceFingerprint } from "../context/evidence.js";
2
+ import { CONTEXT_MAX_READ_BYTES, contextEvidenceFingerprint } from "../context/evidence.js";
3
3
  import { createContextEvidenceReader } from "./context-evidence.js";
4
4
  import { excerpt } from "./excerpt.js";
5
- import { attachNeighbours } from "./project-gathering-around.js";
5
+ import { attachNeighbours, documentPassage, } from "./project-gathering-around.js";
6
6
  import { decodeCheckpoint, encodeCheckpoint, projectGatheringRequest, projectUnits, seenKey, } from "./project-gathering-request.js";
7
7
  const terminal = new Set(["done", "unavailable", "failed"]);
8
8
  export const gatherProjectEvidence = async (ports, request) => {
@@ -72,6 +72,7 @@ export const gatherProjectEvidence = async (ports, request) => {
72
72
  const items = [];
73
73
  const byRef = new Map();
74
74
  const matchedAgain = new Map();
75
+ const oversized = new Map();
75
76
  let stopped = null;
76
77
  const open = (u) => !terminal.has(u.state) && u.state !== "read-limited";
77
78
  const take = (item, lane, kind, source) => {
@@ -101,6 +102,18 @@ export const gatherProjectEvidence = async (ports, request) => {
101
102
  lanes: [lane.id],
102
103
  context: lane.kinds.every((k) => k === "person") ? "person-only" : "project",
103
104
  };
105
+ if (kind === "document" && plan.around.before + plan.around.after) {
106
+ entry.around = [];
107
+ for (const part of documentPassage(item.text, lane.query, plan.around)) {
108
+ const bytes = Buffer.byteLength(part.text);
109
+ if (bytes > plan.budgets.maxReadBytes - usage.readBytes - usage.aroundBytes) {
110
+ entry.aroundIssue = "read-bytes";
111
+ break;
112
+ }
113
+ usage.aroundBytes += bytes;
114
+ entry.around.push({ ref: item.ref, at: item.at, sender: null, ...part });
115
+ }
116
+ }
104
117
  byRef.set(item.ref, entry);
105
118
  items.push(entry);
106
119
  usage.items++;
@@ -126,24 +139,49 @@ export const gatherProjectEvidence = async (ports, request) => {
126
139
  : null;
127
140
  if (stopped)
128
141
  break;
129
- const allowance = Math.min(plan.budgets.maxPageBytes, plan.budgets.maxReadBytes - usage.readBytes);
130
- const reader = createContextEvidenceReader(ports.evidence, [unit.source], {
131
- maxReads: 1,
132
- maxTotalReadBytes: allowance,
133
- });
134
- usage.reads++;
135
- try {
136
- const page = await reader.read({
137
- source: unit.source,
138
- query: unit.lane.query,
139
- after: unit.chunk.after,
140
- before: unit.chunk.before,
141
- limit: 20,
142
- maxReadBytes: allowance,
143
- ...(state.cursor ? { cursor: state.cursor } : {}),
144
- signal,
142
+ const readPage = async (allowance, limit) => {
143
+ const reader = createContextEvidenceReader(ports.evidence, [unit.source], {
144
+ maxReads: 1,
145
+ maxTotalReadBytes: allowance,
145
146
  });
146
- usage.readBytes += page.readBytes;
147
+ usage.reads++;
148
+ try {
149
+ const page = await reader.read({
150
+ source: unit.source,
151
+ query: unit.lane.query,
152
+ after: unit.chunk.after,
153
+ before: unit.chunk.before,
154
+ limit,
155
+ maxReadBytes: allowance,
156
+ ...(state.cursor ? { cursor: state.cursor } : {}),
157
+ signal,
158
+ });
159
+ usage.readBytes += page.readBytes;
160
+ return page;
161
+ }
162
+ catch (error) {
163
+ usage.readBytes += reader.usage().chargedBytes;
164
+ throw error;
165
+ }
166
+ };
167
+ try {
168
+ let page = await readPage(Math.min(plan.budgets.maxPageBytes, plan.budgets.maxReadBytes - usage.readBytes), 20);
169
+ const big = page.oversized;
170
+ if (big && big.bytes <= CONTEXT_MAX_READ_BYTES) {
171
+ if (big.bytes > plan.budgets.maxReadBytes - usage.readBytes || usage.reads >= plan.budgets.maxReads) {
172
+ state.state = "read-limited";
173
+ state.code = "validation_error";
174
+ continue;
175
+ }
176
+ page = await readPage(big.bytes, 1);
177
+ }
178
+ else if (big) {
179
+ const known = oversized.get(big.ref);
180
+ if (!known)
181
+ oversized.set(big.ref, { ref: big.ref, lanes: [unit.lane.id], source: unit.sourceIndex, bytes: big.bytes });
182
+ else if (!known.lanes.includes(unit.lane.id))
183
+ known.lanes.push(unit.lane.id);
184
+ }
147
185
  state.pages++;
148
186
  state.code = page.coverage.index === "ready" ? null : `index-${page.coverage.index}`;
149
187
  for (const item of page.items)
@@ -161,7 +199,6 @@ export const gatherProjectEvidence = async (ports, request) => {
161
199
  }
162
200
  catch (error) {
163
201
  signal?.throwIfAborted();
164
- usage.readBytes += reader.usage().chargedBytes;
165
202
  if (!(error instanceof CliError))
166
203
  throw error;
167
204
  if (error.code === "validation_error" && error.details?.reason === "context.read-budget")
@@ -225,6 +262,7 @@ export const gatherProjectEvidence = async (ports, request) => {
225
262
  issue: progress[index]?.code ?? null,
226
263
  })),
227
264
  items,
265
+ oversized: [...oversized.values()],
228
266
  matchedAgain: [...matchedAgain].map(([ref, lanes]) => ({ ref, lanes: [...lanes] })),
229
267
  usage: { ...usage, elapsedMs: now() - started },
230
268
  totals,
@@ -1,5 +1,4 @@
1
- import { CliError } from "@wirecat/cli-base";
2
- import { contextCursorScope, contextEvidenceRequest, } from "../../context/evidence.js";
1
+ import { contextCursorScope, contextEvidenceRequest, fittingRows, } from "../../context/evidence.js";
3
2
  import { formatLocator, parseLocator } from "../../domain/locator.js";
4
3
  import { NORMALIZER_VERSION, normalize } from "../normalize.js";
5
4
  import { contextDocumentTime, contextDocumentTimeKind, contextDocumentUrl } from "./context-document-source.js";
@@ -122,38 +121,48 @@ export const contextEvidenceOver = ({ database }) => ({
122
121
  if (cursor)
123
122
  params.push(cursor.at, cursor.at, cursor.id);
124
123
  params.push(limit + 1);
125
- const probe = database
126
- .prepare(`SELECT coalesce(sum(
124
+ const sized = database
125
+ .prepare(`SELECT id,at,provider,account,chat,external_id,
127
126
  length(CAST(coalesce(title,'') AS BLOB))+length(CAST(coalesce(body,'') AS BLOB))+
128
127
  length(CAST(coalesce(url,'') AS BLOB))+length(CAST(provider AS BLOB))+
129
128
  length(CAST(account AS BLOB))+length(CAST(external_id AS BLOB))+
130
- length(CAST(coalesce(chat,'') AS BLOB))+length(CAST(time_kind AS BLOB))),0) AS bytes FROM (${selection})`)
131
- .get(...params);
132
- const readBytes = Number(probe?.bytes);
133
- if (!Number.isSafeInteger(readBytes) || readBytes < 0 || readBytes > maxReadBytes)
134
- throw new CliError("validation_error", "context evidence exceeds maxReadBytes before hydration", {
135
- reason: "context.read-budget",
129
+ length(CAST(coalesce(chat,'') AS BLOB))+length(CAST(time_kind AS BLOB)) AS bytes FROM (${selection})`)
130
+ .all(...params);
131
+ const refOf = (row) => source.kind === "document"
132
+ ? `document:${row.id}`
133
+ : formatLocator({
134
+ provider: String(row.provider),
135
+ account: String(row.account),
136
+ chat: String(row.chat),
137
+ message: String(row.external_id),
136
138
  });
139
+ const past = (row) => ({ at: Number(row.at), id: Number(row.id), scope });
140
+ const fit = fittingRows(sized.map((row) => Number(row.bytes)), limit, maxReadBytes);
141
+ const first = sized[0];
142
+ if (!fit.count && first) {
143
+ coverage.hasMore = sized.length > 1;
144
+ return {
145
+ source,
146
+ items: [],
147
+ readBytes: 0,
148
+ coverage,
149
+ next: coverage.hasMore ? past(first) : null,
150
+ oversized: { ref: refOf(first), bytes: Number(first.bytes) },
151
+ };
152
+ }
137
153
  signal?.throwIfAborted();
138
- const rows = database.prepare(selection).all(...params);
154
+ params[params.length - 1] = fit.count;
155
+ const selected = fit.count ? database.prepare(selection).all(...params) : [];
139
156
  signal?.throwIfAborted();
140
- coverage.hasMore = rows.length > limit;
141
- const selected = rows.slice(0, limit);
157
+ coverage.hasMore = sized.length > selected.length;
142
158
  const last = selected.at(-1);
143
159
  return {
144
160
  source,
145
- readBytes,
161
+ readBytes: fit.bytes,
146
162
  coverage,
147
- next: coverage.hasMore && last ? { at: Number(last.at), id: Number(last.id), scope } : null,
163
+ next: coverage.hasMore && last ? past(last) : null,
148
164
  items: selected.map((row) => ({
149
- ref: source.kind === "document"
150
- ? `document:${row.id}`
151
- : formatLocator({
152
- provider: String(row.provider),
153
- account: String(row.account),
154
- chat: String(row.chat),
155
- message: String(row.external_id),
156
- }),
165
+ ref: refOf(row),
157
166
  provider: String(row.provider),
158
167
  account: String(row.account),
159
168
  title: row.title == null ? null : String(row.title),
@@ -1,5 +1,4 @@
1
- import { CliError } from "@wirecat/cli-base";
2
- import { contextCursorScope } from "../../context/evidence.js";
1
+ import { contextCursorScope, fittingRows, } from "../../context/evidence.js";
3
2
  import { normalize } from "../normalize.js";
4
3
  import { read } from "./transaction.js";
5
4
  /** Transcript cues keep their retained revision reference; a meeting title alone is not a transcript match. */
@@ -57,29 +56,40 @@ export const readMeetingContextEvidence = (database, request) => {
57
56
  if (cursor)
58
57
  params.push(cursor.at, cursor.at, cursor.id);
59
58
  params.push(limit + 1);
60
- const probe = database
61
- .prepare(`SELECT coalesce(sum(length(CAST(coalesce(title,'') AS BLOB))+
59
+ const sized = database
60
+ .prepare(`SELECT id,at,ref,length(CAST(coalesce(title,'') AS BLOB))+
62
61
  length(CAST(text AS BLOB))+length(CAST(provider AS BLOB))+
63
- length(CAST(account AS BLOB))+length(CAST(ref AS BLOB))+length('meeting-start')),0) AS bytes FROM (${selection})`)
64
- .get(...params);
65
- const readBytes = Number(probe?.bytes);
66
- if (!Number.isSafeInteger(readBytes) || readBytes < 0 || readBytes > maxReadBytes)
67
- throw new CliError("validation_error", "meeting context evidence exceeds maxReadBytes before hydration", {
68
- reason: "context.read-budget",
69
- });
62
+ length(CAST(account AS BLOB))+length(CAST(ref AS BLOB))+length('meeting-start') AS bytes FROM (${selection})`)
63
+ .all(...params);
64
+ const past = (row) => ({
65
+ at: Number(row.at),
66
+ id: Number(row.id),
67
+ scope: contextCursorScope(request),
68
+ });
69
+ const fit = fittingRows(sized.map((row) => Number(row.bytes)), limit, maxReadBytes);
70
+ const first = sized[0];
71
+ if (!fit.count && first) {
72
+ coverage.hasMore = sized.length > 1;
73
+ return {
74
+ source,
75
+ items: [],
76
+ readBytes: 0,
77
+ coverage,
78
+ next: coverage.hasMore ? past(first) : null,
79
+ oversized: { ref: String(first.ref), bytes: Number(first.bytes) },
80
+ };
81
+ }
70
82
  signal?.throwIfAborted();
71
- const rows = database.prepare(selection).all(...params);
83
+ params[params.length - 1] = fit.count;
84
+ const selected = fit.count ? database.prepare(selection).all(...params) : [];
72
85
  signal?.throwIfAborted();
73
- coverage.hasMore = rows.length > limit;
74
- const selected = rows.slice(0, limit);
86
+ coverage.hasMore = sized.length > selected.length;
75
87
  const last = selected.at(-1);
76
88
  return {
77
89
  source,
78
- readBytes,
90
+ readBytes: fit.bytes,
79
91
  coverage,
80
- next: coverage.hasMore && last
81
- ? { at: Number(last.at), id: Number(last.id), scope: contextCursorScope(request) }
82
- : null,
92
+ next: coverage.hasMore && last ? past(last) : null,
83
93
  items: selected.map((row) => ({
84
94
  ref: String(row.ref),
85
95
  provider: String(row.provider),
@@ -54,9 +54,9 @@ export const evidenceFindingsStoreOver = (context) => {
54
54
  receipt: proposal.result,
55
55
  };
56
56
  };
57
- const snapshot = (input) => {
57
+ const cite = () => {
58
58
  let readBytes = 0;
59
- return core.snapshot(input, (evidence, _finding, _project, seen) => {
59
+ return (evidence, _finding, _project, seen) => {
60
60
  if (seen.has(evidence.reference)) {
61
61
  if (seen.get(evidence.reference) !== evidence.fingerprint)
62
62
  return stale();
@@ -72,8 +72,9 @@ export const evidenceFindingsStoreOver = (context) => {
72
72
  if (fingerprint !== evidence.fingerprint)
73
73
  return stale();
74
74
  seen.set(evidence.reference, fingerprint);
75
- });
75
+ };
76
76
  };
77
+ const snapshot = (input) => core.snapshot(input, cite());
77
78
  const decider = (by) => {
78
79
  if (by === undefined || by === "owner")
79
80
  return { author: "owner", decision: { by: "owner" } };
@@ -109,6 +110,7 @@ export const evidenceFindingsStoreOver = (context) => {
109
110
  return view(proposal);
110
111
  }
111
112
  const snapshotHash = snapshot(input);
113
+ const snapshotParts = core.snapshotParts(input, cite());
112
114
  const fingerprints = input.findings.map(findingFingerprint);
113
115
  if (new Set(fingerprints).size !== fingerprints.length)
114
116
  return invalid("the batch contains duplicate findings");
@@ -116,7 +118,7 @@ export const evidenceFindingsStoreOver = (context) => {
116
118
  kind: EVIDENCE_FINDINGS_KIND,
117
119
  by,
118
120
  target: [...projects][0],
119
- payload: { ...input, inputHash, snapshotHash, agentModel: by.model ?? null },
121
+ payload: { ...input, inputHash, snapshotHash, snapshotParts, agentModel: by.model ?? null },
120
122
  }));
121
123
  });
122
124
  },
@@ -152,9 +154,21 @@ export const evidenceFindingsStoreOver = (context) => {
152
154
  return batch;
153
155
  }
154
156
  const input = proposal.payload;
155
- // Rejecting creates nothing, so a batch whose evidence changed can still be closed.
156
- if (accepted.length && snapshot(batch.input) !== input.snapshotHash)
157
- return stale();
157
+ // Rejecting creates nothing, so a batch whose evidence changed can still be closed. Accepting needs only the
158
+ // accepted findings' own project, assignee and evidence unchanged; a batch staged before parts were kept needs all.
159
+ if (accepted.length) {
160
+ const parts = input.snapshotParts;
161
+ if (!parts) {
162
+ if (snapshot(batch.input) !== input.snapshotHash)
163
+ return stale();
164
+ }
165
+ else {
166
+ const chosen = { ...batch.input, findings: batch.input.findings.filter((f) => accepted.includes(f.id)) };
167
+ const now = core.snapshotParts(chosen, cite());
168
+ if (Object.entries(now).some(([key, hash]) => parts[key] !== hash))
169
+ return stale();
170
+ }
171
+ }
158
172
  if (proposal.proposedBy.type !== "bot" || !proposal.proposedBy.name)
159
173
  return invalid("a findings author must be an agent");
160
174
  const agent = {
@@ -12,6 +12,7 @@ export declare const findingsAgent: (agent: FindingsAgent) => FindingsAgent;
12
12
  */
13
13
  export declare const findingsCoreOver: (context: StoreContext, kind: string) => {
14
14
  snapshot: (input: FindingsInput, cite: (evidence: FindingEvidence, finding: Finding, projectId: string, seen: Map<string, unknown>) => void) => string;
15
+ snapshotParts: (input: FindingsInput, cite: (evidence: FindingEvidence, finding: Finding, projectId: string, seen: Map<string, unknown>) => void) => Record<string, string>;
15
16
  previous: (account: number | null, fingerprint: string) => FindingOutcome | undefined;
16
17
  outcomes: (account: number | null, input: FindingsInput, accepted: string[], agent: FindingsAgent) => FindingOutcome[];
17
18
  };
@@ -27,7 +27,7 @@ export const findingsCoreOver = (context, kind) => {
27
27
  const { database } = context;
28
28
  const facts = factOperationsOver(context);
29
29
  const tasks = workTaskOperationsOver(context);
30
- const snapshot = (input, cite) => {
30
+ const cited = (input, cite) => {
31
31
  const seen = new Map();
32
32
  for (const finding of input.findings) {
33
33
  const project = requiredThing(database, finding.project, "the project");
@@ -51,8 +51,11 @@ export const findingsCoreOver = (context, kind) => {
51
51
  for (const evidence of finding.evidence)
52
52
  cite(evidence, finding, String(project.id), seen);
53
53
  }
54
- return findingHash([...seen].sort(([a], [b]) => a.localeCompare(b)));
54
+ return seen;
55
55
  };
56
+ const snapshot = (...args) => findingHash([...cited(...args)].sort(([a], [b]) => a.localeCompare(b)));
57
+ /** The same snapshot, one hash per project, person and cited reference, so part of a batch can be checked alone. */
58
+ const snapshotParts = (...args) => Object.fromEntries([...cited(...args)].map(([key, value]) => [key, findingHash(value)]));
56
59
  const previous = (account, fingerprint) => {
57
60
  const row = database
58
61
  .prepare("SELECT substr(j.value,1,4096) AS item FROM proposed_actions p,json_each(p.result,'$.items') j WHERE p.account_id IS ? AND p.kind=? AND p.status='approved' AND json_extract(j.value,'$.fingerprint')=? ORDER BY p.id LIMIT 1")
@@ -104,5 +107,5 @@ export const findingsCoreOver = (context, kind) => {
104
107
  reused: false,
105
108
  };
106
109
  });
107
- return { snapshot, previous, outcomes };
110
+ return { snapshot, snapshotParts, previous, outcomes };
108
111
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.366.0",
3
+ "version": "0.367.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",