@devspecai/pi-plugin 0.0.0-stage → 0.10.3
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 +899 -0
- package/LICENSE +21 -0
- package/README.md +103 -2
- package/extensions/.gitkeep +0 -0
- package/extensions/core/activation-recovery.ts +135 -0
- package/extensions/core/active-deliveries.ts +171 -0
- package/extensions/core/active-session-plans.ts +374 -0
- package/extensions/core/adapter-overlap.ts +247 -0
- package/extensions/core/assistant-speech.ts +44 -0
- package/extensions/core/attach-context.ts +25 -0
- package/extensions/core/attachment-limits.ts +20 -0
- package/extensions/core/attachments.ts +487 -0
- package/extensions/core/auth-store.ts +91 -0
- package/extensions/core/automation-run-retry.ts +31 -0
- package/extensions/core/background-journal.ts +85 -0
- package/extensions/core/background-observer.ts +279 -0
- package/extensions/core/bond-recovery.ts +65 -0
- package/extensions/core/bond-state.ts +80 -0
- package/extensions/core/clear-conversation.ts +53 -0
- package/extensions/core/client-versions.ts +56 -0
- package/extensions/core/client.ts +704 -0
- package/extensions/core/command-offer.ts +88 -0
- package/extensions/core/command-runner.ts +62 -0
- package/extensions/core/command-turn.ts +88 -0
- package/extensions/core/commit-ledger.ts +195 -0
- package/extensions/core/commit-stamp.ts +98 -0
- package/extensions/core/config.ts +63 -0
- package/extensions/core/connection-context.ts +28 -0
- package/extensions/core/context-budget.ts +175 -0
- package/extensions/core/control-result-outbox.ts +80 -0
- package/extensions/core/control.ts +156 -0
- package/extensions/core/ctx-guard.ts +33 -0
- package/extensions/core/debug.ts +136 -0
- package/extensions/core/delivery-queue.ts +92 -0
- package/extensions/core/dispatch.ts +108 -0
- package/extensions/core/duck.ts +128 -0
- package/extensions/core/git-observer.ts +382 -0
- package/extensions/core/github-identity.ts +26 -0
- package/extensions/core/implementation-contract.ts +773 -0
- package/extensions/core/instructions.ts +40 -0
- package/extensions/core/interaction-events.ts +834 -0
- package/extensions/core/json-schema.ts +16 -0
- package/extensions/core/local-activity.ts +34 -0
- package/extensions/core/mcp-config-guard.ts +117 -0
- package/extensions/core/model-catalog.ts +76 -0
- package/extensions/core/model-surface.ts +125 -0
- package/extensions/core/notice-reply.ts +215 -0
- package/extensions/core/owner-inject.ts +148 -0
- package/extensions/core/post-session-receipt.ts +70 -0
- package/extensions/core/preempt.ts +100 -0
- package/extensions/core/project-choice.ts +145 -0
- package/extensions/core/project-scope.ts +335 -0
- package/extensions/core/project-selection-ui.ts +202 -0
- package/extensions/core/provenance-refs.ts +158 -0
- package/extensions/core/push-channel.ts +281 -0
- package/extensions/core/question-receipt-lifecycle.ts +158 -0
- package/extensions/core/recall.ts +121 -0
- package/extensions/core/remote-payload.ts +1329 -0
- package/extensions/core/remote-session-arg.ts +24 -0
- package/extensions/core/repository-context.ts +35 -0
- package/extensions/core/request-chain.ts +1244 -0
- package/extensions/core/result-projection.ts +939 -0
- package/extensions/core/result.ts +100 -0
- package/extensions/core/review-context.ts +70 -0
- package/extensions/core/review-watch.ts +94 -0
- package/extensions/core/room-access.ts +12 -0
- package/extensions/core/room-context.ts +178 -0
- package/extensions/core/room-reader.ts +308 -0
- package/extensions/core/room-store.ts +381 -0
- package/extensions/core/room-sync.ts +79 -0
- package/extensions/core/room-tools.ts +86 -0
- package/extensions/core/room-unread.ts +173 -0
- package/extensions/core/session-events.ts +62 -0
- package/extensions/core/session-polls.ts +292 -0
- package/extensions/core/settings.ts +39 -0
- package/extensions/core/status-line.ts +432 -0
- package/extensions/core/still-to-discuss.ts +124 -0
- package/extensions/core/subagent-fleet.ts +238 -0
- package/extensions/core/system-notice.ts +227 -0
- package/extensions/core/telemetry.ts +177 -0
- package/extensions/core/tool-selection.ts +72 -0
- package/extensions/core/trail-events.ts +247 -0
- package/extensions/core/trail-projection.ts +373 -0
- package/extensions/core/trail.ts +465 -0
- package/extensions/core/turn-lifecycle.ts +472 -0
- package/extensions/core/version-mismatch.ts +165 -0
- package/extensions/core/work-prompt.ts +44 -0
- package/extensions/core/work-state.ts +179 -0
- package/extensions/devspec-adapter-guard.ts +106 -0
- package/extensions/devspec-auth.ts +191 -0
- package/extensions/devspec-brand.ts +140 -0
- package/extensions/devspec-commit-ledger.ts +477 -0
- package/extensions/devspec-context.ts +146 -0
- package/extensions/devspec-memory.ts +136 -0
- package/extensions/devspec-provenance-assistance.ts +233 -0
- package/extensions/devspec-remote.ts +5152 -0
- package/extensions/devspec-tools.ts +552 -0
- package/extensions/devspec-work.ts +244 -0
- package/extensions/generated/README.md +18 -0
- package/extensions/generated/mcp-catalog.ts +15811 -0
- package/package.json +71 -4
- package/prompts/.gitkeep +0 -0
- package/runtime/commit-stamp.mjs +279 -0
- package/runtime/git-observer-shim.mjs +390 -0
- package/skills/.gitkeep +0 -0
- package/themes/.gitkeep +0 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrap a server JSON Schema as a TypeBox TSchema.
|
|
3
|
+
*
|
|
4
|
+
* The catalog is generated from DevSpec's own MCP tool-definitions. Re-encoding
|
|
5
|
+
* every property into Type.Object / StringEnum by hand is how this rots
|
|
6
|
+
* (item 523f1512). Type.Unsafe keeps the server schema byte-faithful.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Type, type TSchema } from "typebox";
|
|
10
|
+
|
|
11
|
+
export function jsonSchemaToTypebox(schema: Record<string, unknown> | undefined): TSchema {
|
|
12
|
+
if (!schema || typeof schema !== "object") {
|
|
13
|
+
return Type.Object({});
|
|
14
|
+
}
|
|
15
|
+
return Type.Unsafe(schema);
|
|
16
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Exact local admission uses existing attempt IDs, never speech or latest-at-completion. */
|
|
2
|
+
export interface LocalActivityScope {
|
|
3
|
+
connection_id: string;
|
|
4
|
+
expected_session_id: string;
|
|
5
|
+
expected_attachment_id: string;
|
|
6
|
+
expected_attached_at: string;
|
|
7
|
+
}
|
|
8
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
|
+
export async function startLocalActivity(input: {
|
|
10
|
+
scope: LocalActivityScope;
|
|
11
|
+
attemptId: string;
|
|
12
|
+
previousAttemptId: string | null;
|
|
13
|
+
current: () => boolean;
|
|
14
|
+
request: (args: Record<string, unknown>) => Promise<unknown>;
|
|
15
|
+
}): Promise<string | null> {
|
|
16
|
+
const { scope, attemptId, current, request } = input;
|
|
17
|
+
let previous = input.previousAttemptId;
|
|
18
|
+
try {
|
|
19
|
+
for (let retry = 0; retry < 2; retry++) {
|
|
20
|
+
if (!current()) return null;
|
|
21
|
+
const raw = await request({ ...scope, local_activity_version: 1, local_turn: true,
|
|
22
|
+
attempt_id: attemptId, previous_attempt_id: previous });
|
|
23
|
+
if (!current() || !raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
24
|
+
const result = raw as Record<string, unknown>;
|
|
25
|
+
if (result.connection_id !== scope.connection_id || result.attempt_id !== attemptId ||
|
|
26
|
+
result.expected_session_id !== scope.expected_session_id || result.expected_attachment_id !== scope.expected_attachment_id ||
|
|
27
|
+
typeof result.expected_attached_at !== "string" || Date.parse(result.expected_attached_at) !== Date.parse(scope.expected_attached_at)) return null;
|
|
28
|
+
if (result.status === "working" && typeof result.already_started === "boolean") return attemptId;
|
|
29
|
+
if (result.status !== "predecessor_changed" || typeof result.previous_attempt_id !== "string" || !uuid.test(result.previous_attempt_id)) return null;
|
|
30
|
+
previous = result.previous_attempt_id;
|
|
31
|
+
}
|
|
32
|
+
} catch { /* Unknown admission never becomes a completion identity. */ }
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** Pi 1.x configuration ownership (9021dd85). No shared/other-host files are written. */
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, lstatSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { findDevspecServers, type DevspecServerHit } from "./adapter-overlap.ts";
|
|
7
|
+
|
|
8
|
+
type ObjectMap = Record<string, unknown>;
|
|
9
|
+
export interface ConfigSnapshot { path: string; text: string | null; value: ObjectMap }
|
|
10
|
+
function object(value: unknown): value is ObjectMap {
|
|
11
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
export function readConfig(path: string): ConfigSnapshot {
|
|
14
|
+
let text: string;
|
|
15
|
+
try { text = readFileSync(path, "utf8"); }
|
|
16
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { path, text: null, value: {} }; throw error; }
|
|
17
|
+
const value: unknown = JSON.parse(text);
|
|
18
|
+
if (!object(value) || (value.mcpServers !== undefined && !object(value.mcpServers))) throw new Error("Invalid MCP config shape");
|
|
19
|
+
return { path, text, value };
|
|
20
|
+
}
|
|
21
|
+
function unchanged(snapshot: ConfigSnapshot): void {
|
|
22
|
+
if (readConfig(snapshot.path).text !== snapshot.text) throw new Error("MCP configuration changed concurrently");
|
|
23
|
+
// Do not follow a symlink into a shared configuration or another host's settings.
|
|
24
|
+
try { if (lstatSync(snapshot.path).isSymbolicLink()) throw new Error("MCP configuration is a symlink; review it manually"); }
|
|
25
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
26
|
+
}
|
|
27
|
+
export function saveConfig(snapshot: ConfigSnapshot, value: ObjectMap): boolean {
|
|
28
|
+
if (isDeepStrictEqual(snapshot.value, value)) return false;
|
|
29
|
+
unchanged(snapshot);
|
|
30
|
+
mkdirSync(dirname(snapshot.path), { recursive: true });
|
|
31
|
+
const temp = `${snapshot.path}.${randomUUID()}.tmp`;
|
|
32
|
+
try {
|
|
33
|
+
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
34
|
+
unchanged(snapshot);
|
|
35
|
+
renameSync(temp, snapshot.path);
|
|
36
|
+
} finally { try { unlinkSync(temp); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } }
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Move only recognized legacy overlay entries. Full native entries are never migrated. */
|
|
41
|
+
export function migrateLegacyOverlay(nativePath: string, adapterPath: string): boolean {
|
|
42
|
+
const source = readConfig(nativePath);
|
|
43
|
+
const servers = (source.value.mcpServers ?? {}) as ObjectMap;
|
|
44
|
+
const legacy = Object.entries(servers).filter(([, entry]) => object(entry)
|
|
45
|
+
&& Object.keys(entry).length > 0
|
|
46
|
+
&& Object.keys(entry).every((key) => key === "disabled" || key === "protocolVersion")
|
|
47
|
+
&& (entry.disabled === undefined || typeof entry.disabled === "boolean")
|
|
48
|
+
&& (entry.protocolVersion === undefined || typeof entry.protocolVersion === "string"));
|
|
49
|
+
if (legacy.length === 0) return false;
|
|
50
|
+
const destination = readConfig(adapterPath);
|
|
51
|
+
const nextServers: ObjectMap = Object.assign(Object.create(null), destination.value.mcpServers ?? {});
|
|
52
|
+
for (const [name, entry] of legacy) {
|
|
53
|
+
const existing = nextServers[name];
|
|
54
|
+
if (existing !== undefined && !object(existing)) throw new Error("Invalid adapter override");
|
|
55
|
+
for (const [key, value] of Object.entries(entry as ObjectMap)) {
|
|
56
|
+
if (object(existing) && key in existing && !isDeepStrictEqual(existing[key], value)) throw new Error("Conflicting MCP overrides; review manually");
|
|
57
|
+
}
|
|
58
|
+
nextServers[name] = { ...(existing as ObjectMap ?? {}), ...(entry as ObjectMap) };
|
|
59
|
+
}
|
|
60
|
+
// Both files are validated before either changes. Save destination first: interruption
|
|
61
|
+
// may leave a retryable duplicate, never delete the only copy of a user's setting.
|
|
62
|
+
unchanged(source);
|
|
63
|
+
unchanged(destination);
|
|
64
|
+
writeFileSync(`${nativePath}.devspec-backup-${randomUUID()}`, source.text!, { flag: "wx", mode: 0o600 });
|
|
65
|
+
saveConfig(destination, { ...destination.value, mcpServers: nextServers });
|
|
66
|
+
const remaining = { ...servers };
|
|
67
|
+
for (const [name] of legacy) delete remaining[name];
|
|
68
|
+
saveConfig(source, { ...source.value, mcpServers: remaining });
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function disableNativeDuplicates(path: string, endpoint: string, effectiveNames?: Set<string>): { hits: DevspecServerHit[]; changed: boolean } {
|
|
73
|
+
const source = readConfig(path);
|
|
74
|
+
const servers = (source.value.mcpServers ?? {}) as Record<string, { url?: unknown; enabled?: unknown }>;
|
|
75
|
+
const hits = findDevspecServers(servers, path, endpoint).filter((hit) => !effectiveNames || effectiveNames.has(hit.name)).map((hit) => ({ ...hit, alreadyDisabled: servers[hit.name].enabled === false }));
|
|
76
|
+
const next = { ...servers };
|
|
77
|
+
for (const hit of hits) next[hit.name] = { ...next[hit.name], enabled: false };
|
|
78
|
+
return { hits, changed: hits.length > 0 && saveConfig(source, { ...source.value, mcpServers: next }) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function disableEffectiveNativeDuplicates(paths: string[], endpoint: string) {
|
|
82
|
+
const owners = new Map<string, string>();
|
|
83
|
+
for (const path of paths) for (const name of Object.keys(readConfig(path).value.mcpServers as ObjectMap ?? {})) owners.set(name, path);
|
|
84
|
+
let changed = false;
|
|
85
|
+
const hits: DevspecServerHit[] = [];
|
|
86
|
+
for (const path of paths) {
|
|
87
|
+
const result = disableNativeDuplicates(path, endpoint, new Set([...owners].filter(([, owner]) => owner === path).map(([name]) => name)));
|
|
88
|
+
hits.push(...result.hits); changed = result.changed || changed;
|
|
89
|
+
}
|
|
90
|
+
return { hits, changed };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface AdapterConfigApi {
|
|
94
|
+
loadMcpConfig: (overridePath?: string, cwd?: string) => { mcpServers: Record<string, { url?: unknown; disabled?: unknown }> };
|
|
95
|
+
writeProjectServerDisabledOverride: (overridePath: string | undefined, cwd: string, name: string, disabled: boolean) => { path: string; changed: boolean };
|
|
96
|
+
}
|
|
97
|
+
export function disableAdapterDuplicates(api: AdapterConfigApi, cwd: string, endpoint: string, overridePath?: string) {
|
|
98
|
+
// Let the adapter own imports, precedence, custom config paths, JSONC and credential
|
|
99
|
+
// handling. Do not maintain a second, incomplete implementation of its discovery.
|
|
100
|
+
const config = api.loadMcpConfig(overridePath, cwd);
|
|
101
|
+
const hits = findDevspecServers(config.mcpServers, "pi-mcp-adapter", endpoint);
|
|
102
|
+
let changed = false;
|
|
103
|
+
for (const hit of hits) {
|
|
104
|
+
if (!hit.alreadyDisabled) {
|
|
105
|
+
const path = join(cwd, ".pi", "mcp-adapter.json");
|
|
106
|
+
try { if (lstatSync(path).isSymbolicLink()) throw new Error("Adapter override is a symlink"); }
|
|
107
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
108
|
+
changed = api.writeProjectServerDisabledOverride(overridePath, cwd, hit.name, true).changed || changed;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const verified = api.loadMcpConfig(overridePath, cwd);
|
|
112
|
+
if (hits.some((hit) => verified.mcpServers[hit.name]?.disabled !== true)) throw new Error("Adapter did not apply duplicate override; review exclusive/in-memory config");
|
|
113
|
+
return { hits, changed };
|
|
114
|
+
}
|
|
115
|
+
export function configPaths(agentDir: string, cwd: string, trusted: boolean) {
|
|
116
|
+
return [agentDir, ...(trusted ? [join(cwd, ".pi")] : [])].map((dir) => ({ native: join(dir, "mcp.json"), adapter: join(dir, "mcp-adapter.json") }));
|
|
117
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live host model snapshot for CONTROL list_models (item abbec798).
|
|
3
|
+
*
|
|
4
|
+
* The list is THIS process’s list right now — scopedModels if the session is
|
|
5
|
+
* scoped, else auth-filtered getAvailable(). Never a DevSpec-owned menu.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ModelCatalogEntry {
|
|
9
|
+
provider: string;
|
|
10
|
+
id: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ModelCatalogSnapshot {
|
|
15
|
+
v: 1;
|
|
16
|
+
current: string | null;
|
|
17
|
+
models: ModelCatalogEntry[];
|
|
18
|
+
at: string;
|
|
19
|
+
truncated?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type ModelLike = {
|
|
23
|
+
provider?: string;
|
|
24
|
+
id?: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function collectModelCatalog(ctx: {
|
|
29
|
+
scopedModels?: readonly { model?: ModelLike }[] | null;
|
|
30
|
+
modelRegistry?: {
|
|
31
|
+
getAvailable?: () => readonly ModelLike[];
|
|
32
|
+
// Pi's Model is a subtype; we only read provider/id/name.
|
|
33
|
+
hasConfiguredAuth?: (model: never) => boolean;
|
|
34
|
+
} | null;
|
|
35
|
+
model?: ModelLike | null;
|
|
36
|
+
now?: string;
|
|
37
|
+
}): ModelCatalogSnapshot {
|
|
38
|
+
const scoped = (ctx.scopedModels ?? [])
|
|
39
|
+
.map((s) => s?.model)
|
|
40
|
+
.filter((m): m is ModelLike => Boolean(m?.provider && m?.id));
|
|
41
|
+
let raw: readonly ModelLike[];
|
|
42
|
+
if (scoped.length > 0) raw = scoped;
|
|
43
|
+
else {
|
|
44
|
+
const available = ctx.modelRegistry?.getAvailable?.();
|
|
45
|
+
if (!Array.isArray(available)) throw new Error("Host model registry unavailable");
|
|
46
|
+
// Unknown auth state is a failed read, not proof that a provider has no models.
|
|
47
|
+
raw = available.filter((m: ModelLike) => !ctx.modelRegistry?.hasConfiguredAuth || ctx.modelRegistry.hasConfiguredAuth(m as never));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const seen = new Set<string>();
|
|
51
|
+
const models: ModelCatalogEntry[] = [];
|
|
52
|
+
for (const m of raw) {
|
|
53
|
+
const provider = typeof m.provider === "string" ? m.provider.trim() : "";
|
|
54
|
+
const id = typeof m.id === "string" ? m.id.trim() : "";
|
|
55
|
+
if (!provider || !id || (m.name !== undefined && typeof m.name !== "string")) throw new Error("Invalid host model catalogue entry");
|
|
56
|
+
const key = `${provider}/${id}`;
|
|
57
|
+
if (seen.has(key)) continue;
|
|
58
|
+
seen.add(key);
|
|
59
|
+
const name = typeof m.name === "string" ? m.name.trim() : "";
|
|
60
|
+
models.push({
|
|
61
|
+
provider,
|
|
62
|
+
id,
|
|
63
|
+
...(name && name !== id ? { name } : {}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const currentProvider = typeof ctx.model?.provider === "string" ? ctx.model.provider.trim() : "";
|
|
68
|
+
const currentId = typeof ctx.model?.id === "string" ? ctx.model.id.trim() : "";
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
v: 1,
|
|
72
|
+
current: currentProvider && currentId ? `${currentProvider}/${currentId}` : null,
|
|
73
|
+
models,
|
|
74
|
+
at: ctx.now ?? new Date().toISOString(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** Native model surface: every safe eligible operation is available immediately.
|
|
2
|
+
* The wire catalogue, authored model guidance and optional search index are
|
|
3
|
+
* generated from the shared contract. Search recommends; it never grants access.
|
|
4
|
+
*/
|
|
5
|
+
import MiniSearch from 'minisearch';
|
|
6
|
+
import { MCP_CATALOG, MODEL_VERB_NAMES, TOOL_SEARCH_DOCUMENTS, type CatalogEntry } from '../generated/mcp-catalog.ts';
|
|
7
|
+
import { ROOM_READ_DESCRIPTION, ROOM_READ_SCHEMA, ROOM_READ_TOOL, ROOM_READ_PROMPT_METADATA } from './room-reader.ts';
|
|
8
|
+
|
|
9
|
+
export const TOOL_LOADER_NAME = 'search_devspec_tools';
|
|
10
|
+
export const PLAN_TOOL_NAME = 'manage_plan';
|
|
11
|
+
export const POLL_TOOL_NAME = 'manage_poll';
|
|
12
|
+
export const DIRECTED_QUESTION_TOOL_NAME = 'manage_directed_question';
|
|
13
|
+
export const ACTIVATION_ONLY_VERBS = new Set<string>([PLAN_TOOL_NAME, POLL_TOOL_NAME, DIRECTED_QUESTION_TOOL_NAME]);
|
|
14
|
+
/** Retained export name for host consumers; no longer a selected fifteen-tool tier. */
|
|
15
|
+
export const HOT_SET_VERBS = MODEL_VERB_NAMES.filter(name => !ACTIVATION_ONLY_VERBS.has(name) && name !== TOOL_LOADER_NAME);
|
|
16
|
+
export type HotSetVerb = (typeof HOT_SET_VERBS)[number];
|
|
17
|
+
export const HOT_SET = new Set<string>(HOT_SET_VERBS);
|
|
18
|
+
export const SCOPE_KEYS = ['git_remote', 'project_id', 'pinned_project_id', 'session_id', 'connection_id', 'local_session_id', 'commit_provenance_source', 'provenance_source'] as const;
|
|
19
|
+
export const SCOPE_KEY_SET = new Set<string>(SCOPE_KEYS);
|
|
20
|
+
export const MODEL_OWNED_SESSION_ID_VERBS = new Set<string>(['get_session_transcript']);
|
|
21
|
+
export function scopeKeysForTool(name?: string): Set<string> {
|
|
22
|
+
const keys = new Set<string>(SCOPE_KEYS);
|
|
23
|
+
if (name && MODEL_OWNED_SESSION_ID_VERBS.has(name)) keys.delete('session_id');
|
|
24
|
+
return keys;
|
|
25
|
+
}
|
|
26
|
+
export function isConnectionBoundTool(name: string): boolean { return ACTIVATION_ONLY_VERBS.has(name); }
|
|
27
|
+
export const TOOL_LOADER_DESCRIPTION = MCP_CATALOG.find(entry => entry.name === TOOL_LOADER_NAME)!.description;
|
|
28
|
+
export interface ToolPromptMetadata { promptSnippet?: string; promptGuidelines?: string[]; }
|
|
29
|
+
export const TOOL_LOADER_PROMPT_METADATA: ToolPromptMetadata = {};
|
|
30
|
+
|
|
31
|
+
/** Drop host identity without discarding operational sentences or schema constraints. */
|
|
32
|
+
export function slimSchema(schema: Record<string, unknown> | undefined, omitKeys: Set<string> = SCOPE_KEY_SET): Record<string, unknown> {
|
|
33
|
+
if (!schema || typeof schema !== 'object') return { type: 'object', properties: {} };
|
|
34
|
+
const visit = (value: unknown): unknown => {
|
|
35
|
+
if (Array.isArray(value)) return value.map(visit);
|
|
36
|
+
if (!value || typeof value !== 'object') return value;
|
|
37
|
+
return slimSchema(value as Record<string, unknown>, omitKeys);
|
|
38
|
+
};
|
|
39
|
+
const out: Record<string, unknown> = {};
|
|
40
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
41
|
+
if (key === 'properties' && value && typeof value === 'object' && !Array.isArray(value)) {
|
|
42
|
+
out.properties = Object.fromEntries(Object.entries(value).filter(([name, child]) => !omitKeys.has(name) && !(child && typeof child === 'object' && (child as Record<string, unknown>)['x-devspec-host-only'] === true)).map(([name, child]) => [name, visit(child)]));
|
|
43
|
+
} else if (key === 'required' && Array.isArray(value)) {
|
|
44
|
+
const required = value.filter(name => typeof name === 'string' && !omitKeys.has(name));
|
|
45
|
+
if (required.length) out.required = required;
|
|
46
|
+
} else out[key] = visit(value);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
export function slimCatalogEntry(entry: CatalogEntry): CatalogEntry {
|
|
51
|
+
const inputSchema = slimSchema(entry.modelInputSchema, scopeKeysForTool(entry.name));
|
|
52
|
+
if (MODEL_OWNED_SESSION_ID_VERBS.has(entry.name) && Array.isArray(inputSchema.required)) {
|
|
53
|
+
const required = inputSchema.required.filter(name => name !== 'session_id');
|
|
54
|
+
if (required.length) inputSchema.required = required; else delete inputSchema.required;
|
|
55
|
+
}
|
|
56
|
+
return { ...entry, inputSchema };
|
|
57
|
+
}
|
|
58
|
+
export function catalogEntry(name: string): CatalogEntry | undefined { return MCP_CATALOG.find(entry => entry.name === name); }
|
|
59
|
+
export function allModelFacingTools(): CatalogEntry[] { return MCP_CATALOG.filter(entry => entry.kind === 'model').map(slimCatalogEntry); }
|
|
60
|
+
export function modelFacingSurface(): CatalogEntry[] { return allModelFacingTools().filter(entry => HOT_SET.has(entry.name)); }
|
|
61
|
+
/** No deferred model tier remains. Kept for session-recovery consumers. */
|
|
62
|
+
export function rareModelSurface(): CatalogEntry[] { return []; }
|
|
63
|
+
export function rareModelVerbs(): string[] { return []; }
|
|
64
|
+
export function isCallableModelVerb(name: string): boolean { return catalogEntry(name)?.kind === 'model'; }
|
|
65
|
+
export function isHotSetVerb(name: string): boolean { return HOT_SET.has(name); }
|
|
66
|
+
/** Tool schemas already carry the guidance; do not repeat the catalogue in prose. */
|
|
67
|
+
export function hotToolPromptMetadata(_entry: CatalogEntry): ToolPromptMetadata { return {}; }
|
|
68
|
+
|
|
69
|
+
export function exactModelToolNames(query: string): string[] | null {
|
|
70
|
+
const names = query.trim().replace(/`/g, '').split(/[\s,]+/).map(name => name.replace(/^\+/, ''));
|
|
71
|
+
return names.length && names.every(name => /^[a-z][a-z0-9]*_[a-z0-9_]+$/.test(name)) ? [...new Set(names)] : null;
|
|
72
|
+
}
|
|
73
|
+
const index = new MiniSearch({ fields: ['name', 'intent'], storeFields: ['description'], searchOptions: { boost: { intent: 3, name: 1 }, prefix: false, fuzzy: false } });
|
|
74
|
+
index.addAll(TOOL_SEARCH_DOCUMENTS);
|
|
75
|
+
const genericTerms = new Set('a an the to of for in on by with this that it my our me we you all is are can could please what how do does show get find list read write create update save send add tool tools'.split(' '));
|
|
76
|
+
/** A page of recommendations, the same shape and bounds as the server's. */
|
|
77
|
+
export interface ToolSearchPage { total_matching: number; offset: number; limit: number; returned: number; has_more: boolean; next_offset: number | null }
|
|
78
|
+
export function toolSearchPage(total: number, offset: number, limit: number, returned: number): ToolSearchPage {
|
|
79
|
+
const end = offset + returned;
|
|
80
|
+
return { total_matching: total, offset, limit, returned, has_more: end < total, next_offset: end < total ? end : null };
|
|
81
|
+
}
|
|
82
|
+
/** The server's page size: three by default, ten at most. */
|
|
83
|
+
export function toolSearchLimit(limit: unknown): number { return Math.max(1, Math.min(10, Math.trunc(typeof limit === 'number' && Number.isFinite(limit) ? limit : 3) || 3)); }
|
|
84
|
+
/** Offline BM25 fallback. The server's hybrid recommendation is optional. */
|
|
85
|
+
export function rankRareModelTools(query: string, availableNames: readonly string[] = HOT_SET_VERBS): CatalogEntry[] {
|
|
86
|
+
const available = new Set(availableNames);
|
|
87
|
+
const names = exactModelToolNames(query);
|
|
88
|
+
const specific = (query.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter(term => !genericTerms.has(term));
|
|
89
|
+
const hits = names ?? (specific.length ? index.search(query).filter(hit => specific.some(term => (hit.match[term]?.length ?? 0) > 0)).map(hit => String(hit.id)) : []);
|
|
90
|
+
return hits.filter(name => available.has(name) && name !== TOOL_LOADER_NAME).flatMap(name => {
|
|
91
|
+
const entry = catalogEntry(name);
|
|
92
|
+
return entry?.kind === 'model' ? [slimCatalogEntry(entry)] : [];
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
export function searchRareModelTools(query: string, limit = 3, availableNames: readonly string[] = HOT_SET_VERBS, offset = 0): CatalogEntry[] {
|
|
96
|
+
const start = Math.max(0, Math.trunc(offset) || 0);
|
|
97
|
+
return rankRareModelTools(query, availableNames).slice(start, start + toolSearchLimit(limit));
|
|
98
|
+
}
|
|
99
|
+
/** The loader's parameters are the tool's own definition, less what the host fills in. */
|
|
100
|
+
export function toolLoaderInputSchema(): Record<string, unknown> {
|
|
101
|
+
return slimCatalogEntry(catalogEntry(TOOL_LOADER_NAME)!).inputSchema;
|
|
102
|
+
}
|
|
103
|
+
export function estimatePrefixChars(instructions: string): number {
|
|
104
|
+
const tools = modelFacingSurface().map(entry => ({ name: entry.name, description: entry.description, inputSchema: entry.inputSchema }));
|
|
105
|
+
return JSON.stringify(tools).length + JSON.stringify({ name: TOOL_LOADER_NAME, description: TOOL_LOADER_DESCRIPTION, inputSchema: toolLoaderInputSchema() }).length
|
|
106
|
+
+ JSON.stringify({ name: ROOM_READ_TOOL, description: ROOM_READ_DESCRIPTION, inputSchema: ROOM_READ_SCHEMA }).length
|
|
107
|
+
+ instructions.length + (ROOM_READ_PROMPT_METADATA.promptSnippet ? `- ${ROOM_READ_TOOL}: ${ROOM_READ_PROMPT_METADATA.promptSnippet}`.length : 0);
|
|
108
|
+
}
|
|
109
|
+
export function estimatePlanToolChars(): number {
|
|
110
|
+
const entry = catalogEntry(PLAN_TOOL_NAME);
|
|
111
|
+
if (!entry) return 0;
|
|
112
|
+
const tool = slimCatalogEntry(entry);
|
|
113
|
+
return JSON.stringify({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }).length;
|
|
114
|
+
}
|
|
115
|
+
export function withPlanToolActive(active: readonly string[], enabled: boolean): string[] {
|
|
116
|
+
const rest = active.filter(name => name !== PLAN_TOOL_NAME); return enabled ? [...rest, PLAN_TOOL_NAME] : rest;
|
|
117
|
+
}
|
|
118
|
+
export function withDirectedQuestionToolActive(active: readonly string[], enabled: boolean): string[] {
|
|
119
|
+
const rest = active.filter(name => name !== DIRECTED_QUESTION_TOOL_NAME); return enabled ? [...rest, DIRECTED_QUESTION_TOOL_NAME] : rest;
|
|
120
|
+
}
|
|
121
|
+
export function withPollToolActive(active: readonly string[], enabled: boolean): string[] {
|
|
122
|
+
const rest = active.filter(name => name !== POLL_TOOL_NAME); return enabled ? [...rest, POLL_TOOL_NAME] : rest;
|
|
123
|
+
}
|
|
124
|
+
export function estimatePrefixTokens(instructions: string): number { return Math.ceil(estimatePrefixChars(instructions) / 4); }
|
|
125
|
+
// Footprint is diagnostic, never a quota that hides operations or clips prose.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A notice-woken run's reply, posted into the room as a reply to the DevSpec
|
|
3
|
+
* notices that woke it (DevSpec item 6aad2b46; the server side is 8885423d).
|
|
4
|
+
*
|
|
5
|
+
* What such a post means — outside every command, turn and attempt, labelled as
|
|
6
|
+
* a reply to a DevSpec notice and to no person — is served at
|
|
7
|
+
* devspec://product/remote-ingress-contract (semantics.notice_reply). This module
|
|
8
|
+
* is Pi's side only: which notices a run answers, what counts as its final reply,
|
|
9
|
+
* the post's arguments, reading the receipt or the refusal, and reading the
|
|
10
|
+
* negotiated room snapshot of such a row exactly as the server's schema does.
|
|
11
|
+
*/
|
|
12
|
+
import { hasAssistantSpeech } from "./assistant-speech.ts";
|
|
13
|
+
import { advertisesRoomContext } from "./room-context.ts";
|
|
14
|
+
import { unwrapMcpResult } from "./result.ts";
|
|
15
|
+
|
|
16
|
+
/** The server's NoticeReplySnapshot version and bounds (parity-tested against the served schema). */
|
|
17
|
+
export const NOTICE_REPLY_SNAPSHOT_VERSION = 1;
|
|
18
|
+
export const NOTICE_REPLY_MAX_NOTICES = 25;
|
|
19
|
+
export const NOTICE_REPLY_NOTICE_KINDS = ["server_notice", "unlinked_commit_item_review"] as const;
|
|
20
|
+
export const NOTICE_REPLY_NOTICE_KEYS = ["notice_id", "kind", "topic", "notice_type", "summary", "records"] as const;
|
|
21
|
+
export const NOTICE_REPLY_RECORD_KEYS = ["label", "record_type", "record_id", "title", "url"] as const;
|
|
22
|
+
/** zod's z.string().uuid(), the pattern the served JSON schema carries for notice_id. */
|
|
23
|
+
export const NOTICE_REPLY_UUID_PATTERN = "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$";
|
|
24
|
+
const UUID = new RegExp(NOTICE_REPLY_UUID_PATTERN);
|
|
25
|
+
|
|
26
|
+
export interface NoticeReplyRecord { label: string; record_type: string; record_id: string; title: string; url: string | null }
|
|
27
|
+
export interface NoticeReplyNotice {
|
|
28
|
+
notice_id: string;
|
|
29
|
+
kind: (typeof NOTICE_REPLY_NOTICE_KINDS)[number];
|
|
30
|
+
topic: string;
|
|
31
|
+
notice_type: string;
|
|
32
|
+
summary: string;
|
|
33
|
+
records: NoticeReplyRecord[];
|
|
34
|
+
}
|
|
35
|
+
/** room_context.messages[].notice_reply: the server's snapshot of the notices a row replies to. */
|
|
36
|
+
export interface NoticeReplySnapshot { version: 1; notices: NoticeReplyNotice[] }
|
|
37
|
+
|
|
38
|
+
type Row = Record<string, unknown>;
|
|
39
|
+
function row(value: unknown): Row | null {
|
|
40
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Row : null;
|
|
41
|
+
}
|
|
42
|
+
function exactly(value: Row, keys: readonly string[]): boolean {
|
|
43
|
+
return keys.every((key) => Object.hasOwn(value, key)) && Object.keys(value).every((key) => keys.includes(key));
|
|
44
|
+
}
|
|
45
|
+
const strings = (value: Row, keys: readonly string[]) => keys.every((key) => typeof value[key] === "string");
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The snapshot exactly as the server's NoticeReplySnapshotSchema reads it, or null
|
|
49
|
+
* when the server's schema would refuse it. Strings may be empty, as zod's
|
|
50
|
+
* z.string() allows; nothing is trimmed or rewritten.
|
|
51
|
+
*/
|
|
52
|
+
export function parseNoticeReplySnapshot(value: unknown): NoticeReplySnapshot | null {
|
|
53
|
+
const snapshot = row(value);
|
|
54
|
+
if (!snapshot || !exactly(snapshot, ["version", "notices"]) || snapshot.version !== NOTICE_REPLY_SNAPSHOT_VERSION) return null;
|
|
55
|
+
const notices = snapshot.notices;
|
|
56
|
+
if (!Array.isArray(notices) || notices.length < 1 || notices.length > NOTICE_REPLY_MAX_NOTICES) return null;
|
|
57
|
+
const parsed: NoticeReplyNotice[] = [];
|
|
58
|
+
for (const item of notices) {
|
|
59
|
+
const notice = row(item);
|
|
60
|
+
if (!notice || !exactly(notice, NOTICE_REPLY_NOTICE_KEYS) || !strings(notice, ["notice_id", "topic", "notice_type", "summary"])) return null;
|
|
61
|
+
if (!UUID.test(notice.notice_id as string) || !NOTICE_REPLY_NOTICE_KINDS.includes(notice.kind as NoticeReplyNotice["kind"])) return null;
|
|
62
|
+
if (!Array.isArray(notice.records)) return null;
|
|
63
|
+
const records: NoticeReplyRecord[] = [];
|
|
64
|
+
for (const entry of notice.records) {
|
|
65
|
+
const record = row(entry);
|
|
66
|
+
if (!record || !exactly(record, NOTICE_REPLY_RECORD_KEYS) || !strings(record, ["label", "record_type", "record_id", "title"])) return null;
|
|
67
|
+
if (record.url !== null && typeof record.url !== "string") return null;
|
|
68
|
+
records.push({ label: record.label as string, record_type: record.record_type as string, record_id: record.record_id as string, title: record.title as string, url: record.url as string | null });
|
|
69
|
+
}
|
|
70
|
+
parsed.push({ notice_id: notice.notice_id as string, kind: notice.kind as NoticeReplyNotice["kind"], topic: notice.topic as string, notice_type: notice.notice_type as string, summary: notice.summary as string, records });
|
|
71
|
+
}
|
|
72
|
+
return { version: 1, notices: parsed };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Opt in to the snapshot only when the served contract advertises it: an older server refuses the argument. */
|
|
76
|
+
export function advertisesNoticeReply(resource: unknown): boolean {
|
|
77
|
+
try {
|
|
78
|
+
for (const item of (row(resource)?.contents ?? []) as unknown[]) {
|
|
79
|
+
if (!advertisesRoomContext({ contents: [item] })) continue;
|
|
80
|
+
const contract = row(JSON.parse(String(row(item)?.text)));
|
|
81
|
+
const activation = row(contract?.activation);
|
|
82
|
+
const requires = row(activation?.notice_reply_requires);
|
|
83
|
+
if (activation?.notice_reply_negotiation_argument === "notice_reply_version" && requires?.room_context_version === 1 && requires?.notice_reply_version === 1) return true;
|
|
84
|
+
}
|
|
85
|
+
} catch { /* An older or unreadable advertisement keeps the room lane without the snapshot. */ }
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The notice ids of one delivered batch, from the details Pi put on the notice message itself. */
|
|
90
|
+
export function noticeIdsFromDelivery(details: unknown): string[] {
|
|
91
|
+
const notices = row(details)?.notices;
|
|
92
|
+
if (!Array.isArray(notices)) return [];
|
|
93
|
+
const ids: string[] = [];
|
|
94
|
+
for (const notice of notices) {
|
|
95
|
+
const id = row(notice)?.notice_id;
|
|
96
|
+
if (typeof id === "string" && UUID.test(id) && !ids.includes(id)) ids.push(id);
|
|
97
|
+
}
|
|
98
|
+
return ids;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The run's final reply: the text of its last assistant message, when the model
|
|
103
|
+
* finished that message itself. A run that ends on a tool call, an abort or a
|
|
104
|
+
* provider error, or whose last message has no text, has no final reply.
|
|
105
|
+
*/
|
|
106
|
+
export function noticeReplyFinalText(message: { text: string; stopReason?: string | null }): string | null {
|
|
107
|
+
if (message.stopReason !== "stop" && message.stopReason !== "length") return null;
|
|
108
|
+
return hasAssistantSpeech(message.text) ? message.text : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What one notice-woken segment answers and what it ended up saying. */
|
|
112
|
+
export interface NoticeReplyCapture {
|
|
113
|
+
/** The connection the notices were delivered to; only it may name them. */
|
|
114
|
+
connectionId: string;
|
|
115
|
+
noticeIds: string[];
|
|
116
|
+
/** The notice framing told the model its final message reaches the room. */
|
|
117
|
+
roomPromised: boolean;
|
|
118
|
+
finalText: string | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type NoticeReplyCallResult = { raw: unknown } | { refusal: string };
|
|
122
|
+
|
|
123
|
+
export type NoticeReplyOutcome =
|
|
124
|
+
| { kind: "posted"; messageId: string; sessionId: string; idempotent: boolean }
|
|
125
|
+
| { kind: "no_final_text" }
|
|
126
|
+
| { kind: "not_attached" }
|
|
127
|
+
| { kind: "connection_changed" }
|
|
128
|
+
| { kind: "refused"; reason: string; detail: string }
|
|
129
|
+
| { kind: "unconfirmed"; detail: string };
|
|
130
|
+
|
|
131
|
+
const canonical = (ids: readonly string[]) => [...new Set(ids.map((id) => id.trim().toLowerCase()))].sort();
|
|
132
|
+
|
|
133
|
+
/** A stored receipt for exactly this reply, or null. Anything else is not proof the room shows it. */
|
|
134
|
+
export function readNoticeReplyReceipt(raw: unknown, expected: { connectionId: string; noticeIds: readonly string[] }):
|
|
135
|
+
{ messageId: string; sessionId: string; idempotent: boolean } | null {
|
|
136
|
+
const { details, isError } = unwrapMcpResult(raw);
|
|
137
|
+
const data = row(details);
|
|
138
|
+
const delivery = row(data?.delivery);
|
|
139
|
+
if (isError || !data || !delivery) return null;
|
|
140
|
+
if (delivery.status !== "stored" && delivery.status !== "idempotently_stored") return null;
|
|
141
|
+
const messageId = data.message_id;
|
|
142
|
+
const sessionId = data.session_id;
|
|
143
|
+
if (typeof messageId !== "string" || !messageId || delivery.message_id !== messageId || typeof sessionId !== "string" || !sessionId) return null;
|
|
144
|
+
if (data.connection_id !== expected.connectionId || data.phase !== "answer" || data.turn_effect !== "none") return null;
|
|
145
|
+
const answered = row(data.notice_reply)?.notice_ids;
|
|
146
|
+
if (!Array.isArray(answered) || !answered.every((id) => typeof id === "string")) return null;
|
|
147
|
+
if (JSON.stringify(canonical(answered as string[])) !== JSON.stringify(canonical(expected.noticeIds))) return null;
|
|
148
|
+
return { messageId, sessionId, idempotent: delivery.status === "idempotently_stored" };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The server's refusal: its delivery reason and its own words, verbatim. */
|
|
152
|
+
export function readNoticeReplyRefusal(text: string): { reason: string; detail: string } {
|
|
153
|
+
try {
|
|
154
|
+
const data = row(JSON.parse(text));
|
|
155
|
+
const delivery = row(data?.delivery);
|
|
156
|
+
if (delivery?.status === "rejected" && typeof delivery.reason === "string" && delivery.reason) {
|
|
157
|
+
return { reason: delivery.reason, detail: typeof data?.error === "string" && data.error.trim() ? data.error.trim() : text.trim() };
|
|
158
|
+
}
|
|
159
|
+
} catch { /* A refusal before anything was read is plain text. */ }
|
|
160
|
+
return { reason: "refused", detail: text.trim() };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Post the segment's final reply as a reply to its notices. Only connection_id,
|
|
165
|
+
* the text and the notice ids identify it: no command, turn, attempt, trail or
|
|
166
|
+
* completion, so nothing here can change Working, Pending or idle. The server
|
|
167
|
+
* stores the same notices and text once, so a room change between its read and
|
|
168
|
+
* its write is sent once more.
|
|
169
|
+
*/
|
|
170
|
+
export async function postNoticeReply(input: {
|
|
171
|
+
capture: NoticeReplyCapture;
|
|
172
|
+
/** This Pi's DevSpec connection and room right now, or null without one. */
|
|
173
|
+
current: () => { connectionId: string; sessionId: string | null } | null;
|
|
174
|
+
call: (args: Record<string, unknown>) => Promise<NoticeReplyCallResult>;
|
|
175
|
+
/** agent_name, agent_stats and model, as every answer carries them. */
|
|
176
|
+
extraArgs?: Record<string, unknown>;
|
|
177
|
+
}): Promise<NoticeReplyOutcome> {
|
|
178
|
+
const { capture } = input;
|
|
179
|
+
if (capture.finalText === null || capture.noticeIds.length === 0) return { kind: "no_final_text" };
|
|
180
|
+
const args = { ...input.extraArgs, connection_id: capture.connectionId, message: capture.finalText, system_notice_ids: [...capture.noticeIds] };
|
|
181
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
182
|
+
const now = input.current();
|
|
183
|
+
if (!now || now.connectionId !== capture.connectionId) return { kind: "connection_changed" };
|
|
184
|
+
if (!now.sessionId) return { kind: "not_attached" };
|
|
185
|
+
let result: NoticeReplyCallResult;
|
|
186
|
+
try { result = await input.call(args); }
|
|
187
|
+
catch (error) { return { kind: "unconfirmed", detail: error instanceof Error ? error.message : String(error) }; }
|
|
188
|
+
if ("refusal" in result) {
|
|
189
|
+
const refusal = readNoticeReplyRefusal(result.refusal);
|
|
190
|
+
if (refusal.reason === "room_changed" && attempt === 0) continue;
|
|
191
|
+
return { kind: "refused", ...refusal };
|
|
192
|
+
}
|
|
193
|
+
const receipt = readNoticeReplyReceipt(result.raw, { connectionId: capture.connectionId, noticeIds: capture.noticeIds });
|
|
194
|
+
return receipt ? { kind: "posted", ...receipt } : { kind: "unconfirmed", detail: "DevSpec's receipt did not confirm this reply" };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** What the person at Pi is told, or null when there is nothing to tell. Never silent about a reply that did not land. */
|
|
199
|
+
export function noticeReplyTerminalMessage(outcome: NoticeReplyOutcome, capture: NoticeReplyCapture): string | null {
|
|
200
|
+
const what = capture.noticeIds.length === 1 ? "The reply to the DevSpec notice" : `The reply to ${capture.noticeIds.length} DevSpec notices`;
|
|
201
|
+
switch (outcome.kind) {
|
|
202
|
+
case "posted":
|
|
203
|
+
case "no_final_text":
|
|
204
|
+
return null;
|
|
205
|
+
case "not_attached":
|
|
206
|
+
// Not attached when the notice arrived: the model was told its reply stays here.
|
|
207
|
+
return capture.roomPromised ? `DevSpec: ${what} was not posted, because Pi is no longer attached to a room. It stays here in the terminal.` : null;
|
|
208
|
+
case "connection_changed":
|
|
209
|
+
return `DevSpec: ${what} was not posted, because this Pi's DevSpec connection changed during the run. It stays here in the terminal.`;
|
|
210
|
+
case "refused":
|
|
211
|
+
return `DevSpec: ${what} was not posted to the room (${outcome.reason}). ${outcome.detail}`;
|
|
212
|
+
case "unconfirmed":
|
|
213
|
+
return `DevSpec: ${what} may not have reached the room (${outcome.detail}). Check the room before sending it again.`;
|
|
214
|
+
}
|
|
215
|
+
}
|