@try-works/dsh-model-as-tool 0.1.1 → 0.1.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.
Files changed (79) hide show
  1. package/README.md +51 -0
  2. package/bin/dsh-tool-mode-mcp-stdio.mjs +80 -0
  3. package/catalog/desert-ant-models.json +77 -13
  4. package/lib/adapters/index.d.ts +19 -6
  5. package/lib/adapters/index.d.ts.map +1 -1
  6. package/lib/adapters/index.js +28 -13
  7. package/lib/adapters/tongue.d.ts +11 -3
  8. package/lib/adapters/tongue.d.ts.map +1 -1
  9. package/lib/adapters/tongue.js +12 -4
  10. package/lib/adapters/types.d.ts +11 -0
  11. package/lib/adapters/types.d.ts.map +1 -1
  12. package/lib/adapters/voz.d.ts +7 -1
  13. package/lib/adapters/voz.d.ts.map +1 -1
  14. package/lib/adapters/voz.js +16 -4
  15. package/lib/asset-acquisition.d.ts +137 -0
  16. package/lib/asset-acquisition.d.ts.map +1 -0
  17. package/lib/asset-acquisition.js +161 -0
  18. package/lib/authority.d.ts +153 -0
  19. package/lib/authority.d.ts.map +1 -0
  20. package/lib/authority.js +194 -0
  21. package/lib/bindings.d.ts +115 -0
  22. package/lib/bindings.d.ts.map +1 -0
  23. package/lib/bindings.js +239 -0
  24. package/lib/catalog.d.ts.map +1 -1
  25. package/lib/catalog.js +57 -0
  26. package/lib/contract-payload.d.ts +47 -0
  27. package/lib/contract-payload.d.ts.map +1 -0
  28. package/lib/contract-payload.js +137 -0
  29. package/lib/deployment-coordinator.d.ts +213 -0
  30. package/lib/deployment-coordinator.d.ts.map +1 -0
  31. package/lib/deployment-coordinator.js +274 -0
  32. package/lib/harness-budgets.d.ts +158 -0
  33. package/lib/harness-budgets.d.ts.map +1 -0
  34. package/lib/harness-budgets.js +152 -0
  35. package/lib/index.d.ts +41 -0
  36. package/lib/index.d.ts.map +1 -1
  37. package/lib/index.js +424 -16
  38. package/lib/licensing.d.ts +221 -0
  39. package/lib/licensing.d.ts.map +1 -0
  40. package/lib/licensing.js +188 -0
  41. package/lib/mcp/host-binding.d.ts +91 -0
  42. package/lib/mcp/host-binding.d.ts.map +1 -0
  43. package/lib/mcp/host-binding.js +133 -0
  44. package/lib/mcp/http.d.ts +130 -0
  45. package/lib/mcp/http.d.ts.map +1 -0
  46. package/lib/mcp/http.js +274 -0
  47. package/lib/mcp/protocol.d.ts +261 -0
  48. package/lib/mcp/protocol.d.ts.map +1 -0
  49. package/lib/mcp/protocol.js +387 -0
  50. package/lib/mcp/server.d.ts +115 -0
  51. package/lib/mcp/server.d.ts.map +1 -0
  52. package/lib/mcp/server.js +200 -0
  53. package/lib/mcp/stdio.d.ts +80 -0
  54. package/lib/mcp/stdio.d.ts.map +1 -0
  55. package/lib/mcp/stdio.js +176 -0
  56. package/lib/mcp/subscriptions.d.ts +135 -0
  57. package/lib/mcp/subscriptions.d.ts.map +1 -0
  58. package/lib/mcp/subscriptions.js +207 -0
  59. package/lib/provider-bridge.d.ts +69 -0
  60. package/lib/provider-bridge.d.ts.map +1 -0
  61. package/lib/provider-bridge.js +218 -0
  62. package/lib/publication.d.ts +160 -0
  63. package/lib/publication.d.ts.map +1 -0
  64. package/lib/publication.js +173 -0
  65. package/lib/receipt.d.ts +208 -0
  66. package/lib/receipt.d.ts.map +1 -0
  67. package/lib/receipt.js +150 -0
  68. package/lib/registrar.d.ts +13 -0
  69. package/lib/registrar.d.ts.map +1 -1
  70. package/lib/registrar.js +20 -3
  71. package/lib/resource-governance.d.ts +204 -0
  72. package/lib/resource-governance.d.ts.map +1 -0
  73. package/lib/resource-governance.js +213 -0
  74. package/lib/server-lifecycle.d.ts +175 -0
  75. package/lib/server-lifecycle.d.ts.map +1 -0
  76. package/lib/server-lifecycle.js +269 -0
  77. package/lib/types.d.ts +21 -0
  78. package/lib/types.d.ts.map +1 -1
  79. package/package.json +9 -2
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Caller authority: the grant a tool invocation runs under (SEC-03, SEC-06).
3
+ *
4
+ * The governing rule is BASE-05: an external request cannot supply or forge these
5
+ * authorities. Everything here is constructed by the HOST from operator
6
+ * configuration, never from a request body, a `clientInfo.name`, a requested tool
7
+ * list, or a path a caller supplied. A caller's arguments are then checked
8
+ * AGAINST the grant; they never expand it.
9
+ *
10
+ * Path handling is deliberately paranoid. SEC-06 requires resolving Windows drive
11
+ * letters, separators, Unicode, junctions and symlinks safely, rejecting escapes,
12
+ * unsupported UNC/device paths and unauthorized roots, and refusing when
13
+ * authorization cannot be established. The resolver therefore returns the
14
+ * CANONICAL path for the caller to open, rather than validating a string and
15
+ * letting a later open re-resolve it — that gap is the classic check-then-open
16
+ * race, and a symlink swapped between the two steps would win it.
17
+ *
18
+ * @module @try-works/dsh-model-as-tool/authority
19
+ */
20
+ import { realpathSync } from 'node:fs';
21
+ import { isAbsolute, sep } from 'node:path';
22
+ /** A refused invocation. Carries a recovery hint, not just a refusal. */
23
+ export class AuthorityError extends Error {
24
+ name = 'AuthorityError';
25
+ code;
26
+ /** Actionable next step for the caller or operator. */
27
+ hint;
28
+ constructor(code, message, hint) {
29
+ super(message);
30
+ this.code = code;
31
+ this.hint = hint;
32
+ }
33
+ }
34
+ /** Conservative defaults: a grant that declares nothing permits almost nothing. */
35
+ export const DEFAULT_LIMITS = {
36
+ maxInputBytes: 64 * 1024 * 1024,
37
+ maxAudioSeconds: 3600,
38
+ maxResultBytes: 8 * 1024 * 1024,
39
+ };
40
+ /** Whether this process is on a case-insensitive filesystem. */
41
+ const CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin';
42
+ /**
43
+ * Canonicalize a root for containment comparison.
44
+ *
45
+ * A root that does not exist is still admitted, because an operator may declare a
46
+ * root before creating it; it simply can never contain anything until it exists,
47
+ * which fails closed.
48
+ * @param root - the declared root.
49
+ * @returns the canonical root, or undefined when it cannot be resolved yet.
50
+ */
51
+ function canonicalizeRoot(root) {
52
+ if (!isAbsolute(root))
53
+ return undefined;
54
+ try {
55
+ return realpathSync.native(root);
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ /**
62
+ * Build a grant from operator configuration.
63
+ *
64
+ * This is the only constructor: a request cannot reach it, and an approved root
65
+ * that is not absolute or not resolvable is dropped rather than trusted.
66
+ * @param input - the operator's declaration.
67
+ * @returns the canonicalized grant.
68
+ */
69
+ export function createGrant(input) {
70
+ const roots = [];
71
+ for (const root of input.approvedRoots) {
72
+ const canonical = canonicalizeRoot(root);
73
+ // A root that cannot be canonicalized is dropped, not guessed at. Dropping
74
+ // fails closed; keeping an unresolved string would fail open.
75
+ if (canonical !== undefined)
76
+ roots.push(canonical);
77
+ }
78
+ return {
79
+ grantId: input.grantId,
80
+ allowedTools: [...input.allowedTools],
81
+ approvedRoots: roots,
82
+ remoteDestinations: [...(input.remoteDestinations ?? [])],
83
+ hostedCode: input.hostedCode ?? false,
84
+ recordingPolicy: input.recordingPolicy ?? 'none',
85
+ limits: { ...DEFAULT_LIMITS, ...input.limits },
86
+ };
87
+ }
88
+ /** Compare two paths for equality under this platform's case rules. */
89
+ function samePath(a, b) {
90
+ return CASE_INSENSITIVE ? a.toLowerCase() === b.toLowerCase() : a === b;
91
+ }
92
+ /** Whether `child` is `root` itself or lies beneath it, on a separator boundary. */
93
+ function isWithin(child, root) {
94
+ if (samePath(child, root))
95
+ return true;
96
+ const prefix = root.endsWith(sep) ? root : root + sep;
97
+ return CASE_INSENSITIVE
98
+ ? child.toLowerCase().startsWith(prefix.toLowerCase())
99
+ : child.startsWith(prefix);
100
+ }
101
+ /**
102
+ * Reject path shapes v1 does not admit, before any filesystem call.
103
+ * @param candidate - the caller-supplied path.
104
+ * @throws AuthorityError with `PATH_UNSUPPORTED` or `PATH_NOT_ABSOLUTE`.
105
+ */
106
+ function assertAdmissibleShape(candidate) {
107
+ // Windows device namespaces bypass normal path handling entirely, and a UNC
108
+ // path reaches another host that no approved root can describe.
109
+ if (candidate.startsWith('\\\\') || candidate.startsWith('//')) {
110
+ throw new AuthorityError('PATH_UNSUPPORTED', 'UNC and Windows device paths are not admitted', 'Use an absolute path on a local drive that lies under an approved root.');
111
+ }
112
+ if (candidate.includes('\0')) {
113
+ throw new AuthorityError('PATH_UNSUPPORTED', 'the path contains a NUL byte', 'Pass the path as a plain string without embedded control characters.');
114
+ }
115
+ if (!isAbsolute(candidate)) {
116
+ throw new AuthorityError('PATH_NOT_ABSOLUTE', 'relative paths are not admitted; v1 authorizes absolute paths only', 'Resolve the path to an absolute path under an approved root before calling.');
117
+ }
118
+ }
119
+ /**
120
+ * Authorize one caller-supplied path against a grant (SEC-06).
121
+ *
122
+ * Returns the CANONICAL path, which the caller must open. Validating the original
123
+ * string and opening it later would leave a window in which a symlink could be
124
+ * swapped; resolving once and returning the result closes it.
125
+ * @param grant - the caller's grant.
126
+ * @param candidate - the caller-supplied path.
127
+ * @returns the canonical, authorized path.
128
+ * @throws AuthorityError when the path is inadmissible, missing, or outside the grant.
129
+ */
130
+ export function authorizePath(grant, candidate) {
131
+ if (typeof candidate !== 'string' || candidate.length === 0) {
132
+ throw new AuthorityError('PATH_NOT_ABSOLUTE', 'a path is required', 'Supply an absolute path under an approved root.');
133
+ }
134
+ assertAdmissibleShape(candidate);
135
+ let canonical;
136
+ try {
137
+ // Resolves junctions and symlinks, so containment is checked against what the
138
+ // path actually IS rather than what it spells.
139
+ canonical = realpathSync.native(candidate);
140
+ }
141
+ catch {
142
+ throw new AuthorityError('PATH_NOT_FOUND', 'the path does not exist, so it cannot be authorized', 'Check the path, or ask the operator to add its root to the grant.');
143
+ }
144
+ for (const root of grant.approvedRoots) {
145
+ if (isWithin(canonical, root))
146
+ return canonical;
147
+ }
148
+ // The message names the grant, not the allowed roots: enumerating a caller's
149
+ // permitted directories would disclose the operator's configuration.
150
+ throw new AuthorityError('PATH_OUTSIDE_GRANT', 'the path resolves outside every root approved for this caller', 'Ask the operator to add the containing root to this grant.');
151
+ }
152
+ /**
153
+ * Authorize a tool name against a grant (SEC-03).
154
+ * @param grant - the caller's grant.
155
+ * @param toolName - the requested tool.
156
+ * @throws AuthorityError with `TOOL_NOT_GRANTED`.
157
+ */
158
+ export function authorizeTool(grant, toolName) {
159
+ if (!grant.allowedTools.includes(toolName)) {
160
+ throw new AuthorityError('TOOL_NOT_GRANTED', `tool "${toolName}" is not granted to this caller`, 'Request access from the operator; a requested tool list does not extend a grant.');
161
+ }
162
+ }
163
+ /**
164
+ * Authorize a remote destination against a grant (SEC-10).
165
+ *
166
+ * No silent local-to-remote fallback: if a destination was not explicitly
167
+ * approved, the call is refused rather than downgraded or upgraded.
168
+ * @param grant - the caller's grant.
169
+ * @param destination - the destination origin.
170
+ * @throws AuthorityError with `TOOL_NOT_GRANTED`.
171
+ */
172
+ export function authorizeDestination(grant, destination) {
173
+ if (!grant.remoteDestinations.includes(destination)) {
174
+ throw new AuthorityError('TOOL_NOT_GRANTED', `destination "${destination}" is not authorized for this caller`, 'A remote destination requires explicit authorization; there is no silent fallback.');
175
+ }
176
+ }
177
+ /**
178
+ * Build the invocation authority for one call.
179
+ *
180
+ * The result is constructed by the host; a request body has no path to it.
181
+ * @param grant - the caller's grant.
182
+ * @param callerKind - how the host classified the caller.
183
+ * @returns the authority handed to the implementation.
184
+ */
185
+ export function createInvocationAuthority(grant, callerKind) {
186
+ return {
187
+ grantId: grant.grantId,
188
+ callerKind,
189
+ limits: grant.limits,
190
+ recordingPolicy: grant.recordingPolicy,
191
+ resolvePath: candidate => authorizePath(grant, candidate),
192
+ };
193
+ }
194
+ //# sourceMappingURL=authority.js.map
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Contract-driven registration: one tool per contract, not one per model.
3
+ *
4
+ * This is the host half of BASE-02. The contract decides the tool's name, schema
5
+ * and description; the implementation behind it is host configuration. A second
6
+ * model implementing `stt.transcribe` therefore joins the existing
7
+ * `stt_transcribe` tool rather than appearing as a competing duplicate — which is
8
+ * the behaviour the previous per-model registrar could not express.
9
+ *
10
+ * The host also owns provenance (DECL-03): `meta` is stamped here, from host
11
+ * state, so an implementation cannot claim another package's identity.
12
+ *
13
+ * @module @try-works/dsh-model-as-tool/bindings
14
+ */
15
+ import { type ExecutionLocation, type ToolContract, type ToolMeta } from '@try-works/tool-mode-contracts';
16
+ import type { ModelCatalogEntry, ModelRoute } from './types.ts';
17
+ import { type InvocationReceipt } from './receipt.ts';
18
+ import { type InvocationAuthority, type ToolGrant } from './authority.ts';
19
+ import type { ModelAdapter } from './adapters/index.ts';
20
+ /** Host identity stamped into every result. */
21
+ export interface HostIdentity {
22
+ /** The host package name. */
23
+ readonly extension: string;
24
+ /** The host package version. */
25
+ readonly extensionVersion: string;
26
+ /** Activation generation, incremented when the tool surface changes. */
27
+ readonly activationGeneration: number;
28
+ /** Receipt id factory; injectable so tests are deterministic. */
29
+ readonly nextReceiptId: () => string;
30
+ /**
31
+ * The grant this tool surface runs under (SEC-03).
32
+ *
33
+ * Built by the host from operator configuration. A caller's request cannot
34
+ * reach it, which is what makes the authority trustworthy (BASE-05).
35
+ */
36
+ readonly grant: ToolGrant;
37
+ /** How the host classified this caller. */
38
+ readonly callerKind: InvocationAuthority['callerKind'];
39
+ /**
40
+ * Where an invocation receipt is delivered (EXT-35).
41
+ *
42
+ * Optional so a host without a receipt store still works; when present it sees one
43
+ * receipt per SUCCESSFUL call, which is what makes the provenance recoverable
44
+ * after the fact rather than only during the call.
45
+ */
46
+ readonly recordReceipt?: (receipt: InvocationReceipt) => void;
47
+ }
48
+ /** One contract with the implementation chosen to serve it. */
49
+ export interface ContractBinding {
50
+ /** The contract that defines the tool. */
51
+ readonly contract: ToolContract;
52
+ /** The catalog entry selected to execute it. */
53
+ readonly implementation: ModelCatalogEntry;
54
+ /** The adapter that invokes that model. */
55
+ readonly adapter: ModelAdapter;
56
+ /** The route the capability resolver proved, used for `executionLocation`. */
57
+ readonly route: ModelRoute;
58
+ /** Other runnable implementations of the same contract, for reporting. */
59
+ readonly alternatives: readonly string[];
60
+ }
61
+ /**
62
+ * Translate a resolver route into the caller-facing execution location (SPEC §6).
63
+ *
64
+ * "Local" is deliberately not one value: a bridge is another machine, and a
65
+ * caller reasoning about data boundaries needs to know which.
66
+ * @param route - the resolved route.
67
+ * @returns the declared execution location.
68
+ */
69
+ export declare function executionLocationOf(route: ModelRoute): ExecutionLocation;
70
+ /**
71
+ * Build the `meta` envelope for one execution.
72
+ *
73
+ * Every field comes from host state or the contract, never from the model's
74
+ * output, which is what makes the provenance trustworthy (DECL-03).
75
+ * @param binding - the contract and implementation that are executing.
76
+ * @param host - the host identity to stamp.
77
+ * @param warnings - non-fatal conditions observed during this call.
78
+ * @returns the envelope.
79
+ */
80
+ export declare function buildMeta(binding: Pick<ContractBinding, 'contract' | 'implementation' | 'route'>, host: HostIdentity, warnings?: readonly string[]): ToolMeta;
81
+ /**
82
+ * Build the EXT-35 receipt for one call.
83
+ *
84
+ * Separate from {@link buildMeta} because it answers a different question: the
85
+ * envelope says what the caller received, while the receipt says what RAN and from
86
+ * which bytes. The extra provenance — the extension package, its content digest,
87
+ * the model and recipe identity — is what makes a receipt usable after the fact,
88
+ * when the version alone no longer identifies the code.
89
+ * @param binding - the contract and the implementation chosen for it.
90
+ * @param host - host identity and the receipt factory.
91
+ * @param meta - the envelope already stamped, for the receipt identity.
92
+ * @returns the invocation receipt.
93
+ */
94
+ export declare function buildReceipt(binding: Pick<ContractBinding, 'contract' | 'implementation' | 'route'>, host: HostIdentity, meta: Pick<ToolMeta, 'receiptId'>, warnings?: readonly string[]): InvocationReceipt;
95
+ /**
96
+ * Register one contract as a single DSH tool.
97
+ *
98
+ * The tool body validates arguments against the contract, applies the cross-field
99
+ * rules the DSH DSL cannot express, invokes the selected implementation, then
100
+ * validates the RESULT before returning it. A violation surfaces as a tool
101
+ * failure rather than as malformed data reaching a caller (AT-11).
102
+ * @param ctx - the context whose tool registry receives the definition.
103
+ * @param binding - the contract and the implementation chosen for it.
104
+ * @param host - host identity to stamp into results.
105
+ * @returns the registered tool name and the registry disposer.
106
+ */
107
+ export declare function registerContractTool(ctx: {
108
+ tools: {
109
+ register(definition: unknown): () => void;
110
+ };
111
+ }, binding: ContractBinding, host: HostIdentity): {
112
+ toolName: string;
113
+ dispose: () => void;
114
+ };
115
+ //# sourceMappingURL=bindings.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bindings.d.ts","sourceRoot":"","sources":["../src/bindings.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,EAML,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,QAAQ,EACd,MAAM,gCAAgC,CAAA;AACvC,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE/D,OAAO,EAA0B,KAAK,iBAAiB,EAAE,MAAM,cAAc,CAAA;AAC7E,OAAO,EAGL,KAAK,mBAAmB,EACxB,KAAK,SAAS,EACf,MAAM,gBAAgB,CAAA;AACvB,OAAO,KAAK,EAAE,YAAY,EAAa,MAAM,qBAAqB,CAAA;AAElE,+CAA+C;AAC/C,MAAM,WAAW,YAAY;IAC3B,6BAA6B;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,gCAAgC;IAChC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IACjC,wEAAwE;IACxE,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAA;IACrC,iEAAiE;IACjE,QAAQ,CAAC,aAAa,EAAE,MAAM,MAAM,CAAA;IACpC;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAA;IACzB,2CAA2C;IAC3C,QAAQ,CAAC,UAAU,EAAE,mBAAmB,CAAC,YAAY,CAAC,CAAA;IACtD;;;;;;OAMG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,iBAAiB,KAAK,IAAI,CAAA;CAC9D;AAED,+DAA+D;AAC/D,MAAM,WAAW,eAAe;IAC9B,0CAA0C;IAC1C,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAA;IAC/B,gDAAgD;IAChD,QAAQ,CAAC,cAAc,EAAE,iBAAiB,CAAA;IAC1C,2CAA2C;IAC3C,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAA;IAC9B,8EAA8E;IAC9E,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAA;IAC1B,0EAA0E;IAC1E,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;CACzC;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,UAAU,GAAG,iBAAiB,CAaxE;AAED;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CACvB,OAAO,EAAE,IAAI,CAAC,eAAe,EAAE,UAAU,GAAG,gBAAgB,GAAG,OAAO,CAAC,EACvE,IAAI,EAAE,YAAY,EAClB,QAAQ,GAAE,SAAS,MAAM,EAAO,GAC/B,QAAQ,CAYV;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,IAAI,CAAC,eAAe,EAAE,UAAU,GAAG,gBAAgB,GAAG,OAAO,CAAC,EACvE,IAAI,EAAE,YAAY,EAClB,IAAI,EAAE,IAAI,CAAC,QAAQ,EAAE,WAAW,CAAC,EACjC,QAAQ,GAAE,SAAS,MAAM,EAAO,GAC/B,iBAAiB,CAoBnB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE;IAAE,KAAK,EAAE;QAAE,QAAQ,CAAC,UAAU,EAAE,OAAO,GAAG,MAAM,IAAI,CAAA;KAAE,CAAA;CAAE,EAC7D,OAAO,EAAE,eAAe,EACxB,IAAI,EAAE,YAAY,GACjB;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,CAoG3C"}
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Contract-driven registration: one tool per contract, not one per model.
3
+ *
4
+ * This is the host half of BASE-02. The contract decides the tool's name, schema
5
+ * and description; the implementation behind it is host configuration. A second
6
+ * model implementing `stt.transcribe` therefore joins the existing
7
+ * `stt_transcribe` tool rather than appearing as a competing duplicate — which is
8
+ * the behaviour the previous per-model registrar could not express.
9
+ *
10
+ * The host also owns provenance (DECL-03): `meta` is stamped here, from host
11
+ * state, so an implementation cannot claim another package's identity.
12
+ *
13
+ * @module @try-works/dsh-model-as-tool/bindings
14
+ */
15
+ import { defineTool } from '@deepseek-ai/dsh-tools';
16
+ import { assertValidContract, assertValidInput, assertValidOutput, crossFieldRules, toDshParameters, } from '@try-works/tool-mode-contracts';
17
+ import { toContractPayload, toNativeArgs } from "./contract-payload.js";
18
+ import { buildInvocationReceipt } from "./receipt.js";
19
+ import { authorizeTool, createInvocationAuthority, } from "./authority.js";
20
+ /**
21
+ * Translate a resolver route into the caller-facing execution location (SPEC §6).
22
+ *
23
+ * "Local" is deliberately not one value: a bridge is another machine, and a
24
+ * caller reasoning about data boundaries needs to know which.
25
+ * @param route - the resolved route.
26
+ * @returns the declared execution location.
27
+ */
28
+ export function executionLocationOf(route) {
29
+ switch (route) {
30
+ case 'npm-native':
31
+ case 'npm-onnxruntime':
32
+ return 'host';
33
+ case 'cli':
34
+ return 'host';
35
+ case 'remote':
36
+ return 'team-server';
37
+ /* v8 ignore next -- closed union; a new route must fail compilation here. */
38
+ default:
39
+ return 'host';
40
+ }
41
+ }
42
+ /**
43
+ * Build the `meta` envelope for one execution.
44
+ *
45
+ * Every field comes from host state or the contract, never from the model's
46
+ * output, which is what makes the provenance trustworthy (DECL-03).
47
+ * @param binding - the contract and implementation that are executing.
48
+ * @param host - the host identity to stamp.
49
+ * @param warnings - non-fatal conditions observed during this call.
50
+ * @returns the envelope.
51
+ */
52
+ export function buildMeta(binding, host, warnings = []) {
53
+ return {
54
+ contract: binding.contract.contract,
55
+ contractVersion: binding.contract.version,
56
+ implementationId: binding.implementation.id,
57
+ extension: host.extension,
58
+ extensionVersion: host.extensionVersion,
59
+ activationGeneration: host.activationGeneration,
60
+ executionLocation: executionLocationOf(binding.route),
61
+ receiptId: host.nextReceiptId(),
62
+ warnings: [...warnings],
63
+ };
64
+ }
65
+ /**
66
+ * Build the EXT-35 receipt for one call.
67
+ *
68
+ * Separate from {@link buildMeta} because it answers a different question: the
69
+ * envelope says what the caller received, while the receipt says what RAN and from
70
+ * which bytes. The extra provenance — the extension package, its content digest,
71
+ * the model and recipe identity — is what makes a receipt usable after the fact,
72
+ * when the version alone no longer identifies the code.
73
+ * @param binding - the contract and the implementation chosen for it.
74
+ * @param host - host identity and the receipt factory.
75
+ * @param meta - the envelope already stamped, for the receipt identity.
76
+ * @returns the invocation receipt.
77
+ */
78
+ export function buildReceipt(binding, host, meta, warnings = []) {
79
+ return buildInvocationReceipt({
80
+ receiptId: meta.receiptId,
81
+ contract: binding.contract.contract,
82
+ contractVersion: binding.contract.version,
83
+ implementationId: binding.implementation.id,
84
+ extension: binding.implementation.provider?.extension
85
+ ?? host.extension,
86
+ extensionVersion: host.extensionVersion,
87
+ // The catalog carries no digest, so it is reported as unknown rather than
88
+ // invented. A receipt that guesses at provenance is worse than one that admits
89
+ // it does not have it.
90
+ extensionContentDigest: null,
91
+ dependencyLockDigest: null,
92
+ modelIdentity: binding.implementation.provider?.modelId ?? binding.implementation.id,
93
+ recipeVersion: null,
94
+ activationGeneration: host.activationGeneration,
95
+ executionLocation: executionLocationOf(binding.route),
96
+ warnings,
97
+ });
98
+ }
99
+ /**
100
+ * Register one contract as a single DSH tool.
101
+ *
102
+ * The tool body validates arguments against the contract, applies the cross-field
103
+ * rules the DSH DSL cannot express, invokes the selected implementation, then
104
+ * validates the RESULT before returning it. A violation surfaces as a tool
105
+ * failure rather than as malformed data reaching a caller (AT-11).
106
+ * @param ctx - the context whose tool registry receives the definition.
107
+ * @param binding - the contract and the implementation chosen for it.
108
+ * @param host - host identity to stamp into results.
109
+ * @returns the registered tool name and the registry disposer.
110
+ */
111
+ export function registerContractTool(ctx, binding, host) {
112
+ const { contract, adapter, implementation } = binding;
113
+ // Registration and argument validation must agree, so the contract is checked
114
+ // here rather than trusted: a definition that cannot compile would otherwise
115
+ // fail only when a caller happened to hit the broken branch.
116
+ assertValidContract(contract);
117
+ const rules = crossFieldRules(contract);
118
+ const definition = defineTool({
119
+ name: contract.toolName,
120
+ description: contract.description,
121
+ // `toDshParameters` returns the projected JSON-Schema subset; the registry's
122
+ // DSL type is structurally narrower, and `ProjectionError` has already
123
+ // refused any keyword the DSL cannot express.
124
+ parameters: toDshParameters(contract),
125
+ output: {
126
+ /**
127
+ * An open object schema, and DECL-02 names this as INADEQUATE.
128
+ *
129
+ * The requirement says in as many words that an unconstrained
130
+ * `{type: "object", additionalProperties: true}` is not an adequate implementation
131
+ * of an ordinary leaf tool's contract. It is right, and this repository cannot yet
132
+ * do better: the contract's real output schema CANNOT be projected into DSH's
133
+ * value-schema DSL. Attempting it produced four distinct rejections at
134
+ * registration, recorded here because the next person will try the same thing:
135
+ *
136
+ * 1. `schema.required is not supported by the value schema DSL`
137
+ * 2. `schema.properties.candidates.items.required is not supported` - so required
138
+ * is unsupported at EVERY depth, not merely at the root
139
+ * 3. `schema.properties.words.items is not supported` - `items` is in the
140
+ * documented supported set but its support does not extend to an array nested
141
+ * inside an object, which is what every output schema has
142
+ * 4. `schema.properties.reliability.enum must be a non-empty array of scalar
143
+ * values` - and separate attempts to express the union type as `oneOf` were
144
+ * rejected too, with `oneOf must be an array of at least two value schemas`
145
+ *
146
+ * Stripping each keyword in turn does converge, but it converges on a schema that
147
+ * advertises almost nothing while APPEARING to be a real declaration - which is
148
+ * worse than this open object, because a client would trust it. The contract
149
+ * itself is unchanged and complete: `assertValidOutput` enforces the real schema at
150
+ * runtime against the untouched contract, so results ARE validated. What is
151
+ * missing is the ADVERTISEMENT.
152
+ */
153
+ schema: { type: 'object', additionalProperties: true },
154
+ render: (_args, value) => {
155
+ const record = value;
156
+ return [{ type: 'text', text: renderForModel(contract, record) }];
157
+ },
158
+ },
159
+ async execute(args, exec) {
160
+ const record = args;
161
+ assertValidInput(contract, record);
162
+ for (const rule of rules) {
163
+ const violation = rule(record);
164
+ if (violation !== undefined) {
165
+ throw new Error(`${contract.toolName}: ${violation}`);
166
+ }
167
+ }
168
+ // SEC-03: the grant is consulted before any work happens, so an ungranted
169
+ // tool is refused before inference rather than after it.
170
+ authorizeTool(host.grant, contract.toolName);
171
+ const authority = createInvocationAuthority(host.grant, host.callerKind);
172
+ // The contract's caller-facing arguments are translated into the adapter's
173
+ // native vocabulary before invocation; the reverse translation runs on the
174
+ // result. Both are explicit, so a name change on either side is a compile-
175
+ // or test-time failure rather than a runtime mystery.
176
+ const nativeArgs = toNativeArgs(contract.contract, record);
177
+ const native = await adapter.invoke({ args: nativeArgs, signal: exec.signal, authority });
178
+ // The implementation's native result is translated into the contract's
179
+ // payload before validation: the contract is the authority, so a mismatch is
180
+ // resolved here rather than by loosening the schema.
181
+ const payload = toContractPayload(contract.contract, native, record);
182
+ // `ToolMeta` is a plain interface without an index signature, so it is not
183
+ // structurally a `JsonValue`; the value is losslessly JSON either way, and
184
+ // `assertValidOutput` is the real check.
185
+ const meta = buildMeta(binding, host);
186
+ const value = { ...payload, meta };
187
+ assertValidOutput(contract, value);
188
+ // EXT-35: the receipt is emitted once the call SUCCEEDED. A receipt for a
189
+ // call that failed validation would claim provenance for a result nobody
190
+ // received, so the sink sees only calls that actually completed.
191
+ host.recordReceipt?.(buildReceipt(binding, host, meta));
192
+ return value;
193
+ },
194
+ presentCall(callArgs) {
195
+ const primary = Object.values(callArgs)[0];
196
+ return {
197
+ card: 'generic',
198
+ title: contract.toolName,
199
+ kind: 'read',
200
+ ...typeof primary === 'string' ? { rawInput: primary } : {},
201
+ };
202
+ },
203
+ });
204
+ void implementation;
205
+ return { toolName: contract.toolName, dispose: ctx.tools.register(definition) };
206
+ }
207
+ /**
208
+ * Render a contract result for the model.
209
+ *
210
+ * The canonical value is the source of truth; this only chooses the presentation.
211
+ * It never invents a field the contract did not declare.
212
+ * @param contract - the contract that produced the value.
213
+ * @param value - the validated canonical value.
214
+ * @returns a short text projection.
215
+ */
216
+ function renderForModel(contract, value) {
217
+ if (contract.contract === 'text.language_detect') {
218
+ const language = value.language;
219
+ const candidates = Array.isArray(value.candidates) ? value.candidates : [];
220
+ const detail = candidates
221
+ .filter((candidate) => typeof candidate === 'object' && candidate !== null
222
+ && typeof candidate.language === 'string')
223
+ .map(candidate => `${candidate.language} ${(candidate.probability * 100).toFixed(1)}%`)
224
+ .join(', ');
225
+ const tooClose = value.tooCloseToCall === true ? ' (too close to call)' : '';
226
+ return `Detected language: ${String(language)}${tooClose}`
227
+ + (detail.length > 0 ? `\nCandidates: ${detail}` : '');
228
+ }
229
+ if (contract.contract === 'stt.transcribe') {
230
+ const text = typeof value.text === 'string' ? value.text : '';
231
+ const coverage = value.coverage;
232
+ const partial = coverage?.complete === false
233
+ ? `\n[warnings] partial transcript: ${String(coverage.transcribedSeconds ?? 0)}s covered\n`
234
+ : '';
235
+ return text.length > 0 ? `${text}${partial}` : `(no speech recognized)${partial}`;
236
+ }
237
+ return JSON.stringify(value, null, 2);
238
+ }
239
+ //# sourceMappingURL=bindings.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,iBAAiB,EAA0D,MAAM,YAAY,CAAA;AAE3G,uEAAuE;AACvE,eAAO,MAAM,oBAAoB,QAEhC,CAAA;AAWD,8EAA8E;AAC9E,qBAAa,YAAa,SAAQ,KAAK;IACrC,SAAkB,IAAI,kBAAiB;CACxC;AA8CD;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,iBAAiB,CAqGhG;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,IAAI,GAAE,MAA6B,GAAG,iBAAiB,EAAE,CA6BpF;AAED,mCAAmC;AACnC,wBAAgB,YAAY,CAAC,OAAO,EAAE,SAAS,iBAAiB,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAElG"}
1
+ {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,iBAAiB,EAA0D,MAAM,YAAY,CAAA;AAE3G,uEAAuE;AACvE,eAAO,MAAM,oBAAoB,QAEhC,CAAA;AAWD,8EAA8E;AAC9E,qBAAa,YAAa,SAAQ,KAAK;IACrC,SAAkB,IAAI,kBAAiB;CACxC;AAyGD;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,iBAAiB,CAuGhG;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,IAAI,GAAE,MAA6B,GAAG,iBAAiB,EAAE,CA6BpF;AAED,mCAAmC;AACnC,wBAAgB,YAAY,CAAC,OAAO,EAAE,SAAS,iBAAiB,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAElG"}
package/lib/catalog.js CHANGED
@@ -62,6 +62,61 @@ function parseParam(raw, scope, key) {
62
62
  }
63
63
  throw new CatalogError(`${scope} tool.params.${key} must be a description string or a parameter object`);
64
64
  }
65
+ /**
66
+ * Validate the optional `contract` binding.
67
+ *
68
+ * A model without a contract is legitimate — it keeps a per-model tool — but a
69
+ * malformed contract identity would bind a tool to a name no caller can address,
70
+ * so it fails at load rather than at registration.
71
+ * @param value - the raw field.
72
+ * @param scope - entry description for error messages.
73
+ * @param source - document description for error messages.
74
+ * @returns the contract id, or null when unbound.
75
+ */
76
+ /**
77
+ * Parse a model's extension provider binding (BASE-09).
78
+ *
79
+ * The binding names the package that implements the model. It is validated here
80
+ * rather than trusted, because it decides which package's code the host will
81
+ * import: a malformed binding must fail the catalog load rather than surface later
82
+ * as an import of something unintended.
83
+ * @param raw - the raw `provider` field.
84
+ * @param source - the catalog source, for error messages.
85
+ * @returns the binding field, or an empty object when absent.
86
+ */
87
+ function parseProvider(raw, source) {
88
+ if (raw === undefined || raw === null)
89
+ return {};
90
+ const value = raw;
91
+ const extension = value.extension;
92
+ const modelId = value.modelId;
93
+ if (typeof extension !== 'string' || extension.length === 0) {
94
+ throw new Error(`${source}: provider.extension must be a non-empty package name`);
95
+ }
96
+ if (typeof modelId !== 'string' || modelId.length === 0) {
97
+ throw new Error(`${source}: provider.modelId must be a non-empty string`);
98
+ }
99
+ // A relative path is admitted so a first-party package can be exercised from a
100
+ // checkout; anything else must look like a package specifier.
101
+ const packageLike = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(extension);
102
+ const relativeLike = extension.startsWith('./') || extension.startsWith('../');
103
+ if (!packageLike && !relativeLike) {
104
+ throw new Error(`${source}: provider.extension ${JSON.stringify(extension)} is neither a package specifier nor a relative path`);
105
+ }
106
+ return { provider: { extension, modelId } };
107
+ }
108
+ function parseContract(value, scope, source) {
109
+ if (value === null || value === undefined)
110
+ return null;
111
+ if (typeof value !== 'string') {
112
+ throw new CatalogError(`${source}: ${scope} field "contract" must be a contract id or null`);
113
+ }
114
+ if (!/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(value)) {
115
+ throw new CatalogError(`${source}: ${scope} field "contract" ${JSON.stringify(value)} must be a dotted lower-case `
116
+ + 'contract identity (for example "stt.transcribe")');
117
+ }
118
+ return value;
119
+ }
65
120
  /**
66
121
  * Parse and validate one catalog entry. Validation is strict on purpose: every
67
122
  * downstream decision (which tool to register, whether it can run, what the
@@ -139,6 +194,8 @@ export function parseCatalogEntry(raw, source, index) {
139
194
  name: requireString(entry, 'name', source),
140
195
  summary: requireString(entry, 'summary', source),
141
196
  capability: requireString(entry, 'capability', source),
197
+ contract: parseContract(entry.contract, scope, source),
198
+ ...parseProvider(entry.provider, source),
142
199
  input: input,
143
200
  output: requireString(entry, 'output', source),
144
201
  languages: nullableNumber('languages'),