@pellux/goodvibes-tui 0.22.0 → 0.23.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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Compaction preview and after-notice builders.
3
+ *
4
+ * Pre-compact: shows an honest estimate of what compaction will do
5
+ * (message count and token estimate before → after). The SDK has no dry-run
6
+ * API; we derive the after-estimate from the DEFAULT_COMPACTION_CONFIG
7
+ * totalCeiling (6500 tokens) — clearly labelled as an ESTIMATE.
8
+ *
9
+ * Post-compact: shows a before/after notice using the CompactionEvent
10
+ * data returned by compactMessages(), which contains the real
11
+ * tokensBeforeEstimate and tokensAfterEstimate figures.
12
+ *
13
+ * Honest wording policy:
14
+ * - Pre-compact notice says "estimate" every time; never claims certainty.
15
+ * - Post-compact notice uses "~N" prefix on every token figure.
16
+ * - Pinned session memories that survive are mentioned by count.
17
+ */
18
+
19
+ import { estimateConversationTokens } from '@pellux/goodvibes-sdk/platform/core';
20
+ import type { CompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
21
+ import type { ProviderMessage } from '@pellux/goodvibes-sdk/platform/providers';
22
+
23
+ /**
24
+ * Default compaction totalCeiling from context-compaction DEFAULT_COMPACTION_CONFIG.
25
+ * Kept local so we don't take a runtime dependency on the internal config object.
26
+ * Used only for the pre-compact ESTIMATE; the real figure comes from CompactionEvent.
27
+ */
28
+ const COMPACTION_OUTPUT_CEILING_ESTIMATE = 6500;
29
+
30
+ export interface CompactionPreviewOptions {
31
+ /** Messages currently in the conversation. */
32
+ readonly messages: readonly ProviderMessage[];
33
+ /** Context window size for the current model (0 if unknown). */
34
+ readonly contextWindow: number;
35
+ /** Number of session memories that will survive compaction. */
36
+ readonly pinnedMemoryCount: number;
37
+ /** Whether this is triggered automatically or manually. */
38
+ readonly trigger: 'auto' | 'manual';
39
+ }
40
+
41
+ export interface CompactionAfterOptions {
42
+ /** The CompactionEvent returned by the SDK after compaction completes. */
43
+ readonly event: CompactionEvent;
44
+ /** Number of session memories that survived compaction. */
45
+ readonly pinnedMemoryCount: number;
46
+ }
47
+
48
+ /**
49
+ * Build the pre-compaction notice string.
50
+ *
51
+ * Returned as a plain string intended for `ctx.print()` or `systemMessageRouter`.
52
+ * Always labelled as an estimate; uses the SDK totalCeiling as the after-estimate.
53
+ */
54
+ export function buildCompactionPreview(opts: CompactionPreviewOptions): string {
55
+ const { messages, contextWindow, pinnedMemoryCount, trigger } = opts;
56
+ const msgCount = messages.length;
57
+ const tokensBefore = estimateConversationTokens(messages as ProviderMessage[]);
58
+ const tokensAfterEstimate = COMPACTION_OUTPUT_CEILING_ESTIMATE;
59
+
60
+ const contextStr = contextWindow > 0
61
+ ? ` (${Math.round((tokensBefore / contextWindow) * 100)}% of ${fmtN(contextWindow)} context window)`
62
+ : '';
63
+
64
+ const pinStr = pinnedMemoryCount > 0
65
+ ? ` ${pinnedMemoryCount} pinned session memor${pinnedMemoryCount === 1 ? 'y' : 'ies'} will be preserved.`
66
+ : '';
67
+
68
+ const triggerStr = trigger === 'auto' ? 'Auto-compacting' : 'Compacting';
69
+
70
+ return (
71
+ `[Context] ${triggerStr} conversation: ~${fmtN(tokensBefore)} tokens across ${msgCount} message${msgCount === 1 ? '' : 's'}${contextStr}.` +
72
+ ` Estimated result: ~${fmtN(tokensAfterEstimate)} tokens (estimate — actual depends on content).` +
73
+ (pinStr ? ` ${pinStr.trim()}` : '')
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Build the post-compaction before/after notice string.
79
+ *
80
+ * Uses the real CompactionEvent figures (not estimates) for both before and
81
+ * after token counts. The trigger field controls wording.
82
+ */
83
+ export function buildCompactionAfterNotice(opts: CompactionAfterOptions): string {
84
+ const { event, pinnedMemoryCount } = opts;
85
+ const {
86
+ messagesBeforeCompaction,
87
+ messagesAfterCompaction,
88
+ tokensBeforeEstimate,
89
+ tokensAfterEstimate,
90
+ trigger,
91
+ } = event;
92
+
93
+ const savings = Math.max(0, tokensBeforeEstimate - tokensAfterEstimate);
94
+ const savingsPct = tokensBeforeEstimate > 0
95
+ ? Math.round((savings / tokensBeforeEstimate) * 100)
96
+ : 0;
97
+
98
+ const pinStr = pinnedMemoryCount > 0
99
+ ? ` ${pinnedMemoryCount} pinned memor${pinnedMemoryCount === 1 ? 'y' : 'ies'} preserved.`
100
+ : '';
101
+
102
+ const triggerStr = trigger === 'auto' ? 'Auto-compact complete' : 'Compact complete';
103
+
104
+ return (
105
+ `[Context] ${triggerStr}: ${messagesBeforeCompaction} → ${messagesAfterCompaction} messages,` +
106
+ ` ~${fmtN(tokensBeforeEstimate)} → ~${fmtN(tokensAfterEstimate)} tokens` +
107
+ ` (saved ~${fmtN(savings)}, ${savingsPct}%).` +
108
+ (pinStr ? ` ${pinStr.trim()}` : '')
109
+ );
110
+ }
111
+
112
+ /** Format a number with thousands separators. */
113
+ function fmtN(n: number): string {
114
+ return n.toLocaleString();
115
+ }
116
+
117
+ /**
118
+ * Build the /keep command usage text (shown when no args are provided).
119
+ *
120
+ * Exported for testability — the shell-core handler renders this string directly.
121
+ */
122
+ export function buildPinUsageText(): string {
123
+ return (
124
+ '[Pin] Usage: /keep <text>\n' +
125
+ 'Pinned entries are stored as session memories and included in the compaction handoff as pinned memories.\n' +
126
+ 'What pinning guarantees: the text survives the next compaction.\n' +
127
+ 'What pinning does NOT guarantee: recovery after process restart (session memories are in-memory only).'
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Build the /keep command success text.
133
+ *
134
+ * @param id - The assigned memory ID (e.g. "mem-1")
135
+ * @param text - The pinned text
136
+ * @param count - Total pinned memory count after adding
137
+ *
138
+ * Exported for testability — the shell-core handler renders this string directly.
139
+ */
140
+ export function buildPinSuccessText(id: string, text: string, count: number): string {
141
+ return (
142
+ `[Pin] Pinned as ${id}: "${text.slice(0, 60)}${text.length > 60 ? '...' : ''}"\n` +
143
+ ` ${count} pinned memor${count === 1 ? 'y' : 'ies'} will survive the next compaction.\n` +
144
+ ' Note: session memories are in-memory only and do not persist across restarts.'
145
+ );
146
+ }
@@ -4,8 +4,8 @@
4
4
  * architecture cap. No layout logic lives here.
5
5
  */
6
6
 
7
- import type { SettingEntry, McpEntry, SubscriptionEntry } from '../input/settings-modal.ts';
8
- import { SETTINGS_CATEGORIES } from '../input/settings-modal.ts';
7
+ import type { SettingEntry, McpEntry, SubscriptionEntry } from '../input/settings-modal-types.ts';
8
+ import { SETTINGS_CATEGORIES } from '../input/settings-modal-types.ts';
9
9
  import { isSecretConfigKey, isSecretReferenceValue } from '../config/secret-config.ts';
10
10
 
11
11
  function maskSecretValue(value: string): string {
@@ -123,6 +123,88 @@ export interface BootstrapCoreState {
123
123
 
124
124
  export type CompanionMessagePayload = Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>;
125
125
 
126
+ // ---------------------------------------------------------------------------
127
+ // Operator narration of inbound channel events
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Narrate an inbound channel event to the operator via the SystemMessageRouter.
132
+ *
133
+ * When an external surface (GitHub, Slack, ntfy, etc.) triggers an agent turn,
134
+ * this function produces a human-readable system message so the operator can
135
+ * observe which event caused the turn. Returns null for internal/companion
136
+ * sources that do not need operator narration.
137
+ *
138
+ * @param event - The normalized inbound event descriptor.
139
+ * @returns A narration string, or null if no narration is appropriate.
140
+ */
141
+ export function narrateInboundEvent(event: {
142
+ source: string;
143
+ metadata: Readonly<Record<string, unknown>> | undefined;
144
+ }): string | null {
145
+ const { source, metadata } = event;
146
+ if (!source) return null;
147
+
148
+ // Derive the effective surface — prefer metadata.surface, fall back to source.
149
+ const surface = typeof metadata?.surface === 'string' ? metadata.surface : source;
150
+
151
+ // Internal / companion sources do not need operator narration.
152
+ if (surface === 'companion' || source === 'companion') return null;
153
+ if (surface === 'internal' || source === 'internal') return null;
154
+
155
+ // Build a surface label for the log prefix.
156
+ const label = ((): string => {
157
+ switch (surface) {
158
+ case 'github': return '[GitHub]';
159
+ case 'slack': return '[Slack]';
160
+ case 'discord': return '[Discord]';
161
+ case 'ntfy': return '[ntfy]';
162
+ case 'homeassistant': return '[HomeAssistant]';
163
+ case 'telegram': return '[Telegram]';
164
+ case 'google-chat': return '[Google Chat]';
165
+ case 'signal': return '[Signal]';
166
+ case 'whatsapp': return '[WhatsApp]';
167
+ case 'msteams': return '[Teams]';
168
+ case 'imessage': return '[iMessage]';
169
+ case 'bluebubbles': return '[BlueBubbles]';
170
+ case 'mattermost': return '[Mattermost]';
171
+ case 'matrix': return '[Matrix]';
172
+ case 'webhook': return '[Webhook]';
173
+ default: return `[${surface[0]!.toUpperCase()}${surface.slice(1)}]`;
174
+ }
175
+ })();
176
+
177
+ const eventType = typeof metadata?.eventType === 'string' ? metadata.eventType : null;
178
+ const eventAction = typeof metadata?.eventAction === 'string' ? metadata.eventAction : null;
179
+ const topic = typeof metadata?.topic === 'string' ? metadata.topic : null;
180
+ const prNumber = typeof metadata?.prNumber === 'number' ? metadata.prNumber : null;
181
+ const issueNumber = typeof metadata?.issueNumber === 'number' ? metadata.issueNumber : null;
182
+ const repo = typeof metadata?.repo === 'string' ? metadata.repo : null;
183
+
184
+ // Build event-specific detail for GitHub events.
185
+ if (surface === 'github' && eventType) {
186
+ const actionPart = eventAction ? ` ${eventAction}` : '';
187
+ let detail = `${eventType}${actionPart} → agent triggered`;
188
+ if (prNumber !== null) {
189
+ detail = `PR #${prNumber}${repo ? ` (${repo})` : ''} ${eventAction ?? eventType} → agent triggered`;
190
+ } else if (issueNumber !== null) {
191
+ detail = `Issue #${issueNumber}${repo ? ` (${repo})` : ''} ${eventAction ?? eventType} → agent triggered`;
192
+ } else if (repo) {
193
+ detail = `${eventType}${actionPart} in ${repo} → agent triggered`;
194
+ }
195
+ return `${label} ${detail}`;
196
+ }
197
+
198
+ // ntfy: include topic when available.
199
+ if (surface === 'ntfy' && topic) {
200
+ return `${label} inbound message on topic '${topic}' → agent triggered`;
201
+ }
202
+
203
+ // Generic narration for all other surfaces.
204
+ const eventDetail = eventType ? ` ${eventType}${eventAction ? ` ${eventAction}` : ''}` : '';
205
+ return `${label}${eventDetail} inbound event → agent triggered`;
206
+ }
207
+
126
208
  export function companionMessageToOrchestratorInputOptions(
127
209
  payload: CompanionMessagePayload,
128
210
  ): OrchestratorUserInputOptions {
@@ -525,6 +607,16 @@ export async function initializeBootstrapCore(
525
607
  runtimeUnsubs.push(runtimeBus.on<Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>>(
526
608
  'COMPANION_MESSAGE_RECEIVED',
527
609
  ({ payload }) => {
610
+ // Narrate inbound external events to the operator so they can observe
611
+ // which channel event triggered the agent turn.
612
+ const narration = narrateInboundEvent({
613
+ source: payload.source,
614
+ metadata: payload.metadata,
615
+ });
616
+ if (narration) {
617
+ routeOrBuffer(narration, 'low');
618
+ }
619
+
528
620
  if (orchestratorHandleUserInputRef.value) {
529
621
  // Delegate to the orchestrator: adds user message + fires a real LLM turn.
530
622
  // Preserve surface origin metadata so the SDK can correlate replies back
@@ -0,0 +1,29 @@
1
+ /**
2
+ * browser.ts — cross-platform browser launcher utility.
3
+ *
4
+ * Extracted from cli/management.ts so it can be used by input-layer commands
5
+ * without creating an upward cli→input dependency. Lives in utils (Layer 0)
6
+ * and has no imports from shell-UI or entrypoint layers.
7
+ */
8
+
9
+ import { spawn } from 'node:child_process';
10
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
11
+
12
+ /**
13
+ * Open the given URL in the user's default browser.
14
+ * Returns a status string (success or error description).
15
+ * Does not throw — errors are returned as a descriptive string.
16
+ */
17
+ export function openBrowser(url: string): string {
18
+ const platform = process.platform;
19
+ const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
20
+ const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
21
+ try {
22
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
23
+ child.once('error', () => {});
24
+ child.unref();
25
+ return 'browser open requested';
26
+ } catch (error) {
27
+ return `browser open failed: ${summarizeError(error)}`;
28
+ }
29
+ }
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '0.22.0';
9
+ let _version = '0.23.0';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;