@bububuger/spanory 0.1.15 → 0.1.18

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.
@@ -1,8 +1,9 @@
1
1
  // @ts-nocheck
2
- import { readFile, stat } from 'node:fs/promises';
2
+ import { readdir, readFile, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { RUNTIME_CAPABILITIES } from '../shared/capabilities.js';
5
5
  import { normalizeTranscriptMessages, parseProjectIdFromTranscriptPath, pickUsage } from '../shared/normalize.js';
6
+ const INFER_WINDOW_EPSILON_MS = 1200;
6
7
  function parseTimestamp(entry) {
7
8
  const raw = entry?.timestamp ?? entry?.created_at ?? entry?.createdAt;
8
9
  const date = raw ? new Date(raw) : new Date();
@@ -140,6 +141,139 @@ function normalizeAgentId(entry) {
140
141
  ?? entry?.payload?.agentId
141
142
  ?? entry?.payload?.agent_id);
142
143
  }
144
+ function extractToolUses(content) {
145
+ if (!Array.isArray(content))
146
+ return [];
147
+ return content.filter((block) => block && typeof block === 'object' && block.type === 'tool_use');
148
+ }
149
+ function extractToolResults(content) {
150
+ if (!Array.isArray(content))
151
+ return [];
152
+ return content.filter((block) => block && typeof block === 'object' && block.type === 'tool_result');
153
+ }
154
+ function isToolResultOnlyContent(content) {
155
+ return Array.isArray(content)
156
+ && content.length > 0
157
+ && content.every((block) => block && typeof block === 'object' && block.type === 'tool_result');
158
+ }
159
+ function isPromptUserMessage(message) {
160
+ if (!message || message.role !== 'user' || message.isMeta)
161
+ return false;
162
+ const { content } = message;
163
+ if (typeof content === 'string')
164
+ return content.trim().length > 0;
165
+ if (!Array.isArray(content))
166
+ return false;
167
+ if (isToolResultOnlyContent(content))
168
+ return false;
169
+ return content.length > 0;
170
+ }
171
+ function findChildSessionHints(messages) {
172
+ const hasSidechainSignal = messages.some((m) => m?.isSidechain === true || String(m?.agentId ?? '').trim().length > 0);
173
+ if (!hasSidechainSignal)
174
+ return null;
175
+ const hasParentLink = messages.some((m) => String(m?.parentSessionId ?? m?.parent_session_id ?? '').trim().length > 0
176
+ || String(m?.parentTurnId ?? m?.parent_turn_id ?? '').trim().length > 0
177
+ || String(m?.parentToolCallId ?? m?.parent_tool_call_id ?? '').trim().length > 0);
178
+ if (hasParentLink)
179
+ return null;
180
+ const sorted = [...messages].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
181
+ const childStartedAt = sorted[0]?.timestamp;
182
+ if (!childStartedAt)
183
+ return null;
184
+ return { childStartedAt };
185
+ }
186
+ function extractTaskWindows(messages, sessionId) {
187
+ const sorted = [...messages].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
188
+ const windows = [];
189
+ const byCallId = new Map();
190
+ let turnIndex = 0;
191
+ let currentTurnId = 'turn-1';
192
+ for (const msg of sorted) {
193
+ if (isPromptUserMessage(msg)) {
194
+ turnIndex += 1;
195
+ currentTurnId = `turn-${turnIndex}`;
196
+ }
197
+ if (msg.role === 'assistant') {
198
+ for (const tu of extractToolUses(msg.content)) {
199
+ const toolName = String(tu.name ?? '').trim();
200
+ if (toolName !== 'Task')
201
+ continue;
202
+ const callId = String(tu.id ?? '').trim();
203
+ if (!callId)
204
+ continue;
205
+ const window = {
206
+ parentSessionId: sessionId,
207
+ parentTurnId: currentTurnId,
208
+ parentToolCallId: callId,
209
+ startedAtMs: msg.timestamp.getTime(),
210
+ endedAtMs: msg.timestamp.getTime(),
211
+ };
212
+ windows.push(window);
213
+ byCallId.set(callId, window);
214
+ }
215
+ }
216
+ if (msg.role === 'user') {
217
+ for (const tr of extractToolResults(msg.content)) {
218
+ const callId = String(tr.tool_use_id ?? tr.toolUseId ?? '').trim();
219
+ if (!callId)
220
+ continue;
221
+ const window = byCallId.get(callId);
222
+ if (window) {
223
+ window.endedAtMs = Math.max(window.endedAtMs, msg.timestamp.getTime());
224
+ }
225
+ }
226
+ }
227
+ }
228
+ return windows;
229
+ }
230
+ async function inferParentLinkFromSiblingSessions({ transcriptPath, messages }) {
231
+ const hints = findChildSessionHints(messages);
232
+ if (!hints)
233
+ return messages;
234
+ const currentSessionId = path.basename(transcriptPath, '.jsonl');
235
+ const dir = path.dirname(transcriptPath);
236
+ let entries = [];
237
+ try {
238
+ entries = await readdir(dir, { withFileTypes: true });
239
+ }
240
+ catch {
241
+ return messages;
242
+ }
243
+ const candidates = [];
244
+ for (const entry of entries) {
245
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
246
+ continue;
247
+ const siblingSessionId = entry.name.slice(0, -'.jsonl'.length);
248
+ if (siblingSessionId === currentSessionId)
249
+ continue;
250
+ const siblingPath = path.join(dir, entry.name);
251
+ const siblingMessages = await readOpenclawTranscript(siblingPath);
252
+ const windows = extractTaskWindows(siblingMessages, siblingSessionId);
253
+ for (const window of windows) {
254
+ const childAtMs = hints.childStartedAt.getTime();
255
+ const lower = window.startedAtMs - INFER_WINDOW_EPSILON_MS;
256
+ const upper = window.endedAtMs + INFER_WINDOW_EPSILON_MS;
257
+ if (childAtMs < lower || childAtMs > upper)
258
+ continue;
259
+ candidates.push({
260
+ ...window,
261
+ score: Math.abs(childAtMs - window.startedAtMs),
262
+ });
263
+ }
264
+ }
265
+ if (candidates.length === 0)
266
+ return messages;
267
+ candidates.sort((a, b) => a.score - b.score);
268
+ const best = candidates[0];
269
+ return messages.map((msg) => ({
270
+ ...msg,
271
+ parentSessionId: best.parentSessionId,
272
+ parentTurnId: best.parentTurnId,
273
+ parentToolCallId: best.parentToolCallId,
274
+ parentLinkConfidence: 'inferred',
275
+ }));
276
+ }
143
277
  async function readOpenclawTranscript(transcriptPath) {
144
278
  const raw = await readFile(transcriptPath, 'utf-8');
145
279
  const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
@@ -165,6 +299,9 @@ async function readOpenclawTranscript(transcriptPath) {
165
299
  isMeta: entry?.isMeta ?? entry?.is_meta ?? false,
166
300
  isSidechain: normalizeIsSidechain(entry),
167
301
  agentId: normalizeAgentId(entry),
302
+ parentSessionId: entry?.parentSessionId ?? entry?.parent_session_id,
303
+ parentTurnId: entry?.parentTurnId ?? entry?.parent_turn_id,
304
+ parentToolCallId: entry?.parentToolCallId ?? entry?.parent_tool_call_id,
168
305
  content: normalizeContent(entry),
169
306
  model: normalizeModel(entry),
170
307
  usage: normalizeUsage(entry),
@@ -231,7 +368,8 @@ export const openclawAdapter = {
231
368
  },
232
369
  async collectEvents(context) {
233
370
  const transcriptPath = await resolveTranscriptPath(context);
234
- const messages = await readOpenclawTranscript(transcriptPath);
371
+ const loaded = await readOpenclawTranscript(transcriptPath);
372
+ const messages = await inferParentLinkFromSiblingSessions({ transcriptPath, messages: loaded });
235
373
  return normalizeTranscriptMessages({
236
374
  runtime: 'openclaw',
237
375
  projectId: context.projectId,
@@ -0,0 +1,19 @@
1
+ type FileStat = {
2
+ mtimeMs: number;
3
+ };
4
+ type WaitForFileSettleOptions = {
5
+ filePath: string;
6
+ stableWindowMs?: number;
7
+ timeoutMs?: number;
8
+ pollMs?: number;
9
+ statFn?: (filePath: string) => Promise<FileStat>;
10
+ sleepFn?: (ms: number) => Promise<void>;
11
+ nowFn?: () => number;
12
+ };
13
+ type WaitForFileSettleResult = {
14
+ settled: boolean;
15
+ lastMtimeMs?: number;
16
+ waitedMs: number;
17
+ };
18
+ export declare function waitForFileMtimeToSettle(options: WaitForFileSettleOptions): Promise<WaitForFileSettleResult>;
19
+ export {};
@@ -0,0 +1,44 @@
1
+ import { stat } from 'node:fs/promises';
2
+ function sleep(ms) {
3
+ return new Promise((resolve) => setTimeout(resolve, ms));
4
+ }
5
+ export async function waitForFileMtimeToSettle(options) {
6
+ const stableWindowMs = options.stableWindowMs ?? 400;
7
+ const timeoutMs = options.timeoutMs ?? 2500;
8
+ const pollMs = options.pollMs ?? 100;
9
+ const statFn = options.statFn ?? (async (filePath) => stat(filePath));
10
+ const sleepFn = options.sleepFn ?? sleep;
11
+ const nowFn = options.nowFn ?? Date.now;
12
+ const startedAt = nowFn();
13
+ const deadline = startedAt + timeoutMs;
14
+ let lastMtimeMs;
15
+ let unchangedSinceMs;
16
+ while (nowFn() <= deadline) {
17
+ const current = await statFn(options.filePath);
18
+ const mtimeMs = Number(current.mtimeMs);
19
+ if (lastMtimeMs === mtimeMs) {
20
+ if (unchangedSinceMs === undefined)
21
+ unchangedSinceMs = nowFn();
22
+ if (nowFn() - unchangedSinceMs >= stableWindowMs) {
23
+ return {
24
+ settled: true,
25
+ lastMtimeMs: mtimeMs,
26
+ waitedMs: nowFn() - startedAt,
27
+ };
28
+ }
29
+ }
30
+ else {
31
+ lastMtimeMs = mtimeMs;
32
+ unchangedSinceMs = undefined;
33
+ }
34
+ const remainingMs = deadline - nowFn();
35
+ if (remainingMs <= 0)
36
+ break;
37
+ await sleepFn(Math.min(pollMs, remainingMs));
38
+ }
39
+ return {
40
+ settled: false,
41
+ lastMtimeMs,
42
+ waitedMs: nowFn() - startedAt,
43
+ };
44
+ }