my-durable 0.0.0-stage → 0.0.2
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/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +328 -2
- package/ai_docs/durable_pi/announcement.md +860 -0
- package/ai_docs/durable_pi/changelog.md +51 -0
- package/ai_docs/durable_pi/chord-delta-findings.md +362 -0
- package/ai_docs/durable_pi/coding-agent-integration.md +70 -0
- package/ai_docs/durable_pi/pico-v5-chord-usage.md +391 -0
- package/ai_docs/durable_pi/pico-v5-handoff.md +921 -0
- package/ai_docs/durable_pi/readme.md +617 -0
- package/ai_docs/durable_pi/sources.md +22 -0
- package/ai_docs/durable_pi/spec.md +4696 -0
- package/ai_docs/durable_pi/vacation-planner.md +57 -0
- package/ai_docs/pi/chord.md +268 -0
- package/ai_docs/pi/cli-integration.md +110 -0
- package/ai_docs/pi/cli.md +325 -0
- package/ai_docs/pi/codemode.md +211 -0
- package/ai_docs/pi/compaction.md +467 -0
- package/ai_docs/pi/configuration.md +51 -0
- package/ai_docs/pi/containerization.md +187 -0
- package/ai_docs/pi/custom-provider.md +177 -0
- package/ai_docs/pi/environment-variables.md +102 -0
- package/ai_docs/pi/extensions.md +278 -0
- package/ai_docs/pi/how-pi-works.md +53 -0
- package/ai_docs/pi/index.md +43 -0
- package/ai_docs/pi/json.md +230 -0
- package/ai_docs/pi/keybindings.md +198 -0
- package/ai_docs/pi/llama-cpp.md +118 -0
- package/ai_docs/pi/mcp.md +274 -0
- package/ai_docs/pi/message-types.md +265 -0
- package/ai_docs/pi/models.md +209 -0
- package/ai_docs/pi/overview.md +166 -0
- package/ai_docs/pi/packages.md +133 -0
- package/ai_docs/pi/pi-agent-core.md +568 -0
- package/ai_docs/pi/pi-ai.md +1942 -0
- package/ai_docs/pi/prompt-templates.md +63 -0
- package/ai_docs/pi/providers.md +204 -0
- package/ai_docs/pi/quickstart.md +140 -0
- package/ai_docs/pi/readme.md +99 -0
- package/ai_docs/pi/rpc-commands.md +864 -0
- package/ai_docs/pi/rpc-extension-ui.md +204 -0
- package/ai_docs/pi/rpc.md +197 -0
- package/ai_docs/pi/sdk.md +151 -0
- package/ai_docs/pi/security.md +103 -0
- package/ai_docs/pi/session-format.md +297 -0
- package/ai_docs/pi/sessions.md +70 -0
- package/ai_docs/pi/settings.md +173 -0
- package/ai_docs/pi/shell-aliases.md +97 -0
- package/ai_docs/pi/skills.md +97 -0
- package/ai_docs/pi/slash-commands.md +64 -0
- package/ai_docs/pi/sources.md +57 -0
- package/ai_docs/pi/terminal-setup.md +223 -0
- package/ai_docs/pi/termux.md +121 -0
- package/ai_docs/pi/themes.md +140 -0
- package/ai_docs/pi/tmux.md +59 -0
- package/ai_docs/pi/toolkit.md +168 -0
- package/ai_docs/pi/tui.md +129 -0
- package/ai_docs/pi/usage.md +98 -0
- package/ai_docs/pi/virtual-models.md +118 -0
- package/ai_docs/pi/windows.md +71 -0
- package/apps/dpi-backend/.nvmrc +1 -0
- package/apps/dpi-backend/API.md +99 -0
- package/apps/dpi-backend/README.md +119 -0
- package/apps/dpi-backend/deploy/backend.env.example +9 -0
- package/apps/dpi-backend/deploy/dpi-backend.plist +26 -0
- package/apps/dpi-backend/deploy/dpi-backend.service +22 -0
- package/apps/dpi-backend/package.json +33 -0
- package/apps/dpi-backend/prompts/one_sentence.md +3 -0
- package/apps/dpi-backend/prompts/smart_ass_opus_fix.md +169 -0
- package/apps/dpi-backend/scripts/check-contract.mjs +151 -0
- package/apps/dpi-backend/scripts/migrate-playground.ts +66 -0
- package/apps/dpi-backend/scripts/record-nested-fixture.mts +154 -0
- package/apps/dpi-backend/scripts/token.ts +57 -0
- package/apps/dpi-backend/src/addons.ts +139 -0
- package/apps/dpi-backend/src/auth.ts +20 -0
- package/apps/dpi-backend/src/config.ts +54 -0
- package/apps/dpi-backend/src/contract-check.ts +118 -0
- package/apps/dpi-backend/src/errors.ts +50 -0
- package/apps/dpi-backend/src/extensions.ts +510 -0
- package/apps/dpi-backend/src/faux.ts +80 -0
- package/apps/dpi-backend/src/history.ts +165 -0
- package/apps/dpi-backend/src/idempotency.ts +20 -0
- package/apps/dpi-backend/src/jev.ts +43 -0
- package/apps/dpi-backend/src/jobs.ts +790 -0
- package/apps/dpi-backend/src/main.ts +76 -0
- package/apps/dpi-backend/src/model-catalog.ts +180 -0
- package/apps/dpi-backend/src/pairing-files.ts +142 -0
- package/apps/dpi-backend/src/pairing.ts +459 -0
- package/apps/dpi-backend/src/routes.ts +217 -0
- package/apps/dpi-backend/src/shared-state.ts +85 -0
- package/apps/dpi-backend/src/store.ts +1240 -0
- package/apps/dpi-backend/src/streams.ts +256 -0
- package/apps/dpi-backend/src/tunnel.ts +955 -0
- package/apps/dpi-backend/tsconfig.json +13 -0
- package/apps/dpi-playground/README.md +83 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/index.html +15 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +180 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/commands.ts +119 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/composables.ts +129 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/hub.ts +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/icon-path.ts +7 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/ids.ts +2 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/jobs.ts +80 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/link-policy.ts +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown-policy.ts +115 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-close.ts +23 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +166 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +323 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/signature.ts +146 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/store.ts +373 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/stream.ts +183 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +342 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Composer.vue +198 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ContextBar.vue +254 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceCredentialsDialog.vue +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceIcon.vue +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DirBrowser.vue +95 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ExtensionsPanel.vue +96 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/HelpDialog.vue +69 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +425 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/JobCard.vue +221 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/MobileNav.vue +39 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ModelPicker.vue +71 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NetworkSidebar.vue +236 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/OfflinePanel.vue +128 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +433 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PromptDialog.vue +89 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/StateEditor.vue +179 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TasksPanel.vue +280 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Toast.vue +11 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TokenManager.vue +175 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TopBar.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TranscriptItem.vue +152 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +195 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TunnelStatusBar.vue +107 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/extensions.css +105 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/jobs.css +655 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +22 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/network.css +675 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +3203 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/tsconfig.json +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/vite.config.ts +14 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +406 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/client-ip.ts +72 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +265 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/guard.ts +43 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/http-errors.ts +156 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/identity.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/limiter.ts +165 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +460 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/monitor.ts +320 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/mux.ts +277 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +816 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/proxy.ts +128 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/rotation.ts +583 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/seed.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/transport.ts +108 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-token.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-transport.ts +560 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +873 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/tsconfig.json +13 -0
- package/apps/dpi-playground/package.json +37 -0
- package/apps/dpi-sdk/README.md +74 -0
- package/apps/dpi-sdk/package.json +20 -0
- package/apps/dpi-sdk/scripts/net.ts +73 -0
- package/apps/dpi-sdk/src/api.ts +631 -0
- package/apps/dpi-sdk/src/app-state.ts +111 -0
- package/apps/dpi-sdk/src/client.ts +330 -0
- package/apps/dpi-sdk/src/errors.ts +16 -0
- package/apps/dpi-sdk/src/jobs.ts +99 -0
- package/apps/dpi-sdk/src/models.ts +472 -0
- package/apps/dpi-sdk/src/node/chunk.ts +488 -0
- package/apps/dpi-sdk/src/node/config.ts +175 -0
- package/apps/dpi-sdk/src/node/drivers.ts +402 -0
- package/apps/dpi-sdk/src/node/ops.ts +114 -0
- package/apps/dpi-sdk/src/node/private-dir.ts +48 -0
- package/apps/dpi-sdk/src/node/registry-migration.ts +549 -0
- package/apps/dpi-sdk/src/node/registry.ts +390 -0
- package/apps/dpi-sdk/src/node/sse-writer.ts +151 -0
- package/apps/dpi-sdk/src/protocol/backoff.ts +31 -0
- package/apps/dpi-sdk/src/protocol/limits.ts +199 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +311 -0
- package/apps/dpi-sdk/src/protocol/rest-routes.ts +105 -0
- package/apps/dpi-sdk/src/protocol/tunnel.ts +331 -0
- package/apps/dpi-sdk/src/sse.ts +117 -0
- package/apps/dpi-sdk/src/transcript.ts +291 -0
- package/apps/dpi-sdk/tsconfig.json +13 -0
- package/apps/dpi-sdk/w +20 -0
- package/apps/dpi-tui/README.md +54 -0
- package/apps/dpi-tui/bin/dpi-tui.mjs +5 -0
- package/apps/dpi-tui/package.json +20 -0
- package/apps/dpi-tui/src/app.ts +433 -0
- package/apps/dpi-tui/src/commands.ts +151 -0
- package/apps/dpi-tui/src/main.ts +122 -0
- package/apps/dpi-tui/src/pairing.ts +287 -0
- package/apps/dpi-tui/src/pickers.ts +238 -0
- package/apps/dpi-tui/src/render.ts +164 -0
- package/apps/dpi-tui/src/theme.ts +57 -0
- package/apps/dpi-tui/tsconfig.json +14 -0
- package/bin/device.mjs +5 -0
- package/bin/hub.mjs +5 -0
- package/bin/tui.mjs +5 -0
- package/deploy/Caddyfile +120 -0
- package/docs/verification.md +102 -0
- package/dpi-network.example.yaml +62 -0
- package/images/01_durable_agent_network.svg +185 -0
- package/images/02_pi_made_durable.svg +204 -0
- package/images/03_restart_mid_tool.svg +168 -0
- package/images/07_one_api_every_client.svg +123 -0
- package/images/08_device_session_agent.svg +123 -0
- package/images/09_compact_keep_history.svg +143 -0
- package/images/10_shared_app_state.svg +177 -0
- package/images/11_durable_jobs_wait.svg +141 -0
- package/images/12_tools_change_mid_run.svg +231 -0
- package/images/13_hook_blocks_tool.svg +122 -0
- package/images/14_fork_at_any_entry.svg +119 -0
- package/images/15_web_client.png +0 -0
- package/images/16_terminal_client.svg +43 -0
- package/images/17_devices_are_data.svg +182 -0
- package/images/18_should_every_agent_be_durable.svg +118 -0
- package/images/19_sqlite_on_every_device.svg +155 -0
- package/images/20_admission_and_effects.svg +134 -0
- package/justfile +33 -0
- package/package.json +41 -4
- package/scripts/verify.mjs +210 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P2-23 — typed admin pairing client for the browser.
|
|
3
|
+
*
|
|
4
|
+
* Contract (plan §P2-23):
|
|
5
|
+
* - Every admin pairing/rotation/revoke call is typed with the SDK DTOs.
|
|
6
|
+
* - Wizard enrollment state lives in ISOLATED per-session RAM (created via
|
|
7
|
+
* createPairingSession()); it never writes into the network/pairings
|
|
8
|
+
* metadata or the monitor six-status feed.
|
|
9
|
+
* - The browser only ever talks to hub /api endpoints — never to a device
|
|
10
|
+
* URL directly (the hub proxy injects device tokens; the browser holds none).
|
|
11
|
+
* - A closed dialog/tab aborts its polls; a stale session (superseded by a
|
|
12
|
+
* newer wizard) can never update anything.
|
|
13
|
+
* - HTTP 401 maps to a typed error (needsAuth) so the UI can ask for
|
|
14
|
+
* re-authentication without any redirect to a login screen.
|
|
15
|
+
* - No secret (code/token/hash) is ever logged or persisted here.
|
|
16
|
+
*/
|
|
17
|
+
import { DpiError } from "../../../../../dpi-sdk/src/errors";
|
|
18
|
+
import type {
|
|
19
|
+
PairCodeIssue,
|
|
20
|
+
PairCodeState,
|
|
21
|
+
PairApproval,
|
|
22
|
+
PublicPairing,
|
|
23
|
+
} from "../../../../../dpi-sdk/src/api";
|
|
24
|
+
|
|
25
|
+
/** One hub REST call. 401 surfaces as DpiError(status=401) → needsAuth(). */
|
|
26
|
+
async function call<T>(
|
|
27
|
+
method: string,
|
|
28
|
+
path: string,
|
|
29
|
+
body?: unknown,
|
|
30
|
+
signal?: AbortSignal,
|
|
31
|
+
keepalive?: boolean,
|
|
32
|
+
): Promise<T> {
|
|
33
|
+
const r = await fetch(`/api${path}`, {
|
|
34
|
+
method,
|
|
35
|
+
headers: body === undefined ? {} : { "content-type": "application/json" },
|
|
36
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
37
|
+
signal,
|
|
38
|
+
...(keepalive ? { keepalive } : {}),
|
|
39
|
+
});
|
|
40
|
+
const data = (await r.json().catch(() => undefined)) as
|
|
41
|
+
| T
|
|
42
|
+
| { error?: { code?: string; message?: string } }
|
|
43
|
+
| undefined;
|
|
44
|
+
if (!r.ok) {
|
|
45
|
+
const error = (data as { error?: { code?: string; message?: string } } | undefined)?.error;
|
|
46
|
+
throw new DpiError(
|
|
47
|
+
(error?.code as never) ?? "internal",
|
|
48
|
+
error?.message ?? `${r.status} ${r.statusText}`,
|
|
49
|
+
r.status,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return data as T;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The only auth failure the browser treats as "authenticate again". */
|
|
56
|
+
export const needsAuth = (e: unknown): boolean => e instanceof DpiError && e.status === 401;
|
|
57
|
+
|
|
58
|
+
export type RevokeReason = "user" | "compromise";
|
|
59
|
+
|
|
60
|
+
export const pairingAdmin = {
|
|
61
|
+
pairings: (signal?: AbortSignal) => call<PublicPairing[]>("GET", "/pairings", undefined, signal),
|
|
62
|
+
/** Operator opens the wizard: mint a code (admin-gated upstream by Caddy). */
|
|
63
|
+
createCode: (signal?: AbortSignal) => call<PairCodeIssue>("POST", "/pair/code", {}, signal),
|
|
64
|
+
/** Resolve an existing handoff through a same-origin JSON body, never a code-bearing API URL. */
|
|
65
|
+
resolveCode: (code: string, signal?: AbortSignal) =>
|
|
66
|
+
call<PairCodeIssue>("POST", "/pair/resolve", { code }, signal),
|
|
67
|
+
/** Phase metadata only — the issued token is NEVER in this response. */
|
|
68
|
+
codeState: (codeId: string, signal?: AbortSignal) =>
|
|
69
|
+
call<PairCodeState>("GET", `/pair/codes/${encodeURIComponent(codeId)}`, undefined, signal),
|
|
70
|
+
/** Full-fingerprint approval (P-8/P-14): tuple must match the enrollment exactly. */
|
|
71
|
+
approve: (codeId: string, approval: PairApproval, signal?: AbortSignal) =>
|
|
72
|
+
call<PairCodeState>(
|
|
73
|
+
"POST",
|
|
74
|
+
`/pair/codes/${encodeURIComponent(codeId)}/approve`,
|
|
75
|
+
approval,
|
|
76
|
+
signal,
|
|
77
|
+
),
|
|
78
|
+
/** `keepalive` lets the request outlive the page; use it only when the wizard is going away. */
|
|
79
|
+
cancel: (codeId: string, signal?: AbortSignal, keepalive?: boolean) =>
|
|
80
|
+
call<PairCodeState>(
|
|
81
|
+
"POST",
|
|
82
|
+
`/pair/codes/${encodeURIComponent(codeId)}/cancel`,
|
|
83
|
+
{},
|
|
84
|
+
signal,
|
|
85
|
+
keepalive,
|
|
86
|
+
),
|
|
87
|
+
/** Ask the hub to rotate a device credential (202 — async via tunnel). */
|
|
88
|
+
rotate: (deviceId: string, signal?: AbortSignal) =>
|
|
89
|
+
call<{ ok: boolean }>("POST", `/pairings/${encodeURIComponent(deviceId)}/rotate`, {}, signal),
|
|
90
|
+
/** Durable revoke (P2-20): every hash dies at commit; live transport is cut. */
|
|
91
|
+
revoke: (deviceId: string, reason: RevokeReason = "user", signal?: AbortSignal) =>
|
|
92
|
+
call<{ ok: boolean }>(
|
|
93
|
+
"POST",
|
|
94
|
+
`/pairings/${encodeURIComponent(deviceId)}/revoke`,
|
|
95
|
+
{ reason },
|
|
96
|
+
signal,
|
|
97
|
+
),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type PairPhase = PairCodeState["phase"];
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Isolated wizard session. `gen` increments per session; a poll from an old
|
|
104
|
+
* session (stale closure) sees `closed` and stops without touching anything.
|
|
105
|
+
*/
|
|
106
|
+
export interface PairingSession {
|
|
107
|
+
readonly gen: number;
|
|
108
|
+
/** Latest codeState snapshot (RAM only — never persisted). */
|
|
109
|
+
state?: PairCodeState;
|
|
110
|
+
readonly close: () => void;
|
|
111
|
+
readonly closed: () => boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let sessionGen = 0;
|
|
115
|
+
|
|
116
|
+
export function createPairingSession(): PairingSession {
|
|
117
|
+
const gen = ++sessionGen;
|
|
118
|
+
let isClosed = false;
|
|
119
|
+
return {
|
|
120
|
+
gen,
|
|
121
|
+
close: () => (isClosed = true),
|
|
122
|
+
closed: () => isClosed,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface PollOptions {
|
|
127
|
+
signal?: AbortSignal;
|
|
128
|
+
/** Overall budget; the hub's own code TTL (5 min) bounds reality. */
|
|
129
|
+
timeoutMs?: number;
|
|
130
|
+
intervalMs?: number;
|
|
131
|
+
onState?: (state: PairCodeState) => void;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export class PollTimeout extends Error {
|
|
135
|
+
constructor(readonly codeId: string) {
|
|
136
|
+
super(`pairing poll timed out for ${codeId}`);
|
|
137
|
+
this.name = "PollTimeout";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const terminal = (phase: PairPhase) =>
|
|
142
|
+
phase === "issued" || phase === "cancelled" || phase === "expired";
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Poll a code until it reaches a terminal phase. Aborting (signal or
|
|
146
|
+
* session.close()) stops cleanly; superseded sessions never write state.
|
|
147
|
+
*/
|
|
148
|
+
export async function pollCode(
|
|
149
|
+
session: PairingSession,
|
|
150
|
+
codeId: string,
|
|
151
|
+
opts: PollOptions = {},
|
|
152
|
+
): Promise<PairCodeState | undefined> {
|
|
153
|
+
const interval = opts.intervalMs ?? 1_000; // PAIR_POLL_MS cadence
|
|
154
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 330_000);
|
|
155
|
+
for (;;) {
|
|
156
|
+
if (session.closed()) return undefined; // dialog/tab closed — stop silently
|
|
157
|
+
if (opts.signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
|
|
158
|
+
if (Date.now() > deadline) throw new PollTimeout(codeId);
|
|
159
|
+
const state = await pairingAdmin.codeState(codeId, opts.signal);
|
|
160
|
+
if (session.closed()) return undefined; // closed while in flight — discard
|
|
161
|
+
session.state = state;
|
|
162
|
+
opts.onState?.(state);
|
|
163
|
+
if (terminal(state.phase)) return state;
|
|
164
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal local QR encoder for the PairDeviceWizard (P2-24: "QR render local;
|
|
3
|
+
* no remote QR service"). Byte mode, EC level L, versions 1–5 (pair URLs are
|
|
4
|
+
* ≤ ~106 bytes). Pure function: text → boolean[size][size] module matrix.
|
|
5
|
+
* Cross-validated against the reference `python-qrcode` implementation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// -- GF(256), poly 0x11D ------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const EXP = new Uint8Array(512);
|
|
11
|
+
const LOG = new Uint8Array(256);
|
|
12
|
+
(() => {
|
|
13
|
+
let x = 1;
|
|
14
|
+
for (let i = 0; i < 255; i++) {
|
|
15
|
+
EXP[i] = x;
|
|
16
|
+
LOG[x] = i;
|
|
17
|
+
x <<= 1;
|
|
18
|
+
if (x & 0x100) x ^= 0x11d;
|
|
19
|
+
}
|
|
20
|
+
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
|
21
|
+
})();
|
|
22
|
+
const gmul = (a: number, b: number) => (a === 0 || b === 0 ? 0 : EXP[LOG[a] + LOG[b]]);
|
|
23
|
+
|
|
24
|
+
/** Generator polynomial, HIGH→low coefficients, monic (gen[0] = 1). */
|
|
25
|
+
function rsGenerator(degree: number): number[] {
|
|
26
|
+
let g = [1];
|
|
27
|
+
for (let i = 0; i < degree; i++) {
|
|
28
|
+
const next = new Array<number>(g.length + 1).fill(0);
|
|
29
|
+
for (let j = 0; j < g.length; j++) {
|
|
30
|
+
next[j] ^= g[j]; // multiply by x
|
|
31
|
+
next[j + 1] ^= gmul(g[j], EXP[i]); // multiply by α^i
|
|
32
|
+
}
|
|
33
|
+
g = next;
|
|
34
|
+
}
|
|
35
|
+
return g;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function rsEncode(data: number[], ecLen: number): number[] {
|
|
39
|
+
const gen = rsGenerator(ecLen);
|
|
40
|
+
const rem = new Array<number>(ecLen).fill(0);
|
|
41
|
+
for (const byte of data) {
|
|
42
|
+
const factor = byte ^ rem[0];
|
|
43
|
+
rem.shift();
|
|
44
|
+
rem.push(0);
|
|
45
|
+
// gen[0] is the x^ecLen term (cancels against the shifted-out lead); gen[i+1] is x^{ecLen-1-i}.
|
|
46
|
+
for (let i = 0; i < ecLen; i++) rem[i] ^= gmul(gen[i + 1], factor);
|
|
47
|
+
}
|
|
48
|
+
return rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// -- version tables (EC level L, single data block for v1–5) -------------------
|
|
52
|
+
|
|
53
|
+
const SIZES = [21, 25, 29, 33, 37];
|
|
54
|
+
const DATA_CODEWORDS = [19, 34, 55, 80, 108];
|
|
55
|
+
const EC_PER_BLOCK = [7, 10, 15, 20, 26];
|
|
56
|
+
const ALIGN = [[], [6, 18], [6, 22], [6, 26], [6, 30]]; // alignment centers
|
|
57
|
+
const REMAINDER_BITS = [0, 7, 7, 7, 7];
|
|
58
|
+
|
|
59
|
+
function selectVersion(byteLen: number): number {
|
|
60
|
+
for (let v = 1; v <= 5; v++) {
|
|
61
|
+
// byte mode: 4-bit indicator + 8-bit count (v1–9) + payload
|
|
62
|
+
if (byteLen + 2 <= DATA_CODEWORDS[v - 1]) return v;
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`qr: payload too long (${byteLen} bytes; v5L max ${DATA_CODEWORDS[4] - 2})`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// -- bit stream ---------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
function buildCodewords(text: string, version: number): number[] {
|
|
70
|
+
const bytes = [...new TextEncoder().encode(text)];
|
|
71
|
+
const bits: number[] = [];
|
|
72
|
+
const push = (value: number, len: number) => {
|
|
73
|
+
for (let i = len - 1; i >= 0; i--) bits.push((value >> i) & 1);
|
|
74
|
+
};
|
|
75
|
+
push(0b0100, 4); // byte mode
|
|
76
|
+
push(bytes.length, 8);
|
|
77
|
+
for (const b of bytes) push(b, 8);
|
|
78
|
+
const dataCodewords = DATA_CODEWORDS[version - 1];
|
|
79
|
+
const capacityBits = dataCodewords * 8;
|
|
80
|
+
push(0, Math.min(4, capacityBits - bits.length)); // terminator
|
|
81
|
+
while (bits.length % 8 !== 0) bits.push(0);
|
|
82
|
+
const padBytes = [0xec, 0x11];
|
|
83
|
+
let pi = 0;
|
|
84
|
+
while (bits.length < capacityBits) push(padBytes[pi++ % 2], 8);
|
|
85
|
+
const out: number[] = [];
|
|
86
|
+
for (let i = 0; i < dataCodewords; i++) {
|
|
87
|
+
let byte = 0;
|
|
88
|
+
for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i * 8 + j];
|
|
89
|
+
out.push(byte);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// -- matrix scaffolding ---------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
function makeMatrix(size: number): boolean[][] {
|
|
97
|
+
return Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function placeFunctionPatterns(m: boolean[][], version: number): void {
|
|
101
|
+
const size = SIZES[version - 1];
|
|
102
|
+
const finder = (row: number, col: number) => {
|
|
103
|
+
for (let r = -1; r <= 7; r++)
|
|
104
|
+
for (let c = -1; c <= 7; c++) {
|
|
105
|
+
const rr = row + r;
|
|
106
|
+
const cc = col + c;
|
|
107
|
+
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
|
|
108
|
+
const inRing =
|
|
109
|
+
r >= 0 && r <= 6 && c >= 0 && c <= 6 && (r === 0 || r === 6 || c === 0 || c === 6);
|
|
110
|
+
const inCore = r >= 2 && r <= 4 && c >= 2 && c <= 4;
|
|
111
|
+
m[rr][cc] = inRing || inCore;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
finder(0, 0);
|
|
115
|
+
finder(0, size - 7);
|
|
116
|
+
finder(size - 7, 0);
|
|
117
|
+
// timing patterns
|
|
118
|
+
for (let i = 8; i < size - 8; i++) {
|
|
119
|
+
m[6][i] = i % 2 === 0;
|
|
120
|
+
m[i][6] = i % 2 === 0;
|
|
121
|
+
}
|
|
122
|
+
// alignment patterns (skip those overlapping finders)
|
|
123
|
+
const centers = ALIGN[version - 1];
|
|
124
|
+
for (const r of centers)
|
|
125
|
+
for (const c of centers) {
|
|
126
|
+
if ((r <= 8 && c <= 8) || (r <= 8 && c >= size - 9) || (r >= size - 9 && c <= 8)) continue;
|
|
127
|
+
for (let rr = -2; rr <= 2; rr++)
|
|
128
|
+
for (let cc = -2; cc <= 2; cc++)
|
|
129
|
+
m[r + rr][c + cc] = Math.max(Math.abs(rr), Math.abs(cc)) !== 1;
|
|
130
|
+
}
|
|
131
|
+
// dark module
|
|
132
|
+
m[size - 8][8] = true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// format info: EC level L (01) + mask, BCH(15,5), XOR 0x5412
|
|
136
|
+
function formatBits(mask: number): boolean[] {
|
|
137
|
+
const data = (0b01 << 3) | mask;
|
|
138
|
+
let rem = data;
|
|
139
|
+
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >> 9) & 1 ? 0x537 : 0);
|
|
140
|
+
const bits15 = ((data << 10) | rem) ^ 0x5412;
|
|
141
|
+
const out: boolean[] = [];
|
|
142
|
+
for (let i = 14; i >= 0; i--) out.push(((bits15 >> i) & 1) === 1);
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function placeFormat(m: boolean[][], mask: number): void {
|
|
147
|
+
const size = SIZES[0] * 0 + m.length;
|
|
148
|
+
const bits = formatBits(mask);
|
|
149
|
+
// around top-left finder
|
|
150
|
+
for (let i = 0; i <= 5; i++) m[8][i] = bits[i];
|
|
151
|
+
m[8][7] = bits[6];
|
|
152
|
+
m[8][8] = bits[7];
|
|
153
|
+
m[7][8] = bits[8];
|
|
154
|
+
for (let i = 9; i < 15; i++) m[14 - i][8] = bits[i];
|
|
155
|
+
// second copy: bottom-left (col 8, rows size-7..size-1) = bits 6..0; the
|
|
156
|
+
// dark module at (size-8, 8) is NOT part of the format string.
|
|
157
|
+
for (let i = 0; i < 7; i++) m[size - 1 - i][8] = bits[i];
|
|
158
|
+
m[8][size - 8] = bits[7];
|
|
159
|
+
// top-right (row 8, cols size-7..size-1) = bits 8..14 left→right
|
|
160
|
+
for (let i = 8; i < 15; i++) m[8][size - 15 + i] = bits[i];
|
|
161
|
+
// the dark module cell (size-8,8) is NOT part of format
|
|
162
|
+
m[size - 8][8] = true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function placeData(m: boolean[][], codewords: number[], version: number, mask: number): void {
|
|
166
|
+
const size = m.length;
|
|
167
|
+
const total = SIZES[version - 1] * SIZES[version - 1];
|
|
168
|
+
const bitAt = (i: number): boolean => {
|
|
169
|
+
const cw = codewords[i >> 3] ?? 0;
|
|
170
|
+
return ((cw >> (7 - (i & 7))) & 1) === 1;
|
|
171
|
+
};
|
|
172
|
+
let bitIndex = 0;
|
|
173
|
+
let upward = true;
|
|
174
|
+
for (let col = size - 1; col > 0; col -= 2) {
|
|
175
|
+
if (col === 6) col--; // skip timing column
|
|
176
|
+
for (let step = 0; step < size; step++) {
|
|
177
|
+
const row = upward ? size - 1 - step : step;
|
|
178
|
+
for (const c of [col, col - 1]) {
|
|
179
|
+
if (isFunctionModule(row, c, version)) continue;
|
|
180
|
+
if (bitIndex >= (DATA_CODEWORDS[version - 1] + EC_PER_BLOCK[version - 1]) * 8) {
|
|
181
|
+
// past data+ec bits → remainder modules (decoders ignore them)
|
|
182
|
+
bitIndex++;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
let bit = bitAt(bitIndex++);
|
|
186
|
+
if (maskModule(row, c, mask)) bit = !bit;
|
|
187
|
+
m[row][c] = bit;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
upward = !upward;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function maskModule(row: number, col: number, mask: number): boolean {
|
|
195
|
+
switch (mask) {
|
|
196
|
+
case 0:
|
|
197
|
+
return (row + col) % 2 === 0;
|
|
198
|
+
case 1:
|
|
199
|
+
return row % 2 === 0;
|
|
200
|
+
case 2:
|
|
201
|
+
return col % 3 === 0;
|
|
202
|
+
case 3:
|
|
203
|
+
return (row + col) % 3 === 0;
|
|
204
|
+
case 4:
|
|
205
|
+
return (Math.floor(row / 2) + Math.floor(col / 3)) % 2 === 0;
|
|
206
|
+
case 5:
|
|
207
|
+
return ((row * col) % 2) + ((row * col) % 3) === 0;
|
|
208
|
+
case 6:
|
|
209
|
+
return (((row * col) % 2) + ((row * col) % 3)) % 2 === 0;
|
|
210
|
+
default:
|
|
211
|
+
return (((row + col) % 2) + ((row * col) % 3)) % 2 === 0;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isFunctionModule(row: number, col: number, version: number): boolean {
|
|
216
|
+
const size = SIZES[version - 1];
|
|
217
|
+
// finders + separators
|
|
218
|
+
if (row <= 8 && col <= 8) return true;
|
|
219
|
+
if (row <= 8 && col >= size - 8) return true;
|
|
220
|
+
if (row >= size - 8 && col <= 8) return true;
|
|
221
|
+
// timing
|
|
222
|
+
if (row === 6 || col === 6) return true;
|
|
223
|
+
// alignment
|
|
224
|
+
const centers = ALIGN[version - 1];
|
|
225
|
+
for (const r of centers)
|
|
226
|
+
for (const c of centers) {
|
|
227
|
+
if ((r <= 8 && c <= 8) || (r <= 8 && c >= size - 9) || (r >= size - 9 && c <= 8)) continue;
|
|
228
|
+
if (Math.abs(row - r) <= 2 && Math.abs(col - c) <= 2) return true;
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// -- penalty scoring -------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
function penalty(m: boolean[][]): number {
|
|
236
|
+
const size = m.length;
|
|
237
|
+
let score = 0;
|
|
238
|
+
// N1: runs ≥5 same colour in rows/cols
|
|
239
|
+
for (let axis = 0; axis < 2; axis++)
|
|
240
|
+
for (let i = 0; i < size; i++) {
|
|
241
|
+
let run = 1;
|
|
242
|
+
for (let j = 1; j < size; j++) {
|
|
243
|
+
const cur = axis === 0 ? m[i][j] : m[j][i];
|
|
244
|
+
const prev = axis === 0 ? m[i][j - 1] : m[j - 1][i];
|
|
245
|
+
if (cur === prev) {
|
|
246
|
+
run++;
|
|
247
|
+
if (j === size - 1 && run >= 5) score += 3 + (run - 5);
|
|
248
|
+
} else {
|
|
249
|
+
if (run >= 5) score += 3 + (run - 5);
|
|
250
|
+
run = 1;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// N2: 2×2 blocks
|
|
255
|
+
for (let r = 0; r < size - 1; r++)
|
|
256
|
+
for (let c = 0; c < size - 1; c++) {
|
|
257
|
+
const v = m[r][c];
|
|
258
|
+
if (m[r][c + 1] === v && m[r + 1][c] === v && m[r + 1][c + 1] === v) score += 3;
|
|
259
|
+
}
|
|
260
|
+
// N3: finder-like 1:1:3:1:1 patterns (simplified: exact pattern with quiet edges)
|
|
261
|
+
const pat = [true, false, true, true, true, false, true, false, false, false, false];
|
|
262
|
+
const matches = (get: (i: number) => boolean, len: number) => {
|
|
263
|
+
for (let start = 0; start + 11 <= len; start++) {
|
|
264
|
+
let ok = true;
|
|
265
|
+
for (let k = 0; k < 11; k++)
|
|
266
|
+
if (get(start + k) !== pat[k]) {
|
|
267
|
+
ok = false;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
if (ok) score += 40;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
for (let r = 0; r < size; r++) matches((c) => m[r][c], size);
|
|
274
|
+
for (let c = 0; c < size; c++) matches((r) => m[r][c], size);
|
|
275
|
+
// N4: dark ratio
|
|
276
|
+
let dark = 0;
|
|
277
|
+
for (const row of m) for (const v of row) if (v) dark++;
|
|
278
|
+
const pct = (dark * 100) / (size * size);
|
|
279
|
+
score += Math.floor(Math.abs(pct - 50) / 5) * 10;
|
|
280
|
+
return score;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// -- public API -------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
function matrixWithMask(text: string, mask: number): boolean[][] {
|
|
286
|
+
const version = selectVersion(new TextEncoder().encode(text).length);
|
|
287
|
+
const size = SIZES[version - 1];
|
|
288
|
+
const data = buildCodewords(text, version);
|
|
289
|
+
const ec = rsEncode(data, EC_PER_BLOCK[version - 1]);
|
|
290
|
+
const all = [...data, ...ec];
|
|
291
|
+
const m = makeMatrix(size);
|
|
292
|
+
placeFunctionPatterns(m, version);
|
|
293
|
+
placeData(m, all, version, mask);
|
|
294
|
+
placeFormat(m, mask);
|
|
295
|
+
return m;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Encode text into a QR matrix (true = dark module), EC level L, versions 1–5. */
|
|
299
|
+
export function qrMatrix(text: string, maskOverride?: number): boolean[][] {
|
|
300
|
+
if (maskOverride !== undefined) return matrixWithMask(text, maskOverride);
|
|
301
|
+
let best: { m: boolean[][]; score: number } | undefined;
|
|
302
|
+
for (let mask = 0; mask < 8; mask++) {
|
|
303
|
+
const m = matrixWithMask(text, mask);
|
|
304
|
+
const score = penalty(m);
|
|
305
|
+
if (!best || score < best.score) best = { m, score };
|
|
306
|
+
}
|
|
307
|
+
return best!.m;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Render the matrix as an SVG string (quiet zone 4 modules, black on white). */
|
|
311
|
+
export function qrSvg(text: string, pixelSize = 160): string {
|
|
312
|
+
const m = qrMatrix(text);
|
|
313
|
+
const n = m.length;
|
|
314
|
+
const quiet = 4;
|
|
315
|
+
const total = n + quiet * 2;
|
|
316
|
+
const cell = pixelSize / total;
|
|
317
|
+
let rects = "";
|
|
318
|
+
for (let r = 0; r < n; r++)
|
|
319
|
+
for (let c = 0; c < n; c++)
|
|
320
|
+
if (m[r][c])
|
|
321
|
+
rects += `<rect x="${((c + quiet) * cell).toFixed(2)}" y="${((r + quiet) * cell).toFixed(2)}" width="${cell.toFixed(3)}" height="${cell.toFixed(3)}"/>`;
|
|
322
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${pixelSize} ${pixelSize}" shape-rendering="crispEdges"><rect width="${pixelSize}" height="${pixelSize}" fill="#fff"/><g fill="#000">${rects}</g></svg>`;
|
|
323
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// A tool's JSON Schema as a TypeScript function declaration, already split into highlight tokens.
|
|
2
|
+
// We generate the code, so we know every token's kind without a highlighter.
|
|
3
|
+
|
|
4
|
+
export type TokenKind =
|
|
5
|
+
| "keyword"
|
|
6
|
+
| "function"
|
|
7
|
+
| "property"
|
|
8
|
+
| "type"
|
|
9
|
+
| "string"
|
|
10
|
+
| "number"
|
|
11
|
+
| "comment"
|
|
12
|
+
| "punct"
|
|
13
|
+
| "text";
|
|
14
|
+
export interface Token {
|
|
15
|
+
text: string;
|
|
16
|
+
kind: TokenKind;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Schema {
|
|
20
|
+
type?: string | string[];
|
|
21
|
+
properties?: Record<string, Schema>;
|
|
22
|
+
required?: string[];
|
|
23
|
+
additionalProperties?: boolean | Schema;
|
|
24
|
+
items?: Schema;
|
|
25
|
+
anyOf?: Schema[];
|
|
26
|
+
oneOf?: Schema[];
|
|
27
|
+
enum?: unknown[];
|
|
28
|
+
const?: unknown;
|
|
29
|
+
description?: string;
|
|
30
|
+
default?: unknown;
|
|
31
|
+
minimum?: number;
|
|
32
|
+
maximum?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Emit = (text: string, kind?: TokenKind) => void;
|
|
36
|
+
const IDENT = /^[A-Za-z_$][\w$]*$/;
|
|
37
|
+
const indent = (depth: number) => " ".repeat(depth);
|
|
38
|
+
|
|
39
|
+
export function toolSignature(name: string, parameters: unknown): Token[] {
|
|
40
|
+
const out: Token[] = [];
|
|
41
|
+
const emit: Emit = (text, kind = "text") => out.push({ text, kind });
|
|
42
|
+
emit("function", "keyword");
|
|
43
|
+
emit(" ");
|
|
44
|
+
emit(name, "function");
|
|
45
|
+
emit("(", "punct");
|
|
46
|
+
emit("args");
|
|
47
|
+
emit(": ", "punct");
|
|
48
|
+
type(parameters as Schema, 0, emit);
|
|
49
|
+
emit("): ", "punct");
|
|
50
|
+
emit("Promise", "type");
|
|
51
|
+
emit("<", "punct");
|
|
52
|
+
emit("ToolResult", "type");
|
|
53
|
+
emit(">;", "punct");
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function type(s: Schema | undefined, depth: number, emit: Emit) {
|
|
58
|
+
if (!s || typeof s !== "object") return emit("unknown", "type");
|
|
59
|
+
if ("const" in s) return literal(s.const, emit);
|
|
60
|
+
if (s.enum)
|
|
61
|
+
return union(
|
|
62
|
+
s.enum.map((value) => ({ const: value })),
|
|
63
|
+
depth,
|
|
64
|
+
emit,
|
|
65
|
+
);
|
|
66
|
+
if (s.anyOf ?? s.oneOf) return union((s.anyOf ?? s.oneOf)!, depth, emit);
|
|
67
|
+
if (Array.isArray(s.type))
|
|
68
|
+
return union(
|
|
69
|
+
s.type.map((t) => ({ ...s, type: t })),
|
|
70
|
+
depth,
|
|
71
|
+
emit,
|
|
72
|
+
);
|
|
73
|
+
switch (s.type) {
|
|
74
|
+
case "string":
|
|
75
|
+
case "boolean":
|
|
76
|
+
case "null":
|
|
77
|
+
return emit(s.type, "type");
|
|
78
|
+
case "number":
|
|
79
|
+
case "integer":
|
|
80
|
+
return emit("number", "type");
|
|
81
|
+
case "array": {
|
|
82
|
+
const grouped = !!(s.items?.anyOf ?? s.items?.oneOf ?? s.items?.enum);
|
|
83
|
+
if (grouped) emit("(", "punct");
|
|
84
|
+
type(s.items, depth, emit);
|
|
85
|
+
if (grouped) emit(")", "punct");
|
|
86
|
+
return emit("[]", "punct");
|
|
87
|
+
}
|
|
88
|
+
case "object":
|
|
89
|
+
return object(s, depth, emit);
|
|
90
|
+
default:
|
|
91
|
+
return emit("unknown", "type");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function object(s: Schema, depth: number, emit: Emit) {
|
|
96
|
+
const props = Object.entries(s.properties ?? {});
|
|
97
|
+
if (!props.length) {
|
|
98
|
+
if (s.additionalProperties && typeof s.additionalProperties === "object") {
|
|
99
|
+
emit("Record", "type");
|
|
100
|
+
emit("<", "punct");
|
|
101
|
+
emit("string", "type");
|
|
102
|
+
emit(", ", "punct");
|
|
103
|
+
type(s.additionalProperties, depth, emit);
|
|
104
|
+
return emit(">", "punct");
|
|
105
|
+
}
|
|
106
|
+
return emit("{}", "punct");
|
|
107
|
+
}
|
|
108
|
+
const required = new Set(s.required ?? []);
|
|
109
|
+
emit("{", "punct");
|
|
110
|
+
emit("\n");
|
|
111
|
+
for (const [key, prop] of props) {
|
|
112
|
+
const doc = docComment(prop);
|
|
113
|
+
if (doc) emit(`${indent(depth + 1)}${doc}\n`, "comment");
|
|
114
|
+
emit(indent(depth + 1));
|
|
115
|
+
emit(IDENT.test(key) ? key : JSON.stringify(key), "property");
|
|
116
|
+
emit(required.has(key) ? ": " : "?: ", "punct");
|
|
117
|
+
type(prop, depth + 1, emit);
|
|
118
|
+
emit(";", "punct");
|
|
119
|
+
emit("\n");
|
|
120
|
+
}
|
|
121
|
+
emit(indent(depth));
|
|
122
|
+
emit("}", "punct");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function union(variants: Schema[], depth: number, emit: Emit) {
|
|
126
|
+
variants.forEach((variant, i) => {
|
|
127
|
+
if (i) emit(" | ", "punct");
|
|
128
|
+
type(variant, depth, emit);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function literal(value: unknown, emit: Emit) {
|
|
133
|
+
if (typeof value === "string") return emit(JSON.stringify(value), "string");
|
|
134
|
+
if (typeof value === "number") return emit(String(value), "number");
|
|
135
|
+
emit(String(value), "type");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function docComment(s: Schema) {
|
|
139
|
+
const parts = [
|
|
140
|
+
s.description,
|
|
141
|
+
s.minimum !== undefined ? `@minimum ${s.minimum}` : "",
|
|
142
|
+
s.maximum !== undefined ? `@maximum ${s.maximum}` : "",
|
|
143
|
+
s.default !== undefined ? `@default ${JSON.stringify(s.default)}` : "",
|
|
144
|
+
].filter(Boolean);
|
|
145
|
+
return parts.length ? `/** ${parts.join(" ")} */` : "";
|
|
146
|
+
}
|