@foxden-app/foxclaw 0.5.9 → 0.5.11

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.
@@ -98,6 +98,61 @@ export class BridgeStore {
98
98
  created_at INTEGER NOT NULL,
99
99
  PRIMARY KEY (input_local_id, question_index, message_kind)
100
100
  );
101
+ CREATE TABLE IF NOT EXISTS queued_turn_inputs (
102
+ queue_id TEXT PRIMARY KEY,
103
+ scope_id TEXT NOT NULL,
104
+ chat_id TEXT NOT NULL,
105
+ chat_type TEXT NOT NULL,
106
+ topic_id INTEGER,
107
+ thread_id TEXT NOT NULL,
108
+ input_json TEXT NOT NULL,
109
+ source_summary TEXT NOT NULL,
110
+ message_id INTEGER,
111
+ status TEXT NOT NULL,
112
+ error TEXT,
113
+ created_at INTEGER NOT NULL,
114
+ updated_at INTEGER NOT NULL,
115
+ resolved_at INTEGER
116
+ );
117
+ CREATE INDEX IF NOT EXISTS queued_turn_inputs_scope_status_idx
118
+ ON queued_turn_inputs(scope_id, status, created_at);
119
+ CREATE TABLE IF NOT EXISTS pending_attachment_batches (
120
+ batch_id TEXT PRIMARY KEY,
121
+ scope_id TEXT NOT NULL,
122
+ chat_id TEXT NOT NULL,
123
+ chat_type TEXT NOT NULL,
124
+ topic_id INTEGER,
125
+ thread_id TEXT NOT NULL,
126
+ cwd TEXT,
127
+ media_group_id TEXT,
128
+ attachments_json TEXT NOT NULL,
129
+ caption TEXT NOT NULL,
130
+ message_id INTEGER,
131
+ status TEXT NOT NULL,
132
+ created_at INTEGER NOT NULL,
133
+ updated_at INTEGER NOT NULL,
134
+ resolved_at INTEGER
135
+ );
136
+ CREATE INDEX IF NOT EXISTS pending_attachment_batches_scope_status_idx
137
+ ON pending_attachment_batches(scope_id, status, updated_at);
138
+ CREATE TABLE IF NOT EXISTS guided_plan_sessions (
139
+ session_id TEXT PRIMARY KEY,
140
+ scope_id TEXT NOT NULL,
141
+ chat_id TEXT NOT NULL,
142
+ chat_type TEXT NOT NULL,
143
+ topic_id INTEGER,
144
+ thread_id TEXT NOT NULL,
145
+ turn_id TEXT NOT NULL,
146
+ cwd TEXT,
147
+ plan_markdown TEXT NOT NULL,
148
+ message_id INTEGER,
149
+ state TEXT NOT NULL,
150
+ created_at INTEGER NOT NULL,
151
+ updated_at INTEGER NOT NULL,
152
+ resolved_at INTEGER
153
+ );
154
+ CREATE INDEX IF NOT EXISTS guided_plan_sessions_scope_state_idx
155
+ ON guided_plan_sessions(scope_id, state, updated_at);
101
156
  CREATE TABLE IF NOT EXISTS audit_logs (
102
157
  id INTEGER PRIMARY KEY AUTOINCREMENT,
103
158
  direction TEXT NOT NULL,
@@ -438,6 +493,204 @@ export class BridgeStore {
438
493
  const row = this.db.prepare('SELECT COUNT(*) AS count FROM pending_user_inputs WHERE resolved_at IS NULL').get();
439
494
  return Number(row?.count ?? 0);
440
495
  }
496
+ saveQueuedTurnInput(record) {
497
+ this.db.prepare(`
498
+ INSERT INTO queued_turn_inputs (
499
+ queue_id, scope_id, chat_id, chat_type, topic_id, thread_id, input_json, source_summary,
500
+ message_id, status, error, created_at, updated_at, resolved_at
501
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
502
+ ON CONFLICT(queue_id) DO UPDATE SET
503
+ scope_id = excluded.scope_id,
504
+ chat_id = excluded.chat_id,
505
+ chat_type = excluded.chat_type,
506
+ topic_id = excluded.topic_id,
507
+ thread_id = excluded.thread_id,
508
+ input_json = excluded.input_json,
509
+ source_summary = excluded.source_summary,
510
+ message_id = excluded.message_id,
511
+ status = excluded.status,
512
+ error = excluded.error,
513
+ created_at = excluded.created_at,
514
+ updated_at = excluded.updated_at,
515
+ resolved_at = excluded.resolved_at
516
+ `).run(record.queueId, record.scopeId, record.chatId, record.chatType, record.topicId, record.threadId, record.inputJson, record.sourceSummary, record.messageId, record.status, record.error, record.createdAt, record.updatedAt, record.resolvedAt);
517
+ }
518
+ getQueuedTurnInput(queueId) {
519
+ const row = this.db.prepare('SELECT * FROM queued_turn_inputs WHERE queue_id = ?').get(queueId);
520
+ return row ? this.mapQueuedTurnInput(row) : null;
521
+ }
522
+ peekQueuedTurnInput(scopeId) {
523
+ const row = this.db.prepare(`
524
+ SELECT * FROM queued_turn_inputs
525
+ WHERE scope_id = ? AND status = 'queued'
526
+ ORDER BY created_at ASC
527
+ LIMIT 1
528
+ `).get(scopeId);
529
+ return row ? this.mapQueuedTurnInput(row) : null;
530
+ }
531
+ listQueuedTurnInputs(scopeId) {
532
+ const sql = scopeId
533
+ ? `SELECT * FROM queued_turn_inputs WHERE scope_id = ? AND status IN ('queued', 'processing') ORDER BY created_at ASC`
534
+ : `SELECT * FROM queued_turn_inputs WHERE status IN ('queued', 'processing') ORDER BY created_at ASC`;
535
+ const rows = scopeId
536
+ ? this.db.prepare(sql).all(scopeId)
537
+ : this.db.prepare(sql).all();
538
+ return rows.map((row) => this.mapQueuedTurnInput(row));
539
+ }
540
+ countQueuedTurnInputs(scopeId) {
541
+ const row = scopeId
542
+ ? this.db.prepare(`SELECT COUNT(*) AS count FROM queued_turn_inputs WHERE scope_id = ? AND status IN ('queued', 'processing')`).get(scopeId)
543
+ : this.db.prepare(`SELECT COUNT(*) AS count FROM queued_turn_inputs WHERE status IN ('queued', 'processing')`).get();
544
+ return Number(row?.count ?? 0);
545
+ }
546
+ updateQueuedTurnInputStatus(queueId, status, error = null) {
547
+ const resolvedAt = status === 'queued' || status === 'processing' ? null : Date.now();
548
+ this.db.prepare(`
549
+ UPDATE queued_turn_inputs
550
+ SET status = ?, error = ?, updated_at = ?, resolved_at = ?
551
+ WHERE queue_id = ?
552
+ `).run(status, error, Date.now(), resolvedAt, queueId);
553
+ }
554
+ cancelQueuedTurnInputs(scopeId) {
555
+ const result = this.db.prepare(`
556
+ UPDATE queued_turn_inputs
557
+ SET status = 'cancelled', updated_at = ?, resolved_at = ?
558
+ WHERE scope_id = ? AND status = 'queued'
559
+ `).run(Date.now(), Date.now(), scopeId);
560
+ return Number(result.changes ?? 0);
561
+ }
562
+ requeueInterruptedQueuedTurnInputs() {
563
+ const result = this.db.prepare(`
564
+ UPDATE queued_turn_inputs
565
+ SET status = 'queued', error = NULL, updated_at = ?, resolved_at = NULL
566
+ WHERE status = 'processing'
567
+ `).run(Date.now());
568
+ return Number(result.changes ?? 0);
569
+ }
570
+ savePendingAttachmentBatch(record) {
571
+ this.db.prepare(`
572
+ INSERT INTO pending_attachment_batches (
573
+ batch_id, scope_id, chat_id, chat_type, topic_id, thread_id, cwd, media_group_id,
574
+ attachments_json, caption, message_id, status, created_at, updated_at, resolved_at
575
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
576
+ ON CONFLICT(batch_id) DO UPDATE SET
577
+ scope_id = excluded.scope_id,
578
+ chat_id = excluded.chat_id,
579
+ chat_type = excluded.chat_type,
580
+ topic_id = excluded.topic_id,
581
+ thread_id = excluded.thread_id,
582
+ cwd = excluded.cwd,
583
+ media_group_id = excluded.media_group_id,
584
+ attachments_json = excluded.attachments_json,
585
+ caption = excluded.caption,
586
+ message_id = excluded.message_id,
587
+ status = excluded.status,
588
+ created_at = excluded.created_at,
589
+ updated_at = excluded.updated_at,
590
+ resolved_at = excluded.resolved_at
591
+ `).run(record.batchId, record.scopeId, record.chatId, record.chatType, record.topicId, record.threadId, record.cwd, record.mediaGroupId, record.attachmentsJson, record.caption, record.messageId, record.status, record.createdAt, record.updatedAt, record.resolvedAt);
592
+ }
593
+ getPendingAttachmentBatch(batchId) {
594
+ const row = this.db.prepare('SELECT * FROM pending_attachment_batches WHERE batch_id = ?').get(batchId);
595
+ return row ? this.mapPendingAttachmentBatch(row) : null;
596
+ }
597
+ findPendingAttachmentBatchByMediaGroup(scopeId, mediaGroupId) {
598
+ const row = this.db.prepare(`
599
+ SELECT * FROM pending_attachment_batches
600
+ WHERE scope_id = ? AND media_group_id = ? AND status = 'pending'
601
+ ORDER BY updated_at DESC
602
+ LIMIT 1
603
+ `).get(scopeId, mediaGroupId);
604
+ return row ? this.mapPendingAttachmentBatch(row) : null;
605
+ }
606
+ getLatestPendingAttachmentBatch(scopeId) {
607
+ const row = this.db.prepare(`
608
+ SELECT * FROM pending_attachment_batches
609
+ WHERE scope_id = ? AND status = 'pending'
610
+ ORDER BY updated_at DESC
611
+ LIMIT 1
612
+ `).get(scopeId);
613
+ return row ? this.mapPendingAttachmentBatch(row) : null;
614
+ }
615
+ updatePendingAttachmentBatchMessage(batchId, messageId) {
616
+ this.db.prepare('UPDATE pending_attachment_batches SET message_id = ?, updated_at = ? WHERE batch_id = ?').run(messageId, Date.now(), batchId);
617
+ }
618
+ resolvePendingAttachmentBatch(batchId, status) {
619
+ this.db.prepare(`
620
+ UPDATE pending_attachment_batches
621
+ SET status = ?, updated_at = ?, resolved_at = ?
622
+ WHERE batch_id = ?
623
+ `).run(status, Date.now(), Date.now(), batchId);
624
+ }
625
+ clearPendingAttachmentBatches(scopeId) {
626
+ const result = this.db.prepare(`
627
+ UPDATE pending_attachment_batches
628
+ SET status = 'cleared', updated_at = ?, resolved_at = ?
629
+ WHERE scope_id = ? AND status = 'pending'
630
+ `).run(Date.now(), Date.now(), scopeId);
631
+ return Number(result.changes ?? 0);
632
+ }
633
+ saveGuidedPlanSession(record) {
634
+ this.db.prepare(`
635
+ INSERT INTO guided_plan_sessions (
636
+ session_id, scope_id, chat_id, chat_type, topic_id, thread_id, turn_id, cwd,
637
+ plan_markdown, message_id, state, created_at, updated_at, resolved_at
638
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
639
+ ON CONFLICT(session_id) DO UPDATE SET
640
+ scope_id = excluded.scope_id,
641
+ chat_id = excluded.chat_id,
642
+ chat_type = excluded.chat_type,
643
+ topic_id = excluded.topic_id,
644
+ thread_id = excluded.thread_id,
645
+ turn_id = excluded.turn_id,
646
+ cwd = excluded.cwd,
647
+ plan_markdown = excluded.plan_markdown,
648
+ message_id = excluded.message_id,
649
+ state = excluded.state,
650
+ created_at = excluded.created_at,
651
+ updated_at = excluded.updated_at,
652
+ resolved_at = excluded.resolved_at
653
+ `).run(record.sessionId, record.scopeId, record.chatId, record.chatType, record.topicId, record.threadId, record.turnId, record.cwd, record.planMarkdown, record.messageId, record.state, record.createdAt, record.updatedAt, record.resolvedAt);
654
+ }
655
+ getGuidedPlanSession(sessionId) {
656
+ const row = this.db.prepare('SELECT * FROM guided_plan_sessions WHERE session_id = ?').get(sessionId);
657
+ return row ? this.mapGuidedPlanSession(row) : null;
658
+ }
659
+ findOpenGuidedPlanSession(scopeId, turnId) {
660
+ const row = turnId
661
+ ? this.db.prepare(`
662
+ SELECT * FROM guided_plan_sessions
663
+ WHERE scope_id = ? AND turn_id = ? AND state = 'awaiting_confirmation'
664
+ ORDER BY updated_at DESC
665
+ LIMIT 1
666
+ `).get(scopeId, turnId)
667
+ : this.db.prepare(`
668
+ SELECT * FROM guided_plan_sessions
669
+ WHERE scope_id = ? AND state = 'awaiting_confirmation'
670
+ ORDER BY updated_at DESC
671
+ LIMIT 1
672
+ `).get(scopeId);
673
+ return row ? this.mapGuidedPlanSession(row) : null;
674
+ }
675
+ listOpenGuidedPlanSessions() {
676
+ const rows = this.db.prepare(`
677
+ SELECT * FROM guided_plan_sessions
678
+ WHERE state = 'awaiting_confirmation'
679
+ ORDER BY updated_at ASC
680
+ `).all();
681
+ return rows.map((row) => this.mapGuidedPlanSession(row));
682
+ }
683
+ updateGuidedPlanSessionMessage(sessionId, messageId) {
684
+ this.db.prepare('UPDATE guided_plan_sessions SET message_id = ?, updated_at = ? WHERE session_id = ?').run(messageId, Date.now(), sessionId);
685
+ }
686
+ updateGuidedPlanSessionState(sessionId, state) {
687
+ const resolvedAt = state === 'awaiting_confirmation' || state === 'executing' ? null : Date.now();
688
+ this.db.prepare(`
689
+ UPDATE guided_plan_sessions
690
+ SET state = ?, updated_at = ?, resolved_at = ?
691
+ WHERE session_id = ?
692
+ `).run(state, Date.now(), resolvedAt, sessionId);
693
+ }
441
694
  insertAudit(direction, chatId, eventType, summary) {
442
695
  this.db.prepare('INSERT INTO audit_logs (direction, chat_id, event_type, summary, created_at) VALUES (?, ?, ?, ?, ?)').run(direction, chatId, eventType, summary, Date.now());
443
696
  }
@@ -482,6 +735,61 @@ export class BridgeStore {
482
735
  resolvedAt: row.resolved_at === null ? null : Number(row.resolved_at),
483
736
  };
484
737
  }
738
+ mapQueuedTurnInput(row) {
739
+ return {
740
+ queueId: String(row.queue_id),
741
+ scopeId: String(row.scope_id),
742
+ chatId: String(row.chat_id),
743
+ chatType: String(row.chat_type),
744
+ topicId: row.topic_id === null ? null : Number(row.topic_id),
745
+ threadId: String(row.thread_id),
746
+ inputJson: String(row.input_json),
747
+ sourceSummary: String(row.source_summary),
748
+ messageId: row.message_id === null ? null : Number(row.message_id),
749
+ status: normalizeQueuedTurnInputStatus(row.status),
750
+ error: row.error === null ? null : String(row.error),
751
+ createdAt: Number(row.created_at),
752
+ updatedAt: Number(row.updated_at),
753
+ resolvedAt: row.resolved_at === null ? null : Number(row.resolved_at),
754
+ };
755
+ }
756
+ mapPendingAttachmentBatch(row) {
757
+ return {
758
+ batchId: String(row.batch_id),
759
+ scopeId: String(row.scope_id),
760
+ chatId: String(row.chat_id),
761
+ chatType: String(row.chat_type),
762
+ topicId: row.topic_id === null ? null : Number(row.topic_id),
763
+ threadId: String(row.thread_id),
764
+ cwd: row.cwd === null ? null : String(row.cwd),
765
+ mediaGroupId: row.media_group_id === null ? null : String(row.media_group_id),
766
+ attachmentsJson: String(row.attachments_json),
767
+ caption: String(row.caption ?? ''),
768
+ messageId: row.message_id === null ? null : Number(row.message_id),
769
+ status: normalizePendingAttachmentBatchStatus(row.status),
770
+ createdAt: Number(row.created_at),
771
+ updatedAt: Number(row.updated_at),
772
+ resolvedAt: row.resolved_at === null ? null : Number(row.resolved_at),
773
+ };
774
+ }
775
+ mapGuidedPlanSession(row) {
776
+ return {
777
+ sessionId: String(row.session_id),
778
+ scopeId: String(row.scope_id),
779
+ chatId: String(row.chat_id),
780
+ chatType: String(row.chat_type),
781
+ topicId: row.topic_id === null ? null : Number(row.topic_id),
782
+ threadId: String(row.thread_id),
783
+ turnId: String(row.turn_id),
784
+ cwd: row.cwd === null ? null : String(row.cwd),
785
+ planMarkdown: String(row.plan_markdown),
786
+ messageId: row.message_id === null ? null : Number(row.message_id),
787
+ state: normalizeGuidedPlanSessionState(row.state),
788
+ createdAt: Number(row.created_at),
789
+ updatedAt: Number(row.updated_at),
790
+ resolvedAt: row.resolved_at === null ? null : Number(row.resolved_at),
791
+ };
792
+ }
485
793
  writeChatSettings(chatId, model, reasoningEffort, locale, accessPreset, collaborationMode, serviceTier, activeTurnMessageMode) {
486
794
  this.db.prepare(`
487
795
  INSERT INTO chat_settings (chat_id, model, reasoning_effort, locale, access_preset, collaboration_mode, service_tier, active_turn_message_mode, updated_at)
@@ -615,3 +923,16 @@ function normalizeCollaborationMode(value) {
615
923
  function normalizeActiveTurnMessageMode(value) {
616
924
  return value === 'steer' || value === 'queue' ? value : null;
617
925
  }
926
+ function normalizeQueuedTurnInputStatus(value) {
927
+ return value === 'processing' || value === 'completed' || value === 'cancelled' || value === 'failed'
928
+ ? value
929
+ : 'queued';
930
+ }
931
+ function normalizePendingAttachmentBatchStatus(value) {
932
+ return value === 'consumed' || value === 'cleared' ? value : 'pending';
933
+ }
934
+ function normalizeGuidedPlanSessionState(value) {
935
+ return value === 'executing' || value === 'cancelled' || value === 'completed'
936
+ ? value
937
+ : 'awaiting_confirmation';
938
+ }
@@ -12,6 +12,7 @@ export interface TelegramTextEvent {
12
12
  userId: string;
13
13
  text: string;
14
14
  messageId: number;
15
+ mediaGroupId?: string | null;
15
16
  attachments: TelegramInboundAttachment[];
16
17
  entities: TelegramMessageEntity[];
17
18
  replyToBot: boolean;
@@ -245,6 +245,7 @@ export class TelegramGateway extends EventEmitter {
245
245
  userId: String(update.message.from.id),
246
246
  text,
247
247
  messageId: update.message.message_id,
248
+ mediaGroupId: update.message.media_group_id ?? null,
248
249
  attachments,
249
250
  entities,
250
251
  replyToBot,
package/dist/types.d.ts CHANGED
@@ -25,6 +25,58 @@ export interface ChatSessionSettings {
25
25
  activeTurnMessageMode: ActiveTurnMessageMode | null;
26
26
  updatedAt: number;
27
27
  }
28
+ export type QueuedTurnInputStatus = 'queued' | 'processing' | 'completed' | 'cancelled' | 'failed';
29
+ export interface QueuedTurnInputRecord {
30
+ queueId: string;
31
+ scopeId: string;
32
+ chatId: string;
33
+ chatType: string;
34
+ topicId: number | null;
35
+ threadId: string;
36
+ inputJson: string;
37
+ sourceSummary: string;
38
+ messageId: number | null;
39
+ status: QueuedTurnInputStatus;
40
+ error: string | null;
41
+ createdAt: number;
42
+ updatedAt: number;
43
+ resolvedAt: number | null;
44
+ }
45
+ export type PendingAttachmentBatchStatus = 'pending' | 'consumed' | 'cleared';
46
+ export interface PendingAttachmentBatchRecord {
47
+ batchId: string;
48
+ scopeId: string;
49
+ chatId: string;
50
+ chatType: string;
51
+ topicId: number | null;
52
+ threadId: string;
53
+ cwd: string | null;
54
+ mediaGroupId: string | null;
55
+ attachmentsJson: string;
56
+ caption: string;
57
+ messageId: number | null;
58
+ status: PendingAttachmentBatchStatus;
59
+ createdAt: number;
60
+ updatedAt: number;
61
+ resolvedAt: number | null;
62
+ }
63
+ export type GuidedPlanSessionState = 'awaiting_confirmation' | 'executing' | 'cancelled' | 'completed';
64
+ export interface GuidedPlanSessionRecord {
65
+ sessionId: string;
66
+ scopeId: string;
67
+ chatId: string;
68
+ chatType: string;
69
+ topicId: number | null;
70
+ threadId: string;
71
+ turnId: string;
72
+ cwd: string | null;
73
+ planMarkdown: string;
74
+ messageId: number | null;
75
+ state: GuidedPlanSessionState;
76
+ createdAt: number;
77
+ updatedAt: number;
78
+ resolvedAt: number | null;
79
+ }
28
80
  export interface CachedThread {
29
81
  index: number;
30
82
  threadId: string;
@@ -332,6 +384,7 @@ export interface RuntimeStatus {
332
384
  currentBindings: number;
333
385
  pendingApprovals: number;
334
386
  pendingUserInputs: number;
387
+ queuedTurns: number;
335
388
  activeTurns: number;
336
389
  lastError: string | null;
337
390
  updatedAt: string;
package/dist/update.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface SelfUpdateStatus {
7
7
  locale: AppLocale;
8
8
  fromVersion: string;
9
9
  toVersion: string | null;
10
+ releaseNotes?: string[] | null;
11
+ releaseNotesVersion?: string | null;
10
12
  codexUpdate?: string | null;
11
13
  codexFromVersion?: string | null;
12
14
  codexToVersion?: string | null;
@@ -74,4 +76,5 @@ export declare function buildSelfUpdateLaunchCommand(options: {
74
76
  unitName?: string;
75
77
  }): SelfUpdateLaunchCommand;
76
78
  export declare function performSelfUpdate(options: PerformSelfUpdateOptions): SelfUpdateOutcome;
79
+ export declare function extractReleaseNotes(changelog: string, version: string, locale: AppLocale): string[] | null;
77
80
  export {};
package/dist/update.js CHANGED
@@ -132,6 +132,10 @@ export function readSelfUpdateStatus(statusFile) {
132
132
  locale: parsed.locale,
133
133
  fromVersion: parsed.fromVersion,
134
134
  toVersion: typeof parsed.toVersion === 'string' ? parsed.toVersion : null,
135
+ ...(Array.isArray(parsed.releaseNotes) ? {
136
+ releaseNotes: parsed.releaseNotes.filter((entry) => typeof entry === 'string'),
137
+ } : {}),
138
+ ...(typeof parsed.releaseNotesVersion === 'string' ? { releaseNotesVersion: parsed.releaseNotesVersion } : {}),
135
139
  ...(typeof parsed.codexUpdate === 'string' ? { codexUpdate: parsed.codexUpdate } : {}),
136
140
  ...(typeof parsed.codexFromVersion === 'string' ? { codexFromVersion: parsed.codexFromVersion } : {}),
137
141
  ...(typeof parsed.codexToVersion === 'string' ? { codexToVersion: parsed.codexToVersion } : {}),
@@ -185,6 +189,8 @@ export function createSelfUpdateRuntime(options) {
185
189
  locale,
186
190
  fromVersion: options.version,
187
191
  toVersion: null,
192
+ releaseNotes: null,
193
+ releaseNotesVersion: null,
188
194
  codexUpdate: null,
189
195
  codexFromVersion: null,
190
196
  codexToVersion: null,
@@ -229,6 +235,8 @@ export function createSelfUpdateRuntime(options) {
229
235
  locale,
230
236
  fromVersion: options.version,
231
237
  toVersion: null,
238
+ releaseNotes: null,
239
+ releaseNotesVersion: null,
232
240
  codexUpdate: null,
233
241
  codexFromVersion: null,
234
242
  codexToVersion: null,
@@ -296,9 +304,10 @@ export function performSelfUpdate(options) {
296
304
  runInherited(installer.command, installer.installArgs, installerEnv);
297
305
  const updatedEntryPoint = resolveUpdatedEntryPoint(installer, installerEnv);
298
306
  toVersion = readInstalledPackageVersion(updatedEntryPoint);
307
+ const releaseNotes = readInstalledReleaseNotes(updatedEntryPoint, toVersion, options.notificationFile);
299
308
  console.log('[UPDATE] Running checks and restarting the FoxClaw service...');
300
309
  runInherited(options.nodePath, [updatedEntryPoint, 'start'], installerEnv);
301
- completeNotification(options.notificationFile, 'succeeded', toVersion, codexUpdate, null);
310
+ completeNotification(options.notificationFile, 'succeeded', toVersion, codexUpdate, null, releaseNotes);
302
311
  console.log(`[OK] FoxClaw updated and restarted: ${options.version} -> ${toVersion}`);
303
312
  return {
304
313
  ok: true,
@@ -309,7 +318,7 @@ export function performSelfUpdate(options) {
309
318
  }
310
319
  catch (error) {
311
320
  const message = formatError(error);
312
- completeNotification(options.notificationFile, 'failed', toVersion, codexUpdate, message);
321
+ completeNotification(options.notificationFile, 'failed', toVersion, codexUpdate, message, null);
313
322
  console.error(`[FAIL] FoxClaw update failed: ${message}`);
314
323
  return {
315
324
  ok: false,
@@ -456,7 +465,65 @@ function readInstalledPackageVersion(updatedEntryPoint) {
456
465
  return 'unknown';
457
466
  }
458
467
  }
459
- function completeNotification(notificationFile, state, toVersion, codexUpdate, error) {
468
+ function readInstalledReleaseNotes(updatedEntryPoint, version, notificationFile) {
469
+ if (!version || version === 'unknown') {
470
+ return null;
471
+ }
472
+ const pending = notificationFile ? readSelfUpdateStatus(notificationFile) : null;
473
+ const locale = pending?.locale ?? 'zh';
474
+ const changelogPath = path.resolve(path.dirname(updatedEntryPoint), '..', 'CHANGELOG.md');
475
+ try {
476
+ return extractReleaseNotes(fs.readFileSync(changelogPath, 'utf8'), version, locale);
477
+ }
478
+ catch {
479
+ return null;
480
+ }
481
+ }
482
+ export function extractReleaseNotes(changelog, version, locale) {
483
+ const escapedVersion = escapeRegExp(version.replace(/^v/i, ''));
484
+ const versionHeadingPattern = new RegExp(`^##\\s+\\[?v?${escapedVersion}\\]?\\b.*$`, 'im');
485
+ const versionMatch = versionHeadingPattern.exec(changelog);
486
+ if (!versionMatch || versionMatch.index === undefined) {
487
+ return null;
488
+ }
489
+ const sectionStart = versionMatch.index + versionMatch[0].length;
490
+ const nextHeadingMatch = /^##\s+/m.exec(changelog.slice(sectionStart));
491
+ const versionSection = nextHeadingMatch
492
+ ? changelog.slice(sectionStart, sectionStart + nextHeadingMatch.index)
493
+ : changelog.slice(sectionStart);
494
+ const localizedSection = extractLocalizedReleaseNoteSection(versionSection, locale) ?? versionSection;
495
+ const bullets = localizedSection
496
+ .split(/\r?\n/)
497
+ .map(line => line.trim())
498
+ .filter(line => /^[-*]\s+/.test(line))
499
+ .map(line => line.replace(/^[-*]\s+/, '').trim())
500
+ .filter(Boolean)
501
+ .slice(0, 8);
502
+ return bullets.length > 0 ? bullets : null;
503
+ }
504
+ function extractLocalizedReleaseNoteSection(section, locale) {
505
+ const headingPattern = /^###\s+(.+)$/gm;
506
+ const headings = [...section.matchAll(headingPattern)];
507
+ if (headings.length === 0) {
508
+ return null;
509
+ }
510
+ const preferred = locale === 'zh' ? ['中文', 'Chinese'] : ['English', '英文'];
511
+ for (let index = 0; index < headings.length; index += 1) {
512
+ const heading = headings[index];
513
+ const headingText = heading[1].trim().toLowerCase();
514
+ if (!preferred.some(label => headingText === label.toLowerCase())) {
515
+ continue;
516
+ }
517
+ const start = heading.index + heading[0].length;
518
+ const next = headings[index + 1];
519
+ return next ? section.slice(start, next.index) : section.slice(start);
520
+ }
521
+ return null;
522
+ }
523
+ function escapeRegExp(value) {
524
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
525
+ }
526
+ function completeNotification(notificationFile, state, toVersion, codexUpdate, error, releaseNotes) {
460
527
  if (!notificationFile) {
461
528
  return;
462
529
  }
@@ -468,6 +535,8 @@ function completeNotification(notificationFile, state, toVersion, codexUpdate, e
468
535
  ...pending,
469
536
  state,
470
537
  toVersion,
538
+ releaseNotes,
539
+ releaseNotesVersion: releaseNotes && toVersion ? toVersion : null,
471
540
  codexUpdate: codexUpdate?.message ?? null,
472
541
  codexFromVersion: codexUpdate?.fromVersion ?? null,
473
542
  codexToVersion: codexUpdate?.toVersion ?? null,