@pellux/goodvibes-agent 1.5.5 → 1.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +1 -1
- package/dist/package/main.js +94130 -79335
- package/docs/tools-and-commands.md +2 -1
- package/package.json +6 -3
- package/src/agent/autonomy-schedule-format.ts +1 -1
- package/src/agent/autonomy-schedule.ts +3 -3
- package/src/agent/calendar/calendar-oauth-service.ts +324 -0
- package/src/agent/calendar-subscription-registry.ts +497 -0
- package/src/agent/email/email-service.ts +57 -0
- package/src/agent/email/smtp-client.ts +30 -10
- package/src/agent/memory-prompt.ts +143 -12
- package/src/agent/operator-actions.ts +26 -22
- package/src/agent/prompt-context-receipts.ts +45 -18
- package/src/agent/reminder-schedule-format.ts +1 -1
- package/src/agent/reminder-schedule.ts +3 -3
- package/src/agent/routine-schedule-format.ts +2 -2
- package/src/agent/routine-schedule-promotion.ts +5 -5
- package/src/agent/routine-schedule-receipts.ts +2 -2
- package/src/agent/schedule-edit-format.ts +1 -1
- package/src/agent/schedule-edit.ts +5 -5
- package/src/agent/session-registration.ts +281 -0
- package/src/agent/vibe-file.ts +50 -0
- package/src/cli/local-library-command-shared.ts +146 -0
- package/src/cli/management.ts +2 -1
- package/src/cli/personas-command.ts +252 -0
- package/src/cli/resume-relaunch-notice.ts +151 -0
- package/src/cli/routines-command.ts +25 -2
- package/src/cli/service-posture.ts +3 -2
- package/src/cli/skill-bundle-command.ts +175 -0
- package/src/cli/skills-command.ts +309 -0
- package/src/cli/status.ts +43 -7
- package/src/cli/tui-startup.ts +24 -1
- package/src/config/agent-settings-policy.ts +0 -1
- package/src/config/secret-config.ts +4 -0
- package/src/core/conversation-rendering.ts +20 -11
- package/src/core/focus-tracker.ts +41 -0
- package/src/core/system-message-noise.ts +108 -0
- package/src/core/system-message-router.ts +54 -1
- package/src/core/thinking-overlay.ts +83 -0
- package/src/input/agent-workspace-access-command-editor-submission.ts +75 -108
- package/src/input/agent-workspace-access-command-editors.ts +108 -128
- package/src/input/agent-workspace-activation.ts +15 -0
- package/src/input/agent-workspace-basic-command-editor-submission.ts +292 -513
- package/src/input/agent-workspace-basic-command-editors.ts +433 -564
- package/src/input/agent-workspace-calendar-connect-editor.ts +116 -0
- package/src/input/agent-workspace-calendar-oauth-editor.ts +152 -0
- package/src/input/agent-workspace-calendar-subscribe-editor.ts +128 -0
- package/src/input/agent-workspace-categories.ts +7 -2
- package/src/input/agent-workspace-channel-command-editor-submission.ts +38 -52
- package/src/input/agent-workspace-channel-command-editors.ts +42 -46
- package/src/input/agent-workspace-command-editor-engine.ts +133 -0
- package/src/input/agent-workspace-command-editor.ts +4 -0
- package/src/input/agent-workspace-direct-editor-submission.ts +59 -0
- package/src/input/agent-workspace-email-connect-editor.ts +162 -0
- package/src/input/agent-workspace-knowledge-command-editor-submission.ts +52 -81
- package/src/input/agent-workspace-knowledge-command-editors.ts +71 -71
- package/src/input/agent-workspace-library-command-editor-submission.ts +10 -28
- package/src/input/agent-workspace-library-command-editors.ts +16 -2
- package/src/input/agent-workspace-live-counters.ts +55 -0
- package/src/input/agent-workspace-mcp-command-editor-submission.ts +60 -82
- package/src/input/agent-workspace-mcp-command-editors.ts +52 -0
- package/src/input/agent-workspace-media-command-editor-submission.ts +12 -39
- package/src/input/agent-workspace-media-command-editors.ts +10 -8
- package/src/input/agent-workspace-memory-command-editor-submission.ts +76 -151
- package/src/input/agent-workspace-memory-command-editors.ts +116 -141
- package/src/input/agent-workspace-operations-command-editor-submission.ts +131 -184
- package/src/input/agent-workspace-operations-command-editors.ts +150 -162
- package/src/input/agent-workspace-provider-command-editor-submission.ts +60 -106
- package/src/input/agent-workspace-provider-command-editors.ts +58 -68
- package/src/input/agent-workspace-search.ts +8 -1
- package/src/input/agent-workspace-session-command-editor-submission.ts +104 -132
- package/src/input/agent-workspace-session-command-editors.ts +160 -195
- package/src/input/agent-workspace-settings.ts +40 -1
- package/src/input/agent-workspace-skill-bundle-command-editor-submission.ts +55 -71
- package/src/input/agent-workspace-skill-bundle-command-editors.ts +65 -69
- package/src/input/agent-workspace-snapshot-builders.ts +431 -0
- package/src/input/agent-workspace-snapshot-config.ts +43 -0
- package/src/input/agent-workspace-snapshot.ts +198 -432
- package/src/input/agent-workspace-task-command-editor-submission.ts +34 -44
- package/src/input/agent-workspace-task-command-editors.ts +27 -28
- package/src/input/agent-workspace-types.ts +35 -1
- package/src/input/agent-workspace.ts +5 -14
- package/src/input/command-registry.ts +23 -1
- package/src/input/commands/calendar-connect-runtime.ts +226 -0
- package/src/input/commands/calendar-runtime.ts +110 -7
- package/src/input/commands/calendar-subscription-runtime.ts +225 -0
- package/src/input/commands/email-runtime.ts +100 -40
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/network-scan-runtime.ts +75 -0
- package/src/input/commands/operator-actions-runtime.ts +9 -6
- package/src/input/commands/personas-runtime.ts +1 -1
- package/src/input/commands/schedule-runtime.ts +4 -4
- package/src/input/commands/session-content.ts +13 -1
- package/src/input/commands/session-workflow.ts +15 -20
- package/src/input/commands/session.ts +3 -1
- package/src/input/commands/shell-core.ts +20 -6
- package/src/input/commands.ts +2 -2
- package/src/input/delete-key-policy.ts +46 -0
- package/src/input/feed-context-factory.ts +10 -0
- package/src/input/handler-feed.ts +87 -0
- package/src/input/handler-modal-routes.ts +6 -1
- package/src/input/handler.ts +5 -0
- package/src/input/panel-paste-flood-guard.ts +94 -0
- package/src/input/settings-modal-types.ts +5 -3
- package/src/input/settings-modal.ts +21 -0
- package/src/main.ts +36 -36
- package/src/permissions/approval-posture.ts +141 -0
- package/src/renderer/agent-workspace-context-lines.ts +10 -2
- package/src/renderer/compositor.ts +27 -4
- package/src/renderer/diff.ts +61 -18
- package/src/renderer/fullscreen-primitives.ts +37 -18
- package/src/renderer/markdown.ts +20 -10
- package/src/renderer/modal-factory.ts +25 -15
- package/src/renderer/overlay-box.ts +23 -12
- package/src/renderer/process-indicator.ts +8 -3
- package/src/renderer/prompt-content-width.ts +16 -0
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +2 -0
- package/src/renderer/startup-theme-probe.ts +35 -0
- package/src/renderer/status-glyphs.ts +11 -15
- package/src/renderer/system-message.ts +17 -2
- package/src/renderer/term-caps.ts +318 -0
- package/src/renderer/terminal-bg-probe.ts +373 -0
- package/src/renderer/terminal-escapes.ts +24 -0
- package/src/renderer/theme-mode-config.ts +87 -0
- package/src/renderer/theme.ts +241 -0
- package/src/renderer/thinking.ts +12 -3
- package/src/renderer/tool-call.ts +7 -3
- package/src/renderer/ui-factory.ts +92 -36
- package/src/renderer/ui-primitives.ts +33 -93
- package/src/runtime/bootstrap-core.ts +15 -0
- package/src/runtime/bootstrap-hook-bridge.ts +6 -0
- package/src/runtime/bootstrap-shell.ts +2 -0
- package/src/runtime/bootstrap.ts +68 -5
- package/src/runtime/calendar-boot-refresh.ts +105 -0
- package/src/runtime/lan-scan-consent.ts +253 -0
- package/src/runtime/services.ts +127 -2
- package/src/runtime/session-spine-rest-transport.ts +160 -0
- package/src/runtime/terminal-output-guard.ts +6 -1
- package/src/runtime/ui-services.ts +3 -0
- package/src/shell/agent-workspace-fullscreen.ts +5 -0
- package/src/shell/terminal-focus-mode.ts +120 -0
- package/src/shell/ui-openers.ts +13 -4
- package/src/tools/agent-harness-autonomy-intake.ts +5 -5
- package/src/tools/agent-harness-autonomy-queue.ts +1 -1
- package/src/tools/agent-harness-background-processes.ts +2 -1
- package/src/tools/agent-harness-metadata.ts +16 -8
- package/src/tools/agent-harness-operator-methods.ts +44 -8
- package/src/tools/agent-harness-personal-ops-discovery.ts +57 -10
- package/src/tools/agent-harness-personal-ops-lanes.ts +17 -5
- package/src/tools/agent-harness-personal-ops-operations.ts +7 -7
- package/src/tools/agent-harness-personal-ops-types.ts +6 -0
- package/src/tools/agent-harness-prompt-context.ts +26 -12
- package/src/tools/agent-harness-workspace-actions.ts +4 -0
- package/src/tools/agent-local-registry-args.ts +117 -0
- package/src/tools/agent-local-registry-memory.ts +227 -0
- package/src/tools/agent-local-registry-tool.ts +19 -237
- package/src/tools/agent-operator-briefing-tool.ts +2 -2
- package/src/tools/agent-operator-method-tool.ts +13 -0
- package/src/tools/agent-policy-explanation.ts +39 -4
- package/src/tools/agent-schedule-tool.ts +5 -5
- package/src/utils/terminal-width.ts +98 -1
- package/src/version.ts +1 -1
- package/src/cli/local-library-command.ts +0 -825
|
@@ -1,9 +1,51 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { AgentCalendarRegistry } from '../../agent/calendar-registry.ts';
|
|
3
3
|
import { parseIcs } from '../../agent/ics-calendar.ts';
|
|
4
|
+
import { parseIcs as parseIcsHonest } from '@pellux/goodvibes-sdk/platform/calendar';
|
|
4
5
|
import type { CommandContext, CommandRegistry } from '../command-registry.ts';
|
|
5
6
|
import { parseAgentLocalLibraryArgs } from './agent-local-library-args.ts';
|
|
6
7
|
import { requireShellPaths } from './runtime-services.ts';
|
|
8
|
+
import {
|
|
9
|
+
CALENDAR_SUBSCRIPTION_VERBS,
|
|
10
|
+
renderSubscribedSection,
|
|
11
|
+
runCalendarSubscriptionCommand,
|
|
12
|
+
subscriptionRegistryForRead,
|
|
13
|
+
} from './calendar-subscription-runtime.ts';
|
|
14
|
+
import {
|
|
15
|
+
fetchProviderUpcoming,
|
|
16
|
+
runCalendarAccounts,
|
|
17
|
+
runCalendarConnect,
|
|
18
|
+
runCalendarDisconnect,
|
|
19
|
+
sourceLabel,
|
|
20
|
+
} from './calendar-connect-runtime.ts';
|
|
21
|
+
|
|
22
|
+
/** Today's date as 'YYYY-MM-DD' (UTC), for windowed occurrence expansion. */
|
|
23
|
+
function todayIsoDate(): string {
|
|
24
|
+
return new Date().toISOString().slice(0, 10);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function addDaysIsoDate(days: number): string {
|
|
28
|
+
return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Append the merged, source-labeled subscribed section to a local-events block. */
|
|
32
|
+
function withSubscribedSection(localBlock: string, subscribed: string): string {
|
|
33
|
+
return subscribed ? `${localBlock}\n\n${subscribed}` : localBlock;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* F7b: the old unknown-subcommand usage line was one 13-verb run-on
|
|
38
|
+
* ("list|upcoming ...|connect ...|disconnect ...|accounts|import ...|export
|
|
39
|
+
* ...|add ...|delete ...|subscribe ...|unsubscribe ...|subscriptions|refresh
|
|
40
|
+
* ..."), unreadable at a glance. Grouped by what each verb is FOR instead.
|
|
41
|
+
*/
|
|
42
|
+
const CALENDAR_USAGE_MESSAGE = [
|
|
43
|
+
'Usage: /calendar <command>',
|
|
44
|
+
' Viewing list | upcoming [--days N]',
|
|
45
|
+
' Connecting connect <google|outlook> [--device] | disconnect <google|outlook> | accounts',
|
|
46
|
+
' Subscriptions subscribe <ics-url> [--name N] [--every MIN] [--yes] | unsubscribe <name> --yes | subscriptions | refresh [name]',
|
|
47
|
+
' Local events add --title <title> --start <ISO> --yes | delete <id> --yes | import <path> [--yes] | export [--dest <path>] [--yes]',
|
|
48
|
+
].join('\n');
|
|
7
49
|
|
|
8
50
|
const CALENDAR_VALUE_FLAGS = ['title', 'start', 'end', 'location', 'notes', 'dest', 'path', 'days'] as const;
|
|
9
51
|
|
|
@@ -43,15 +85,40 @@ function renderEventList(events: readonly Parameters<typeof formatEventLine>[0][
|
|
|
43
85
|
].join('\n');
|
|
44
86
|
}
|
|
45
87
|
|
|
46
|
-
export function runCalendarRuntimeCommand(args: readonly string[], ctx: CommandContext): void {
|
|
88
|
+
export async function runCalendarRuntimeCommand(args: readonly string[], ctx: CommandContext): Promise<void> {
|
|
47
89
|
const sub = (args[0] ?? 'list').toLowerCase();
|
|
90
|
+
|
|
91
|
+
// Connected-provider verbs (OAuth). Handled before the local-store registry so a
|
|
92
|
+
// connect/accounts call never touches the local .ics file.
|
|
93
|
+
if (sub === 'connect' || sub === 'disconnect' || sub === 'accounts') {
|
|
94
|
+
try {
|
|
95
|
+
if (sub === 'connect') await runCalendarConnect(args, ctx);
|
|
96
|
+
else if (sub === 'disconnect') await runCalendarDisconnect(args, ctx);
|
|
97
|
+
else await runCalendarAccounts(ctx);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
printError(ctx, error);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
48
104
|
const registry = registryFromContext(ctx);
|
|
105
|
+
// A read-only subscription registry merges subscribed feed events into the
|
|
106
|
+
// views below. Tolerant of a missing secret manager (seeds/occurrences read
|
|
107
|
+
// only the on-disk cache), so a failure to build it never breaks local views.
|
|
108
|
+
let subs: ReturnType<typeof subscriptionRegistryForRead> | null = null;
|
|
109
|
+
try {
|
|
110
|
+
subs = subscriptionRegistryForRead(ctx);
|
|
111
|
+
} catch {
|
|
112
|
+
subs = null;
|
|
113
|
+
}
|
|
49
114
|
|
|
50
115
|
try {
|
|
51
116
|
// list / upcoming
|
|
52
117
|
if (sub === 'list' || sub === 'all') {
|
|
53
118
|
const snap = registry.snapshot();
|
|
54
|
-
|
|
119
|
+
const local = renderEventList([...snap.events], snap.path);
|
|
120
|
+
const subscribed = subs ? renderSubscribedSection(subs.seeds()) : '';
|
|
121
|
+
ctx.print(withSubscribedSection(local, subscribed));
|
|
55
122
|
return;
|
|
56
123
|
}
|
|
57
124
|
|
|
@@ -60,7 +127,24 @@ export function runCalendarRuntimeCommand(args: readonly string[], ctx: CommandC
|
|
|
60
127
|
const days = parsed.flags.has('days') ? Math.max(1, parseInt(parsed.flags.get('days') ?? '7', 10) || 7) : 7;
|
|
61
128
|
const snap = registry.snapshot();
|
|
62
129
|
const events = registry.upcoming(days);
|
|
63
|
-
|
|
130
|
+
const local = renderEventList([...events], snap.path);
|
|
131
|
+
// A9's subscribed-feed section (ics-feed source), merged read-only.
|
|
132
|
+
const subscribed = subs ? renderSubscribedSection(subs.occurrencesInWindow(todayIsoDate(), addDaysIsoDate(days))) : '';
|
|
133
|
+
// A10: connected-provider events (google-api / microsoft-graph), best-effort.
|
|
134
|
+
const now = new Date();
|
|
135
|
+
const to = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
|
136
|
+
const { events: providerEvents, notes } = await fetchProviderUpcoming(ctx, {
|
|
137
|
+
timeMin: now.toISOString(),
|
|
138
|
+
timeMax: to.toISOString(),
|
|
139
|
+
});
|
|
140
|
+
const providerLines = providerEvents.map((e) =>
|
|
141
|
+
` [${sourceLabel(e.source)}] ${e.start.value.slice(0, 16).replace('T', ' ')} ${e.summary}${e.calendarLabel ? ` (${e.calendarLabel})` : ''}`,
|
|
142
|
+
);
|
|
143
|
+
const providerBlock = providerEvents.length > 0
|
|
144
|
+
? [`Connected calendars (${providerEvents.length})`, ...providerLines].join('\n')
|
|
145
|
+
: '';
|
|
146
|
+
const notesBlock = notes.length > 0 ? ['Notes', ...notes].join('\n') : '';
|
|
147
|
+
ctx.print([withSubscribedSection(local, subscribed), providerBlock, notesBlock].filter(Boolean).join('\n\n'));
|
|
64
148
|
return;
|
|
65
149
|
}
|
|
66
150
|
|
|
@@ -90,12 +174,21 @@ export function runCalendarRuntimeCommand(args: readonly string[], ctx: CommandC
|
|
|
90
174
|
return;
|
|
91
175
|
}
|
|
92
176
|
const { imported, skipped, secretSkipped } = registry.importIcs(content);
|
|
177
|
+
// Honest recurrence/skip report from the SDK reader: events the local store
|
|
178
|
+
// could not use, and recurring events whose RRULE this build does not fully
|
|
179
|
+
// expand (imported as a single seed, never silently dropped or mis-expanded).
|
|
180
|
+
const honest = parseIcsHonest(content);
|
|
181
|
+
const unsupportedRecurring = honest.events.filter((e) => e.recurrence?.expansion === 'unsupported');
|
|
93
182
|
ctx.print([
|
|
94
183
|
`Imported ${imported.length} event${imported.length === 1 ? '' : 's'} from ${filePath}`,
|
|
95
184
|
...imported.slice(0, 10).map((e) => ` ${e.id} ${e.start} ${e.title}`),
|
|
96
185
|
imported.length > 10 ? ` ... and ${imported.length - 10} more` : '',
|
|
97
186
|
skipped > 0 ? `Skipped ${skipped} event${skipped === 1 ? '' : 's'} that already exist.` : '',
|
|
98
187
|
secretSkipped > 0 ? `Skipped ${secretSkipped} event${secretSkipped === 1 ? '' : 's'} for safety.` : '',
|
|
188
|
+
honest.skipped.length > 0 ? `Could not parse ${honest.skipped.length} entr${honest.skipped.length === 1 ? 'y' : 'ies'}: ${honest.skipped[0]?.message ?? ''}` : '',
|
|
189
|
+
unsupportedRecurring.length > 0
|
|
190
|
+
? `${unsupportedRecurring.length} recurring event${unsupportedRecurring.length === 1 ? '' : 's'} imported as a single date only (recurrence not fully expanded): ${unsupportedRecurring.slice(0, 3).map((e) => e.summary).join(', ')}${unsupportedRecurring.length > 3 ? ', ...' : ''}`
|
|
191
|
+
: '',
|
|
99
192
|
].filter(Boolean).join('\n'));
|
|
100
193
|
return;
|
|
101
194
|
}
|
|
@@ -169,6 +262,11 @@ export function runCalendarRuntimeCommand(args: readonly string[], ctx: CommandC
|
|
|
169
262
|
ctx.print('Usage: /calendar delete <id> --yes');
|
|
170
263
|
return;
|
|
171
264
|
}
|
|
265
|
+
if (id.startsWith('sub:')) {
|
|
266
|
+
const subName = id.split(':')[1] ?? 'a subscription';
|
|
267
|
+
ctx.print(`Cannot delete ${id}: it is a read-only event from the '${subName}' subscription. Edit it in the source calendar, or run /calendar unsubscribe ${subName} --yes to remove the whole feed.`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
172
270
|
if (!parsed.yes) {
|
|
173
271
|
const event = registry.get(id);
|
|
174
272
|
if (!event) {
|
|
@@ -189,7 +287,7 @@ export function runCalendarRuntimeCommand(args: readonly string[], ctx: CommandC
|
|
|
189
287
|
return;
|
|
190
288
|
}
|
|
191
289
|
|
|
192
|
-
ctx.print(
|
|
290
|
+
ctx.print(CALENDAR_USAGE_MESSAGE);
|
|
193
291
|
} catch (error) {
|
|
194
292
|
printError(ctx, error);
|
|
195
293
|
}
|
|
@@ -199,11 +297,16 @@ export function registerCalendarRuntimeCommands(registry: CommandRegistry): void
|
|
|
199
297
|
registry.register({
|
|
200
298
|
name: 'calendar',
|
|
201
299
|
aliases: ['cal'],
|
|
202
|
-
description: 'Manage local calendar events',
|
|
300
|
+
description: 'Manage local calendar events, external calendar subscriptions, and connected calendar accounts',
|
|
203
301
|
hidden: true,
|
|
204
|
-
usage: '[list|upcoming [--days N]|import <path> [--yes]|export [--dest <path>] [--yes]|add --title <title> --start <ISO> [--end <ISO>] [--location <loc>] [--notes <notes>] --yes|delete <id> --yes]',
|
|
302
|
+
usage: '[list|upcoming [--days N]|connect <google|outlook> [--device]|disconnect <google|outlook>|accounts|import <path> [--yes]|export [--dest <path>] [--yes]|add --title <title> --start <ISO> [--end <ISO>] [--location <loc>] [--notes <notes>] --yes|delete <id> --yes|subscribe <ics-url> [--name N] [--every MIN] [--yes]|unsubscribe <name> --yes|subscriptions|refresh [name]]',
|
|
205
303
|
handler: async (args: readonly string[], ctx: CommandContext) => {
|
|
206
|
-
|
|
304
|
+
const sub = (args[0] ?? 'list').toLowerCase();
|
|
305
|
+
if (CALENDAR_SUBSCRIPTION_VERBS.has(sub)) {
|
|
306
|
+
await runCalendarSubscriptionCommand(sub, args.slice(1), ctx);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
await runCalendarRuntimeCommand(args, ctx);
|
|
207
310
|
},
|
|
208
311
|
});
|
|
209
312
|
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CalendarSubscriptionRegistry,
|
|
3
|
+
createHttpFeedFetcher,
|
|
4
|
+
type SubscribedOccurrence,
|
|
5
|
+
type SubscriptionStatus,
|
|
6
|
+
type SubscriptionSecretStore,
|
|
7
|
+
} from '../../agent/calendar-subscription-registry.ts';
|
|
8
|
+
import type { FeedFetcher } from '@pellux/goodvibes-sdk/platform/calendar';
|
|
9
|
+
import type { CommandContext } from '../command-registry.ts';
|
|
10
|
+
import { parseAgentLocalLibraryArgs } from './agent-local-library-args.ts';
|
|
11
|
+
import { requireShellPaths } from './runtime-services.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* External-calendar SUBSCRIPTION verbs for /calendar — the no-OAuth read path
|
|
15
|
+
* (W4-A9). Subscriptions are iCalendar feeds (Google secret address, Outlook
|
|
16
|
+
* published .ics, or any .ics URL). The parse/RRULE/fetch-status engine is the
|
|
17
|
+
* SDK's platform/calendar; this file is the /calendar command surface over the
|
|
18
|
+
* agent-side CalendarSubscriptionRegistry.
|
|
19
|
+
*
|
|
20
|
+
* A feed URL is secrets-adjacent (a Google secret address grants read access),
|
|
21
|
+
* so it is only ever stored through the secret manager and shown masked; the
|
|
22
|
+
* subscribe verb refuses to run without a secret manager present.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const SUBSCRIPTION_VALUE_FLAGS = ['name', 'every'] as const;
|
|
26
|
+
export const CALENDAR_SUBSCRIPTION_VERBS = new Set(['subscribe', 'unsubscribe', 'subscriptions', 'subs', 'refresh']);
|
|
27
|
+
|
|
28
|
+
/** A no-op secret store for the read-only merged view, where no secret is ever read. */
|
|
29
|
+
const READ_ONLY_SECRET_STUB: SubscriptionSecretStore = {
|
|
30
|
+
get: async () => null,
|
|
31
|
+
set: async () => { throw new Error('read-only'); },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function secretStoreFrom(ctx: CommandContext): SubscriptionSecretStore | null {
|
|
35
|
+
const sm = ctx.platform.secretsManager;
|
|
36
|
+
if (!sm) return null;
|
|
37
|
+
return sm as unknown as SubscriptionSecretStore;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a registry for a WRITE/network verb — requires a secret manager. Returns
|
|
42
|
+
* null (and the caller prints the honest reason) when none is available.
|
|
43
|
+
*/
|
|
44
|
+
export function subscriptionRegistryForWrite(ctx: CommandContext, fetcher?: FeedFetcher): CalendarSubscriptionRegistry | null {
|
|
45
|
+
const secrets = secretStoreFrom(ctx);
|
|
46
|
+
if (!secrets) return null;
|
|
47
|
+
return CalendarSubscriptionRegistry.create(requireShellPaths(ctx), secrets, fetcher ?? createHttpFeedFetcher());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Build a registry for the READ-ONLY merged view — tolerant of a missing secret manager. */
|
|
51
|
+
export function subscriptionRegistryForRead(ctx: CommandContext): CalendarSubscriptionRegistry {
|
|
52
|
+
const secrets = secretStoreFrom(ctx) ?? READ_ONLY_SECRET_STUB;
|
|
53
|
+
return CalendarSubscriptionRegistry.create(requireShellPaths(ctx), secrets, createHttpFeedFetcher());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseArgs(args: readonly string[]) {
|
|
57
|
+
return parseAgentLocalLibraryArgs(args, { valueFlags: SUBSCRIPTION_VALUE_FLAGS });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatHealth(s: SubscriptionStatus): string {
|
|
61
|
+
const base = `${s.health}`;
|
|
62
|
+
return s.detail ? `${base} — ${s.detail}` : base;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Render the subscribed-events section merged into /calendar list & upcoming. */
|
|
66
|
+
export function renderSubscribedSection(occurrences: readonly SubscribedOccurrence[]): string {
|
|
67
|
+
if (occurrences.length === 0) return '';
|
|
68
|
+
const lines = occurrences.map((o) => {
|
|
69
|
+
const when = o.allDay ? o.start : o.start.slice(0, 16).replace('T', ' ');
|
|
70
|
+
const loc = o.location ? ` at ${o.location}` : '';
|
|
71
|
+
const marker = o.recurrenceNotFullyExpanded ? ' [recurrence not fully expanded]' : '';
|
|
72
|
+
return ` [${o.subscription}] ${when} ${o.title}${loc}${marker} (read-only)`;
|
|
73
|
+
});
|
|
74
|
+
return [`Subscribed calendar events (${occurrences.length})`, ...lines].join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Handle a subscription verb. Returns true if it handled the sub-command (so the
|
|
79
|
+
* caller's local-store path is skipped).
|
|
80
|
+
*/
|
|
81
|
+
export async function runCalendarSubscriptionCommand(
|
|
82
|
+
sub: string,
|
|
83
|
+
args: readonly string[],
|
|
84
|
+
ctx: CommandContext,
|
|
85
|
+
/** Injectable for tests — production callers pass undefined and get the real HTTP fetcher. */
|
|
86
|
+
fetcher?: FeedFetcher,
|
|
87
|
+
): Promise<boolean> {
|
|
88
|
+
if (!CALENDAR_SUBSCRIPTION_VERBS.has(sub)) return false;
|
|
89
|
+
|
|
90
|
+
if (sub === 'subscribe') {
|
|
91
|
+
await handleSubscribe(args, ctx, fetcher);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
if (sub === 'unsubscribe') {
|
|
95
|
+
await handleUnsubscribe(args, ctx, fetcher);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
if (sub === 'subscriptions' || sub === 'subs') {
|
|
99
|
+
await handleList(ctx, fetcher);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
if (sub === 'refresh') {
|
|
103
|
+
await handleRefresh(args, ctx, fetcher);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function requireRegistry(ctx: CommandContext, fetcher: FeedFetcher | undefined, action: string): CalendarSubscriptionRegistry | null {
|
|
110
|
+
const registry = subscriptionRegistryForWrite(ctx, fetcher);
|
|
111
|
+
if (!registry) {
|
|
112
|
+
ctx.print(`Cannot ${action}: no secret manager is available in this runtime, and feed URLs are stored only as secrets.`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return registry;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function handleSubscribe(args: readonly string[], ctx: CommandContext, fetcher?: FeedFetcher): Promise<void> {
|
|
119
|
+
const parsed = parseArgs(args);
|
|
120
|
+
const url = parsed.rest[0] ?? parsed.flags.get('url');
|
|
121
|
+
if (!url) {
|
|
122
|
+
ctx.print('Usage: /calendar subscribe <ics-url> [--name <name>] [--every <minutes>] [--yes]');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const requestedName = parsed.flags.get('name')?.trim() || undefined;
|
|
126
|
+
const everyMin = parsed.flags.has('every') ? Math.max(15, parseInt(parsed.flags.get('every') ?? '', 10) || 60) : undefined;
|
|
127
|
+
const refreshIntervalMs = everyMin !== undefined ? everyMin * 60_000 : undefined;
|
|
128
|
+
|
|
129
|
+
const registry = requireRegistry(ctx, fetcher, 'subscribe');
|
|
130
|
+
if (!registry) return;
|
|
131
|
+
|
|
132
|
+
// Consent-at-add preview: validate by fetching and state exactly what will be
|
|
133
|
+
// fetched and how often, before anything is saved.
|
|
134
|
+
if (!parsed.yes) {
|
|
135
|
+
const check = await registry.validate(url, requestedName);
|
|
136
|
+
if (!check.ok) {
|
|
137
|
+
ctx.print([`Subscription preview failed at the ${check.stage} stage.`, ` ${check.detail}`].join('\n'));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const intervalMin = Math.round((refreshIntervalMs ?? 60 * 60_000) / 60_000);
|
|
141
|
+
ctx.print([
|
|
142
|
+
'Calendar subscription preview',
|
|
143
|
+
` calendar ${check.calendarName ?? '(unnamed feed)'}`,
|
|
144
|
+
` name ${check.derivedName}`,
|
|
145
|
+
` events ${check.eventCount} found`,
|
|
146
|
+
` refresh every ${intervalMin} min (read-only; the feed URL is stored as a secret)`,
|
|
147
|
+
' This will fetch the calendar from the URL you provided, now and on each refresh.',
|
|
148
|
+
' rerun with --yes to subscribe',
|
|
149
|
+
].join('\n'));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const result = await registry.subscribe(url, requestedName, refreshIntervalMs);
|
|
154
|
+
if (!result.ok) {
|
|
155
|
+
ctx.print([`Could not subscribe (${result.stage}).`, ` ${result.detail}`].join('\n'));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const intervalMin = Math.round(result.refreshIntervalMs / 60_000);
|
|
159
|
+
ctx.print([
|
|
160
|
+
`Subscribed to '${result.name}'.`,
|
|
161
|
+
` events ${result.eventCount} loaded`,
|
|
162
|
+
` refresh every ${intervalMin} min — run /calendar refresh to update now`,
|
|
163
|
+
' These events are read-only and appear source-labeled in /calendar list.',
|
|
164
|
+
].join('\n'));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function handleUnsubscribe(args: readonly string[], ctx: CommandContext, fetcher?: FeedFetcher): Promise<void> {
|
|
168
|
+
const parsed = parseArgs(args);
|
|
169
|
+
const name = parsed.rest.join(' ').trim() || parsed.flags.get('name')?.trim();
|
|
170
|
+
if (!name) {
|
|
171
|
+
ctx.print('Usage: /calendar unsubscribe <name> [--yes]');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const registry = requireRegistry(ctx, fetcher, 'unsubscribe');
|
|
175
|
+
if (!registry) return;
|
|
176
|
+
if (!parsed.yes) {
|
|
177
|
+
ctx.print([`Unsubscribe preview`, ` name ${name}`, ' This removes the subscription, its cached events, and its stored feed URL.', ' rerun with --yes to unsubscribe'].join('\n'));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const removed = await registry.unsubscribe(name);
|
|
181
|
+
ctx.print(removed ? `Unsubscribed from '${name}'.` : `No subscription named '${name}'.`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function handleList(ctx: CommandContext, fetcher?: FeedFetcher): Promise<void> {
|
|
185
|
+
const registry = subscriptionRegistryForWrite(ctx, fetcher) ?? subscriptionRegistryForRead(ctx);
|
|
186
|
+
const statuses = await registry.statuses();
|
|
187
|
+
if (statuses.length === 0) {
|
|
188
|
+
ctx.print(['Calendar subscriptions', ' None. Add one with /calendar subscribe <ics-url> --yes'].join('\n'));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
ctx.print([
|
|
192
|
+
`Calendar subscriptions (${statuses.length})`,
|
|
193
|
+
...statuses.map((s) => [
|
|
194
|
+
` ${s.name}`,
|
|
195
|
+
` url ${s.maskedUrl}`,
|
|
196
|
+
` status ${formatHealth(s)}`,
|
|
197
|
+
` events ${s.eventCount}`,
|
|
198
|
+
` refresh every ${Math.round(s.refreshIntervalMs / 60_000)} min`,
|
|
199
|
+
].join('\n')),
|
|
200
|
+
].join('\n'));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function handleRefresh(args: readonly string[], ctx: CommandContext, fetcher?: FeedFetcher): Promise<void> {
|
|
204
|
+
const parsed = parseArgs(args);
|
|
205
|
+
const name = parsed.rest.join(' ').trim() || undefined;
|
|
206
|
+
const registry = requireRegistry(ctx, fetcher, 'refresh subscriptions');
|
|
207
|
+
if (!registry) return;
|
|
208
|
+
if (!registry.hasAny()) {
|
|
209
|
+
ctx.print('No calendar subscriptions to refresh. Add one with /calendar subscribe <ics-url> --yes');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const outcomes = await registry.refresh(name, { force: true });
|
|
213
|
+
if (outcomes.length === 0) {
|
|
214
|
+
ctx.print(name ? `No subscription named '${name}'.` : 'No calendar subscriptions to refresh.');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
ctx.print([
|
|
218
|
+
`Refreshed ${outcomes.length} subscription${outcomes.length === 1 ? '' : 's'}`,
|
|
219
|
+
...outcomes.map((o) => {
|
|
220
|
+
const detail = o.detail ? ` — ${o.detail}` : '';
|
|
221
|
+
const count = o.eventCount !== undefined ? ` (${o.eventCount} events)` : '';
|
|
222
|
+
return ` ${o.name}: ${o.outcome}${count}${detail}`;
|
|
223
|
+
}),
|
|
224
|
+
].join('\n'));
|
|
225
|
+
}
|
|
@@ -61,6 +61,93 @@ function getConfigManager(ctx: CommandContext): { get: (key: string) => unknown
|
|
|
61
61
|
return ctx.platform.configManager as unknown as { get: (key: string) => unknown };
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** Config manager surface needed to persist one email field. Matches ctx.platform.configManager. */
|
|
65
|
+
export interface EmailConfigManagerLike {
|
|
66
|
+
get: (key: string) => unknown;
|
|
67
|
+
setDynamic: (key: string, value: unknown) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* SecretsManager surface needed to store the raw password. The real
|
|
72
|
+
* ctx.platform.secretsManager (SDK SecretsManager) has more methods;
|
|
73
|
+
* persistSecretBackedConfigValue is called through an opaque cast, matching
|
|
74
|
+
* the existing /email set pattern.
|
|
75
|
+
*/
|
|
76
|
+
export interface EmailSecretsManagerLike {
|
|
77
|
+
readonly get: (key: string) => Promise<string | null>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface PersistEmailConfigFieldResult {
|
|
81
|
+
readonly ok: boolean;
|
|
82
|
+
readonly error?: string;
|
|
83
|
+
readonly configKey?: string;
|
|
84
|
+
/** Set only for the passwordRef field — the stored goodvibes:// reference (never the raw value). */
|
|
85
|
+
readonly secretRef?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Persist one `email.<field>` config value through the single reviewed path:
|
|
90
|
+
* non-secret fields go through cm.setDynamic after per-field validation; the
|
|
91
|
+
* password field (fieldName 'passwordRef', rawValue is the RAW password) is
|
|
92
|
+
* routed through persistSecretBackedConfigValue so only a goodvibes:// secret
|
|
93
|
+
* reference ever reaches settings.json. Never logs or returns the raw value.
|
|
94
|
+
*
|
|
95
|
+
* Reused by both the /email set CLI subcommand (handleSet, below) and the
|
|
96
|
+
* Agent workspace connect wizard editor — one persistence path, no fork.
|
|
97
|
+
*/
|
|
98
|
+
export async function persistEmailConfigField(
|
|
99
|
+
cm: EmailConfigManagerLike,
|
|
100
|
+
secretsManager: EmailSecretsManagerLike,
|
|
101
|
+
fieldName: string,
|
|
102
|
+
rawValue: string,
|
|
103
|
+
): Promise<PersistEmailConfigFieldResult> {
|
|
104
|
+
if (!KNOWN_EMAIL_FIELDS.has(fieldName)) {
|
|
105
|
+
return { ok: false, error: `Unknown email config key: ${fieldName}` };
|
|
106
|
+
}
|
|
107
|
+
const configKey = `email.${fieldName}`;
|
|
108
|
+
|
|
109
|
+
if (fieldName === 'passwordRef') {
|
|
110
|
+
const storedRef = await persistSecretBackedConfigValue(
|
|
111
|
+
cm as unknown as Parameters<typeof persistSecretBackedConfigValue>[0],
|
|
112
|
+
secretsManager as unknown as Parameters<typeof persistSecretBackedConfigValue>[1],
|
|
113
|
+
configKey as unknown as ConfigKey,
|
|
114
|
+
rawValue,
|
|
115
|
+
{ scope: 'user' },
|
|
116
|
+
);
|
|
117
|
+
return { ok: true, configKey, secretRef: storedRef };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let coerced: unknown = rawValue;
|
|
121
|
+
if (fieldName === 'imapPort' || fieldName === 'smtpPort') {
|
|
122
|
+
const n = parseInt(rawValue, 10);
|
|
123
|
+
if (!isFinite(n) || n < 1 || n > 65535) {
|
|
124
|
+
return { ok: false, error: `Invalid port number: ${rawValue}. Must be an integer between 1 and 65535.`, configKey };
|
|
125
|
+
}
|
|
126
|
+
coerced = n;
|
|
127
|
+
} else if (fieldName === 'smtpSecurity') {
|
|
128
|
+
if (rawValue !== 'tls' && rawValue !== 'starttls' && rawValue !== 'auto') {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
error: `Invalid smtpSecurity value: "${rawValue}". Valid values: tls, starttls, auto.`,
|
|
132
|
+
configKey,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
coerced = rawValue;
|
|
136
|
+
} else if (fieldName === 'enabled') {
|
|
137
|
+
const lower = rawValue.toLowerCase();
|
|
138
|
+
if (lower === 'true' || lower === '1' || lower === 'yes') {
|
|
139
|
+
coerced = true;
|
|
140
|
+
} else if (lower === 'false' || lower === '0' || lower === 'no') {
|
|
141
|
+
coerced = false;
|
|
142
|
+
} else {
|
|
143
|
+
return { ok: false, error: `Invalid boolean value: ${rawValue}. Use true or false.`, configKey };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
cm.setDynamic(configKey, coerced);
|
|
148
|
+
return { ok: true, configKey };
|
|
149
|
+
}
|
|
150
|
+
|
|
64
151
|
function buildEmailService(ctx: CommandContext): EmailService {
|
|
65
152
|
const secretsManager = requireSecretsManager(ctx);
|
|
66
153
|
const cm = getConfigManager(ctx);
|
|
@@ -247,58 +334,31 @@ async function handleSet(
|
|
|
247
334
|
}
|
|
248
335
|
|
|
249
336
|
const secretsManager = requireSecretsManager(ctx);
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
rawValue,
|
|
256
|
-
{ scope: 'user' },
|
|
257
|
-
);
|
|
337
|
+
const result = await persistEmailConfigField(cm, secretsManager, fieldName, rawValue);
|
|
338
|
+
if (!result.ok) {
|
|
339
|
+
ctx.print(result.error ?? `Could not store ${configKey}.`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
258
342
|
cm.save();
|
|
259
343
|
ctx.print(
|
|
260
344
|
`Email config updated\n` +
|
|
261
345
|
` key: ${configKey}\n` +
|
|
262
|
-
` value: [stored as secret; reference: ${
|
|
346
|
+
` value: [stored as secret; reference: ${(result.secretRef ?? '').slice(0, 40)}...]\n` +
|
|
263
347
|
' policy: raw password stored in secrets manager; settings.json contains only the goodvibes:// ref',
|
|
264
348
|
);
|
|
265
349
|
return;
|
|
266
350
|
}
|
|
267
351
|
|
|
268
|
-
// Non-secret path: coerce
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
coerced = n;
|
|
277
|
-
} else if (fieldName === 'smtpSecurity') {
|
|
278
|
-
// MIN-2: validate at set time so invalid values never reach the socket factory
|
|
279
|
-
if (rawValue !== 'tls' && rawValue !== 'starttls' && rawValue !== 'auto') {
|
|
280
|
-
ctx.print(
|
|
281
|
-
`Invalid smtpSecurity value: "${rawValue}".\n` +
|
|
282
|
-
'Valid values: tls (direct TLS, e.g. port 465), starttls (STARTTLS upgrade, e.g. port 587), auto (port-based default).',
|
|
283
|
-
);
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
coerced = rawValue;
|
|
287
|
-
} else if (fieldName === 'enabled') {
|
|
288
|
-
const lower = rawValue.toLowerCase();
|
|
289
|
-
if (lower === 'true' || lower === '1' || lower === 'yes') {
|
|
290
|
-
coerced = true;
|
|
291
|
-
} else if (lower === 'false' || lower === '0' || lower === 'no') {
|
|
292
|
-
coerced = false;
|
|
293
|
-
} else {
|
|
294
|
-
ctx.print(`Invalid boolean value: ${rawValue}. Use true or false.`);
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
352
|
+
// Non-secret path: coerce, validate, and persist through the shared field helper
|
|
353
|
+
const secretsManager = requireSecretsManager(ctx);
|
|
354
|
+
const result = await persistEmailConfigField(cm, secretsManager, fieldName, rawValue);
|
|
355
|
+
if (!result.ok) {
|
|
356
|
+
ctx.print(result.error ?? `Could not update ${configKey}.`);
|
|
357
|
+
return;
|
|
297
358
|
}
|
|
298
359
|
|
|
299
|
-
cm.setDynamic(configKey as unknown as ConfigKey, coerced);
|
|
300
360
|
cm.save();
|
|
301
|
-
ctx.print(`Email config updated\n key: ${configKey}\n value: ${String(
|
|
361
|
+
ctx.print(`Email config updated\n key: ${configKey}\n value: ${String(cm.get(configKey))}`);
|
|
302
362
|
}
|
|
303
363
|
|
|
304
364
|
async function handleSend(
|
|
@@ -128,7 +128,7 @@ function requireAgentKnowledgeAsk(context: CommandContext): ((input: KnowledgeAs
|
|
|
128
128
|
|
|
129
129
|
export const knowledgeCommand: SlashCommand = {
|
|
130
130
|
name: 'knowledge',
|
|
131
|
-
aliases: ['know'
|
|
131
|
+
aliases: ['know'],
|
|
132
132
|
description: 'Agent Knowledge: isolated Agent-owned status, ask/search, source/node/issue lists, item lookup, map, connectors, ingest, and review queue.',
|
|
133
133
|
hidden: true,
|
|
134
134
|
usage: '<subcommand> [args]',
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { CommandRegistry } from '../command-registry.ts';
|
|
2
|
+
import { requireShellPaths } from './runtime-services.ts';
|
|
3
|
+
import { GOODVIBES_AGENT_SURFACE_ROOT } from '../../config/surface.ts';
|
|
4
|
+
import {
|
|
5
|
+
discoveredProvidersFilePath,
|
|
6
|
+
loadLanScanConsent,
|
|
7
|
+
saveLanScanConsent,
|
|
8
|
+
NETWORK_SCAN_DISABLE_COMMAND,
|
|
9
|
+
NETWORK_SCAN_ENABLE_COMMAND,
|
|
10
|
+
} from '../../runtime/lan-scan-consent.ts';
|
|
11
|
+
|
|
12
|
+
function statusText(storePath: string, decision: ReturnType<typeof loadLanScanConsent>): string {
|
|
13
|
+
if (!decision || decision.decision === 'declined') {
|
|
14
|
+
return [
|
|
15
|
+
'Local network scanning: off',
|
|
16
|
+
' What it would do Check other devices on your local network (your subnet only — nothing beyond it) for model servers such as Ollama or LM Studio.',
|
|
17
|
+
` What it stores Found servers would be saved to ${storePath}.`,
|
|
18
|
+
` Turn it on ${NETWORK_SCAN_ENABLE_COMMAND}`,
|
|
19
|
+
].join('\n');
|
|
20
|
+
}
|
|
21
|
+
return [
|
|
22
|
+
'Local network scanning: on',
|
|
23
|
+
` Turned on ${decision.decidedAt}`,
|
|
24
|
+
' What it does Checks other devices on your local network (your subnet only — nothing beyond it) for model servers such as Ollama or LM Studio, each time the agent starts.',
|
|
25
|
+
` What it stores Found servers are saved to ${storePath}.`,
|
|
26
|
+
' See discovered /provider',
|
|
27
|
+
` Turn it off ${NETWORK_SCAN_DISABLE_COMMAND}`,
|
|
28
|
+
].join('\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* /network-scan — the consent + status surface for the Phase-8 LAN provider
|
|
33
|
+
* discovery scan (see src/runtime/lan-scan-consent.ts). Declining or never
|
|
34
|
+
* deciding is a first-class, fully supported path: the scan simply never
|
|
35
|
+
* runs until this command turns it on.
|
|
36
|
+
*/
|
|
37
|
+
export function registerNetworkScanRuntimeCommands(registry: CommandRegistry): void {
|
|
38
|
+
registry.register({
|
|
39
|
+
name: 'network-scan',
|
|
40
|
+
aliases: ['discover-lan'],
|
|
41
|
+
description: 'Turn local-network scanning for model servers on or off, or check its status',
|
|
42
|
+
usage: '[on|off|status]',
|
|
43
|
+
argsHint: '[on|off|status]',
|
|
44
|
+
handler(args, ctx) {
|
|
45
|
+
const shellPaths = requireShellPaths(ctx);
|
|
46
|
+
const storePath = discoveredProvidersFilePath(shellPaths, GOODVIBES_AGENT_SURFACE_ROOT);
|
|
47
|
+
const sub = (args[0] ?? 'status').toLowerCase();
|
|
48
|
+
|
|
49
|
+
if (sub === 'status' || sub === '') {
|
|
50
|
+
ctx.print(statusText(storePath, loadLanScanConsent(shellPaths, GOODVIBES_AGENT_SURFACE_ROOT)));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (sub === 'on' || sub === 'enable') {
|
|
55
|
+
saveLanScanConsent(shellPaths, GOODVIBES_AGENT_SURFACE_ROOT, 'granted');
|
|
56
|
+
ctx.print(
|
|
57
|
+
'Local network scanning is now on. Starting next launch, the agent will check other devices on your ' +
|
|
58
|
+
`local network (your subnet only) for model servers such as Ollama or LM Studio, and save what it finds to ${storePath}. ` +
|
|
59
|
+
'Run /provider to see discovered servers. Turn it off anytime with /network-scan off.',
|
|
60
|
+
);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (sub === 'off' || sub === 'disable') {
|
|
65
|
+
saveLanScanConsent(shellPaths, GOODVIBES_AGENT_SURFACE_ROOT, 'declined');
|
|
66
|
+
ctx.print(
|
|
67
|
+
`Local network scanning is now off. Nothing on your network will be probed. Turn it back on anytime with ${NETWORK_SCAN_ENABLE_COMMAND}.`,
|
|
68
|
+
);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
ctx.print(`Usage: /network-scan [on|off|status]\n${statusText(storePath, loadLanScanConsent(shellPaths, GOODVIBES_AGENT_SURFACE_ROOT))}`);
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|