dsh-cursor-dev 0.0.0-stage → 0.11.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/lib/journal.js ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Per-run event journal.
3
+ *
4
+ * The ACP stream is rich (reasoning, tool calls, diffs, permissions) but the
5
+ * parent model must not pay for all of it: the tool result stays compact while
6
+ * the full journal stays available for inspection. Each key keeps a bounded
7
+ * ring so a long session cannot grow without limit.
8
+ */
9
+
10
+ /** Events kept per key. */
11
+ const CAPACITY = 600;
12
+
13
+ /** Floor for the retention window, so a typo cannot evict a running session. */
14
+ const MIN_TTL_MS = 10 * 60 * 1000;
15
+
16
+ /** Shortest gap between retention sweeps. */
17
+ const SWEEP_INTERVAL_MS = 60 * 1000;
18
+
19
+ /** One journal entry. */
20
+ let sequence = 0;
21
+
22
+ /**
23
+ * Bounded, queryable record of everything a Cursor session did.
24
+ */
25
+ export class RunJournal {
26
+ #entries = new Map();
27
+
28
+ #ttlMs;
29
+
30
+ #lastSweep = 0;
31
+
32
+ /**
33
+ * @param options - `ttlMs` retention window (default 60 min, min 10 min).
34
+ */
35
+ constructor(options = {}) {
36
+ this.#ttlMs = Math.max(MIN_TTL_MS, options.ttlMs ?? 60 * 60 * 1000);
37
+ }
38
+
39
+ /** Retention window in milliseconds. */
40
+ get ttlMs() {
41
+ return this.#ttlMs;
42
+ }
43
+
44
+ /**
45
+ * Append one normalized event.
46
+ *
47
+ * @param key - pool key identifying the conversation.
48
+ * @param event - normalized event from `CursorSession`.
49
+ */
50
+ append(key, event) {
51
+ const bucket = this.#bucket(key);
52
+ bucket.updatedAt = Date.now();
53
+ bucket.events.push({ seq: ++sequence, time: Date.now(), ...event });
54
+ if (bucket.events.length > CAPACITY)
55
+ bucket.events.splice(0, bucket.events.length - CAPACITY);
56
+ this.#sweep();
57
+ }
58
+
59
+ /**
60
+ * Record run-level facts for a key.
61
+ *
62
+ * @param key - pool key.
63
+ * @param patch - fields to merge.
64
+ */
65
+ annotate(key, patch) {
66
+ const bucket = this.#bucket(key);
67
+ bucket.updatedAt = Date.now();
68
+ Object.assign(bucket, patch);
69
+ this.#sweep();
70
+ return bucket;
71
+ }
72
+
73
+ /**
74
+ * Read one key's journal.
75
+ *
76
+ * @param key - pool key.
77
+ */
78
+ read(key) {
79
+ return this.#entries.get(key);
80
+ }
81
+
82
+ /**
83
+ * Drop one key's journal.
84
+ *
85
+ * @param key - pool key.
86
+ */
87
+ clear(key) {
88
+ this.#entries.delete(key);
89
+ }
90
+
91
+ /**
92
+ * Read the newest journal belonging to one conversation.
93
+ *
94
+ * Buckets are keyed by the generation-scoped pool key (`parent::label#2`),
95
+ * but the durable conversation index — and therefore the dashboard's
96
+ * 历史会话 table — only knows the conversation key (`parent::label`). A row
97
+ * that links by the durable key would ask for a bucket that never existed,
98
+ * so the lookup walks the generations and answers with the most recent one
99
+ * that still has a journal.
100
+ *
101
+ * @param conversationKey - `parent::label`.
102
+ * @returns `{key, bucket}` for the newest matching generation, else undefined.
103
+ */
104
+ readConversation(conversationKey) {
105
+ const prefix = `${conversationKey}#`;
106
+ let found;
107
+ for (const [key, bucket] of this.#entries) {
108
+ if (!key.startsWith(prefix))
109
+ continue;
110
+ if (found === undefined || (bucket.updatedAt ?? bucket.startedAt) > (found.bucket.updatedAt ?? found.bucket.startedAt))
111
+ found = { key, bucket };
112
+ }
113
+ return found;
114
+ }
115
+
116
+ /**
117
+ * Counts for inspection surfaces (the dashboard, the status route).
118
+ */
119
+ stats() {
120
+ let events = 0;
121
+ for (const bucket of this.#entries.values())
122
+ events += bucket.events.length;
123
+ return { keys: this.#entries.size, events, ttlMs: this.#ttlMs };
124
+ }
125
+
126
+ /** Get or create a key's bucket. */
127
+ #bucket(key) {
128
+ let bucket = this.#entries.get(key);
129
+ if (bucket === undefined) {
130
+ const now = Date.now();
131
+ bucket = { events: [], startedAt: now, updatedAt: now, task: undefined, cursorSessionId: undefined };
132
+ this.#entries.set(key, bucket);
133
+ }
134
+ return bucket;
135
+ }
136
+
137
+ /**
138
+ * Drop journals nobody has touched inside the retention window. Runs at most
139
+ * once a minute and is driven by writes, so it costs nothing when idle.
140
+ */
141
+ #sweep() {
142
+ const now = Date.now();
143
+ if (now - this.#lastSweep < SWEEP_INTERVAL_MS)
144
+ return;
145
+ this.#lastSweep = now;
146
+ const cutoff = now - this.#ttlMs;
147
+ for (const [key, bucket] of this.#entries) {
148
+ if ((bucket.updatedAt ?? bucket.startedAt) < cutoff)
149
+ this.#entries.delete(key);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Reduce a key's journal to the token-cheap signals a parent model benefits
155
+ * from: what tools ran, in what order, and how they ended.
156
+ *
157
+ * @param key - pool key.
158
+ * @param limit - maximum steps reported.
159
+ */
160
+ timeline(key, limit = 40) {
161
+ const bucket = this.#entries.get(key);
162
+ if (bucket === undefined)
163
+ return [];
164
+ const steps = [];
165
+ const index = new Map();
166
+ for (const event of bucket.events) {
167
+ if (event.kind === 'tool-call') {
168
+ const call = event.call;
169
+ // A malformed event must not take down the caller: this runs
170
+ // inside an HTTP handler, where a throw is a process-level
171
+ // unhandled exception rather than a 500.
172
+ if (call === undefined || call.id === undefined)
173
+ continue;
174
+ index.set(call.id, steps.length);
175
+ steps.push({
176
+ id: call.id,
177
+ name: call.name,
178
+ kind: call.kind,
179
+ path: call.path,
180
+ status: call.status,
181
+ diffCount: 0,
182
+ });
183
+ continue;
184
+ }
185
+ if (event.kind === 'tool-update') {
186
+ const call = event.call;
187
+ if (call === undefined || call.id === undefined)
188
+ continue;
189
+ const position = index.get(call.id);
190
+ if (position === undefined)
191
+ continue;
192
+ const step = steps[position];
193
+ step.status = call.status;
194
+ step.name = call.name ?? step.name;
195
+ if (call.path !== undefined)
196
+ step.path = call.path;
197
+ step.diffCount += typeof event.diffCount === 'number' ? event.diffCount : 0;
198
+ }
199
+ }
200
+ return steps.slice(0, limit);
201
+ }
202
+
203
+ /**
204
+ * Whether a key has recorded anything.
205
+ *
206
+ * @param key - pool key.
207
+ */
208
+ has(key) {
209
+ return this.#entries.has(key);
210
+ }
211
+
212
+ /**
213
+ * The newest thing a run did, in one line.
214
+ *
215
+ * This is the answer to "what is the child doing right now" for a table row,
216
+ * where fetching the whole journal per row would be wasteful. The newest
217
+ * event that carries meaning for a reader is used: a tool call, a tool
218
+ * result, a message, a thought. Bookkeeping events (turn-start, mode) are
219
+ * skipped rather than reported as activity.
220
+ *
221
+ * @param key - pool key.
222
+ * @param maxChars - cap for the returned text.
223
+ */
224
+ lastActivity(key, maxChars = 160) {
225
+ const bucket = this.#entries.get(key);
226
+ if (bucket === undefined)
227
+ return undefined;
228
+ for (let position = bucket.events.length - 1; position >= 0; position -= 1) {
229
+ const event = bucket.events[position];
230
+ const text = describeEvent(event);
231
+ if (text === undefined)
232
+ continue;
233
+ return {
234
+ seq: event.seq,
235
+ time: event.time,
236
+ kind: event.kind,
237
+ text: text.length > maxChars ? `${text.slice(0, maxChars - 1)}…` : text,
238
+ };
239
+ }
240
+ return undefined;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * One line of human-readable activity for an event, or `undefined` when the
246
+ * event says nothing about what the run is doing.
247
+ *
248
+ * @param event - normalized journal event.
249
+ */
250
+ function describeEvent(event) {
251
+ const call = event?.call;
252
+ switch (event?.kind) {
253
+ case 'tool-call':
254
+ return call === undefined
255
+ ? undefined
256
+ : `${call.name ?? 'tool'} ${call.path ?? call.arguments ?? ''}`.trim();
257
+ case 'tool-update':
258
+ return call === undefined
259
+ ? undefined
260
+ : `${call.name ?? 'tool'} → ${call.status ?? 'running'}${call.path === undefined ? '' : ` ${call.path}`}`;
261
+ case 'text':
262
+ return typeof event.text === 'string' ? event.text : undefined;
263
+ case 'thought':
264
+ return typeof event.text === 'string' ? `💭 ${event.text}` : undefined;
265
+ case 'diff':
266
+ return `diff ${event.diff?.path ?? ''}`.trim();
267
+ case 'permission':
268
+ return `permission ${event.kindOf ?? ''}`.trim();
269
+ case 'plan':
270
+ return 'plan update';
271
+ case 'turn-end':
272
+ return `turn ${event.index ?? ''} ${event.stopReason ?? ''}`.trim();
273
+ default:
274
+ return undefined;
275
+ }
276
+ }