@adhdev/daemon-core 1.0.18-rc.7 → 1.0.18-rc.8

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.
@@ -82,6 +82,27 @@ export interface CoordinatorOperatingNote {
82
82
  * (see isNoteExpired) governs expiry; pinned notes never expire regardless.
83
83
  */
84
84
  expiresAt?: string;
85
+ /**
86
+ * Phase 2 — the ledger id of this note, when known. Threaded from the ledger
87
+ * entry id at launch so version-supersede can target a specific note and
88
+ * same-class folding can list the subsumed ids. Repo-declared notes may lack
89
+ * one; absent is fine (folding/supersede fall back to the subject key).
90
+ */
91
+ noteId?: string;
92
+ /**
93
+ * Phase 2 (b) version-supersede — an optional note-id or stable subject-key
94
+ * this note replaces. At injection, any earlier LIVE note whose `noteId` or
95
+ * `subjectKey` matches this value is hidden from the prompt (the ledger entry
96
+ * is retained for audit). Optional and lossless: absent = supersedes nothing.
97
+ */
98
+ supersedes?: string;
99
+ /**
100
+ * Phase 2 (b)/(c) — an optional stable subject-key grouping notes about the
101
+ * same subject. Drives version-supersede targeting and same-class folding.
102
+ * When absent, folding derives a key from a leading `[tag]` bracket instead,
103
+ * so legacy notes still collapse by their conventional tag prefix.
104
+ */
105
+ subjectKey?: string;
85
106
  }
86
107
  export interface CoordinatorPromptContext {
87
108
  mesh: LocalMeshEntry;
@@ -117,21 +138,43 @@ export interface CoordinatorPromptContext {
117
138
  }
118
139
  export declare function buildCoordinatorSystemPrompt(ctx: CoordinatorPromptContext): string;
119
140
  /**
120
- * Operating-notes lifecycle selection (read-side, minimal first cut). Given the
121
- * effective notes (oldest-first, ledger order) and the current time, produce the
122
- * ordered, capped list that rides into the prompt:
123
- * (i) ALWAYS include pinned notes.
124
- * (ii) drop expired UNPINNED notes (per category TTL / explicit expiresAt).
125
- * (iii) rank pinned-first, then durable-category, then recency (newest first).
126
- * (iv) apply the existing OPERATING_NOTES_PROMPT_CAP to the ranked list.
127
- * Pure + deterministic given `now`. Returns { shown, omittedCount }.
141
+ * Phase 2 (c) deterministic same-class fold. Given ranked notes (already
142
+ * highest-priority first), collapse runs of UNPINNED notes that share the same
143
+ * category AND subject key into a single injected entry: keep the highest-ranked
144
+ * (first-seen) note and record the note-ids/count it subsumes so the rendered
145
+ * line can say "(+N earlier)". Pinned notes never fold each pinned note is
146
+ * author-opted-in and shown verbatim. The store is untouched; this is a pure,
147
+ * model-free text fold at injection time.
148
+ *
149
+ * Returns the folded note list (order preserved) with per-entry subsumed info.
150
+ */
151
+ interface FoldedNote {
152
+ note: CoordinatorOperatingNote;
153
+ /** note-ids of same-class notes this entry subsumes (may be empty). */
154
+ subsumedIds: string[];
155
+ /** count of subsumed notes (== subsumedIds.length, but counts id-less ones too). */
156
+ subsumedCount: number;
157
+ }
158
+ /**
159
+ * Operating-notes lifecycle selection (read-side). Given the effective notes
160
+ * (oldest-first, ledger order) and the current time, produce the ordered list
161
+ * that rides into the prompt:
162
+ * (i) ALWAYS include pinned notes.
163
+ * (ii) drop expired UNPINNED notes (per category TTL / explicit expiresAt).
164
+ * (iii) Phase 2 (b) drop any note SUPERSEDED by a later live note (by noteId
165
+ * or subjectKey); pinned notes are never dropped by supersede.
166
+ * (iv) rank pinned-first, then durable-category, then recency (newest first).
167
+ * (v) Phase 2 (c) fold same-category/same-subject unpinned runs into one.
168
+ * (vi) Phase 2 (d) fill up to a byte budget AND a count cap; pinned always
169
+ * kept even if they alone exceed the byte budget.
170
+ * Pure + deterministic given `now`. Returns { shown, omittedCount } where `shown`
171
+ * carries per-entry fold info for the renderer.
128
172
  *
129
- * Ranking is stable on the original ledger index so, within a tier, the original
130
- * oldest-first order is preserved before the recency comparison flips it — i.e.
131
- * newest notes lead each tier. The cap keeps the leading (highest-priority) N.
173
+ * Ranking is stable on the original ledger index so, within a tier, newest notes
174
+ * lead. The caps keep the leading (highest-priority) entries.
132
175
  */
133
- export declare function selectOperatingNotesForPrompt(notes: CoordinatorOperatingNote[], now: number, cap?: number): {
134
- shown: CoordinatorOperatingNote[];
176
+ export declare function selectOperatingNotesForPrompt(notes: CoordinatorOperatingNote[], now: number, cap?: number, byteBudget?: number): {
177
+ shown: FoldedNote[];
135
178
  omittedCount: number;
136
179
  };
137
180
  /**
@@ -146,3 +189,4 @@ export declare function selectOperatingNotesForPrompt(notes: CoordinatorOperatin
146
189
  * That keeps a MAGI-less mesh's prompt byte-identical to before.
147
190
  */
148
191
  export declare function buildMagiKindPanelsSection(panels: MagiKindPanelMap | undefined | null): string | null;
192
+ export {};
@@ -201,6 +201,7 @@ export declare class CliProviderInstance implements ProviderInstance {
201
201
  private generatingDebounceTimer;
202
202
  private generatingDebouncePending;
203
203
  private lastApprovalEventFingerprint;
204
+ private lastInteractivePromptEventKey;
204
205
  private autoApproveBusy;
205
206
  private autoApproveBusyTimer;
206
207
  private lastAutoApprovalSignature;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.18-rc.7",
3
+ "version": "1.0.18-rc.8",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "1.0.18-rc.7",
51
- "@adhdev/session-host-core": "1.0.18-rc.7",
50
+ "@adhdev/mesh-shared": "1.0.18-rc.8",
51
+ "@adhdev/session-host-core": "1.0.18-rc.8",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -115,8 +115,12 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
115
115
  try {
116
116
  const { readOperatingNotes } = await import('../../mesh/mesh-ledger.js');
117
117
  // readOperatingNotes filters out tombstoned (forgotten) notes so a
118
- // retracted lesson never rides into the prompt. Newest last; tail 20.
119
- const noteEntries = readOperatingNotes(id, { tail: 20 });
118
+ // retracted lesson never rides into the prompt. Newest last.
119
+ // Phase 2 (d): the byte-budget/count cap now bounds the injected list
120
+ // (selectOperatingNotesForPrompt), so read a larger candidate tail than
121
+ // the old fixed 20 and let injection-side ranking + budget do the
122
+ // bounding. Keep a sane store-read ceiling to avoid unbounded arrays.
123
+ const noteEntries = readOperatingNotes(id, { tail: 100 });
120
124
  const notes = noteEntries
121
125
  .map((e) => {
122
126
  const p = (e.payload || {}) as Record<string, unknown>;
@@ -136,6 +140,12 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
136
140
  // these → pinned defaults false, expiry governed by category TTL.
137
141
  pinned: p.pinned === true,
138
142
  ...(typeof p.expiresAt === 'string' ? { expiresAt: p.expiresAt } : {}),
143
+ // Phase 2 (b)/(c): thread the ledger id + supersedes/subjectKey
144
+ // so version-supersede targeting and same-class folding work.
145
+ // Legacy notes lack them → no supersede, fold by leading [tag].
146
+ noteId: e.id,
147
+ ...(typeof p.supersedes === 'string' ? { supersedes: p.supersedes } : {}),
148
+ ...(typeof p.subjectKey === 'string' ? { subjectKey: p.subjectKey } : {}),
139
149
  };
140
150
  })
141
151
  .filter((n): n is NonNullable<typeof n> => n !== null);
@@ -161,9 +161,13 @@ function normalizeOperatingNote(value: unknown): CoordinatorOperatingNote | null
161
161
  ...(typeof value.createdAt === 'string' ? { createdAt: value.createdAt } : {}),
162
162
  ...(typeof value.sourceCoordinator === 'string' ? { sourceCoordinator: value.sourceCoordinator } : {}),
163
163
  // Operating-notes lifecycle: a repo-declared note may pin itself or set an
164
- // explicit expiry, same as a runtime-recorded one.
164
+ // explicit expiry, same as a runtime-recorded one. Phase 2 (b)/(c): it may
165
+ // also declare supersedes/subjectKey to retire an earlier note or group
166
+ // same-subject notes for folding.
165
167
  ...(value.pinned === true ? { pinned: true } : {}),
166
168
  ...(typeof value.expiresAt === 'string' ? { expiresAt: value.expiresAt } : {}),
169
+ ...(typeof value.supersedes === 'string' ? { supersedes: value.supersedes } : {}),
170
+ ...(typeof value.subjectKey === 'string' ? { subjectKey: value.subjectKey } : {}),
167
171
  };
168
172
  }
169
173
 
@@ -99,6 +99,27 @@ export interface CoordinatorOperatingNote {
99
99
  * (see isNoteExpired) governs expiry; pinned notes never expire regardless.
100
100
  */
101
101
  expiresAt?: string;
102
+ /**
103
+ * Phase 2 — the ledger id of this note, when known. Threaded from the ledger
104
+ * entry id at launch so version-supersede can target a specific note and
105
+ * same-class folding can list the subsumed ids. Repo-declared notes may lack
106
+ * one; absent is fine (folding/supersede fall back to the subject key).
107
+ */
108
+ noteId?: string;
109
+ /**
110
+ * Phase 2 (b) version-supersede — an optional note-id or stable subject-key
111
+ * this note replaces. At injection, any earlier LIVE note whose `noteId` or
112
+ * `subjectKey` matches this value is hidden from the prompt (the ledger entry
113
+ * is retained for audit). Optional and lossless: absent = supersedes nothing.
114
+ */
115
+ supersedes?: string;
116
+ /**
117
+ * Phase 2 (b)/(c) — an optional stable subject-key grouping notes about the
118
+ * same subject. Drives version-supersede targeting and same-class folding.
119
+ * When absent, folding derives a key from a leading `[tag]` bracket instead,
120
+ * so legacy notes still collapse by their conventional tag prefix.
121
+ */
122
+ subjectKey?: string;
102
123
  }
103
124
 
104
125
  // ─── Prompt Builder ─────────────────────────────
@@ -597,6 +618,20 @@ function buildRecentActivitySection(activity?: CoordinatorRecentActivity): strin
597
618
  const OPERATING_NOTES_PROMPT_CAP = 20;
598
619
  const OPERATING_NOTE_MAX_CHARS = 300;
599
620
 
621
+ /**
622
+ * Phase 2 (d) — byte budget for the injected Operating Notes list. The count cap
623
+ * above (20) is still a ceiling, but selection now fills up to this UTF-8 byte
624
+ * budget on the RENDERED note lines, ranked pinned > durable > recency, so a few
625
+ * long notes don't crowd out many short ones and vice-versa. Pinned notes are
626
+ * ALWAYS kept even if they alone exceed the budget (pinned is author-opt-in and
627
+ * bounded by the author); the budget only bounds the unpinned tail.
628
+ *
629
+ * ~6KB ≈ the 20-note cap at typical note length, so on ordinary meshes this
630
+ * changes nothing — it only kicks in when notes run long. It sits well under the
631
+ * whole-prompt PROMPT_SOFT_CAP_BYTES (60KB) so the two caps don't fight.
632
+ */
633
+ const OPERATING_NOTE_INJECTION_BYTE_BUDGET = 6 * 1024;
634
+
600
635
  /**
601
636
  * A category is "durable" (survives the cap ahead of recency) when it has no
602
637
  * TTL entry — provider_quirk, uncategorized, or any unknown category. This
@@ -608,35 +643,135 @@ function isDurableCategory(category?: string): boolean {
608
643
  }
609
644
 
610
645
  /**
611
- * Operating-notes lifecycle selection (read-side, minimal first cut). Given the
612
- * effective notes (oldest-first, ledger order) and the current time, produce the
613
- * ordered, capped list that rides into the prompt:
614
- * (i) ALWAYS include pinned notes.
615
- * (ii) drop expired UNPINNED notes (per category TTL / explicit expiresAt).
616
- * (iii) rank pinned-first, then durable-category, then recency (newest first).
617
- * (iv) apply the existing OPERATING_NOTES_PROMPT_CAP to the ranked list.
618
- * Pure + deterministic given `now`. Returns { shown, omittedCount }.
646
+ * Phase 2 (b)/(c) derive a note's subject key for supersede targeting and
647
+ * same-class folding. Precedence:
648
+ * 1. an explicit `subjectKey` (trimmed, lowercased) when present, else
649
+ * 2. a leading `[tag]` bracket, so legacy notes that follow the conventional
650
+ * `[some-tag] …` prefix still group/supersede by that tag, else
651
+ * 3. undefined the note has no derivable subject and never folds/supersedes
652
+ * by key (it can still be targeted by exact noteId).
653
+ * Pure. Lowercased so `[Foo]` and `[foo]` collapse together.
654
+ */
655
+ function deriveSubjectKey(note: CoordinatorOperatingNote): string | undefined {
656
+ const explicit = typeof note.subjectKey === 'string' ? note.subjectKey.trim().toLowerCase() : '';
657
+ if (explicit) return explicit;
658
+ const text = typeof note.text === 'string' ? note.text.trimStart() : '';
659
+ const m = /^\[([^\]]{1,80})\]/.exec(text);
660
+ const tag = m ? m[1].trim().toLowerCase() : '';
661
+ return tag || undefined;
662
+ }
663
+
664
+ /**
665
+ * Phase 2 (c) — deterministic same-class fold. Given ranked notes (already
666
+ * highest-priority first), collapse runs of UNPINNED notes that share the same
667
+ * category AND subject key into a single injected entry: keep the highest-ranked
668
+ * (first-seen) note and record the note-ids/count it subsumes so the rendered
669
+ * line can say "(+N earlier)". Pinned notes never fold — each pinned note is
670
+ * author-opted-in and shown verbatim. The store is untouched; this is a pure,
671
+ * model-free text fold at injection time.
672
+ *
673
+ * Returns the folded note list (order preserved) with per-entry subsumed info.
674
+ */
675
+ interface FoldedNote {
676
+ note: CoordinatorOperatingNote;
677
+ /** note-ids of same-class notes this entry subsumes (may be empty). */
678
+ subsumedIds: string[];
679
+ /** count of subsumed notes (== subsumedIds.length, but counts id-less ones too). */
680
+ subsumedCount: number;
681
+ }
682
+
683
+ function foldSameClassNotes(ranked: CoordinatorOperatingNote[]): FoldedNote[] {
684
+ const out: FoldedNote[] = [];
685
+ // group key → index into `out` of the surviving (highest-ranked) entry.
686
+ const survivorByKey = new Map<string, number>();
687
+ for (const note of ranked) {
688
+ const subject = deriveSubjectKey(note);
689
+ // Only unpinned notes with a category AND a subject key are foldable —
690
+ // without both there is no reliable "same class/subject" signal, so we
691
+ // keep the note standalone (lossless for legacy/uncategorized notes).
692
+ const foldable = !note.pinned && !!note.category && !!subject;
693
+ if (foldable) {
694
+ const key = `${note.category}${subject}`;
695
+ const existing = survivorByKey.get(key);
696
+ if (existing !== undefined) {
697
+ const survivor = out[existing];
698
+ survivor.subsumedCount += 1;
699
+ if (typeof note.noteId === 'string' && note.noteId) survivor.subsumedIds.push(note.noteId);
700
+ continue;
701
+ }
702
+ survivorByKey.set(key, out.length);
703
+ }
704
+ out.push({ note, subsumedIds: [], subsumedCount: 0 });
705
+ }
706
+ return out;
707
+ }
708
+
709
+ /** Estimate the UTF-8 byte cost of one rendered operating-note line (Phase 2 d). */
710
+ function renderedNoteBytes(folded: FoldedNote): number {
711
+ return byteLength(renderOperatingNoteLine(folded));
712
+ }
713
+
714
+ /**
715
+ * Operating-notes lifecycle selection (read-side). Given the effective notes
716
+ * (oldest-first, ledger order) and the current time, produce the ordered list
717
+ * that rides into the prompt:
718
+ * (i) ALWAYS include pinned notes.
719
+ * (ii) drop expired UNPINNED notes (per category TTL / explicit expiresAt).
720
+ * (iii) Phase 2 (b) drop any note SUPERSEDED by a later live note (by noteId
721
+ * or subjectKey); pinned notes are never dropped by supersede.
722
+ * (iv) rank pinned-first, then durable-category, then recency (newest first).
723
+ * (v) Phase 2 (c) fold same-category/same-subject unpinned runs into one.
724
+ * (vi) Phase 2 (d) fill up to a byte budget AND a count cap; pinned always
725
+ * kept even if they alone exceed the byte budget.
726
+ * Pure + deterministic given `now`. Returns { shown, omittedCount } where `shown`
727
+ * carries per-entry fold info for the renderer.
619
728
  *
620
- * Ranking is stable on the original ledger index so, within a tier, the original
621
- * oldest-first order is preserved before the recency comparison flips it — i.e.
622
- * newest notes lead each tier. The cap keeps the leading (highest-priority) N.
729
+ * Ranking is stable on the original ledger index so, within a tier, newest notes
730
+ * lead. The caps keep the leading (highest-priority) entries.
623
731
  */
624
732
  export function selectOperatingNotesForPrompt(
625
733
  notes: CoordinatorOperatingNote[],
626
734
  now: number,
627
735
  cap: number = OPERATING_NOTES_PROMPT_CAP,
628
- ): { shown: CoordinatorOperatingNote[]; omittedCount: number } {
736
+ byteBudget: number = OPERATING_NOTE_INJECTION_BYTE_BUDGET,
737
+ ): { shown: FoldedNote[]; omittedCount: number } {
629
738
  const valid = Array.isArray(notes)
630
739
  ? notes.filter(n => n && typeof n.text === 'string' && n.text.trim())
631
740
  : [];
632
741
 
633
742
  // (i)+(ii): keep pinned always; drop expired unpinned. Retain original index
634
743
  // for a stable recency tiebreak (later index == newer, ledger is oldest-first).
635
- const kept = valid
744
+ let kept = valid
636
745
  .map((note, index) => ({ note, index }))
637
746
  .filter(({ note }) => note.pinned || !isNoteExpired(note as any, now));
638
747
 
639
- // (iii): rank pinned > durable > recency (newest first within a tier).
748
+ // (iii) Phase 2 (b) version-supersede: a LATER live note may name (via
749
+ // `supersedes`) an earlier note's noteId or subjectKey; the earlier one is
750
+ // then hidden. "Later" == higher ledger index. Collect every supersede target
751
+ // paired with the max index that supersedes it, then drop any UNPINNED note
752
+ // whose id/subjectKey is superseded by a strictly-later note. Pinned notes are
753
+ // never dropped by supersede (pinned always wins, mirroring TTL).
754
+ const supersededAtIndex = new Map<string, number>();
755
+ for (const { note, index } of kept) {
756
+ const target = typeof note.supersedes === 'string' ? note.supersedes.trim().toLowerCase() : '';
757
+ if (!target) continue;
758
+ const prev = supersededAtIndex.get(target);
759
+ if (prev === undefined || index > prev) supersededAtIndex.set(target, index);
760
+ }
761
+ if (supersededAtIndex.size > 0) {
762
+ kept = kept.filter(({ note, index }) => {
763
+ if (note.pinned) return true;
764
+ const byId = typeof note.noteId === 'string' ? note.noteId.trim().toLowerCase() : '';
765
+ const bySubject = deriveSubjectKey(note);
766
+ const supIdx = Math.max(
767
+ byId ? (supersededAtIndex.get(byId) ?? -1) : -1,
768
+ bySubject ? (supersededAtIndex.get(bySubject) ?? -1) : -1,
769
+ );
770
+ return !(supIdx > index); // superseded by a strictly-later note → drop
771
+ });
772
+ }
773
+
774
+ // (iv): rank pinned > durable > recency (newest first within a tier).
640
775
  const rank = (n: CoordinatorOperatingNote): number =>
641
776
  n.pinned ? 0 : isDurableCategory(n.category) ? 1 : 2;
642
777
  kept.sort((a, b) => {
@@ -645,41 +780,81 @@ export function selectOperatingNotesForPrompt(
645
780
  return b.index - a.index; // newer (higher index) first
646
781
  });
647
782
 
648
- // (iv): apply the existing cap to the ranked list.
649
- const omittedCount = Math.max(0, kept.length - cap);
650
- const shown = (omittedCount > 0 ? kept.slice(0, cap) : kept).map(k => k.note);
783
+ // (v) Phase 2 (c): fold same-class/same-subject unpinned runs into one entry.
784
+ const folded = foldSameClassNotes(kept.map(k => k.note));
785
+
786
+ // (vi) Phase 2 (d): fill up to the byte budget AND the count cap. Pinned
787
+ // entries are always kept (never dropped to fit the budget); the budget/cap
788
+ // bound the unpinned tail. Entries are already highest-priority first, so we
789
+ // walk in order and stop once an UNPINNED entry would break a bound.
790
+ const shown: FoldedNote[] = [];
791
+ let usedBytes = 0;
792
+ let usedCount = 0;
793
+ for (const entry of folded) {
794
+ if (entry.note.pinned) {
795
+ shown.push(entry);
796
+ usedBytes += renderedNoteBytes(entry);
797
+ usedCount += 1;
798
+ continue;
799
+ }
800
+ if (usedCount >= cap) break;
801
+ const bytes = renderedNoteBytes(entry);
802
+ if (usedCount > 0 && usedBytes + bytes > byteBudget) break;
803
+ shown.push(entry);
804
+ usedBytes += bytes;
805
+ usedCount += 1;
806
+ }
807
+
808
+ // omittedCount counts eligible (non-expired, non-superseded, post-fold) entries
809
+ // that did not make the cut. Folded-away duplicates are not "omitted" — they are
810
+ // represented by their survivor's "(+N earlier)" marker, so exclude them here.
811
+ const omittedCount = Math.max(0, folded.length - shown.length);
651
812
  return { shown, omittedCount };
652
813
  }
653
814
 
654
- function buildOperatingNotesSection(notes?: CoordinatorOperatingNote[], now: number = Date.now()): string {
655
- const hasAny = Array.isArray(notes)
656
- ? notes.some(n => n && typeof n.text === 'string' && n.text.trim())
657
- : false;
658
- if (!hasAny) return '';
659
-
815
+ /**
816
+ * Render one operating-note bullet line (Phase 2 c fold-aware). Shared by the
817
+ * byte-budget estimator and the section renderer so the budget is measured on
818
+ * the exact bytes that ship.
819
+ */
820
+ function renderOperatingNoteLine(folded: FoldedNote): string {
821
+ const n = folded.note;
660
822
  const categoryLabel: Record<string, string> = {
661
823
  provider_quirk: 'provider quirk',
662
824
  pattern_to_avoid: 'pattern to avoid',
663
825
  recovery_lesson: 'recovery lesson',
664
826
  };
827
+ const pin = n.pinned ? '📌 ' : '';
828
+ const cat = n.category && categoryLabel[n.category] ? `[${categoryLabel[n.category]}] ` : '';
829
+ const fold = folded.subsumedCount > 0
830
+ ? ` _(+${folded.subsumedCount} earlier same-subject note${folded.subsumedCount === 1 ? '' : 's'} folded${folded.subsumedIds.length ? `: ${folded.subsumedIds.join(', ')}` : ''})_`
831
+ : '';
832
+ return `- ${pin}${cat}${truncateNote(n.text.trim())}${fold}`;
833
+ }
834
+
835
+ function buildOperatingNotesSection(notes?: CoordinatorOperatingNote[], now: number = Date.now()): string {
836
+ const hasAny = Array.isArray(notes)
837
+ ? notes.some(n => n && typeof n.text === 'string' && n.text.trim())
838
+ : false;
839
+ if (!hasAny) return '';
665
840
 
666
- // Lifecycle selection: pinned-always + expired-unpinned-dropped + rank
667
- // (pinned > durable > recency) THEN the existing cap. `shown` is already
668
- // highest-priority-first; the cap kept the leading N.
841
+ // Lifecycle selection: pinned-always + expired-unpinned-dropped +
842
+ // superseded-dropped + rank (pinned > durable > recency) + same-class fold +
843
+ // byte-budget/count cap. `shown` is already highest-priority-first and carries
844
+ // per-entry fold info; renderOperatingNoteLine emits the exact bytes the
845
+ // budget was measured against.
669
846
  const { shown, omittedCount } = selectOperatingNotesForPrompt(notes ?? [], now);
670
847
  if (shown.length === 0) return '';
671
848
 
672
849
  const lines: string[] = ['## Operating Notes', ''];
673
850
  lines.push('Lessons earlier coordinators on this mesh recorded via `mesh_record_note`. Treat them as accumulated operating knowledge — apply them. When you learn a durable lesson (a provider quirk, a pattern to avoid, a recovery lesson), record it with `mesh_record_note` so future coordinators inherit it.');
674
851
  lines.push('');
675
- for (const n of shown) {
676
- const pin = n.pinned ? '📌 ' : '';
677
- const cat = n.category && categoryLabel[n.category] ? `[${categoryLabel[n.category]}] ` : '';
678
- lines.push(`- ${pin}${cat}${truncateNote(n.text.trim())}`);
852
+ for (const entry of shown) {
853
+ lines.push(renderOperatingNoteLine(entry));
679
854
  }
680
855
  if (omittedCount > 0) {
681
856
  lines.push('');
682
- lines.push(`_${omittedCount} lower-priority note${omittedCount === 1 ? '' : 's'} omitted (kept in ledger; expired-and-unpinned notes are also hidden from this list but retained for audit; prune with \`mesh_forget_note\`)._`);
857
+ lines.push(`_${omittedCount} lower-priority note${omittedCount === 1 ? '' : 's'} omitted to fit the injection cap/byte-budget (kept in ledger; expired, superseded, and same-subject-folded notes are also hidden from this list but retained for audit; prune with \`mesh_forget_note\`)._`);
683
858
  }
684
859
  return lines.join('\n');
685
860
  }
@@ -371,6 +371,16 @@ export class CliProviderInstance implements ProviderInstance {
371
371
  private generatingDebounceTimer: NodeJS.Timeout | null = null;
372
372
  private generatingDebouncePending: { chatTitle: string; timestamp: number } | null = null;
373
373
  private lastApprovalEventFingerprint = '';
374
+ // INTERACTIVE-PROMPT-PUSH: edge-trigger key for the AskUserQuestion / waiting_choice
375
+ // notification. An AskUserQuestion prompt is surfaced only as a display-only
376
+ // `waiting_choice` overlay in getState(); the raw adapter status stays idle/generating,
377
+ // so detectStatusTransition()'s status-keyed arms never fire and no push-worthy
378
+ // agent:* event is emitted — the owner misses the "ACTION REQUIRED" prompt when the
379
+ // app is backgrounded. We emit one agent:waiting_approval on ENTRY into the prompt
380
+ // state (reusing the existing server push path, no cloud change) and dedupe on this
381
+ // key so repeated status ticks with the same prompt do not re-fire. '' means no
382
+ // prompt is currently active (cleared when the prompt is answered/gone).
383
+ private lastInteractivePromptEventKey = '';
374
384
  private autoApproveBusy = false;
375
385
  private autoApproveBusyTimer: NodeJS.Timeout | null = null;
376
386
  private lastAutoApprovalSignature = '';
@@ -3950,6 +3960,47 @@ export class CliProviderInstance implements ProviderInstance {
3950
3960
  this.lastStatus = newStatus;
3951
3961
  }
3952
3962
 
3963
+ // INTERACTIVE-PROMPT-PUSH: fire a push-worthy notification when the session
3964
+ // ENTERS an AskUserQuestion / waiting_choice state. This is orthogonal to the
3965
+ // status-change block above: an AskUserQuestion prompt is surfaced only as a
3966
+ // display-only `waiting_choice` overlay in getState() (mirrored here off
3967
+ // adapterStatus.activeInteractivePrompt, the exact signal getState uses), while
3968
+ // the raw adapter status stays idle/generating — so none of the status-keyed
3969
+ // arms above ever emit an agent:* event for it and the owner gets no web-push.
3970
+ //
3971
+ // We REUSE agent:waiting_approval (the question message as modalMessage, the
3972
+ // choice labels as modalButtons) so the existing server push path fires with
3973
+ // zero cloud change — the semantics ("the agent needs your input") match.
3974
+ //
3975
+ // Edge-triggered: emit exactly once on entry, keyed on promptId + question text,
3976
+ // and reset the key when the prompt clears so a fresh prompt re-fires and a later
3977
+ // real completion still flows through the idle arm normally. We do NOT emit when
3978
+ // the session is ALSO in a genuine approval state (newStatus === 'waiting_approval'):
3979
+ // that arm already emits agent:waiting_approval, and firing here too would double
3980
+ // up on the same modal.
3981
+ const interactivePrompt = adapterStatus.activeInteractivePrompt ?? null;
3982
+ if (interactivePrompt && newStatus !== 'waiting_approval') {
3983
+ const firstQuestion = interactivePrompt.questions?.[0];
3984
+ const promptKey = `${interactivePrompt.promptId}::${firstQuestion?.question ?? ''}`;
3985
+ if (promptKey !== this.lastInteractivePromptEventKey) {
3986
+ this.lastInteractivePromptEventKey = promptKey;
3987
+ const modalMessage = firstQuestion
3988
+ ? (firstQuestion.header
3989
+ ? `${firstQuestion.header}: ${firstQuestion.question}`
3990
+ : firstQuestion.question)
3991
+ : undefined;
3992
+ const modalButtons = firstQuestion?.options?.map((option: { label: string }) => option.label);
3993
+ this.pushEvent({
3994
+ event: 'agent:waiting_approval', chatTitle, timestamp: now,
3995
+ modalMessage,
3996
+ modalButtons,
3997
+ });
3998
+ }
3999
+ } else if (!interactivePrompt && this.lastInteractivePromptEventKey) {
4000
+ // Prompt answered / gone — reset so the next AskUserQuestion re-fires.
4001
+ this.lastInteractivePromptEventKey = '';
4002
+ }
4003
+
3953
4004
  // GENERATING-BOUNDARY idle-stayed collapse (R4b): the starting→idle
3954
4005
  // fast-collapse arm above only fires when the FIRST turn itself drives the
3955
4006
  // starting→idle transition. When the launch settle already drained