pm-context 2026.10.21 → 2026.10.23
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 +24 -0
- package/README.md +41 -1
- package/dist/file-focus.d.ts +1 -1
- package/dist/file-focus.d.ts.map +1 -1
- package/dist/file-focus.js +6 -2
- package/dist/file-focus.js.map +1 -1
- package/dist/freshness.d.ts +82 -0
- package/dist/freshness.d.ts.map +1 -0
- package/dist/freshness.js +299 -0
- package/dist/freshness.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +67 -10
- package/dist/index.js.map +1 -1
- package/dist/since.d.ts +105 -0
- package/dist/since.d.ts.map +1 -0
- package/dist/since.js +593 -0
- package/dist/since.js.map +1 -0
- package/manifest.json +1 -1
- package/package.json +1 -1
package/dist/since.js
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
/** Resumable delta serving; SDK history remains the only durable event authority. */
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { gzipSync, gunzipSync } from "node:zlib";
|
|
6
|
+
import { PmCliError, acquireLock, applyHistoryPatch, readHistoryEntries, resolveWorkspaceRelationshipKindRegistry } from "@unbrained/pm-cli/sdk";
|
|
7
|
+
import { runActivity } from "@unbrained/pm-cli/sdk/query";
|
|
8
|
+
/** Runtime snapshots are disposable, bounded, and never stored in item history. */
|
|
9
|
+
export const SINCE_RETENTION = { count: 200, bytes: 4_194_304, directory: "runtime/context-since" };
|
|
10
|
+
/** Explicit recovery contracts for expired history, bad input, and impossible budgets. */
|
|
11
|
+
export class SinceError extends PmCliError {
|
|
12
|
+
/** Action that restores a usable starting point. */
|
|
13
|
+
remedy;
|
|
14
|
+
constructor(code, remedy) {
|
|
15
|
+
super(`${code}: ${remedy}`, 2, { code, nextSteps: [remedy] });
|
|
16
|
+
this.remedy = remedy;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Parse explicit-zone ISO timestamps without normalizing invalid calendar dates. */
|
|
20
|
+
function parseFromTimestamp(value) {
|
|
21
|
+
const match = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.exec(value);
|
|
22
|
+
if (!match)
|
|
23
|
+
return NaN;
|
|
24
|
+
// setUTCFullYear preserves years 0000–0099, unlike Date.UTC's 1900 offset.
|
|
25
|
+
const monthEnd = new Date(0);
|
|
26
|
+
monthEnd.setUTCFullYear(Number(match[1]), Number(match[2]), 0);
|
|
27
|
+
if (Number(match[3]) > monthEnd.getUTCDate())
|
|
28
|
+
return NaN;
|
|
29
|
+
return Date.parse(value);
|
|
30
|
+
}
|
|
31
|
+
/** Hash JSON content rather than timestamps, so equal-time and late events survive. */
|
|
32
|
+
function digest(value) {
|
|
33
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
/** Keep object traversal typed when histories contain arbitrary custom metadata. */
|
|
36
|
+
function record(value) {
|
|
37
|
+
return value !== null && typeof value === "object" ? value : {};
|
|
38
|
+
}
|
|
39
|
+
/** Extract bounded prose while disclosing every character removed from excerpts. */
|
|
40
|
+
function excerpt(value, max) {
|
|
41
|
+
const points = [...value];
|
|
42
|
+
return { text: points.slice(0, max).join(""), omitted: Math.max(0, points.length - max) };
|
|
43
|
+
}
|
|
44
|
+
/** Use code-unit ordering, independent of process locale. */
|
|
45
|
+
function compare(a, b) {
|
|
46
|
+
return Number(a > b) - Number(a < b);
|
|
47
|
+
}
|
|
48
|
+
/** Apply SDK history policy/read hooks, then retain physical stream order for equal/backdated timestamps. */
|
|
49
|
+
async function readStreams(pmRoot) {
|
|
50
|
+
const streams = [];
|
|
51
|
+
await runActivity({ raw: true, compact: true, limit: "1" }, { path: pmRoot });
|
|
52
|
+
for (const file of readdirSync(join(pmRoot, "history")).filter((name) => name.endsWith(".jsonl")).sort()) {
|
|
53
|
+
const id = file.slice(0, -6);
|
|
54
|
+
const entries = await readHistoryEntries(join(pmRoot, "history", file), id);
|
|
55
|
+
streams.push(entries.map((entry, ordinal) => ({ ...entry, id, ordinal, key: digest([id, ordinal, entry]), time: parseFromTimestamp(entry.ts) })));
|
|
56
|
+
}
|
|
57
|
+
return streams;
|
|
58
|
+
}
|
|
59
|
+
/** Merge indexed frontiers without sorting or moving the remaining stream entries. */
|
|
60
|
+
function mergeEvents(streams) {
|
|
61
|
+
const indices = streams.map(() => 0);
|
|
62
|
+
const events = [];
|
|
63
|
+
for (;;) {
|
|
64
|
+
let next;
|
|
65
|
+
let selected = -1;
|
|
66
|
+
for (const [index, stream] of streams.entries()) {
|
|
67
|
+
const candidate = stream[indices[index]];
|
|
68
|
+
if (candidate && (!next || ((Number.isNaN(candidate.time) ? -Infinity : candidate.time) - (Number.isNaN(next.time) ? -Infinity : next.time) || compare(candidate.id, next.id)) < 0)) {
|
|
69
|
+
next = candidate;
|
|
70
|
+
selected = index;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!next)
|
|
74
|
+
return events;
|
|
75
|
+
events.push(next);
|
|
76
|
+
indices[selected]++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Hash physical event order, including ordinals, to reject prefix rewrites. */
|
|
80
|
+
function fingerprint(streams) {
|
|
81
|
+
return streams.filter((stream) => stream.length > 0).map((stream) => ({
|
|
82
|
+
id: stream[0].id, count: stream.length, hash: digest(stream.map((event) => event.key)),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
/** Check retained prefixes without merging; later ordinals remain eligible even if backdated. */
|
|
86
|
+
function validatePrefixes(streams, previous) {
|
|
87
|
+
const compacted = [];
|
|
88
|
+
const current = new Map(streams.filter((stream) => stream.length > 0).map((stream) => [stream[0].id, stream]));
|
|
89
|
+
for (const prefix of previous) {
|
|
90
|
+
const stream = current.get(prefix.id);
|
|
91
|
+
if (!stream || stream.length < prefix.count || digest(stream.slice(0, prefix.count).map((event) => event.key)) !== prefix.hash) {
|
|
92
|
+
if (stream?.[0]?.op === "history_compact_baseline") {
|
|
93
|
+
compacted.push(prefix.id);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
throw new SinceError("HISTORY_EXPIRED", "Retained history changed or was pruned. Reorient with pm context --for handoff, then restart with --from <ISO timestamp>.");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return compacted;
|
|
100
|
+
}
|
|
101
|
+
/** Derive only one document's blocking contributions using current endpoint states. */
|
|
102
|
+
function blockerEdges(id, item, documents, terminal, registry) {
|
|
103
|
+
const result = new Map();
|
|
104
|
+
if (terminal.has(String(item.status)))
|
|
105
|
+
return result;
|
|
106
|
+
const legacy = item.blocked_by;
|
|
107
|
+
const legacyActive = typeof legacy === "string" && legacy.trim() && !terminal.has(String(documents.get(legacy.trim())?.status));
|
|
108
|
+
if (item.status === "blocked" || item.blocked_reason || legacyActive)
|
|
109
|
+
result.set(`${id}:status`, id);
|
|
110
|
+
for (const value of Array.isArray(item.dependencies) ? item.dependencies : []) {
|
|
111
|
+
const dep = record(value);
|
|
112
|
+
const target = String(dep.id);
|
|
113
|
+
const definition = registry.resolve(dep.kind);
|
|
114
|
+
if (definition?.ordering) {
|
|
115
|
+
const [waiting, prerequisite] = definition.precedence === "target_before_source" ? [id, target] : [target, id];
|
|
116
|
+
if (!terminal.has(String(documents.get(prerequisite)?.status)) && !terminal.has(String(documents.get(waiting)?.status)))
|
|
117
|
+
result.set(`${waiting}:${prerequisite}`, waiting);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
/** Reverse adjacency bounds graph updates to changed documents and their referrers. */
|
|
123
|
+
class BlockerGraph {
|
|
124
|
+
active = new Map();
|
|
125
|
+
waiting = new Map();
|
|
126
|
+
reverse = new Map();
|
|
127
|
+
owners = new Map();
|
|
128
|
+
contributions = new Map();
|
|
129
|
+
references = new Map();
|
|
130
|
+
documents;
|
|
131
|
+
terminal;
|
|
132
|
+
registry;
|
|
133
|
+
/** Index initial metadata once; streamed documents are populated by causal replay. */
|
|
134
|
+
constructor(documents, terminal, registry) {
|
|
135
|
+
this.documents = documents;
|
|
136
|
+
this.terminal = terminal;
|
|
137
|
+
this.registry = registry;
|
|
138
|
+
for (const id of documents.keys())
|
|
139
|
+
this.update(id, true);
|
|
140
|
+
}
|
|
141
|
+
/** Refresh affected owners and return net changes, deduplicating shared edges. */
|
|
142
|
+
update(id, initial = false) {
|
|
143
|
+
const affected = new Set(initial ? [id] : [id, ...(this.reverse.get(id) ?? [])]);
|
|
144
|
+
const before = new Map();
|
|
145
|
+
for (const owner of affected) {
|
|
146
|
+
const item = this.documents.get(owner);
|
|
147
|
+
const old = this.contributions.get(owner) ?? new Map();
|
|
148
|
+
const next = blockerEdges(owner, item, this.documents, this.terminal, this.registry);
|
|
149
|
+
for (const key of new Set([...old.keys(), ...next.keys()]))
|
|
150
|
+
if (!before.has(key))
|
|
151
|
+
before.set(key, this.active.get(key));
|
|
152
|
+
for (const ref of this.references.get(owner) ?? [])
|
|
153
|
+
this.reverse.get(ref).delete(owner);
|
|
154
|
+
const refs = new Set((Array.isArray(item.dependencies) ? item.dependencies : []).map((value) => String(record(value).id)));
|
|
155
|
+
if (typeof item.blocked_by === "string" && item.blocked_by.trim())
|
|
156
|
+
refs.add(item.blocked_by.trim());
|
|
157
|
+
this.references.set(owner, refs);
|
|
158
|
+
for (const ref of refs) {
|
|
159
|
+
if (!this.reverse.has(ref))
|
|
160
|
+
this.reverse.set(ref, new Set());
|
|
161
|
+
this.reverse.get(ref).add(owner);
|
|
162
|
+
}
|
|
163
|
+
for (const [key, waiting] of old) {
|
|
164
|
+
const owners = this.owners.get(key);
|
|
165
|
+
owners.delete(owner);
|
|
166
|
+
if (!owners.size) {
|
|
167
|
+
this.owners.delete(key);
|
|
168
|
+
this.active.delete(key);
|
|
169
|
+
this.waiting.get(waiting).delete(key);
|
|
170
|
+
if (!this.waiting.get(waiting).size)
|
|
171
|
+
this.waiting.delete(waiting);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const [key, waiting] of next) {
|
|
175
|
+
if (!this.owners.has(key))
|
|
176
|
+
this.owners.set(key, new Set());
|
|
177
|
+
this.owners.get(key).add(owner);
|
|
178
|
+
this.active.set(key, waiting);
|
|
179
|
+
if (!this.waiting.has(waiting))
|
|
180
|
+
this.waiting.set(waiting, new Set());
|
|
181
|
+
this.waiting.get(waiting).add(key);
|
|
182
|
+
}
|
|
183
|
+
this.contributions.set(owner, next);
|
|
184
|
+
}
|
|
185
|
+
const added = new Map();
|
|
186
|
+
const removed = new Map();
|
|
187
|
+
for (const [key, waiting] of before) {
|
|
188
|
+
if (waiting === undefined)
|
|
189
|
+
added.set(key, this.active.get(key));
|
|
190
|
+
if (waiting !== undefined && !this.active.has(key))
|
|
191
|
+
removed.set(key, waiting);
|
|
192
|
+
}
|
|
193
|
+
return { added, removed };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** New comment/note prose only; metadata-wide replacements do not repeat older text. */
|
|
197
|
+
function addedProse(before, after) {
|
|
198
|
+
const text = [];
|
|
199
|
+
for (const field of ["comments", "notes", "learnings"]) {
|
|
200
|
+
const remaining = new Map();
|
|
201
|
+
for (const value of Array.isArray(before[field]) ? before[field] : []) {
|
|
202
|
+
const key = JSON.stringify(value);
|
|
203
|
+
remaining.set(key, (remaining.get(key) ?? 0) + 1);
|
|
204
|
+
}
|
|
205
|
+
for (const value of Array.isArray(after[field]) ? after[field] : []) {
|
|
206
|
+
const key = JSON.stringify(value);
|
|
207
|
+
const count = remaining.get(key) ?? 0;
|
|
208
|
+
if (count > 0) {
|
|
209
|
+
remaining.set(key, count - 1);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const prose = record(value).text;
|
|
213
|
+
if (typeof prose === "string")
|
|
214
|
+
text.push(prose);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return text;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Replay with the SDK patcher to distinguish actual transitions and indirect
|
|
221
|
+
* unblocks, then retain an event row even for edits outside the named categories.
|
|
222
|
+
*/
|
|
223
|
+
function classify(events, selected, terminal, items) {
|
|
224
|
+
const streamed = new Set(events.map((event) => event.id));
|
|
225
|
+
const documents = new Map(items.filter((item) => !streamed.has(item.id)).map((item) => [item.id, { ...item }]));
|
|
226
|
+
const bodies = new Map();
|
|
227
|
+
const rows = [];
|
|
228
|
+
const registry = resolveWorkspaceRelationshipKindRegistry();
|
|
229
|
+
const graph = new BlockerGraph(documents, terminal, registry);
|
|
230
|
+
for (const event of events) {
|
|
231
|
+
const before = documents.get(event.id) ?? {};
|
|
232
|
+
const document = applyHistoryPatch({ metadata: before, body: bodies.get(event.id) ?? "" }, event.patch, event.ordinal + 1, event.op);
|
|
233
|
+
const after = document.metadata;
|
|
234
|
+
documents.set(event.id, after);
|
|
235
|
+
bodies.set(event.id, document.body);
|
|
236
|
+
// Ancestor replacements can change every graph field, including imported baselines.
|
|
237
|
+
const touchesGraph = event.op === "create" || event.op === "delete"
|
|
238
|
+
|| event.patch.some((patch) => /^(?:|\/metadata(?:\/(?:status|dependencies|blocked_by|blocked_reason)(?:\/.*)?)?)$/.test(patch.path));
|
|
239
|
+
const delta = touchesGraph ? graph.update(event.id) : { added: new Map(), removed: new Map() };
|
|
240
|
+
if (selected.has(event.key)) {
|
|
241
|
+
const kinds = [];
|
|
242
|
+
if (event.op === "create")
|
|
243
|
+
kinds.push("created");
|
|
244
|
+
if (terminal.has(String(after.status)) && !terminal.has(String(before.status)))
|
|
245
|
+
kinds.push("closed");
|
|
246
|
+
if (terminal.has(String(before.status)) && typeof after.status === "string" && !terminal.has(after.status))
|
|
247
|
+
kinds.push("reopened");
|
|
248
|
+
if (event.op !== "create" && after.priority !== undefined && before.priority !== after.priority)
|
|
249
|
+
kinds.push("reprioritized");
|
|
250
|
+
const prose = addedProse(before, after);
|
|
251
|
+
if (prose.length)
|
|
252
|
+
kinds.push("comment");
|
|
253
|
+
if (event.op === "release")
|
|
254
|
+
kinds.push("release", "handoff");
|
|
255
|
+
if (event.op === "claim")
|
|
256
|
+
kinds.push("claim");
|
|
257
|
+
const text = [...prose, event.message ?? ""].filter(Boolean).join("\n");
|
|
258
|
+
if (/(?:^|\n)\s*(?:handoff|hand[ -]over)\s*:/i.test(text))
|
|
259
|
+
kinds.push("handoff");
|
|
260
|
+
const clipped = excerpt(text, 384);
|
|
261
|
+
const title = excerpt(String(after.title ?? before.title ?? event.id), 128);
|
|
262
|
+
const row = {
|
|
263
|
+
key: event.key, id: event.id, op: event.op, ts: event.ts, author: event.author,
|
|
264
|
+
kinds: [...new Set(kinds)], title: title.text, type: String(after.type ?? before.type ?? "unknown"), status: String(after.status ?? before.status ?? "unknown"), priority: Number(after.priority ?? 4),
|
|
265
|
+
fields: [...new Set(event.patch.map((patch) => patch.path))], text: clipped.text,
|
|
266
|
+
excerpt_omitted_chars: clipped.omitted + title.omitted,
|
|
267
|
+
};
|
|
268
|
+
// Include scalar transitions as prose; full patches remain available in history.
|
|
269
|
+
const transitions = ["status", "priority", "assignee", "blocked_reason", "blocked_by", "release"].filter((field) => before[field] !== after[field]);
|
|
270
|
+
if (transitions.length) {
|
|
271
|
+
const values = excerpt(transitions.map((field) => `${field}: ${String(before[field])} -> ${String(after[field])}`).join("; "), 256);
|
|
272
|
+
row.text = [row.text, values.text].filter(Boolean).join("\n");
|
|
273
|
+
row.excerpt_omitted_chars += values.omitted;
|
|
274
|
+
}
|
|
275
|
+
for (const [key, id] of delta.added) {
|
|
276
|
+
{
|
|
277
|
+
const title = excerpt(String(documents.get(id)?.title ?? id), 128);
|
|
278
|
+
rows.push({ ...row, key: `${row.key}:blocked:${key}`, id, op: "blocked", kinds: ["blocked"], title: title.text, type: String(documents.get(id)?.type ?? "unknown"), status: String(documents.get(id)?.status ?? "unknown"), text: key, priority: Number(documents.get(id)?.priority ?? 4), excerpt_omitted_chars: title.omitted });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const unblocked = new Set();
|
|
282
|
+
for (const [, id] of delta.removed) {
|
|
283
|
+
if (unblocked.has(id) || graph.waiting.has(id))
|
|
284
|
+
continue;
|
|
285
|
+
const status = documents.get(id)?.status;
|
|
286
|
+
if (typeof status !== "string" || terminal.has(status))
|
|
287
|
+
continue;
|
|
288
|
+
unblocked.add(id);
|
|
289
|
+
const title = excerpt(String(documents.get(id)?.title ?? id), 128);
|
|
290
|
+
const removed = excerpt([...delta.removed].filter(([, waiting]) => waiting === id).map(([edge]) => edge).join(", "), 384);
|
|
291
|
+
rows.push({ ...row, key: `${row.key}:unblocked:${id}`, id, op: "unblocked", kinds: ["unblocked"], title: title.text, type: String(documents.get(id)?.type ?? "unknown"), status, text: removed.text, priority: Number(documents.get(id)?.priority ?? 4), excerpt_omitted_chars: title.omitted + removed.omitted });
|
|
292
|
+
}
|
|
293
|
+
rows.push(row);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const blocked = graph.active;
|
|
297
|
+
const waitingAtEnd = new Set(graph.waiting.keys());
|
|
298
|
+
for (const row of rows) {
|
|
299
|
+
if (row.kinds[0] === "blocked")
|
|
300
|
+
row.superseded = !blocked.has(row.text);
|
|
301
|
+
if (row.kinds[0] === "unblocked") {
|
|
302
|
+
const status = documents.get(row.id)?.status;
|
|
303
|
+
row.superseded = waitingAtEnd.has(row.id) || typeof status !== "string" || terminal.has(status);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return { rows, snapshots: [...documents].map(([id, metadata]) => ({ ...metadata, id })), blockers: blocked };
|
|
307
|
+
}
|
|
308
|
+
/** Prioritize blockers, own work, handoffs and newly actionable work, then priority. */
|
|
309
|
+
function rank(row) {
|
|
310
|
+
if (row.superseded)
|
|
311
|
+
return 11;
|
|
312
|
+
if (row.blocked)
|
|
313
|
+
return 0;
|
|
314
|
+
for (const [index, kind] of ["blocked", "own_claim", "handoff", "unblocked", "claim", "reopened", "reprioritized", "created", "comment", "closed"].entries()) {
|
|
315
|
+
if (row.kinds.includes(kind))
|
|
316
|
+
return index;
|
|
317
|
+
}
|
|
318
|
+
return 10;
|
|
319
|
+
}
|
|
320
|
+
/** Delivery time is advisory; replay identity seals the restart boundary and history inventory. */
|
|
321
|
+
function checkpointDigest(state) {
|
|
322
|
+
const { served_at: _servedAt, ...identity } = state;
|
|
323
|
+
return digest(state.version === 2 ? identity : state);
|
|
324
|
+
}
|
|
325
|
+
/** Read under the same SDK lock as publication so inventory collection cannot race a reader. */
|
|
326
|
+
async function readCheckpoint(directory, token, author) {
|
|
327
|
+
if (!/^s1_[a-f0-9]{64}$/.test(token))
|
|
328
|
+
throw new SinceError("INVALID_CURSOR", "Use the opaque cursor returned by context-pack since.");
|
|
329
|
+
const unlock = await acquireLock(directory, "context-since", 30, author, false, false, 10000);
|
|
330
|
+
try {
|
|
331
|
+
let raw;
|
|
332
|
+
try {
|
|
333
|
+
raw = readFileSync(join(directory, `${token}.json`), "utf8");
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
throw new SinceError("CURSOR_EXPIRED", "Restart using the previous response's inclusive --from restart_from value; retained cursor state is unavailable.");
|
|
337
|
+
}
|
|
338
|
+
let state;
|
|
339
|
+
try {
|
|
340
|
+
const parsed = JSON.parse(raw);
|
|
341
|
+
if (parsed === null || typeof parsed !== "object")
|
|
342
|
+
throw new Error("Invalid checkpoint object");
|
|
343
|
+
state = parsed;
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
throw new SinceError("INVALID_CURSOR", "Restart with --from <ISO timestamp>; cursor state is damaged.");
|
|
347
|
+
}
|
|
348
|
+
if (`s1_${checkpointDigest(state)}` !== token)
|
|
349
|
+
throw new SinceError("INVALID_CURSOR", "Restart with --from <ISO timestamp>; cursor state is damaged.");
|
|
350
|
+
if (state.author !== author)
|
|
351
|
+
throw new SinceError("CURSOR_AUTHOR_MISMATCH", "Resume with the original --author or start a new --from session.");
|
|
352
|
+
if (typeof state.streams === "string") {
|
|
353
|
+
const ref = state.streams;
|
|
354
|
+
try {
|
|
355
|
+
if (!/^[a-f0-9]{64}$/.test(ref))
|
|
356
|
+
throw new Error("Invalid inventory reference");
|
|
357
|
+
const inventory = gunzipSync(readFileSync(join(directory, `i1_${ref}.gz`)), { maxOutputLength: 67_108_864 }).toString("utf8");
|
|
358
|
+
if (createHash("sha256").update(inventory).digest("hex") !== ref)
|
|
359
|
+
throw new Error("Inventory digest mismatch");
|
|
360
|
+
state.streams = JSON.parse(inventory);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
throw new SinceError("CURSOR_EXPIRED", "Restart using --from restart_from from the previous response; the shared history inventory is unavailable or damaged.");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (!Array.isArray(state.streams))
|
|
367
|
+
throw new SinceError("CURSOR_EXPIRED", "Restart with --from <ISO timestamp>; this cursor uses an obsolete checkpoint format.");
|
|
368
|
+
return { ...state, streams: state.streams };
|
|
369
|
+
}
|
|
370
|
+
finally {
|
|
371
|
+
await unlock();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/** Publish runtime bytes by rename while the SDK cursor lock protects readers and collectors. */
|
|
375
|
+
function publishRuntimeFile(directory, file, bytes) {
|
|
376
|
+
const temporary = join(directory, `${file}.${randomUUID()}.tmp`);
|
|
377
|
+
writeFileSync(temporary, bytes, { mode: 0o600 });
|
|
378
|
+
renameSync(temporary, join(directory, file));
|
|
379
|
+
}
|
|
380
|
+
/** Atomically publish a shared inventory and bounded checkpoints using the SDK writer lock. */
|
|
381
|
+
export async function saveCheckpoint(directory, state) {
|
|
382
|
+
const unlock = await acquireLock(directory, "context-since", 30, state.author, false, false, 10000);
|
|
383
|
+
try {
|
|
384
|
+
const servedTime = state.served_at === undefined ? Date.now() : parseFromTimestamp(state.served_at);
|
|
385
|
+
if (!Number.isFinite(servedTime))
|
|
386
|
+
throw new SinceError("INVALID_CURSOR", "Checkpoint serving time must be an explicit-zone ISO timestamp.");
|
|
387
|
+
const inventory = JSON.stringify(state.streams);
|
|
388
|
+
const ref = createHash("sha256").update(inventory).digest("hex");
|
|
389
|
+
const compressed = gzipSync(inventory);
|
|
390
|
+
const stored = { ...state, version: 2, served_at: new Date(servedTime).toISOString(), streams: ref };
|
|
391
|
+
const raw = JSON.stringify(stored);
|
|
392
|
+
if (Buffer.byteLength(raw) + compressed.length > SINCE_RETENTION.bytes)
|
|
393
|
+
throw new SinceError("CURSOR_CAPACITY", "Restart with a later --from timestamp to reduce pending rows; the compressed stream inventory plus pending rows exceeds runtime retention capacity.");
|
|
394
|
+
const token = `s1_${checkpointDigest(stored)}`;
|
|
395
|
+
const target = join(directory, `${token}.json`);
|
|
396
|
+
const inventoryPath = join(directory, `i1_${ref}.gz`);
|
|
397
|
+
let retained;
|
|
398
|
+
try {
|
|
399
|
+
retained = readFileSync(inventoryPath);
|
|
400
|
+
}
|
|
401
|
+
catch { /* Missing disposable inventory is republished. */ }
|
|
402
|
+
if (!retained?.equals(compressed))
|
|
403
|
+
publishRuntimeFile(directory, `i1_${ref}.gz`, compressed);
|
|
404
|
+
if (statSync(target, { throwIfNoEntry: false })) {
|
|
405
|
+
try {
|
|
406
|
+
const existing = JSON.parse(readFileSync(target, "utf8"));
|
|
407
|
+
if (checkpointDigest(existing) === token.slice(3) && typeof existing.served_at === "string" && existing.served_at.length === 24 && Number.isFinite(parseFromTimestamp(existing.served_at)))
|
|
408
|
+
return { cursor: token, served_at: existing.served_at };
|
|
409
|
+
}
|
|
410
|
+
catch { /* A fresh from-window can replace damaged disposable metadata. */ }
|
|
411
|
+
}
|
|
412
|
+
publishRuntimeFile(directory, `${token}.json`, raw);
|
|
413
|
+
const refs = new Map();
|
|
414
|
+
const files = readdirSync(directory).filter((file) => /^s1_[a-f0-9]{64}\.json$/.test(file)).flatMap((file) => {
|
|
415
|
+
const stats = statSync(join(directory, file), { throwIfNoEntry: false });
|
|
416
|
+
if (!stats)
|
|
417
|
+
return [];
|
|
418
|
+
let reference;
|
|
419
|
+
try {
|
|
420
|
+
const checkpoint = JSON.parse(readFileSync(join(directory, file), "utf8"));
|
|
421
|
+
if (typeof checkpoint.streams === "string" && /^[a-f0-9]{64}$/.test(checkpoint.streams))
|
|
422
|
+
reference = checkpoint.streams;
|
|
423
|
+
}
|
|
424
|
+
catch { /* Collect malformed disposable snapshots normally. */ }
|
|
425
|
+
if (reference)
|
|
426
|
+
refs.set(reference, (refs.get(reference) ?? 0) + 1);
|
|
427
|
+
return [{ file, size: stats.size, mtimeMs: stats.mtimeMs, reference }];
|
|
428
|
+
}).sort((a, b) => a.mtimeMs - b.mtimeMs || compare(a.file, b.file));
|
|
429
|
+
const inventories = new Map();
|
|
430
|
+
for (const file of readdirSync(directory).filter((file) => /^i1_[a-f0-9]{64}\.gz$/.test(file))) {
|
|
431
|
+
const reference = file.slice(3, -3);
|
|
432
|
+
if (!refs.has(reference))
|
|
433
|
+
rmSync(join(directory, file), { force: true });
|
|
434
|
+
else
|
|
435
|
+
inventories.set(reference, statSync(join(directory, file)).size);
|
|
436
|
+
}
|
|
437
|
+
let bytes = files.reduce((sum, file) => sum + file.size, 0) + [...inventories.values()].reduce((sum, size) => sum + size, 0);
|
|
438
|
+
let count = files.length;
|
|
439
|
+
for (const file of files) {
|
|
440
|
+
if (count <= SINCE_RETENTION.count && bytes <= SINCE_RETENTION.bytes)
|
|
441
|
+
break;
|
|
442
|
+
if (file.file === `${token}.json`)
|
|
443
|
+
continue;
|
|
444
|
+
rmSync(join(directory, file.file), { force: true });
|
|
445
|
+
bytes -= file.size;
|
|
446
|
+
count--;
|
|
447
|
+
if (file.reference) {
|
|
448
|
+
const remaining = refs.get(file.reference) - 1;
|
|
449
|
+
refs.set(file.reference, remaining);
|
|
450
|
+
if (!remaining) {
|
|
451
|
+
rmSync(join(directory, `i1_${file.reference}.gz`), { force: true });
|
|
452
|
+
bytes -= inventories.get(file.reference) ?? 0;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return { cursor: token, served_at: stored.served_at };
|
|
457
|
+
}
|
|
458
|
+
finally {
|
|
459
|
+
await unlock();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/** Serialize with a conservative byte-token upper bound, including its own digits. */
|
|
463
|
+
export function encodeSincePack(pack) {
|
|
464
|
+
let output = `${JSON.stringify(pack)}\n`;
|
|
465
|
+
while (pack.budget.token_upper_bound !== Buffer.byteLength(output)) {
|
|
466
|
+
pack.budget.token_upper_bound = Buffer.byteLength(output);
|
|
467
|
+
output = `${JSON.stringify(pack)}\n`;
|
|
468
|
+
}
|
|
469
|
+
return output;
|
|
470
|
+
}
|
|
471
|
+
/** Select a prefix in linear serialization work, including exact counter and byte-count digits. */
|
|
472
|
+
export function packSinceRows(pack, rows) {
|
|
473
|
+
pack.changes = [];
|
|
474
|
+
const receipt = pack.omission_receipt;
|
|
475
|
+
receipt.deferred_rows = rows.length;
|
|
476
|
+
receipt.deferred_own_claims = rows.filter((row) => row.op === "own_claim").length;
|
|
477
|
+
receipt.excerpt_omitted_chars = 0;
|
|
478
|
+
/** Count the JSON digits affected by each candidate without serializing the pack. */
|
|
479
|
+
const digits = (value) => String(value).length;
|
|
480
|
+
const fixed = Buffer.byteLength(JSON.stringify(pack)) + 1 - digits(receipt.deferred_rows) - digits(receipt.deferred_own_claims) - digits(receipt.excerpt_omitted_chars) - digits(pack.budget.token_upper_bound);
|
|
481
|
+
let rowBytes = 0;
|
|
482
|
+
for (const row of rows) {
|
|
483
|
+
const candidate = rowBytes + Buffer.byteLength(JSON.stringify(row)) + (pack.changes.length ? 1 : 0);
|
|
484
|
+
const deferred = receipt.deferred_rows - 1;
|
|
485
|
+
const own = receipt.deferred_own_claims - Number(row.op === "own_claim");
|
|
486
|
+
const omitted = receipt.excerpt_omitted_chars + row.excerpt_omitted_chars;
|
|
487
|
+
const size = fixed + candidate + digits(deferred) + digits(own) + digits(omitted);
|
|
488
|
+
let total = size + 1;
|
|
489
|
+
while (total !== size + digits(total))
|
|
490
|
+
total = size + digits(total);
|
|
491
|
+
if (total > pack.budget.ceiling)
|
|
492
|
+
break;
|
|
493
|
+
rowBytes = candidate;
|
|
494
|
+
pack.changes.push(row);
|
|
495
|
+
receipt.deferred_rows = deferred;
|
|
496
|
+
receipt.deferred_own_claims = own;
|
|
497
|
+
receipt.excerpt_omitted_chars = omitted;
|
|
498
|
+
}
|
|
499
|
+
const size = fixed + rowBytes + digits(receipt.deferred_rows) + digits(receipt.deferred_own_claims) + digits(receipt.excerpt_omitted_chars);
|
|
500
|
+
let total = size + 1;
|
|
501
|
+
while (total !== size + digits(total))
|
|
502
|
+
total = size + digits(total);
|
|
503
|
+
pack.budget.token_upper_bound = total;
|
|
504
|
+
encodeSincePack(pack);
|
|
505
|
+
return pack.changes.length;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Serve one deterministic ranked page. Pending pages freeze their source window;
|
|
509
|
+
* writes arriving while they drain are collected after the last pending page.
|
|
510
|
+
* Byte-level budgeting upper-bounds byte-fallback tokenizer costs without an
|
|
511
|
+
* unpinned model tokenizer dependency. Nothing is silently consumed on overflow.
|
|
512
|
+
*/
|
|
513
|
+
export async function buildSincePack(pmRoot, items, terminal, options) {
|
|
514
|
+
if ((!options.cursor === !options.from) || !options.author.trim())
|
|
515
|
+
throw new SinceError("INVALID_ARGUMENT", "Supply exactly one of --cursor or --from and a nonempty --author.");
|
|
516
|
+
if (!Number.isSafeInteger(options.tokenBudget) || options.tokenBudget < 512)
|
|
517
|
+
throw new SinceError("INVALID_BUDGET", "--token-budget must be an integer of at least 512.");
|
|
518
|
+
const directory = join(pmRoot, SINCE_RETENTION.directory);
|
|
519
|
+
const previous = options.cursor ? await readCheckpoint(directory, options.cursor, options.author) : undefined;
|
|
520
|
+
const servedAt = new Date().toISOString();
|
|
521
|
+
const streams = await readStreams(pmRoot);
|
|
522
|
+
const expired = previous ? validatePrefixes(streams, previous.streams) : [];
|
|
523
|
+
const invalid = streams.flat().filter((event) => !Number.isFinite(event.time));
|
|
524
|
+
let receipt = { invalid_timestamp_rows: invalid.length, invalid_timestamp_examples: invalid.slice(0, 8).map(({ id, ordinal, ts }) => ({ id, ordinal, ts: excerpt(ts, 128).text })), expired_streams: expired };
|
|
525
|
+
let restartFrom = previous?.restart_from ?? options.from ?? "0000-01-01T00:00:00Z";
|
|
526
|
+
const restartTime = parseFromTimestamp(restartFrom);
|
|
527
|
+
let earliest = restartTime;
|
|
528
|
+
for (const event of streams.flat())
|
|
529
|
+
if (event.time < earliest) {
|
|
530
|
+
earliest = event.time;
|
|
531
|
+
restartFrom = event.ts;
|
|
532
|
+
}
|
|
533
|
+
let rows;
|
|
534
|
+
let snapshot;
|
|
535
|
+
if (previous && previous.pending.length) {
|
|
536
|
+
rows = previous.pending;
|
|
537
|
+
snapshot = previous.streams;
|
|
538
|
+
receipt = previous.receipt ?? receipt;
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
const events = mergeEvents(streams);
|
|
542
|
+
const from = options.from === undefined ? undefined : parseFromTimestamp(options.from);
|
|
543
|
+
if (from !== undefined && !Number.isFinite(from))
|
|
544
|
+
throw new SinceError("INVALID_FROM", "--from must be a valid ISO timestamp with seconds and an explicit Z or ±HH:MM offset.");
|
|
545
|
+
for (const event of events)
|
|
546
|
+
if (event.op === "history_compact_baseline" && (Number.isNaN(event.time) || (from !== undefined && event.time >= from)) && !receipt.expired_streams.includes(event.id))
|
|
547
|
+
receipt.expired_streams.push(event.id);
|
|
548
|
+
const prior = new Map(previous?.streams.map((stream) => [stream.id, stream.count]));
|
|
549
|
+
const selected = new Set(events.filter((event) => Number.isFinite(event.time) && event.op !== "history_compact_baseline" && (from === undefined ? event.ordinal >= (expired.includes(event.id) ? 1 : prior.get(event.id) ?? 0) : event.time >= from)).map((event) => event.key));
|
|
550
|
+
const classified = classify(events, selected, terminal, items);
|
|
551
|
+
rows = classified.rows;
|
|
552
|
+
const refsByWaiting = new Map();
|
|
553
|
+
for (const [edge, waiting] of classified.blockers) {
|
|
554
|
+
const refs = refsByWaiting.get(waiting) ?? [];
|
|
555
|
+
refs.push(edge);
|
|
556
|
+
refsByWaiting.set(waiting, refs);
|
|
557
|
+
}
|
|
558
|
+
const snapshots = new Map(items.map((item) => [item.id, item]));
|
|
559
|
+
for (const item of classified.snapshots)
|
|
560
|
+
snapshots.set(item.id, item);
|
|
561
|
+
for (const item of snapshots.values()) {
|
|
562
|
+
if (item.assignee === options.author && !terminal.has(String(item.status))) {
|
|
563
|
+
const title = excerpt(String(item.title ?? item.id), 128);
|
|
564
|
+
const refs = refsByWaiting.get(item.id) ?? [];
|
|
565
|
+
const detail = refs.length ? excerpt([String(item.blocked_reason ?? item.blocked_by ?? ""), ...refs].filter(Boolean).join("; "), 384) : excerpt("", 384);
|
|
566
|
+
rows.push({
|
|
567
|
+
key: `own:${item.id}`, id: item.id, op: "own_claim", ts: String(item.updated_at), author: options.author,
|
|
568
|
+
kinds: ["own_claim"], title: title.text, type: String(item.type ?? "unknown"), status: String(item.status ?? "unknown"), priority: Number(item.priority ?? 4), fields: [], text: detail.text, excerpt_omitted_chars: title.omitted + detail.omitted, ...(refs.length ? { blocked: true } : {}),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const times = new Map(events.map((event) => [event.ts, event.time]));
|
|
573
|
+
for (const row of rows)
|
|
574
|
+
if (!times.has(row.ts))
|
|
575
|
+
times.set(row.ts, parseFromTimestamp(row.ts));
|
|
576
|
+
rows.sort((a, b) => rank(a) - rank(b) || a.priority - b.priority || (times.get(b.ts) - times.get(a.ts) || 0) || compare(a.key, b.key));
|
|
577
|
+
snapshot = fingerprint(streams);
|
|
578
|
+
}
|
|
579
|
+
const pack = {
|
|
580
|
+
protocol: "pm-context-since/v1", cursor: `s1_${"0".repeat(64)}`, served_at: servedAt, restart_from: restartFrom, changes: [],
|
|
581
|
+
omission_receipt: { ...receipt, deferred_rows: rows.length, deferred_own_claims: rows.filter((row) => row.op === "own_claim").length, excerpt_omitted_chars: 0, remedy: "Resume --cursor for deferred rows; pm comments <id> or pm history <id> --full for complete excerpts and patches." },
|
|
582
|
+
budget: { ceiling: options.tokenBudget, token_upper_bound: 0, unit: "utf8-bytes" },
|
|
583
|
+
};
|
|
584
|
+
const included = packSinceRows(pack, rows);
|
|
585
|
+
if ((rows.length > 0 && included === 0) || pack.budget.token_upper_bound > options.tokenBudget)
|
|
586
|
+
throw new SinceError("BUDGET_TOO_SMALL", "Increase --token-budget to fit the receipt and at least one complete delta row; no cursor advanced.");
|
|
587
|
+
const publication = await saveCheckpoint(directory, { generation: (previous?.generation ?? -1) + 1, author: options.author, streams: snapshot, pending: rows.slice(included), served_at: servedAt, restart_from: restartFrom, receipt });
|
|
588
|
+
pack.cursor = publication.cursor;
|
|
589
|
+
pack.served_at = publication.served_at;
|
|
590
|
+
encodeSincePack(pack);
|
|
591
|
+
return pack;
|
|
592
|
+
}
|
|
593
|
+
//# sourceMappingURL=since.js.map
|