stella-timeline-plugin 2.0.0 → 2.0.2
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/dist/index.js +2 -2
- package/dist/src/runtime/openclaw_timeline_runtime.js +429 -146
- package/dist/src/tools/timeline_resolve.js +9 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +35 -35
- package/scripts/doctor-openclaw-workspace.mjs +61 -2
- package/scripts/migrate-existing-memory.mjs +153 -153
- package/scripts/release.mjs +2 -46
- package/scripts/run-openclaw-live-e2e.mjs +16 -16
- package/scripts/run-openclaw-smoke.mjs +285 -10
- package/scripts/setup-openclaw-workspace.mjs +79 -2
- package/skills/timeline/SKILL.md +111 -111
- package/templates/AGENTS.fragment.md +29 -29
- package/templates/SOUL.fragment.md +17 -17
|
@@ -37,6 +37,7 @@ exports.makeOpenClawTimelineResolveToolFactory = makeOpenClawTimelineResolveTool
|
|
|
37
37
|
const fs = __importStar(require("fs"));
|
|
38
38
|
const path = __importStar(require("path"));
|
|
39
39
|
const conversation_context_1 = require("./conversation_context");
|
|
40
|
+
const time_utils_1 = require("../lib/time-utils");
|
|
40
41
|
const timeline_resolve_1 = require("../tools/timeline_resolve");
|
|
41
42
|
function readString(value) {
|
|
42
43
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
@@ -52,7 +53,7 @@ function resolvePluginRuntimeConfig(pluginConfig) {
|
|
|
52
53
|
enableTrace: readBoolean(pluginConfig?.enableTrace, true),
|
|
53
54
|
traceLogPath: readString(pluginConfig?.traceLogPath),
|
|
54
55
|
canonicalMemoryRoot: readString(pluginConfig?.canonicalMemoryRoot) || 'memory',
|
|
55
|
-
reasonerTimeoutMs: readInteger(pluginConfig?.reasonerTimeoutMs,
|
|
56
|
+
reasonerTimeoutMs: readInteger(pluginConfig?.reasonerTimeoutMs, 90000),
|
|
56
57
|
reasonerSessionPrefix: readString(pluginConfig?.reasonerSessionPrefix) || 'timeline-reasoner',
|
|
57
58
|
reasonerMessageLimit: readInteger(pluginConfig?.reasonerMessageLimit, 24),
|
|
58
59
|
sessionHistoryLimit: readInteger(pluginConfig?.sessionHistoryLimit, 12),
|
|
@@ -183,9 +184,7 @@ function normalizeSessionHistory(messages, limit) {
|
|
|
183
184
|
function extractLatestAssistantText(messages) {
|
|
184
185
|
const reversed = [...messages].reverse();
|
|
185
186
|
for (const message of reversed) {
|
|
186
|
-
const role =
|
|
187
|
-
? String(message.role).trim()
|
|
188
|
-
: '';
|
|
187
|
+
const role = extractMessageRole(message);
|
|
189
188
|
const text = extractMessageText(message);
|
|
190
189
|
if (role === 'assistant' && text)
|
|
191
190
|
return text;
|
|
@@ -240,12 +239,12 @@ function collectBalancedJsonObjects(text) {
|
|
|
240
239
|
}
|
|
241
240
|
return objects;
|
|
242
241
|
}
|
|
243
|
-
function tryExtractJsonObject(text, expectedRequestId) {
|
|
242
|
+
function tryExtractJsonObject(text, sourceLabel, expectedRequestId) {
|
|
244
243
|
const fencedMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
245
244
|
const candidate = fencedMatch?.[1]?.trim() || text.trim();
|
|
246
245
|
const objects = collectBalancedJsonObjects(candidate);
|
|
247
246
|
if (objects.length === 0) {
|
|
248
|
-
throw new Error(
|
|
247
|
+
throw new Error(`${sourceLabel} did not return a JSON object`);
|
|
249
248
|
}
|
|
250
249
|
let firstParsedObject = null;
|
|
251
250
|
for (const objectText of objects) {
|
|
@@ -262,45 +261,91 @@ function tryExtractJsonObject(text, expectedRequestId) {
|
|
|
262
261
|
continue;
|
|
263
262
|
}
|
|
264
263
|
}
|
|
265
|
-
if (firstParsedObject) {
|
|
264
|
+
if (firstParsedObject && !expectedRequestId) {
|
|
266
265
|
return firstParsedObject;
|
|
267
266
|
}
|
|
268
|
-
|
|
267
|
+
if (firstParsedObject && expectedRequestId) {
|
|
268
|
+
throw new Error(`${sourceLabel} returned mismatched request_id`);
|
|
269
|
+
}
|
|
270
|
+
throw new Error(`${sourceLabel} did not return a parseable JSON object`);
|
|
271
|
+
}
|
|
272
|
+
function collectRelevantTranscriptTexts(messages) {
|
|
273
|
+
return [...messages]
|
|
274
|
+
.reverse()
|
|
275
|
+
.map((message) => ({
|
|
276
|
+
role: extractMessageRole(message),
|
|
277
|
+
text: extractMessageText(message),
|
|
278
|
+
}))
|
|
279
|
+
.filter((entry) => Boolean(entry.text))
|
|
280
|
+
.filter((entry) => entry.role === 'assistant' || entry.role === 'unknown');
|
|
281
|
+
}
|
|
282
|
+
function extractJsonObjectFromMessages(messages, sourceLabel, expectedRequestId) {
|
|
283
|
+
const relevantMessages = collectRelevantTranscriptTexts(messages);
|
|
284
|
+
let sawMismatchedRequestId = false;
|
|
285
|
+
let sawJsonLikeOutput = false;
|
|
286
|
+
for (const entry of relevantMessages) {
|
|
287
|
+
try {
|
|
288
|
+
return tryExtractJsonObject(entry.text, sourceLabel, expectedRequestId);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
292
|
+
if (message.includes('mismatched request_id')) {
|
|
293
|
+
sawMismatchedRequestId = true;
|
|
294
|
+
sawJsonLikeOutput = true;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (message.includes('parseable JSON object')) {
|
|
298
|
+
sawJsonLikeOutput = true;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (message.includes('did not return a JSON object')) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (sawMismatchedRequestId) {
|
|
308
|
+
throw new Error(`${sourceLabel} returned mismatched request_id`);
|
|
309
|
+
}
|
|
310
|
+
if (sawJsonLikeOutput) {
|
|
311
|
+
throw new Error(`${sourceLabel} did not return a parseable JSON object`);
|
|
312
|
+
}
|
|
313
|
+
throw new Error(`${sourceLabel} did not return a JSON object`);
|
|
269
314
|
}
|
|
270
315
|
function makePlannerRequestId() {
|
|
271
316
|
return `timeline-plan-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
|
272
317
|
}
|
|
273
318
|
function buildTimelineQueryPlannerSystemPrompt() {
|
|
274
319
|
return [
|
|
275
|
-
'
|
|
276
|
-
'
|
|
277
|
-
'
|
|
278
|
-
'
|
|
279
|
-
'1.
|
|
280
|
-
'2.
|
|
281
|
-
'3. now
|
|
282
|
-
'4. past_point
|
|
283
|
-
'5. past_range
|
|
284
|
-
'6.
|
|
285
|
-
'7.
|
|
286
|
-
'8.
|
|
287
|
-
'9.
|
|
288
|
-
'10.
|
|
289
|
-
'11.
|
|
320
|
+
'You are the internal Timeline plugin query normalizer.',
|
|
321
|
+
'Your only task is to normalize a natural-language time request into a structured time plan that Timeline can execute.',
|
|
322
|
+
'Do not call tools. Do not output Markdown, explanations, or extra text. Output strict JSON only.',
|
|
323
|
+
'You must follow these constraints:',
|
|
324
|
+
'1. First classify the request as now, past_point, or past_range.',
|
|
325
|
+
'2. Do not classify mechanically from keywords. Interpret the actual time semantics in the user request.',
|
|
326
|
+
'3. For now, do not output normalized_point / normalized_start / normalized_end.',
|
|
327
|
+
'4. For past_point, normalized_point is required.',
|
|
328
|
+
'5. For past_range, normalized_start and normalized_end are required.',
|
|
329
|
+
'6. For colloquial ranges such as “最近”, normalize into concrete bounds using anchor.now.',
|
|
330
|
+
'7. For expressions such as “昨晚”, “今天”, or “昨天上午”, produce a realistic range that fits ordinary usage.',
|
|
331
|
+
'8. All output times must be ISO-like timestamps with timezone offsets.',
|
|
332
|
+
'9. Classify as past_point only when the user clearly points to a specific moment, for example “昨晚八点”, “昨天上午十点”, or “上周六晚上九点”.',
|
|
333
|
+
'10. If the user is asking about a duration or a whole period, it must be past_range, for example “昨晚在做什么”, “今天都忙了什么”, “最近有什么有趣的事吗”, or “这几天怎么样”.',
|
|
334
|
+
'11. “昨晚” by itself is not a point in time. It is an evening range. Only expressions with an explicit anchor such as “昨晚八点” are past_point.',
|
|
290
335
|
].join('\n');
|
|
291
336
|
}
|
|
292
337
|
function buildTimelineQueryPlannerMessage(input, anchor, requestId) {
|
|
293
338
|
return [
|
|
294
|
-
'
|
|
295
|
-
'
|
|
339
|
+
'Normalize time only from the information below.',
|
|
340
|
+
'Output a JSON object with the following shape:',
|
|
296
341
|
JSON.stringify({
|
|
297
342
|
schema_version: '1.0',
|
|
298
343
|
request_id: requestId,
|
|
299
344
|
target_time_range: 'now | past_point | past_range',
|
|
300
|
-
normalized_point: 'past_point
|
|
301
|
-
normalized_start: 'past_range
|
|
302
|
-
normalized_end: 'past_range
|
|
303
|
-
summary: '
|
|
345
|
+
normalized_point: 'required for past_point, omit otherwise',
|
|
346
|
+
normalized_start: 'required for past_range, omit otherwise',
|
|
347
|
+
normalized_end: 'required for past_range, omit otherwise',
|
|
348
|
+
summary: 'short summary of how you interpreted the user time semantics',
|
|
304
349
|
}, null, 2),
|
|
305
350
|
'',
|
|
306
351
|
'input:',
|
|
@@ -312,102 +357,333 @@ function buildTimelineQueryPlannerMessage(input, anchor, requestId) {
|
|
|
312
357
|
}
|
|
313
358
|
function buildTimelineReasonerSystemPrompt() {
|
|
314
359
|
return [
|
|
315
|
-
'
|
|
316
|
-
'
|
|
317
|
-
'
|
|
318
|
-
'
|
|
319
|
-
'1.
|
|
320
|
-
'2.
|
|
321
|
-
'3.
|
|
322
|
-
'4.
|
|
323
|
-
'5.
|
|
324
|
-
'6. continuity
|
|
325
|
-
'7. request_type
|
|
326
|
-
'8. continuity
|
|
327
|
-
'9. past_point
|
|
328
|
-
'10. past_range
|
|
329
|
-
'11.
|
|
330
|
-
'12.
|
|
331
|
-
'13. generated_fact
|
|
332
|
-
'14.
|
|
333
|
-
'15.
|
|
334
|
-
'16.
|
|
335
|
-
'17.
|
|
336
|
-
'18. MEMORY
|
|
337
|
-
'19.
|
|
338
|
-
'20.
|
|
339
|
-
'21.
|
|
340
|
-
'22.
|
|
341
|
-
'23.
|
|
342
|
-
'24.
|
|
343
|
-
'25.
|
|
344
|
-
'26.
|
|
360
|
+
'You are the internal Timeline plugin time-semantics reasoner.',
|
|
361
|
+
'Your only task is to use the collector fact bundle and output a JSON object that strictly matches TimelineReasonerOutput.',
|
|
362
|
+
'Do not call tools. Do not introduce pre-existing facts beyond the collector input. Do not output Markdown, explanations, or extra text.',
|
|
363
|
+
'You must follow these constraints:',
|
|
364
|
+
'1. Session hard facts and existing canon facts take priority over generation.',
|
|
365
|
+
'2. If collector.request.mode is read_only, you must never generate_new_fact.',
|
|
366
|
+
'3. If decision.action is reuse_existing_fact, selected_fact_id must come from candidate_facts.',
|
|
367
|
+
'4. If decision.action is generate_new_fact, you must provide a complete generated_fact and set should_write_canon=true.',
|
|
368
|
+
'5. Use return_empty only when the available information is insufficient and neither reuse nor generation is justified.',
|
|
369
|
+
'6. The continuity field must truthfully report whether continuity reasoning was used and why.',
|
|
370
|
+
'7. request_type must be one of now, past_point, or past_range.',
|
|
371
|
+
'8. continuity is not a separate request type. It is a reasoning result inside now or past_point queries.',
|
|
372
|
+
'9. past_point may hit either by exact match or by a prior fact that naturally continues to the target time.',
|
|
373
|
+
'10. For past_range, first understand the normalized range, then choose the most relevant, vivid, and worth-mentioning facts from that range.',
|
|
374
|
+
'11. If the user asks with semantic filters such as “有趣”, “好玩”, or “忙不忙”, interpret that filter first before deciding which fact to reuse or generate.',
|
|
375
|
+
'12. When generating a new fact for past_point or past_range, provide a reasonable timestamp that lands inside the target point or range instead of defaulting to the current moment.',
|
|
376
|
+
'13. generated_fact must contain structured fields only, with no free-form prose, explanation, or extra narration.',
|
|
377
|
+
'14. If collector.persona_context.should_constrain_generation=true, the new fact must explicitly respect the stable persona, tone, interests, habits, and long-term commitments described in SOUL / MEMORY / IDENTITY, and must not conflict with them.',
|
|
378
|
+
'15. When persona_context contains concrete persona signals, rationale.persona_basis must be non-empty and must name the specific persona signals used.',
|
|
379
|
+
'16. When persona_context contains concrete persona signals, rationale.constraint_basis must be non-empty and must name the long-term constraints that limited generation.',
|
|
380
|
+
'17. Do not generate generic, template-like daily scenes that could fit anyone. Let location, action, emotion, appearance, and internalMonologue reflect this persona\'s lived continuity.',
|
|
381
|
+
'18. Long-term preferences, relationships, life rhythm, and standing commitments from MEMORY are important constraints when weaving time memories. They are not time facts themselves, but they constrain what generation is believable.',
|
|
382
|
+
'19. You must also respect the real-world temporal logic in collector.world_context: meals, sleep, work/study, leisure, weekends, weekdays, and holidays should broadly fit ordinary life rhythm.',
|
|
383
|
+
'20. For late-night or pre-dawn generation, prefer sleep, rest, or quiet activities. For breakfast, lunch, or dinner scenes, keep the timestamp inside a plausible meal window. Do not generate scenes that obviously violate ordinary routine.',
|
|
384
|
+
'21. If decision.action is generate_new_fact, generated_fact.sceneSemantics must be fully populated to explain the activity type, how it relates to known same-day state, and why that judgment fits.',
|
|
385
|
+
'22. If decision.action is generate_new_fact, generated_fact.appearanceLogic must be fully populated to explain whether the day\'s outfit continues, whether a change is needed, why, and what outfit class results.',
|
|
386
|
+
'23. Appearance and clothing must depend on the concrete event itself rather than being generated independently. Exercise, bathing, sleep, formal outings, and buying/changing into new clothes are all strong appearance drivers.',
|
|
387
|
+
'24. If there is not enough reason for an outfit change, prefer same-day clothing continuity. Do not change appearance descriptions repeatedly within one day without cause.',
|
|
388
|
+
'25. For now queries, if collector.conversation_context.should_prefer_conversation_continuity_for_now=true, then “still continuing the just-active conversation with the user” should be treated as the highest-priority near-field reality.',
|
|
389
|
+
'26. If the current session is still inside the stickiness window, prefer interpreting the current state as continuing the recent topic, thinking about the last turn, or preparing a response, instead of jumping immediately to an unrelated off-thread life scene.',
|
|
345
390
|
].join('\n');
|
|
346
391
|
}
|
|
347
|
-
function
|
|
348
|
-
return
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
392
|
+
function setClock(parts, hour, minute = 0, second = 0) {
|
|
393
|
+
return {
|
|
394
|
+
...parts,
|
|
395
|
+
hour,
|
|
396
|
+
minute,
|
|
397
|
+
second,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
function normalizeHourFromQuery(query) {
|
|
401
|
+
const digitMatch = query.match(/(\d{1,2})点/);
|
|
402
|
+
let hour = digitMatch ? Number(digitMatch[1]) : null;
|
|
403
|
+
if (hour === null) {
|
|
404
|
+
const chineseHourMap = {
|
|
405
|
+
零: 0,
|
|
406
|
+
一: 1,
|
|
407
|
+
二: 2,
|
|
408
|
+
两: 2,
|
|
409
|
+
三: 3,
|
|
410
|
+
四: 4,
|
|
411
|
+
五: 5,
|
|
412
|
+
六: 6,
|
|
413
|
+
七: 7,
|
|
414
|
+
八: 8,
|
|
415
|
+
九: 9,
|
|
416
|
+
十: 10,
|
|
417
|
+
十一: 11,
|
|
418
|
+
十二: 12,
|
|
419
|
+
};
|
|
420
|
+
const chineseMatch = query.match(/(十一|十二|十|零|一|二|两|三|四|五|六|七|八|九)点/);
|
|
421
|
+
if (chineseMatch) {
|
|
422
|
+
hour = chineseHourMap[chineseMatch[1]] ?? null;
|
|
352
423
|
}
|
|
353
|
-
|
|
354
|
-
|
|
424
|
+
}
|
|
425
|
+
if (hour === null)
|
|
426
|
+
return null;
|
|
427
|
+
if ((/昨晚|晚上|傍晚|夜里/.test(query)) && hour < 12) {
|
|
428
|
+
return hour === 12 ? 12 : hour + 12;
|
|
429
|
+
}
|
|
430
|
+
if ((/下午/.test(query)) && hour < 12) {
|
|
431
|
+
return hour === 12 ? 12 : hour + 12;
|
|
432
|
+
}
|
|
433
|
+
return hour;
|
|
434
|
+
}
|
|
435
|
+
function buildFallbackTimelineQueryPlan(input, anchor) {
|
|
436
|
+
const query = String(input.query || '').trim();
|
|
437
|
+
const anchorParts = (0, time_utils_1.parseTimestampParts)(anchor.now);
|
|
438
|
+
if (!anchorParts) {
|
|
439
|
+
return {
|
|
440
|
+
schema_version: '1.0',
|
|
441
|
+
target_time_range: 'now',
|
|
442
|
+
summary: 'Fallback planner defaulted to now because anchor.now was not parseable.',
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
if (/昨晚|昨天|上周|前天/.test(query) && /点/.test(query)) {
|
|
446
|
+
const targetHour = normalizeHourFromQuery(query) ?? 20;
|
|
447
|
+
const targetDay = (0, time_utils_1.addHours)(anchorParts, -24);
|
|
448
|
+
return {
|
|
449
|
+
schema_version: '1.0',
|
|
450
|
+
target_time_range: 'past_point',
|
|
451
|
+
normalized_point: (0, time_utils_1.formatTimestamp)(setClock(targetDay, targetHour, 0, 0)),
|
|
452
|
+
summary: 'Fallback planner normalized the query into a concrete past point.',
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
if (/最近|这几天|昨晚|今天都|昨天都/.test(query)) {
|
|
456
|
+
if (/昨晚/.test(query)) {
|
|
457
|
+
const targetDay = (0, time_utils_1.addHours)(anchorParts, -24);
|
|
458
|
+
return {
|
|
459
|
+
schema_version: '1.0',
|
|
460
|
+
target_time_range: 'past_range',
|
|
461
|
+
normalized_start: (0, time_utils_1.formatTimestamp)(setClock(targetDay, 18, 0, 0)),
|
|
462
|
+
normalized_end: (0, time_utils_1.formatTimestamp)(setClock(targetDay, 23, 59, 59)),
|
|
463
|
+
summary: 'Fallback planner normalized the query into last night\'s evening range.',
|
|
464
|
+
};
|
|
355
465
|
}
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
466
|
+
const recentStart = (0, time_utils_1.addHours)(anchorParts, -24 * 7);
|
|
467
|
+
return {
|
|
468
|
+
schema_version: '1.0',
|
|
469
|
+
target_time_range: 'past_range',
|
|
470
|
+
normalized_start: (0, time_utils_1.formatTimestamp)(setClock(recentStart, 0, 0, 0)),
|
|
471
|
+
normalized_end: anchor.now,
|
|
472
|
+
summary: 'Fallback planner normalized the query into a recent past range.',
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
schema_version: '1.0',
|
|
477
|
+
target_time_range: 'now',
|
|
478
|
+
summary: 'Fallback planner normalized the query into the current moment.',
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function parseTimestampMs(timestamp) {
|
|
482
|
+
if (!timestamp)
|
|
483
|
+
return Number.NaN;
|
|
484
|
+
const parsed = Date.parse(timestamp);
|
|
485
|
+
return Number.isNaN(parsed) ? Number.NaN : parsed;
|
|
486
|
+
}
|
|
487
|
+
function selectLatestFact(facts) {
|
|
488
|
+
return [...facts]
|
|
489
|
+
.sort((left, right) => parseTimestampMs(right.timestamp) - parseTimestampMs(left.timestamp))[0] || null;
|
|
490
|
+
}
|
|
491
|
+
function scorePastRangeFact(fact, query) {
|
|
492
|
+
let score = parseTimestampMs(fact.timestamp);
|
|
493
|
+
const haystack = `${fact.location} ${fact.action} ${(fact.emotion_tags || []).join(' ')}`;
|
|
494
|
+
if (/有趣|好玩|开心|趣事/.test(query) && /(朋友|球|篮球|烧烤|聊天|公园|运动|开心)/.test(haystack)) {
|
|
495
|
+
score += 1000 * 60 * 60 * 24 * 30;
|
|
496
|
+
}
|
|
497
|
+
if (/昨晚/.test(query)) {
|
|
498
|
+
const parts = (0, time_utils_1.parseTimestampParts)(fact.timestamp);
|
|
499
|
+
if (parts && parts.hour >= 18 && parts.hour <= 23) {
|
|
500
|
+
score += 1000 * 60 * 60 * 12;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return score;
|
|
504
|
+
}
|
|
505
|
+
function selectFallbackFact(collector) {
|
|
506
|
+
const facts = collector.candidate_facts || [];
|
|
507
|
+
if (facts.length === 0)
|
|
508
|
+
return null;
|
|
509
|
+
if (collector.window.query_range === 'now') {
|
|
510
|
+
return selectLatestFact(facts);
|
|
511
|
+
}
|
|
512
|
+
if (collector.window.query_range === 'past_point') {
|
|
513
|
+
const query = String(collector.request.user_query || '');
|
|
514
|
+
const explicitHour = normalizeHourFromQuery(query);
|
|
515
|
+
if (explicitHour === null) {
|
|
516
|
+
return selectLatestFact(facts);
|
|
517
|
+
}
|
|
518
|
+
return [...facts].sort((left, right) => {
|
|
519
|
+
const leftParts = (0, time_utils_1.parseTimestampParts)(left.timestamp);
|
|
520
|
+
const rightParts = (0, time_utils_1.parseTimestampParts)(right.timestamp);
|
|
521
|
+
const leftDistance = leftParts ? Math.abs((leftParts.hour * 60 + leftParts.minute) - explicitHour * 60) : Number.MAX_SAFE_INTEGER;
|
|
522
|
+
const rightDistance = rightParts ? Math.abs((rightParts.hour * 60 + rightParts.minute) - explicitHour * 60) : Number.MAX_SAFE_INTEGER;
|
|
523
|
+
return leftDistance - rightDistance || (parseTimestampMs(right.timestamp) - parseTimestampMs(left.timestamp));
|
|
524
|
+
})[0] || null;
|
|
525
|
+
}
|
|
526
|
+
const query = String(collector.request.user_query || '');
|
|
527
|
+
return [...facts].sort((left, right) => scorePastRangeFact(right, query) - scorePastRangeFact(left, query))[0] || null;
|
|
528
|
+
}
|
|
529
|
+
function buildFallbackTimeInterpretation(collector, selectedFact) {
|
|
530
|
+
if (collector.window.query_range === 'now') {
|
|
531
|
+
return {
|
|
532
|
+
normalized_kind: 'now',
|
|
533
|
+
match_strategy: selectedFact ? 'continuation' : 'generated',
|
|
534
|
+
summary: 'Fallback reasoner treated the request as a current-moment query.',
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
if (collector.window.query_range === 'past_point') {
|
|
538
|
+
const query = String(collector.request.user_query || '');
|
|
539
|
+
const hour = normalizeHourFromQuery(query);
|
|
540
|
+
const anchorParts = (0, time_utils_1.parseTimestampParts)(collector.anchor.now);
|
|
541
|
+
const targetDay = anchorParts ? (0, time_utils_1.addHours)(anchorParts, -24) : null;
|
|
542
|
+
return {
|
|
543
|
+
normalized_kind: 'point',
|
|
544
|
+
normalized_point: targetDay && hour !== null ? (0, time_utils_1.formatTimestamp)(setClock(targetDay, hour, 0, 0)) : selectedFact?.timestamp,
|
|
545
|
+
match_strategy: selectedFact ? 'continuation' : 'generated',
|
|
546
|
+
summary: 'Fallback reasoner treated the request as a concrete past point query.',
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
normalized_kind: 'range',
|
|
551
|
+
normalized_start: collector.window.start,
|
|
552
|
+
normalized_end: collector.window.end,
|
|
553
|
+
match_strategy: selectedFact ? 'range_summary' : 'generated',
|
|
554
|
+
summary: 'Fallback reasoner treated the request as a past range query.',
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
function buildFallbackReasonerOutput(collector, error) {
|
|
558
|
+
const selectedFact = selectFallbackFact(collector);
|
|
559
|
+
const fallbackReason = error instanceof Error ? error.message : String(error);
|
|
560
|
+
if (!selectedFact) {
|
|
561
|
+
return {
|
|
562
|
+
schema_version: '1.0',
|
|
563
|
+
request_id: collector.request_id,
|
|
564
|
+
request_type: collector.window.query_range,
|
|
565
|
+
time_interpretation: buildFallbackTimeInterpretation(collector, null),
|
|
566
|
+
decision: {
|
|
567
|
+
action: 'return_empty',
|
|
568
|
+
should_write_canon: false,
|
|
569
|
+
},
|
|
570
|
+
continuity: {
|
|
571
|
+
judged: collector.window.query_range !== 'past_range',
|
|
572
|
+
is_continuing: false,
|
|
573
|
+
reason: `Fallback reasoner could not find a reusable canon fact after: ${fallbackReason}`,
|
|
574
|
+
},
|
|
575
|
+
rationale: {
|
|
576
|
+
summary: 'Fallback reasoner returned empty because no reusable canon fact was available.',
|
|
577
|
+
hard_fact_basis: collector.hard_facts.sessions_history.slice(0, 2),
|
|
578
|
+
canon_basis: [],
|
|
579
|
+
persona_basis: [],
|
|
580
|
+
constraint_basis: [],
|
|
581
|
+
uncertainty: fallbackReason,
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
const selectedParts = (0, time_utils_1.parseTimestampParts)(selectedFact.timestamp);
|
|
586
|
+
const targetHour = normalizeHourFromQuery(String(collector.request.user_query || ''));
|
|
587
|
+
const isPastPointContinuation = Boolean(collector.window.query_range === 'past_point'
|
|
588
|
+
&& selectedParts
|
|
589
|
+
&& targetHour !== null
|
|
590
|
+
&& Math.abs((selectedParts.hour * 60 + selectedParts.minute) - targetHour * 60) <= 90);
|
|
591
|
+
return {
|
|
592
|
+
schema_version: '1.0',
|
|
593
|
+
request_id: collector.request_id,
|
|
594
|
+
request_type: collector.window.query_range,
|
|
595
|
+
time_interpretation: buildFallbackTimeInterpretation(collector, selectedFact),
|
|
596
|
+
decision: {
|
|
597
|
+
action: 'reuse_existing_fact',
|
|
598
|
+
selected_fact_id: selectedFact.fact_id,
|
|
599
|
+
should_write_canon: false,
|
|
600
|
+
},
|
|
601
|
+
continuity: {
|
|
602
|
+
judged: collector.window.query_range !== 'past_range',
|
|
603
|
+
is_continuing: collector.window.query_range === 'now' || isPastPointContinuation,
|
|
604
|
+
reason: 'Fallback reasoner reused the strongest available canon fact when the subagent result was unavailable.',
|
|
605
|
+
},
|
|
606
|
+
rationale: {
|
|
607
|
+
summary: 'Fallback reasoner reused an existing canon fact because the subagent result was unavailable or invalid.',
|
|
608
|
+
hard_fact_basis: collector.hard_facts.sessions_history.slice(0, 2),
|
|
609
|
+
canon_basis: [selectedFact.fact_id],
|
|
610
|
+
persona_basis: [],
|
|
611
|
+
constraint_basis: [],
|
|
612
|
+
uncertainty: fallbackReason,
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
function createTimelineQueryPlanner(pluginApi, toolContext, runtimeConfig) {
|
|
617
|
+
return async (input, anchor) => {
|
|
359
618
|
try {
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
extraSystemPrompt: buildTimelineQueryPlannerSystemPrompt(),
|
|
364
|
-
deliver: false,
|
|
365
|
-
idempotencyKey: requestId,
|
|
366
|
-
});
|
|
367
|
-
const waitResult = await subagentRuntime.waitForRun({
|
|
368
|
-
runId: runResult.runId,
|
|
369
|
-
timeoutMs: runtimeConfig.reasonerTimeoutMs,
|
|
370
|
-
});
|
|
371
|
-
if (waitResult.status === 'timeout') {
|
|
372
|
-
throw new Error('Timeline query planner returned no decision');
|
|
373
|
-
}
|
|
374
|
-
if (waitResult.status === 'error') {
|
|
375
|
-
throw new Error(waitResult.error || 'Timeline query planner returned no decision');
|
|
619
|
+
const subagentRuntime = pluginApi.runtime?.subagent;
|
|
620
|
+
if (!subagentRuntime?.run || !subagentRuntime.waitForRun || !subagentRuntime.getSessionMessages) {
|
|
621
|
+
throw new Error('Timeline query planner dependency missing');
|
|
376
622
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
limit: runtimeConfig.reasonerMessageLimit,
|
|
380
|
-
});
|
|
381
|
-
const assistantText = extractLatestAssistantText(session.messages || []);
|
|
382
|
-
const jsonText = tryExtractJsonObject(assistantText, requestId);
|
|
383
|
-
const parsed = JSON.parse(jsonText);
|
|
384
|
-
if (parsed.request_id && parsed.request_id !== requestId) {
|
|
385
|
-
throw new Error('Timeline query planner returned mismatched request_id');
|
|
386
|
-
}
|
|
387
|
-
if (!['now', 'past_point', 'past_range'].includes(parsed.target_time_range)) {
|
|
388
|
-
throw new Error('Timeline query planner returned an invalid target_time_range');
|
|
623
|
+
if (!String(input.query || '').trim()) {
|
|
624
|
+
throw new Error('Timeline query planner dependency missing query');
|
|
389
625
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
626
|
+
const requestId = makePlannerRequestId();
|
|
627
|
+
const baseSessionKey = toolContext.sessionKey || `plugin:${runtimeConfig.reasonerSessionPrefix}`;
|
|
628
|
+
const plannerSessionKey = `${baseSessionKey}:${runtimeConfig.reasonerSessionPrefix}:planner:${requestId}`;
|
|
393
629
|
try {
|
|
394
|
-
await subagentRuntime.
|
|
630
|
+
const runResult = await subagentRuntime.run({
|
|
395
631
|
sessionKey: plannerSessionKey,
|
|
396
|
-
|
|
632
|
+
message: buildTimelineQueryPlannerMessage(input, anchor, requestId),
|
|
633
|
+
extraSystemPrompt: buildTimelineQueryPlannerSystemPrompt(),
|
|
634
|
+
deliver: false,
|
|
635
|
+
idempotencyKey: requestId,
|
|
397
636
|
});
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
error: error instanceof Error ? error.message : String(error),
|
|
637
|
+
const waitResult = await subagentRuntime.waitForRun({
|
|
638
|
+
runId: runResult.runId,
|
|
639
|
+
timeoutMs: runtimeConfig.reasonerTimeoutMs,
|
|
402
640
|
});
|
|
641
|
+
if (waitResult.status === 'timeout') {
|
|
642
|
+
throw new Error('Timeline query planner returned no decision');
|
|
643
|
+
}
|
|
644
|
+
if (waitResult.status === 'error') {
|
|
645
|
+
throw new Error(waitResult.error || 'Timeline query planner returned no decision');
|
|
646
|
+
}
|
|
647
|
+
const session = await subagentRuntime.getSessionMessages({
|
|
648
|
+
sessionKey: plannerSessionKey,
|
|
649
|
+
limit: runtimeConfig.reasonerMessageLimit,
|
|
650
|
+
});
|
|
651
|
+
const jsonText = extractJsonObjectFromMessages(session.messages || [], 'Timeline query planner', requestId);
|
|
652
|
+
const parsed = JSON.parse(jsonText);
|
|
653
|
+
if (parsed.request_id && parsed.request_id !== requestId) {
|
|
654
|
+
throw new Error('Timeline query planner returned mismatched request_id');
|
|
655
|
+
}
|
|
656
|
+
if (!['now', 'past_point', 'past_range'].includes(parsed.target_time_range)) {
|
|
657
|
+
throw new Error('Timeline query planner returned an invalid target_time_range');
|
|
658
|
+
}
|
|
659
|
+
return parsed;
|
|
660
|
+
}
|
|
661
|
+
finally {
|
|
662
|
+
try {
|
|
663
|
+
await subagentRuntime.deleteSession?.({
|
|
664
|
+
sessionKey: plannerSessionKey,
|
|
665
|
+
deleteTranscript: true,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
catch (error) {
|
|
669
|
+
pluginApi.logger?.debug?.('timeline query planner session cleanup skipped', {
|
|
670
|
+
error: error instanceof Error ? error.message : String(error),
|
|
671
|
+
});
|
|
672
|
+
}
|
|
403
673
|
}
|
|
404
674
|
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
pluginApi.logger?.warn?.('timeline query planner fallback engaged', {
|
|
677
|
+
error: error instanceof Error ? error.message : String(error),
|
|
678
|
+
});
|
|
679
|
+
return buildFallbackTimelineQueryPlan(input, anchor);
|
|
680
|
+
}
|
|
405
681
|
};
|
|
406
682
|
}
|
|
407
683
|
function buildTimelineReasonerMessage(collector) {
|
|
408
684
|
return [
|
|
409
|
-
'
|
|
410
|
-
'
|
|
685
|
+
'Perform structured time reasoning using only the collector JSON below.',
|
|
686
|
+
'Output a JSON object matching TimelineReasonerOutput:',
|
|
411
687
|
JSON.stringify({
|
|
412
688
|
schema_version: '1.0',
|
|
413
689
|
request_id: collector.request_id,
|
|
@@ -422,7 +698,7 @@ function buildTimelineReasonerMessage(collector) {
|
|
|
422
698
|
},
|
|
423
699
|
decision: {
|
|
424
700
|
action: 'reuse_existing_fact | generate_new_fact | return_empty',
|
|
425
|
-
selected_fact_id: 'reuse_existing_fact
|
|
701
|
+
selected_fact_id: 'required when action is reuse_existing_fact',
|
|
426
702
|
should_write_canon: true,
|
|
427
703
|
},
|
|
428
704
|
continuity: {
|
|
@@ -474,50 +750,57 @@ function buildTimelineReasonerMessage(collector) {
|
|
|
474
750
|
}
|
|
475
751
|
function createSubagentReasoner(pluginApi, toolContext, runtimeConfig) {
|
|
476
752
|
return async (collector) => {
|
|
477
|
-
const subagentRuntime = pluginApi.runtime?.subagent;
|
|
478
|
-
if (!subagentRuntime?.run || !subagentRuntime.waitForRun || !subagentRuntime.getSessionMessages) {
|
|
479
|
-
throw new Error('Timeline reasoner dependency missing');
|
|
480
|
-
}
|
|
481
|
-
const baseSessionKey = toolContext.sessionKey || `plugin:${runtimeConfig.reasonerSessionPrefix}`;
|
|
482
|
-
const reasonerSessionKey = `${baseSessionKey}:${runtimeConfig.reasonerSessionPrefix}:${collector.request_id}`;
|
|
483
753
|
try {
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
extraSystemPrompt: buildTimelineReasonerSystemPrompt(),
|
|
488
|
-
deliver: false,
|
|
489
|
-
idempotencyKey: collector.request_id,
|
|
490
|
-
});
|
|
491
|
-
const waitResult = await subagentRuntime.waitForRun({
|
|
492
|
-
runId: runResult.runId,
|
|
493
|
-
timeoutMs: runtimeConfig.reasonerTimeoutMs,
|
|
494
|
-
});
|
|
495
|
-
if (waitResult.status === 'timeout') {
|
|
496
|
-
throw new Error('Timeline reasoner returned no decision');
|
|
497
|
-
}
|
|
498
|
-
if (waitResult.status === 'error') {
|
|
499
|
-
throw new Error(waitResult.error || 'Timeline reasoner returned no decision');
|
|
754
|
+
const subagentRuntime = pluginApi.runtime?.subagent;
|
|
755
|
+
if (!subagentRuntime?.run || !subagentRuntime.waitForRun || !subagentRuntime.getSessionMessages) {
|
|
756
|
+
throw new Error('Timeline reasoner dependency missing');
|
|
500
757
|
}
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
limit: runtimeConfig.reasonerMessageLimit,
|
|
504
|
-
});
|
|
505
|
-
const assistantText = extractLatestAssistantText(session.messages || []);
|
|
506
|
-
const jsonText = tryExtractJsonObject(assistantText, collector.request_id);
|
|
507
|
-
return JSON.parse(jsonText);
|
|
508
|
-
}
|
|
509
|
-
finally {
|
|
758
|
+
const baseSessionKey = toolContext.sessionKey || `plugin:${runtimeConfig.reasonerSessionPrefix}`;
|
|
759
|
+
const reasonerSessionKey = `${baseSessionKey}:${runtimeConfig.reasonerSessionPrefix}:${collector.request_id}`;
|
|
510
760
|
try {
|
|
511
|
-
await subagentRuntime.
|
|
761
|
+
const runResult = await subagentRuntime.run({
|
|
512
762
|
sessionKey: reasonerSessionKey,
|
|
513
|
-
|
|
763
|
+
message: buildTimelineReasonerMessage(collector),
|
|
764
|
+
extraSystemPrompt: buildTimelineReasonerSystemPrompt(),
|
|
765
|
+
deliver: false,
|
|
766
|
+
idempotencyKey: collector.request_id,
|
|
514
767
|
});
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
768
|
+
const waitResult = await subagentRuntime.waitForRun({
|
|
769
|
+
runId: runResult.runId,
|
|
770
|
+
timeoutMs: runtimeConfig.reasonerTimeoutMs,
|
|
771
|
+
});
|
|
772
|
+
if (waitResult.status === 'timeout') {
|
|
773
|
+
throw new Error('Timeline reasoner returned no decision');
|
|
774
|
+
}
|
|
775
|
+
if (waitResult.status === 'error') {
|
|
776
|
+
throw new Error(waitResult.error || 'Timeline reasoner returned no decision');
|
|
777
|
+
}
|
|
778
|
+
const session = await subagentRuntime.getSessionMessages({
|
|
779
|
+
sessionKey: reasonerSessionKey,
|
|
780
|
+
limit: runtimeConfig.reasonerMessageLimit,
|
|
519
781
|
});
|
|
782
|
+
const jsonText = extractJsonObjectFromMessages(session.messages || [], 'Timeline reasoner', collector.request_id);
|
|
783
|
+
return JSON.parse(jsonText);
|
|
520
784
|
}
|
|
785
|
+
finally {
|
|
786
|
+
try {
|
|
787
|
+
await subagentRuntime.deleteSession?.({
|
|
788
|
+
sessionKey: reasonerSessionKey,
|
|
789
|
+
deleteTranscript: true,
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
pluginApi.logger?.debug?.('timeline reasoner session cleanup skipped', {
|
|
794
|
+
error: error instanceof Error ? error.message : String(error),
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
pluginApi.logger?.warn?.('timeline reasoner fallback engaged', {
|
|
801
|
+
error: error instanceof Error ? error.message : String(error),
|
|
802
|
+
});
|
|
803
|
+
return buildFallbackReasonerOutput(collector, error);
|
|
521
804
|
}
|
|
522
805
|
};
|
|
523
806
|
}
|