stella-timeline-plugin 2.0.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.
Files changed (44) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +103 -0
  3. package/README_ZH.md +103 -0
  4. package/bin/openclaw-timeline-doctor.mjs +2 -0
  5. package/bin/openclaw-timeline-setup.mjs +2 -0
  6. package/dist/index.js +26 -0
  7. package/dist/src/core/build_consumption_view.js +52 -0
  8. package/dist/src/core/calendar_dates.js +23 -0
  9. package/dist/src/core/collect_sources.js +39 -0
  10. package/dist/src/core/collect_timeline_request.js +76 -0
  11. package/dist/src/core/materialize_generated_candidate.js +87 -0
  12. package/dist/src/core/resolve_window.js +83 -0
  13. package/dist/src/core/runtime_guard.js +170 -0
  14. package/dist/src/core/timeline_reasoner_contract.js +2 -0
  15. package/dist/src/core/trace.js +22 -0
  16. package/dist/src/core/world_rhythm.js +258 -0
  17. package/dist/src/lib/fingerprint.js +46 -0
  18. package/dist/src/lib/holidays.js +95 -0
  19. package/dist/src/lib/inherit-appearance.js +46 -0
  20. package/dist/src/lib/parse-memory.js +171 -0
  21. package/dist/src/lib/time-utils.js +49 -0
  22. package/dist/src/lib/timeline_semantics.js +63 -0
  23. package/dist/src/lib/types.js +2 -0
  24. package/dist/src/openclaw-sdk-compat.js +39 -0
  25. package/dist/src/plugin_metadata.js +9 -0
  26. package/dist/src/runtime/conversation_context.js +128 -0
  27. package/dist/src/runtime/openclaw_timeline_runtime.js +655 -0
  28. package/dist/src/storage/daily_log.js +60 -0
  29. package/dist/src/storage/lock.js +74 -0
  30. package/dist/src/storage/trace_log.js +70 -0
  31. package/dist/src/storage/write-episode.js +164 -0
  32. package/dist/src/tools/timeline_resolve.js +689 -0
  33. package/openclaw.plugin.json +54 -0
  34. package/package.json +73 -0
  35. package/scripts/doctor-openclaw-workspace.mjs +94 -0
  36. package/scripts/migrate-existing-memory.mjs +153 -0
  37. package/scripts/release.mjs +99 -0
  38. package/scripts/run-openclaw-live-e2e.mjs +64 -0
  39. package/scripts/run-openclaw-smoke.mjs +21 -0
  40. package/scripts/setup-openclaw-workspace.mjs +119 -0
  41. package/scripts/workspace-contract.mjs +47 -0
  42. package/skills/timeline/SKILL.md +111 -0
  43. package/templates/AGENTS.fragment.md +29 -0
  44. package/templates/SOUL.fragment.md +17 -0
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateTimelineReasonerOutput = validateTimelineReasonerOutput;
4
+ const timeline_semantics_1 = require("../lib/timeline_semantics");
5
+ const world_rhythm_1 = require("./world_rhythm");
6
+ function factToParsedEpisode(fact) {
7
+ return {
8
+ timestamp: fact.timestamp,
9
+ location: fact.location,
10
+ action: fact.action,
11
+ emotionTags: fact.emotion_tags,
12
+ appearance: fact.appearance,
13
+ internalMonologue: fact.internal_monologue,
14
+ parseLevel: fact.parse_level,
15
+ confidence: fact.confidence,
16
+ };
17
+ }
18
+ function isValidGeneratedDraft(draft) {
19
+ if (!draft)
20
+ return false;
21
+ return Boolean((draft.timestamp === undefined || String(draft.timestamp).trim())
22
+ && String(draft.location || '').trim()
23
+ && String(draft.action || '').trim()
24
+ && Array.isArray(draft.emotionTags)
25
+ && draft.emotionTags.length > 0
26
+ && String(draft.appearance || '').trim()
27
+ && String(draft.internalMonologue || '').trim()
28
+ && Number.isFinite(Number(draft.confidence)));
29
+ }
30
+ function hasStructuredSceneSemantics(draft) {
31
+ return Boolean(draft.sceneSemantics
32
+ && (0, timeline_semantics_1.isActivityMode)(draft.sceneSemantics.activityMode)
33
+ && (0, timeline_semantics_1.isContinuityRelation)(draft.sceneSemantics.continuityRelation)
34
+ && String(draft.sceneSemantics.rationale || '').trim());
35
+ }
36
+ function hasStructuredAppearanceLogic(draft) {
37
+ return Boolean(draft.appearanceLogic
38
+ && (0, timeline_semantics_1.isAppearanceTransition)(draft.appearanceLogic.transition)
39
+ && (0, timeline_semantics_1.isOutfitMode)(draft.appearanceLogic.outfitMode)
40
+ && String(draft.appearanceLogic.changeReason || '').trim());
41
+ }
42
+ function hasPersonaConstraints(collector) {
43
+ return Boolean(collector.persona_context.should_constrain_generation
44
+ || collector.persona_context.soul.trim()
45
+ || collector.persona_context.memory.trim()
46
+ || collector.persona_context.identity.trim());
47
+ }
48
+ function validateTimelineReasonerOutput(collector, reasoner) {
49
+ if (reasoner.request_id !== collector.request_id) {
50
+ return {
51
+ ok: false,
52
+ outcome: 'blocked',
53
+ write_allowed: false,
54
+ block_reason: 'reasoner request_id mismatch',
55
+ };
56
+ }
57
+ if (reasoner.decision.action === 'reuse_existing_fact') {
58
+ const selectedFact = collector.candidate_facts.find((fact) => fact.fact_id === reasoner.decision.selected_fact_id);
59
+ if (!selectedFact) {
60
+ return {
61
+ ok: false,
62
+ outcome: 'blocked',
63
+ write_allowed: false,
64
+ block_reason: 'reasoner selected_fact_id not found in collector candidate_facts',
65
+ };
66
+ }
67
+ return {
68
+ ok: true,
69
+ outcome: 'reuse_existing_fact',
70
+ selected_fact: selectedFact,
71
+ selected_episode: factToParsedEpisode(selectedFact),
72
+ write_allowed: false,
73
+ };
74
+ }
75
+ if (reasoner.decision.action === 'generate_new_fact') {
76
+ if (collector.request.mode !== 'allow_generate') {
77
+ return {
78
+ ok: false,
79
+ outcome: 'blocked',
80
+ write_allowed: false,
81
+ block_reason: 'reasoner requested generation during read_only mode',
82
+ };
83
+ }
84
+ if (!reasoner.decision.should_write_canon) {
85
+ return {
86
+ ok: false,
87
+ outcome: 'blocked',
88
+ write_allowed: false,
89
+ block_reason: 'reasoner generated a new fact without canon write permission',
90
+ };
91
+ }
92
+ if (!isValidGeneratedDraft(reasoner.generated_fact)) {
93
+ return {
94
+ ok: false,
95
+ outcome: 'blocked',
96
+ write_allowed: false,
97
+ block_reason: 'reasoner generated_fact payload is invalid',
98
+ };
99
+ }
100
+ if (!hasStructuredSceneSemantics(reasoner.generated_fact)) {
101
+ return {
102
+ ok: false,
103
+ outcome: 'blocked',
104
+ write_allowed: false,
105
+ block_reason: 'reasoner generated a new fact without structured scene semantics',
106
+ };
107
+ }
108
+ if (!hasStructuredAppearanceLogic(reasoner.generated_fact)) {
109
+ return {
110
+ ok: false,
111
+ outcome: 'blocked',
112
+ write_allowed: false,
113
+ block_reason: 'reasoner generated a new fact without structured appearance logic',
114
+ };
115
+ }
116
+ if (hasPersonaConstraints(collector) && reasoner.rationale.persona_basis.length === 0) {
117
+ return {
118
+ ok: false,
119
+ outcome: 'blocked',
120
+ write_allowed: false,
121
+ block_reason: 'reasoner generated a new fact without persona grounding',
122
+ };
123
+ }
124
+ if (hasPersonaConstraints(collector) && reasoner.rationale.constraint_basis.length === 0) {
125
+ return {
126
+ ok: false,
127
+ outcome: 'blocked',
128
+ write_allowed: false,
129
+ block_reason: 'reasoner generated a new fact without explicit persona constraints',
130
+ };
131
+ }
132
+ if (hasPersonaConstraints(collector) && !String(reasoner.generated_fact.reason || '').trim()) {
133
+ return {
134
+ ok: false,
135
+ outcome: 'blocked',
136
+ write_allowed: false,
137
+ block_reason: 'reasoner generated a new fact without explaining persona-consistent generation',
138
+ };
139
+ }
140
+ const worldRhythmCheck = (0, world_rhythm_1.validateGeneratedWorldRhythm)(reasoner.generated_fact);
141
+ if (!worldRhythmCheck.ok) {
142
+ return {
143
+ ok: false,
144
+ outcome: 'blocked',
145
+ write_allowed: false,
146
+ block_reason: `reasoner generated_fact violates world rhythm: ${worldRhythmCheck.issues.join(' ')}`,
147
+ };
148
+ }
149
+ if (reasoner.generated_fact.appearanceLogic?.transition === 'change_required'
150
+ && reasoner.generated_fact.appearanceLogic.outfitMode === 'unknown') {
151
+ return {
152
+ ok: false,
153
+ outcome: 'blocked',
154
+ write_allowed: false,
155
+ block_reason: 'reasoner generated a required outfit change without a concrete outfit mode',
156
+ };
157
+ }
158
+ return {
159
+ ok: true,
160
+ outcome: 'generate_new_fact',
161
+ generated_fact: reasoner.generated_fact,
162
+ write_allowed: true,
163
+ };
164
+ }
165
+ return {
166
+ ok: true,
167
+ outcome: 'return_empty',
168
+ write_allowed: false,
169
+ };
170
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.makeTraceId = makeTraceId;
4
+ exports.buildTrace = buildTrace;
5
+ function makeTraceId() {
6
+ return `timeline-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
7
+ }
8
+ function buildTrace(input) {
9
+ return {
10
+ trace_id: makeTraceId(),
11
+ ts: new Date().toISOString(),
12
+ requested_range: input.requested_range,
13
+ actual_range: input.actual_range,
14
+ source_order: input.source_order,
15
+ source_summary: input.source_summary,
16
+ fingerprint: input.fingerprint,
17
+ appearance: input.appearance,
18
+ write: input.write,
19
+ decision: input.decision,
20
+ notes: input.notes,
21
+ };
22
+ }
@@ -0,0 +1,258 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildWorldRhythmSlot = buildWorldRhythmSlot;
4
+ exports.buildTimelineWorldContext = buildTimelineWorldContext;
5
+ exports.classifyWorldRhythmModes = classifyWorldRhythmModes;
6
+ exports.validateGeneratedWorldRhythm = validateGeneratedWorldRhythm;
7
+ const holidays_1 = require("../lib/holidays");
8
+ const time_utils_1 = require("../lib/time-utils");
9
+ function inferCountryFromOffset(offset) {
10
+ return offset === '+08:00' ? 'CN' : 'US';
11
+ }
12
+ function inferTimeBand(hour) {
13
+ if (hour <= 4)
14
+ return 'late_night';
15
+ if (hour <= 8)
16
+ return 'early_morning';
17
+ if (hour <= 11)
18
+ return 'morning_work';
19
+ if (hour <= 13)
20
+ return 'midday';
21
+ if (hour <= 16)
22
+ return 'afternoon';
23
+ if (hour <= 19)
24
+ return 'dinner_window';
25
+ if (hour <= 22)
26
+ return 'evening';
27
+ return 'late_evening';
28
+ }
29
+ function describeBand(timeBand, weekday, holidayKey) {
30
+ const notes = [];
31
+ const encouraged = new Set();
32
+ const discouraged = new Set();
33
+ const addEncouraged = (...modes) => modes.forEach((mode) => encouraged.add(mode));
34
+ const addDiscouraged = (...modes) => modes.forEach((mode) => discouraged.add(mode));
35
+ switch (timeBand) {
36
+ case 'late_night':
37
+ addEncouraged('sleep', 'rest', 'leisure');
38
+ addDiscouraged('breakfast', 'lunch', 'dinner', 'errands', 'work_or_study');
39
+ notes.push('Deep-night slots usually favor sleep, winding down, or very quiet activities.');
40
+ break;
41
+ case 'early_morning':
42
+ addEncouraged('wake_up', 'breakfast', 'commute', 'exercise', 'domestic');
43
+ addDiscouraged('nightlife');
44
+ notes.push('Early morning usually fits waking up, breakfast, commuting, or light exercise.');
45
+ break;
46
+ case 'morning_work':
47
+ addEncouraged('work_or_study', 'commute', 'errands');
48
+ addDiscouraged('sleep', 'nightlife');
49
+ notes.push('Late morning often fits work, study, focused errands, or commuting.');
50
+ break;
51
+ case 'midday':
52
+ addEncouraged('lunch', 'work_or_study', 'social', 'errands');
53
+ addDiscouraged('sleep', 'nightlife');
54
+ notes.push('Midday usually fits lunch, a work break, errands, or a short social moment.');
55
+ break;
56
+ case 'afternoon':
57
+ addEncouraged('work_or_study', 'errands', 'exercise', 'domestic');
58
+ addDiscouraged('sleep');
59
+ notes.push('Afternoon often fits work, study, chores, errands, or exercise.');
60
+ break;
61
+ case 'dinner_window':
62
+ addEncouraged('dinner', 'social', 'exercise', 'domestic', 'commute');
63
+ addDiscouraged('sleep');
64
+ notes.push('Early evening usually fits dinner, going out, exercise, or returning home.');
65
+ break;
66
+ case 'evening':
67
+ addEncouraged('social', 'leisure', 'domestic', 'rest');
68
+ addDiscouraged('breakfast');
69
+ notes.push('Evening often fits leisure, social time, domestic tasks, or gentle decompression.');
70
+ break;
71
+ case 'late_evening':
72
+ addEncouraged('rest', 'sleep', 'leisure');
73
+ addDiscouraged('breakfast', 'errands');
74
+ notes.push('Late evening usually fits winding down, resting, or going to sleep.');
75
+ break;
76
+ }
77
+ if (holidayKey) {
78
+ addEncouraged('social', 'leisure', 'domestic');
79
+ notes.push(`Public holiday context: ${holidayKey}. Social, family, celebration, travel, or relaxed home activities are more plausible.`);
80
+ }
81
+ else if (!weekday) {
82
+ addEncouraged('social', 'leisure', 'domestic', 'exercise');
83
+ notes.push('Weekend context: leisure, outings, exercise, social time, or slower domestic routines are especially plausible.');
84
+ }
85
+ else if (timeBand === 'morning_work' || timeBand === 'afternoon') {
86
+ addEncouraged('work_or_study');
87
+ notes.push('Workday daylight context: work, study, or purposeful errands are especially plausible.');
88
+ }
89
+ return {
90
+ encouraged_modes: [...encouraged],
91
+ discouraged_modes: [...discouraged],
92
+ notes,
93
+ };
94
+ }
95
+ function classifyDateKind(parts) {
96
+ const calendarDate = (0, time_utils_1.formatDate)(parts);
97
+ const holidayKey = (0, holidays_1.getHoliday)(calendarDate, inferCountryFromOffset(parts.offset));
98
+ const weekday = ![0, 6].includes((0, time_utils_1.dayOfWeek)(parts));
99
+ return {
100
+ weekday,
101
+ holiday_key: holidayKey,
102
+ day_kind: holidayKey ? 'holiday' : weekday ? 'workday' : 'weekend',
103
+ };
104
+ }
105
+ function buildWorldRhythmSlot(timestamp) {
106
+ const parts = (0, time_utils_1.parseTimestampParts)(timestamp);
107
+ if (!parts)
108
+ return null;
109
+ const calendarDate = (0, time_utils_1.formatDate)(parts);
110
+ const dateKind = classifyDateKind(parts);
111
+ const timeBand = inferTimeBand(parts.hour);
112
+ const bandDescription = describeBand(timeBand, dateKind.weekday, dateKind.holiday_key);
113
+ return {
114
+ timestamp_hint: timestamp,
115
+ calendar_date: calendarDate,
116
+ weekday: dateKind.weekday,
117
+ holiday_key: dateKind.holiday_key,
118
+ day_kind: dateKind.day_kind,
119
+ time_band: timeBand,
120
+ encouraged_modes: bandDescription.encouraged_modes,
121
+ discouraged_modes: bandDescription.discouraged_modes,
122
+ notes: bandDescription.notes,
123
+ };
124
+ }
125
+ function makeDateTimestamp(date, offset) {
126
+ return `${date}T12:00:00${offset || ''}`;
127
+ }
128
+ function buildTimelineWorldContext(window) {
129
+ const startParts = (0, time_utils_1.parseTimestampParts)(window.start);
130
+ const offset = startParts?.offset;
131
+ return {
132
+ target: window.target_timestamp_hint ? buildWorldRhythmSlot(window.target_timestamp_hint) : null,
133
+ range_calendar: window.calendar_dates.map((date) => buildWorldRhythmSlot(makeDateTimestamp(date, offset))).filter(Boolean),
134
+ };
135
+ }
136
+ function matched(text, patterns) {
137
+ return patterns.some((pattern) => pattern.test(text));
138
+ }
139
+ function classifyWorldRhythmModes(input) {
140
+ const text = [input.action || '', input.location || '', input.internalMonologue || ''].join(' ').toLowerCase();
141
+ const modes = new Set();
142
+ if (matched(text, [/\b(sleep|sleeping|asleep|bed|nap|napping|woke|wake up)\b/, /睡|睡觉|午睡|醒来|起床|床上/])) {
143
+ if (matched(text, [/\b(wake|woke|wake up)\b/, /醒来|起床/])) {
144
+ modes.add('wake_up');
145
+ }
146
+ else {
147
+ modes.add('sleep');
148
+ }
149
+ }
150
+ if (matched(text, [/\b(breakfast|coffee|morning meal)\b/, /早餐|早饭|咖啡/]))
151
+ modes.add('breakfast');
152
+ if (matched(text, [/\b(lunch|midday meal)\b/, /午饭|午餐/]))
153
+ modes.add('lunch');
154
+ if (matched(text, [/\b(dinner|bbq|supper)\b/, /晚饭|晚餐|烧烤/]))
155
+ modes.add('dinner');
156
+ if (matched(text, [/\b(work|working|study|studying|notes|desk|meeting|organizing)\b/, /工作|学习|整理|书房|会议|记录|待办/]))
157
+ modes.add('work_or_study');
158
+ if (matched(text, [/\b(commute|subway|bus|train|drive)\b/, /通勤|地铁|公交|开车|路上/]))
159
+ modes.add('commute');
160
+ if (matched(text, [/\b(exercise|gym|run|running|basketball|walk)\b/, /运动|健身|跑步|打球|散步/]))
161
+ modes.add('exercise');
162
+ if (matched(text, [/\b(friend|friends|party|date|chatting|hang out)\b/, /朋友|聚会|约会|聊天|一起/]))
163
+ modes.add('social');
164
+ if (matched(text, [/\b(movie|show|watching|reading|book|game|cafe)\b/, /看剧|电影|看书|书店|咖啡馆|发呆|放松/]))
165
+ modes.add('leisure');
166
+ if (matched(text, [/\b(rest|resting|relax|relaxing|quiet|settle)\b/, /休息|放松|安静|缓下来|歇一会/]))
167
+ modes.add('rest');
168
+ if (matched(text, [/\b(cooking|laundry|cleaning|home)\b/, /做饭|收拾|家务|在家/]))
169
+ modes.add('domestic');
170
+ if (matched(text, [/\b(errand|shopping|bank|grocery)\b/, /买东西|采购|办事|超市/]))
171
+ modes.add('errands');
172
+ if (matched(text, [/\b(bar|club|drinks|late-night)\b/, /酒吧|夜店|喝酒|夜生活/]))
173
+ modes.add('nightlife');
174
+ return [...modes];
175
+ }
176
+ function hourFromTimestamp(timestamp) {
177
+ const parts = (0, time_utils_1.parseTimestampParts)(timestamp);
178
+ return parts ? parts.hour : null;
179
+ }
180
+ function worldModesFromActivityMode(activityMode) {
181
+ switch (activityMode) {
182
+ case 'sleep':
183
+ return ['sleep'];
184
+ case 'bath':
185
+ return ['rest', 'domestic'];
186
+ case 'meal':
187
+ return [];
188
+ case 'work_or_study':
189
+ return ['work_or_study'];
190
+ case 'commute':
191
+ return ['commute'];
192
+ case 'exercise':
193
+ return ['exercise'];
194
+ case 'social':
195
+ return ['social'];
196
+ case 'shopping':
197
+ return ['errands', 'social'];
198
+ case 'leisure':
199
+ return ['leisure'];
200
+ case 'domestic':
201
+ return ['domestic'];
202
+ case 'errands':
203
+ return ['errands'];
204
+ case 'transition':
205
+ return ['commute'];
206
+ case 'rest':
207
+ return ['rest'];
208
+ default:
209
+ return [];
210
+ }
211
+ }
212
+ function validateGeneratedWorldRhythm(draft) {
213
+ if (!draft.timestamp) {
214
+ return { ok: true, matched_modes: [], issues: [] };
215
+ }
216
+ const slot = buildWorldRhythmSlot(draft.timestamp);
217
+ if (!slot) {
218
+ return { ok: true, matched_modes: [], issues: [] };
219
+ }
220
+ const semanticModes = draft.sceneSemantics
221
+ ? worldModesFromActivityMode(draft.sceneSemantics.activityMode)
222
+ : [];
223
+ const matchedModes = semanticModes.length > 0
224
+ ? semanticModes
225
+ : classifyWorldRhythmModes({
226
+ action: draft.action,
227
+ location: draft.location,
228
+ internalMonologue: draft.internalMonologue,
229
+ });
230
+ if (matchedModes.length === 0) {
231
+ return { ok: true, matched_modes: [], issues: [] };
232
+ }
233
+ const hour = hourFromTimestamp(draft.timestamp);
234
+ const issues = [];
235
+ if (matchedModes.includes('breakfast') && (hour === null || hour < 5 || hour > 10)) {
236
+ issues.push('Breakfast-like activity falls outside a plausible breakfast window.');
237
+ }
238
+ if (matchedModes.includes('lunch') && (hour === null || hour < 10 || hour > 15)) {
239
+ issues.push('Lunch-like activity falls outside a plausible lunch window.');
240
+ }
241
+ if (matchedModes.includes('dinner') && (hour === null || hour < 16 || hour > 22)) {
242
+ issues.push('Dinner-like activity falls outside a plausible dinner window.');
243
+ }
244
+ if (matchedModes.includes('sleep') && (hour === null || (hour >= 8 && hour < 21))) {
245
+ issues.push('Sleeping activity falls outside a plausible main sleep window.');
246
+ }
247
+ if (matchedModes.includes('work_or_study') && hour !== null && hour <= 4) {
248
+ issues.push('Work or study activity is implausibly late for a normal real-world routine.');
249
+ }
250
+ if (matchedModes.includes('nightlife') && hour !== null && hour < 18) {
251
+ issues.push('Nightlife activity falls outside a plausible nightlife window.');
252
+ }
253
+ return {
254
+ ok: issues.length === 0,
255
+ matched_modes: matchedModes,
256
+ issues,
257
+ };
258
+ }
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computeFingerprint = computeFingerprint;
4
+ exports.checkReadOnlyHit = checkReadOnlyHit;
5
+ const time_utils_1 = require("./time-utils");
6
+ function normalize(s) {
7
+ return s.toLowerCase().replace(/[\s\p{P}]/gu, '');
8
+ }
9
+ function toTimeBucket(timestamp) {
10
+ const parts = (0, time_utils_1.parseTimestampParts)(timestamp);
11
+ if (parts) {
12
+ const hours = String(parts.hour).padStart(2, '0');
13
+ const minutes = parts.minute >= 30 ? '30' : '00';
14
+ return `${hours}:${minutes}`;
15
+ }
16
+ // Try to parse timestamp e.g. "2026-03-22 14:35:00" or other host-local date strings.
17
+ const dateObj = new Date(timestamp);
18
+ if (!isNaN(dateObj.getTime())) {
19
+ const hours = dateObj.getHours().toString().padStart(2, '0');
20
+ const minutes = dateObj.getMinutes() >= 30 ? '30' : '00';
21
+ return `${hours}:${minutes}`;
22
+ }
23
+ const timeMatch = timestamp.match(/(\d{2}):(\d{2})/);
24
+ if (!timeMatch) {
25
+ return 'unknown_time';
26
+ }
27
+ const hours = timeMatch[1];
28
+ const minutes = Number(timeMatch[2]) >= 30 ? '30' : '00';
29
+ return `${hours}:${minutes}`;
30
+ }
31
+ function computeFingerprint(date, location, action, timestamp) {
32
+ const bucket = toTimeBucket(timestamp);
33
+ return `${normalize(date)}|${normalize(location)}|${normalize(action)}|${bucket}`;
34
+ }
35
+ function checkReadOnlyHit(episodes, target) {
36
+ const targetFingerprint = computeFingerprint(target.date, target.location, target.action, target.timestamp);
37
+ for (const ep of episodes) {
38
+ const epDateMatch = ep.timestamp.match(/^(\d{4}-\d{2}-\d{2})/);
39
+ const epDate = epDateMatch ? epDateMatch[1] : target.date; // Use target date if unable to extract
40
+ const epFingerprint = computeFingerprint(epDate, ep.location, ep.action, ep.timestamp);
41
+ if (targetFingerprint === epFingerprint) {
42
+ return { hit: true, matchedEpisode: ep };
43
+ }
44
+ }
45
+ return { hit: false };
46
+ }
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ /**
3
+ * holidays.ts — Static public holiday lookup (CN + US, 2025–2027).
4
+ *
5
+ * Data sourced from Nager.Date API (global public holidays only).
6
+ * No network requests or file I/O at runtime.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.getHoliday = getHoliday;
10
+ const STATIC_HOLIDAYS = {
11
+ // ── China 2025 ──────────────────────────────────────────────────
12
+ "2025-CN": [
13
+ { date: "2025-01-01", name: "New Year's Day" },
14
+ { date: "2025-01-29", name: "Chinese New Year (Spring Festival)" },
15
+ { date: "2025-05-01", name: "Labour Day" },
16
+ { date: "2025-05-31", name: "Dragon Boat Festival" },
17
+ { date: "2025-10-01", name: "National Day" },
18
+ { date: "2025-10-06", name: "Mid-Autumn Festival" },
19
+ ],
20
+ // ── China 2026 ──────────────────────────────────────────────────
21
+ "2026-CN": [
22
+ { date: "2026-01-01", name: "New Year's Day" },
23
+ { date: "2026-02-17", name: "Chinese New Year (Spring Festival)" },
24
+ { date: "2026-05-01", name: "Labour Day" },
25
+ { date: "2026-06-19", name: "Dragon Boat Festival" },
26
+ { date: "2026-09-25", name: "Mid-Autumn Festival" },
27
+ { date: "2026-10-01", name: "National Day" },
28
+ ],
29
+ // ── China 2027 ──────────────────────────────────────────────────
30
+ "2027-CN": [
31
+ { date: "2027-01-01", name: "New Year's Day" },
32
+ { date: "2027-02-06", name: "Chinese New Year (Spring Festival)" },
33
+ { date: "2027-05-01", name: "Labour Day" },
34
+ { date: "2027-06-09", name: "Dragon Boat Festival" },
35
+ { date: "2027-09-15", name: "Mid-Autumn Festival" },
36
+ { date: "2027-10-01", name: "National Day" },
37
+ ],
38
+ // ── United States 2025 ──────────────────────────────────────────
39
+ "2025-US": [
40
+ { date: "2025-01-01", name: "New Year's Day" },
41
+ { date: "2025-01-20", name: "Martin Luther King, Jr. Day" },
42
+ { date: "2025-02-17", name: "Presidents Day" },
43
+ { date: "2025-05-26", name: "Memorial Day" },
44
+ { date: "2025-06-19", name: "Juneteenth National Independence Day" },
45
+ { date: "2025-07-04", name: "Independence Day" },
46
+ { date: "2025-09-01", name: "Labour Day" },
47
+ { date: "2025-11-11", name: "Veterans Day" },
48
+ { date: "2025-11-27", name: "Thanksgiving Day" },
49
+ { date: "2025-12-25", name: "Christmas Day" },
50
+ ],
51
+ // ── United States 2026 ──────────────────────────────────────────
52
+ "2026-US": [
53
+ { date: "2026-01-01", name: "New Year's Day" },
54
+ { date: "2026-01-19", name: "Martin Luther King, Jr. Day" },
55
+ { date: "2026-02-16", name: "Presidents Day" },
56
+ { date: "2026-05-25", name: "Memorial Day" },
57
+ { date: "2026-06-19", name: "Juneteenth National Independence Day" },
58
+ { date: "2026-07-03", name: "Independence Day" },
59
+ { date: "2026-09-07", name: "Labour Day" },
60
+ { date: "2026-11-11", name: "Veterans Day" },
61
+ { date: "2026-11-26", name: "Thanksgiving Day" },
62
+ { date: "2026-12-25", name: "Christmas Day" },
63
+ ],
64
+ // ── United States 2027 ──────────────────────────────────────────
65
+ "2027-US": [
66
+ { date: "2027-01-01", name: "New Year's Day" },
67
+ { date: "2027-01-18", name: "Martin Luther King, Jr. Day" },
68
+ { date: "2027-02-15", name: "Presidents Day" },
69
+ { date: "2027-05-31", name: "Memorial Day" },
70
+ { date: "2027-06-18", name: "Juneteenth National Independence Day" },
71
+ { date: "2027-07-05", name: "Independence Day" },
72
+ { date: "2027-09-06", name: "Labour Day" },
73
+ { date: "2027-11-11", name: "Veterans Day" },
74
+ { date: "2027-11-25", name: "Thanksgiving Day" },
75
+ { date: "2027-12-24", name: "Christmas Day" },
76
+ ],
77
+ };
78
+ /**
79
+ * Returns the public holiday name for a given date and country, or null if none.
80
+ * Supported countries: CN, US (2025–2027).
81
+ * Falls back to null for unsupported years or countries.
82
+ */
83
+ function getHoliday(dateStr, countryCode = 'CN') {
84
+ const dateMatch = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
85
+ if (!dateMatch)
86
+ return null;
87
+ const year = dateMatch[1];
88
+ const targetDate = `${year}-${dateMatch[2]}-${dateMatch[3]}`;
89
+ const key = `${year}-${countryCode}`;
90
+ const entries = STATIC_HOLIDAYS[key];
91
+ if (!entries)
92
+ return null;
93
+ const found = entries.find(h => h.date === targetDate);
94
+ return found ? found.name : null;
95
+ }
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveAppearance = resolveAppearance;
4
+ function resolveAppearance(dayEpisodes, defaultAppearance, appearanceLogic) {
5
+ const latestKnownEpisode = [...dayEpisodes]
6
+ .reverse()
7
+ .find((episode) => episode.appearance && episode.appearance !== 'unknown');
8
+ const latestKnownAppearance = latestKnownEpisode?.appearance;
9
+ const transition = appearanceLogic?.transition || 'unknown';
10
+ const outfitMode = appearanceLogic?.outfitMode || 'unknown';
11
+ const changeReason = appearanceLogic?.changeReason || 'unspecified';
12
+ if (transition === 'change_required') {
13
+ return {
14
+ appearance: defaultAppearance,
15
+ overridden: true,
16
+ reason: latestKnownAppearance
17
+ ? 'explicit appearance transition required by generated scene semantics'
18
+ : 'explicit appearance transition required and no prior same-day appearance was available',
19
+ sourceEpisodeTimestamp: latestKnownEpisode?.timestamp,
20
+ transition,
21
+ outfitMode,
22
+ changeReason,
23
+ };
24
+ }
25
+ if (latestKnownAppearance) {
26
+ return {
27
+ appearance: latestKnownAppearance,
28
+ overridden: false,
29
+ reason: transition === 'change_allowed'
30
+ ? 'kept the latest known same-day appearance because no forced outfit change was required'
31
+ : 'inherited from latest known same-day appearance',
32
+ sourceEpisodeTimestamp: latestKnownEpisode?.timestamp,
33
+ transition,
34
+ outfitMode,
35
+ changeReason,
36
+ };
37
+ }
38
+ return {
39
+ appearance: defaultAppearance,
40
+ overridden: true,
41
+ reason: 'no prior same-day appearance found; kept the LLM-provided appearance',
42
+ transition,
43
+ outfitMode,
44
+ changeReason,
45
+ };
46
+ }