@pellux/goodvibes-agent 1.2.0 → 1.3.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.
- package/CHANGELOG.md +159 -0
- package/dist/package/main.js +19016 -12684
- package/docs/README.md +3 -3
- package/docs/connected-host.md +2 -2
- package/docs/getting-started.md +11 -9
- package/docs/tools-and-commands.md +12 -10
- package/docs/voice-and-live-tts.md +2 -2
- package/package.json +1 -1
- package/release/release-notes.md +17 -5
- package/release/release-readiness.json +36 -36
- package/src/agent/competitive-feature-inventory.ts +42 -44
- package/src/agent/setup-wizard-artifact-receipts.ts +366 -0
- package/src/agent/setup-wizard.ts +245 -9
- package/src/input/agent-workspace-categories.ts +2 -2
- package/src/input/agent-workspace-onboarding-categories.ts +4 -4
- package/src/input/agent-workspace-settings.ts +52 -1
- package/src/input/agent-workspace-setup-snapshot.ts +20 -1
- package/src/input/agent-workspace-setup.ts +32 -5
- package/src/input/agent-workspace-snapshot.ts +23 -3
- package/src/input/agent-workspace.ts +5 -1
- package/src/input/setup-wizard-live-receipts.ts +76 -0
- package/src/renderer/agent-workspace-context-lines.ts +21 -6
- package/src/renderer/agent-workspace.ts +46 -10
- package/src/runtime/tool-permission-safety.ts +1 -1
- package/src/tools/agent-harness-agent-orchestration-policy.ts +75 -0
- package/src/tools/agent-harness-agent-orchestration.ts +216 -128
- package/src/tools/agent-harness-autonomy-live-records.ts +154 -0
- package/src/tools/agent-harness-autonomy-queue-types.ts +1 -1
- package/src/tools/agent-harness-autonomy-queue.ts +19 -8
- package/src/tools/agent-harness-autonomy-watcher-read-models.ts +509 -0
- package/src/tools/agent-harness-background-processes.ts +8 -4
- package/src/tools/agent-harness-browser-cockpit-route.ts +188 -34
- package/src/tools/agent-harness-browser-control.ts +12 -4
- package/src/tools/agent-harness-browser-pwa-read-models.ts +622 -0
- package/src/tools/agent-harness-device-live-read-models.ts +366 -0
- package/src/tools/agent-harness-execution-posture.ts +3 -0
- package/src/tools/agent-harness-interactive-runtime-records.ts +421 -0
- package/src/tools/agent-harness-local-model-benchmarks.ts +71 -1
- package/src/tools/agent-harness-local-model-endpoints.ts +469 -354
- package/src/tools/agent-harness-local-model-smoke.ts +277 -0
- package/src/tools/agent-harness-local-model-url.ts +78 -0
- package/src/tools/agent-harness-media-posture.ts +27 -12
- package/src/tools/agent-harness-memory-external-providers.ts +796 -0
- package/src/tools/agent-harness-memory-posture.ts +253 -137
- package/src/tools/agent-harness-memory-provider-certification.ts +219 -0
- package/src/tools/agent-harness-memory-refinement.ts +340 -0
- package/src/tools/agent-harness-mode-catalog.ts +4 -2
- package/src/tools/agent-harness-model-provider-health.ts +139 -42
- package/src/tools/agent-harness-model-readiness.ts +31 -5
- package/src/tools/agent-harness-model-routing-types.ts +61 -0
- package/src/tools/agent-harness-model-routing.ts +31 -6
- package/src/tools/agent-harness-pairing-posture.ts +30 -9
- package/src/tools/agent-harness-personal-ops-certification.ts +116 -0
- package/src/tools/agent-harness-personal-ops-lanes.ts +81 -15
- package/src/tools/agent-harness-personal-ops-operations.ts +225 -0
- package/src/tools/agent-harness-personal-ops-provider-records.ts +358 -0
- package/src/tools/agent-harness-personal-ops-provider-task-records.ts +321 -0
- package/src/tools/agent-harness-personal-ops-records.ts +176 -224
- package/src/tools/agent-harness-personal-ops-types.ts +19 -1
- package/src/tools/agent-harness-personal-ops.ts +18 -11
- package/src/tools/agent-harness-remote-read-models.ts +541 -0
- package/src/tools/agent-harness-research-briefing.ts +26 -7
- package/src/tools/agent-harness-research-live-read-models.ts +500 -0
- package/src/tools/agent-harness-research-runs.ts +15 -3
- package/src/tools/agent-harness-research-workflow.ts +92 -19
- package/src/tools/agent-harness-setup-model-helpers.ts +1 -0
- package/src/tools/agent-harness-setup-smoke.ts +26 -1
- package/src/tools/agent-harness-tool-schema.ts +23 -1
- package/src/tools/agent-harness-tool-types.ts +5 -0
- package/src/tools/agent-harness-tool.ts +9 -1
- package/src/tools/agent-harness-workspace-actions.ts +1 -1
- package/src/tools/agent-memory-tool.ts +40 -1
- package/src/tools/agent-model-compare-run.ts +13 -0
- package/src/tools/agent-research-runner.ts +367 -0
- package/src/tools/agent-research-tool.ts +7 -179
- package/src/tools/agent-route-planner-candidates-surfaces.ts +1 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import type { CommandContext } from '../input/command-registry.ts';
|
|
2
|
+
import type { AgentHarnessModelRoutingArgs, LocalModelServerDefaultEndpoint, LocalModelServerEndpoint, LocalModelSmokeTarget } from './agent-harness-model-routing-types.ts';
|
|
3
|
+
import { previewHarnessText } from './agent-harness-text.ts';
|
|
4
|
+
import { readLimit, readRecord, readString } from './agent-harness-model-routing-utils.ts';
|
|
5
|
+
import { collectLocalServerEndpointCandidates, describeLocalServerEndpoint, isPrivateOrLocalUrl, localEndpointSmokeRoute, localModelServerDefaults, parseUrlCandidate } from './agent-harness-local-model-endpoints.ts';
|
|
6
|
+
|
|
7
|
+
function localModelSmokeTargetFromEndpoint(endpoint: LocalModelServerEndpoint): LocalModelSmokeTarget {
|
|
8
|
+
return {
|
|
9
|
+
kind: endpoint.kind,
|
|
10
|
+
id: endpoint.id,
|
|
11
|
+
label: `Local model server ${endpoint.baseUrl}`,
|
|
12
|
+
providerId: endpoint.providerId,
|
|
13
|
+
stack: endpoint.stack,
|
|
14
|
+
baseUrl: endpoint.baseUrl,
|
|
15
|
+
modelsUrl: endpoint.modelsUrl,
|
|
16
|
+
smokeCommand: endpoint.smokeCommand,
|
|
17
|
+
smokeRoute: endpoint.smokeRoute,
|
|
18
|
+
refreshRoute: endpoint.refreshRoute,
|
|
19
|
+
addProviderRoute: endpoint.addProviderRoute,
|
|
20
|
+
source: endpoint.sources.join(', ') || 'local-endpoint',
|
|
21
|
+
notes: endpoint.notes,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function localModelSmokeTargetFromDefault(endpoint: LocalModelServerDefaultEndpoint): LocalModelSmokeTarget {
|
|
26
|
+
return {
|
|
27
|
+
kind: 'suggested-local-server',
|
|
28
|
+
id: endpoint.id,
|
|
29
|
+
label: endpoint.label,
|
|
30
|
+
providerId: null,
|
|
31
|
+
stack: endpoint.stack,
|
|
32
|
+
baseUrl: endpoint.baseUrl,
|
|
33
|
+
modelsUrl: endpoint.modelsUrl,
|
|
34
|
+
smokeCommand: endpoint.smokeCommand,
|
|
35
|
+
smokeRoute: localEndpointSmokeRoute(endpoint.id),
|
|
36
|
+
refreshRoute: 'agent_harness mode:"run_command" command:"/refresh-models" confirm:true explicitUserRequest:"Refresh models after verifying the local server."',
|
|
37
|
+
addProviderRoute: endpoint.addProviderRoute,
|
|
38
|
+
source: 'suggested-default',
|
|
39
|
+
notes: [endpoint.startHint],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function localSmokeTargetSearchText(target: LocalModelSmokeTarget): string {
|
|
44
|
+
return [
|
|
45
|
+
target.kind,
|
|
46
|
+
target.id,
|
|
47
|
+
target.label,
|
|
48
|
+
target.providerId ?? '',
|
|
49
|
+
target.stack ?? '',
|
|
50
|
+
target.baseUrl,
|
|
51
|
+
target.modelsUrl,
|
|
52
|
+
target.source,
|
|
53
|
+
...target.notes,
|
|
54
|
+
].join('\n').toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function localModelSmokeLookup(args: AgentHarnessModelRoutingArgs): string {
|
|
58
|
+
const fields = readRecord(args.fields);
|
|
59
|
+
return readString(args.modelRouteId)
|
|
60
|
+
|| readString(args.target)
|
|
61
|
+
|| readString(args.query)
|
|
62
|
+
|| readString(fields.endpointId)
|
|
63
|
+
|| readString(fields.modelRouteId)
|
|
64
|
+
|| readString(fields.baseUrl)
|
|
65
|
+
|| readString(fields.modelsUrl);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function localModelSmokeTargets(context: CommandContext, args: AgentHarnessModelRoutingArgs): Record<string, unknown> | readonly LocalModelSmokeTarget[] {
|
|
69
|
+
const endpoints = collectLocalServerEndpointCandidates(context)
|
|
70
|
+
.map((endpoint) => localModelSmokeTargetFromEndpoint(describeLocalServerEndpoint(endpoint, true)));
|
|
71
|
+
const defaults = localModelServerDefaults().map(localModelSmokeTargetFromDefault);
|
|
72
|
+
const lookup = localModelSmokeLookup(args);
|
|
73
|
+
const allTargets = [...endpoints, ...defaults];
|
|
74
|
+
if (lookup) {
|
|
75
|
+
const normalized = lookup.toLowerCase();
|
|
76
|
+
const exact = allTargets.filter((target) => target.id === lookup || target.baseUrl === lookup || target.modelsUrl === lookup);
|
|
77
|
+
if (exact.length === 1) return exact;
|
|
78
|
+
if (exact.length > 1) {
|
|
79
|
+
return {
|
|
80
|
+
status: 'ambiguous',
|
|
81
|
+
input: lookup,
|
|
82
|
+
candidates: exact.slice(0, 8).map((target) => ({
|
|
83
|
+
kind: target.kind,
|
|
84
|
+
id: target.id,
|
|
85
|
+
label: target.label,
|
|
86
|
+
baseUrl: target.baseUrl,
|
|
87
|
+
modelsUrl: target.modelsUrl,
|
|
88
|
+
})),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const searched = allTargets.filter((target) => localSmokeTargetSearchText(target).includes(normalized));
|
|
92
|
+
if (searched.length === 1) return searched;
|
|
93
|
+
if (searched.length > 1) {
|
|
94
|
+
return {
|
|
95
|
+
status: 'ambiguous',
|
|
96
|
+
input: lookup,
|
|
97
|
+
candidates: searched.slice(0, 8).map((target) => ({
|
|
98
|
+
kind: target.kind,
|
|
99
|
+
id: target.id,
|
|
100
|
+
label: target.label,
|
|
101
|
+
baseUrl: target.baseUrl,
|
|
102
|
+
modelsUrl: target.modelsUrl,
|
|
103
|
+
})),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
status: 'missing_lookup',
|
|
108
|
+
input: lookup,
|
|
109
|
+
usage: 'Unknown local model endpoint. Use models action:"local" includeParameters:true to inspect local endpoint ids, or omit the lookup to check detected/default local servers.',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const pool = endpoints.length ? endpoints : defaults;
|
|
113
|
+
return pool.slice(0, readLimit(args.limit, 4));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readSmokeTimeoutMs(value: unknown): number {
|
|
117
|
+
const parsed = typeof value === 'string' && value.trim() ? Number(value) : value;
|
|
118
|
+
if (typeof parsed !== 'number' || !Number.isFinite(parsed)) return 1500;
|
|
119
|
+
return Math.max(250, Math.min(10000, Math.trunc(parsed)));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function localSmokeNetworkScope(modelsUrl: string): { readonly allowed: boolean; readonly scope: string; readonly reason?: string } {
|
|
123
|
+
const url = parseUrlCandidate(modelsUrl);
|
|
124
|
+
if (!url || !/^https?:$/.test(url.protocol)) return { allowed: false, scope: 'invalid-url', reason: 'The model-list URL is not a valid HTTP(S) URL.' };
|
|
125
|
+
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
126
|
+
if (host === '0.0.0.0') {
|
|
127
|
+
return { allowed: false, scope: 'bind-all-host', reason: '0.0.0.0 is a bind address, not a client URL. Use 127.0.0.1 or the intended LAN host.' };
|
|
128
|
+
}
|
|
129
|
+
if (!isPrivateOrLocalUrl(url.href)) {
|
|
130
|
+
return { allowed: false, scope: 'non-local-host', reason: 'Local model smoke only probes loopback, local-name, or private LAN endpoints.' };
|
|
131
|
+
}
|
|
132
|
+
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return { allowed: true, scope: 'loopback' };
|
|
133
|
+
if (host.endsWith('.local') || !host.includes('.')) return { allowed: true, scope: 'local-name' };
|
|
134
|
+
return { allowed: true, scope: 'private-lan' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function extractModelIdsFromPayload(payload: unknown): readonly string[] {
|
|
138
|
+
const record = readRecord(payload);
|
|
139
|
+
const candidates = Array.isArray(record.data)
|
|
140
|
+
? record.data
|
|
141
|
+
: Array.isArray(record.models)
|
|
142
|
+
? record.models
|
|
143
|
+
: Array.isArray(payload)
|
|
144
|
+
? payload
|
|
145
|
+
: [];
|
|
146
|
+
const ids = candidates.map((entry) => {
|
|
147
|
+
if (typeof entry === 'string') return entry;
|
|
148
|
+
const item = readRecord(entry);
|
|
149
|
+
return readString(item.id) || readString(item.name) || readString(item.model);
|
|
150
|
+
}).filter(Boolean);
|
|
151
|
+
return [...new Set(ids)].slice(0, 12);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function safeSmokeError(error: unknown): string {
|
|
155
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
156
|
+
return previewHarnessText(message.replace(/https?:\/\/\S+/g, '[redacted-url]'), 180);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function smokeOneLocalModelTarget(target: LocalModelSmokeTarget, timeoutMs: number): Promise<Record<string, unknown>> {
|
|
160
|
+
const network = localSmokeNetworkScope(target.modelsUrl);
|
|
161
|
+
if (!network.allowed) {
|
|
162
|
+
return {
|
|
163
|
+
...target,
|
|
164
|
+
status: 'blocked',
|
|
165
|
+
liveProbe: 'confirmed',
|
|
166
|
+
networkScope: network.scope,
|
|
167
|
+
failure: network.reason,
|
|
168
|
+
nextActions: ['Inspect the endpoint route and correct the base URL before running smoke again.'],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const controller = new AbortController();
|
|
173
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
174
|
+
const started = Date.now();
|
|
175
|
+
try {
|
|
176
|
+
const response = await fetch(target.modelsUrl, {
|
|
177
|
+
method: 'GET',
|
|
178
|
+
headers: { Accept: 'application/json' },
|
|
179
|
+
signal: controller.signal,
|
|
180
|
+
});
|
|
181
|
+
const elapsedMs = Date.now() - started;
|
|
182
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
183
|
+
const text = await response.text();
|
|
184
|
+
let payload: unknown = null;
|
|
185
|
+
let jsonValid = false;
|
|
186
|
+
try {
|
|
187
|
+
payload = text ? JSON.parse(text) : null;
|
|
188
|
+
jsonValid = true;
|
|
189
|
+
} catch {
|
|
190
|
+
jsonValid = false;
|
|
191
|
+
}
|
|
192
|
+
const modelIds = jsonValid ? extractModelIdsFromPayload(payload) : [];
|
|
193
|
+
const status = !response.ok
|
|
194
|
+
? 'http-error'
|
|
195
|
+
: !jsonValid
|
|
196
|
+
? 'invalid-json'
|
|
197
|
+
: modelIds.length === 0
|
|
198
|
+
? 'no-models'
|
|
199
|
+
: 'passed';
|
|
200
|
+
return {
|
|
201
|
+
...target,
|
|
202
|
+
status,
|
|
203
|
+
liveProbe: 'confirmed',
|
|
204
|
+
networkScope: network.scope,
|
|
205
|
+
httpStatus: response.status,
|
|
206
|
+
contentType,
|
|
207
|
+
elapsedMs,
|
|
208
|
+
jsonValid,
|
|
209
|
+
modelCount: modelIds.length,
|
|
210
|
+
sampleModelIds: modelIds.slice(0, 5),
|
|
211
|
+
success: status === 'passed',
|
|
212
|
+
nextActions: status === 'passed'
|
|
213
|
+
? ['Refresh the model catalog, then run a local benchmark before changing the default model.']
|
|
214
|
+
: ['Start or fix the local server, confirm /v1/models returns model ids, then retry this smoke check.'],
|
|
215
|
+
};
|
|
216
|
+
} catch (error) {
|
|
217
|
+
const elapsedMs = Date.now() - started;
|
|
218
|
+
const aborted = controller.signal.aborted;
|
|
219
|
+
return {
|
|
220
|
+
...target,
|
|
221
|
+
status: aborted ? 'timeout' : 'unreachable',
|
|
222
|
+
liveProbe: 'confirmed',
|
|
223
|
+
networkScope: network.scope,
|
|
224
|
+
elapsedMs,
|
|
225
|
+
timeoutMs,
|
|
226
|
+
success: false,
|
|
227
|
+
failure: aborted ? `Timed out after ${timeoutMs}ms.` : safeSmokeError(error),
|
|
228
|
+
nextActions: ['Start the local server, load at least one model, verify the base URL, then retry this smoke check.'],
|
|
229
|
+
};
|
|
230
|
+
} finally {
|
|
231
|
+
clearTimeout(timeout);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function runLocalModelServerSmoke(context: CommandContext, args: AgentHarnessModelRoutingArgs): Promise<Record<string, unknown>> {
|
|
236
|
+
const targets = localModelSmokeTargets(context, args);
|
|
237
|
+
if (!Array.isArray(targets)) {
|
|
238
|
+
return {
|
|
239
|
+
kind: 'local-model-smoke',
|
|
240
|
+
liveProbe: 'not-run',
|
|
241
|
+
...targets,
|
|
242
|
+
policy: 'No local model endpoint was probed because the requested endpoint lookup did not resolve exactly.',
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (targets.length === 0) {
|
|
246
|
+
return {
|
|
247
|
+
kind: 'local-model-smoke',
|
|
248
|
+
status: 'no-candidates',
|
|
249
|
+
liveProbe: 'not-run',
|
|
250
|
+
endpoints: [],
|
|
251
|
+
nextActions: ['Use the local model cookbook to start a local server or configure a local provider endpoint.'],
|
|
252
|
+
cookbookRoute: 'models action:"local" includeParameters:true',
|
|
253
|
+
policy: 'No local model endpoint was probed because no candidate endpoints were available.',
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const timeoutMs = readSmokeTimeoutMs(args.timeoutMs);
|
|
257
|
+
const checkedAt = new Date().toISOString();
|
|
258
|
+
const results = await Promise.all(targets.map((target) => smokeOneLocalModelTarget(target, timeoutMs)));
|
|
259
|
+
const passed = results.filter((result) => result.success === true);
|
|
260
|
+
const blocked = results.filter((result) => result.status === 'blocked');
|
|
261
|
+
return {
|
|
262
|
+
kind: 'local-model-smoke',
|
|
263
|
+
status: passed.length > 0 ? 'ready' : blocked.length === results.length ? 'blocked' : 'needs-attention',
|
|
264
|
+
liveProbe: 'confirmed',
|
|
265
|
+
checkedAt,
|
|
266
|
+
timeoutMs,
|
|
267
|
+
endpointCount: results.length,
|
|
268
|
+
passedCount: passed.length,
|
|
269
|
+
failedCount: results.length - passed.length,
|
|
270
|
+
endpoints: results,
|
|
271
|
+
nextActions: passed.length > 0
|
|
272
|
+
? ['Refresh the model catalog and run the local benchmark action before changing the default route.']
|
|
273
|
+
: ['Start a local model server, load one model, and rerun this confirmed smoke check.'],
|
|
274
|
+
cookbookRoute: 'models action:"local" includeParameters:true',
|
|
275
|
+
policy: 'Confirmed read-only local model smoke. Agent only sends bounded GET requests to discovered or suggested local/private model-list endpoints; it does not add providers, refresh catalogs, benchmark, download models, or change routes.',
|
|
276
|
+
};
|
|
277
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export function localStackFor(value: string): string | null {
|
|
2
|
+
const normalized = value.toLowerCase();
|
|
3
|
+
if (/ollama[-_\s]?cloud/.test(normalized)) return null;
|
|
4
|
+
if (/\bollama\b/.test(normalized)) return 'ollama';
|
|
5
|
+
if (/llama[.-]?cpp|llamacpp/.test(normalized)) return 'llama.cpp';
|
|
6
|
+
if (/\bvllm\b/.test(normalized)) return 'vllm';
|
|
7
|
+
if (/lm[-_\s]?studio/.test(normalized)) return 'openai-compatible';
|
|
8
|
+
if (/localai|text-generation-inference|\btgi\b/.test(normalized)) return 'openai-compatible';
|
|
9
|
+
if (/localhost|127\.0\.0\.1|\[?::1\]?/.test(normalized)) return 'openai-compatible';
|
|
10
|
+
if (/openai-compatible|openai compatible|custom-provider|custom provider/.test(normalized)) return 'openai-compatible';
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function cleanUrlCandidate(value: string): string {
|
|
15
|
+
return value.trim().replace(/[),.;]+$/g, '');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function extractUrls(value: string): readonly string[] {
|
|
19
|
+
const matches = value.match(/https?:\/\/[^\s"'`<>]+/gi) ?? [];
|
|
20
|
+
return [...new Set(matches.map(cleanUrlCandidate).filter(Boolean))];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseUrlCandidate(raw: string): URL | null {
|
|
24
|
+
const trimmed = cleanUrlCandidate(raw);
|
|
25
|
+
if (!trimmed) return null;
|
|
26
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
|
|
27
|
+
try {
|
|
28
|
+
return new URL(withScheme);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isPrivateOrLocalHost(hostname: string): boolean {
|
|
35
|
+
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
36
|
+
if (!host) return false;
|
|
37
|
+
if (host === 'localhost' || host === '0.0.0.0' || host === '::1') return true;
|
|
38
|
+
if (host.endsWith('.local')) return true;
|
|
39
|
+
if (host.includes(':')) return host.startsWith('fc') || host.startsWith('fd') || host.startsWith('fe80:');
|
|
40
|
+
const octets = host.split('.').map((entry) => Number(entry));
|
|
41
|
+
if (octets.length === 4 && octets.every((entry) => Number.isInteger(entry) && entry >= 0 && entry <= 255)) {
|
|
42
|
+
const [first, second] = octets as [number, number, number, number];
|
|
43
|
+
return first === 10
|
|
44
|
+
|| first === 127
|
|
45
|
+
|| (first === 172 && second >= 16 && second <= 31)
|
|
46
|
+
|| (first === 192 && second === 168)
|
|
47
|
+
|| (first === 169 && second === 254)
|
|
48
|
+
|| first === 0;
|
|
49
|
+
}
|
|
50
|
+
return !host.includes('.');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isPrivateOrLocalUrl(raw: string): boolean {
|
|
54
|
+
const url = parseUrlCandidate(raw);
|
|
55
|
+
if (!url || !/^https?:$/.test(url.protocol)) return false;
|
|
56
|
+
return isPrivateOrLocalHost(url.hostname);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function normalizeLocalBaseUrl(raw: string, stackHint?: string | null): string | null {
|
|
60
|
+
const url = parseUrlCandidate(raw);
|
|
61
|
+
if (!url || !/^https?:$/.test(url.protocol)) return null;
|
|
62
|
+
const stack = stackHint ?? localStackFor(raw) ?? (isPrivateOrLocalHost(url.hostname) ? 'openai-compatible' : null);
|
|
63
|
+
if (!isPrivateOrLocalUrl(url.href)) return null;
|
|
64
|
+
|
|
65
|
+
let pathname = url.pathname.replace(/\/+$/g, '');
|
|
66
|
+
if (pathname.endsWith('/models')) pathname = pathname.slice(0, -'/models'.length);
|
|
67
|
+
if (pathname.endsWith('/api/tags')) pathname = pathname.slice(0, -'/api/tags'.length);
|
|
68
|
+
const needsOpenAiPath = stack === 'ollama' || stack === 'llama.cpp' || stack === 'vllm' || stack === 'openai-compatible';
|
|
69
|
+
if (needsOpenAiPath && (!pathname || pathname === '/')) pathname = '/v1';
|
|
70
|
+
url.pathname = pathname || '';
|
|
71
|
+
url.search = '';
|
|
72
|
+
url.hash = '';
|
|
73
|
+
return url.toString().replace(/\/+$/g, '');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function modelsUrlFor(baseUrl: string): string {
|
|
77
|
+
return `${baseUrl.replace(/\/+$/g, '')}/models`;
|
|
78
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { buildAgentWorkspaceVoiceMediaReadiness } from '../input/agent-workspace-voice-media.ts';
|
|
2
2
|
import type { AgentWorkspaceVoiceMediaProviderStatus } from '../input/agent-workspace-voice-media.ts';
|
|
3
3
|
import type { CommandContext } from '../input/command-registry.ts';
|
|
4
|
+
import { certifiedDeviceLiveRecords, deviceLiveReadModelSnapshot } from './agent-harness-device-live-read-models.ts';
|
|
4
5
|
import { previewHarnessText } from './agent-harness-text.ts';
|
|
5
6
|
|
|
6
7
|
export interface AgentHarnessMediaArgs {
|
|
@@ -136,6 +137,11 @@ function buildVoiceInteractionWorkflows(
|
|
|
136
137
|
context: CommandContext,
|
|
137
138
|
readiness: ReturnType<typeof buildReadiness>,
|
|
138
139
|
): readonly VoiceInteractionWorkflow[] {
|
|
140
|
+
const liveDevice = deviceLiveReadModelSnapshot(context);
|
|
141
|
+
const pushToTalkRecords = certifiedDeviceLiveRecords(liveDevice, 'push-to-talk', ['push to talk', 'speech input', 'microphone']);
|
|
142
|
+
const transcriptionRecords = certifiedDeviceLiveRecords(liveDevice, 'voice-memo-transcription', ['voice memo', 'speech-to-text', 'audio transcription']);
|
|
143
|
+
const spokenResponseRecords = certifiedDeviceLiveRecords(liveDevice, 'spoken-responses', ['tts', 'spoken response', 'speaker']);
|
|
144
|
+
const wakeRecords = certifiedDeviceLiveRecords(liveDevice, 'wake-and-speak', ['wake word', 'always listening']);
|
|
139
145
|
const voiceEnabled = readConfigBoolean(context, 'ui.voiceEnabled', false);
|
|
140
146
|
const spokenTurnRuntime = typeof context.submitSpokenInput === 'function';
|
|
141
147
|
const stopSpokenOutputRuntime = typeof context.stopSpokenOutput === 'function';
|
|
@@ -143,11 +149,11 @@ function buildVoiceInteractionWorkflows(
|
|
|
143
149
|
const speechInputReady = hasReadyVoiceFeature(readiness, ['stt', 'realtime']);
|
|
144
150
|
const speechInputRegistered = hasRegisteredVoiceFeature(readiness, ['stt', 'realtime']);
|
|
145
151
|
const selectedTtsReady = readiness.selectedTtsProviderStatus === 'ready';
|
|
146
|
-
const spokenReady = spokenTurnRuntime && selectedTtsReady && readiness.ttsVoiceConfigured;
|
|
152
|
+
const spokenReady = spokenResponseRecords.length > 0 || (spokenTurnRuntime && selectedTtsReady && readiness.ttsVoiceConfigured);
|
|
147
153
|
const spokenAttention = spokenTurnRuntime && selectedTtsReady && !readiness.ttsVoiceConfigured;
|
|
148
|
-
const pushToTalkReady = voiceEnabled && spokenTurnRuntime && speechInputReady;
|
|
154
|
+
const pushToTalkReady = pushToTalkRecords.length > 0 || (voiceEnabled && spokenTurnRuntime && speechInputReady);
|
|
149
155
|
const pushToTalkAttention = voiceEnabled && (spokenTurnRuntime || speechInputRegistered);
|
|
150
|
-
const transcriptionReady = transcribeRuntime && speechInputReady;
|
|
156
|
+
const transcriptionReady = transcriptionRecords.length > 0 || (transcribeRuntime && speechInputReady);
|
|
151
157
|
const transcriptionAttention = transcribeRuntime || speechInputRegistered;
|
|
152
158
|
|
|
153
159
|
return [
|
|
@@ -157,7 +163,7 @@ function buildVoiceInteractionWorkflows(
|
|
|
157
163
|
status: pushToTalkReady ? 'ready' : pushToTalkAttention ? 'attention' : 'setup-needed',
|
|
158
164
|
userOutcome: 'Speak a short prompt when the local voice surface and speech-input provider are ready.',
|
|
159
165
|
summary: pushToTalkReady
|
|
160
|
-
? 'Voice surface, spoken-turn runtime, and a ready STT/realtime provider are available.'
|
|
166
|
+
? pushToTalkRecords.length > 0 ? 'The SDK/daemon published a certified push-to-talk route with permission-scoped microphone evidence.' : 'Voice surface, spoken-turn runtime, and a ready STT/realtime provider are available.'
|
|
161
167
|
: pushToTalkAttention
|
|
162
168
|
? 'Some voice input pieces are present, but the full push-to-talk route is not ready.'
|
|
163
169
|
: 'Voice input needs the voice surface plus a ready STT or realtime provider.',
|
|
@@ -176,6 +182,7 @@ function buildVoiceInteractionWorkflows(
|
|
|
176
182
|
spokenTurnRuntime,
|
|
177
183
|
speechInputReady,
|
|
178
184
|
speechInputRegistered,
|
|
185
|
+
...(pushToTalkRecords.length > 0 ? { certifiedLiveRecords: pushToTalkRecords.slice(0, 5) } : {}),
|
|
179
186
|
},
|
|
180
187
|
policy: 'Voice input stays a visible local operator surface; provider setup and transcript submission are separate explicit routes.',
|
|
181
188
|
},
|
|
@@ -185,7 +192,7 @@ function buildVoiceInteractionWorkflows(
|
|
|
185
192
|
status: transcriptionReady ? 'ready' : transcriptionAttention ? 'attention' : 'setup-needed',
|
|
186
193
|
userOutcome: 'Transcribe an audio note only when a speech-to-text provider and runtime route are both present.',
|
|
187
194
|
summary: transcriptionReady
|
|
188
|
-
? 'Speech-to-text provider and voiceService.transcribe are available.'
|
|
195
|
+
? transcriptionRecords.length > 0 ? 'The SDK/daemon published a certified voice memo transcription route.' : 'Speech-to-text provider and voiceService.transcribe are available.'
|
|
189
196
|
: transcriptionAttention
|
|
190
197
|
? 'A speech-to-text provider or runtime route is present, but transcription is not fully ready.'
|
|
191
198
|
: 'No ready speech-to-text transcription route is available.',
|
|
@@ -202,6 +209,7 @@ function buildVoiceInteractionWorkflows(
|
|
|
202
209
|
transcribeRuntime,
|
|
203
210
|
speechInputReady,
|
|
204
211
|
speechInputRegistered,
|
|
212
|
+
...(transcriptionRecords.length > 0 ? { certifiedLiveRecords: transcriptionRecords.slice(0, 5) } : {}),
|
|
205
213
|
},
|
|
206
214
|
policy: 'Audio bytes are not printed into chat; transcription must use reviewed media or connected-host voice routes.',
|
|
207
215
|
},
|
|
@@ -211,7 +219,7 @@ function buildVoiceInteractionWorkflows(
|
|
|
211
219
|
status: spokenReady ? 'ready' : spokenAttention ? 'attention' : 'setup-needed',
|
|
212
220
|
userOutcome: 'Play an assistant answer aloud with a predictable TTS provider and voice.',
|
|
213
221
|
summary: spokenReady
|
|
214
|
-
? 'Spoken-turn runtime, selected TTS provider, and voice setting are ready.'
|
|
222
|
+
? spokenResponseRecords.length > 0 ? 'The SDK/daemon published a certified spoken-response route.' : 'Spoken-turn runtime, selected TTS provider, and voice setting are ready.'
|
|
215
223
|
: spokenAttention
|
|
216
224
|
? 'Spoken-turn runtime and provider are ready, but the exact voice is not configured.'
|
|
217
225
|
: 'Spoken responses need a ready selected TTS provider and runtime spoken-turn route.',
|
|
@@ -230,26 +238,33 @@ function buildVoiceInteractionWorkflows(
|
|
|
230
238
|
stopSpokenOutputRuntime,
|
|
231
239
|
selectedTtsProviderStatus: readiness.selectedTtsProviderStatus,
|
|
232
240
|
ttsVoiceConfigured: readiness.ttsVoiceConfigured,
|
|
241
|
+
...(spokenResponseRecords.length > 0 ? { certifiedLiveRecords: spokenResponseRecords.slice(0, 5) } : {}),
|
|
233
242
|
},
|
|
234
243
|
policy: 'Spoken turns submit normal assistant prompts and may call model/speech providers; playback stop is local runtime control.',
|
|
235
244
|
},
|
|
236
245
|
{
|
|
237
246
|
id: 'wake-and-speak',
|
|
238
247
|
label: 'Wake and speak',
|
|
239
|
-
status: 'not-published',
|
|
248
|
+
status: wakeRecords.length > 0 ? 'ready' : 'not-published',
|
|
240
249
|
userOutcome: 'Use wake-word or always-listening input only after a permission-scoped runtime contract exists.',
|
|
241
|
-
summary:
|
|
242
|
-
|
|
250
|
+
summary: wakeRecords.length > 0
|
|
251
|
+
? 'The SDK/daemon published certified wake-word evidence with permission scope, receipt metadata, and exact control routes.'
|
|
252
|
+
: 'Wake-word or always-listening voice capture is not published by the current Agent runtime contract.',
|
|
253
|
+
nextStep: wakeRecords.length > 0
|
|
254
|
+
? 'Inspect the certified wake-word route and keep microphone capture on visible permission controls.'
|
|
255
|
+
: 'Use explicit voice input or /tts until a wake-word route is published with visible permission controls.',
|
|
243
256
|
capabilities: ['wake word', 'always listening', 'permission repair'],
|
|
244
|
-
modelRoute: 'agent_harness mode:"media_posture" query:"wake word" includeParameters:true',
|
|
257
|
+
modelRoute: wakeRecords[0]?.modelRoute ?? 'agent_harness mode:"media_posture" query:"wake word" includeParameters:true',
|
|
245
258
|
setupRoutes: [
|
|
259
|
+
...wakeRecords.slice(0, 3).map((record) => record.modelRoute),
|
|
246
260
|
'agent_harness mode:"media_posture" query:"push to talk" includeParameters:true',
|
|
247
261
|
'agent_harness mode:"pairing_posture" query:"device" includeParameters:true',
|
|
248
262
|
],
|
|
249
263
|
evidence: {
|
|
250
|
-
publishedByCurrentAgentContract:
|
|
264
|
+
publishedByCurrentAgentContract: wakeRecords.length > 0,
|
|
265
|
+
...(wakeRecords.length > 0 ? { certifiedLiveRecords: wakeRecords.slice(0, 5) } : {}),
|
|
251
266
|
},
|
|
252
|
-
policy: 'Agent does not claim always-listening behavior without an explicit permission-scoped runtime contract.',
|
|
267
|
+
policy: 'Agent does not claim always-listening behavior without an explicit permission-scoped runtime contract and certified SDK/daemon receipt evidence.',
|
|
253
268
|
},
|
|
254
269
|
];
|
|
255
270
|
}
|