scoutline 0.2.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +240 -18
  2. package/dist/capabilities/diagnostics.d.ts +70 -32
  3. package/dist/capabilities/diagnostics.d.ts.map +1 -1
  4. package/dist/capabilities/diagnostics.js +97 -46
  5. package/dist/capabilities/diagnostics.js.map +1 -1
  6. package/dist/capabilities/reader.d.ts +227 -0
  7. package/dist/capabilities/reader.d.ts.map +1 -0
  8. package/dist/capabilities/reader.js +100 -0
  9. package/dist/capabilities/reader.js.map +1 -0
  10. package/dist/capabilities/repository.d.ts +221 -0
  11. package/dist/capabilities/repository.d.ts.map +1 -0
  12. package/dist/capabilities/repository.js +172 -0
  13. package/dist/capabilities/repository.js.map +1 -0
  14. package/dist/commands/cache.d.ts +106 -0
  15. package/dist/commands/cache.d.ts.map +1 -0
  16. package/dist/commands/cache.js +203 -0
  17. package/dist/commands/cache.js.map +1 -0
  18. package/dist/commands/doctor.d.ts +17 -6
  19. package/dist/commands/doctor.d.ts.map +1 -1
  20. package/dist/commands/doctor.js +42 -17
  21. package/dist/commands/doctor.js.map +1 -1
  22. package/dist/commands/read.d.ts +74 -14
  23. package/dist/commands/read.d.ts.map +1 -1
  24. package/dist/commands/read.js +257 -117
  25. package/dist/commands/read.js.map +1 -1
  26. package/dist/commands/repo.d.ts +53 -7
  27. package/dist/commands/repo.d.ts.map +1 -1
  28. package/dist/commands/repo.js +104 -123
  29. package/dist/commands/repo.js.map +1 -1
  30. package/dist/commands/repository-explorer.d.ts +147 -0
  31. package/dist/commands/repository-explorer.d.ts.map +1 -0
  32. package/dist/commands/repository-explorer.js +550 -0
  33. package/dist/commands/repository-explorer.js.map +1 -0
  34. package/dist/index.d.ts +20 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +209 -34
  37. package/dist/index.js.map +1 -1
  38. package/dist/lib/cache.d.ts +123 -18
  39. package/dist/lib/cache.d.ts.map +1 -1
  40. package/dist/lib/cache.js +324 -49
  41. package/dist/lib/cache.js.map +1 -1
  42. package/dist/lib/errors.d.ts +24 -1
  43. package/dist/lib/errors.d.ts.map +1 -1
  44. package/dist/lib/errors.js +33 -2
  45. package/dist/lib/errors.js.map +1 -1
  46. package/dist/lib/execution.d.ts +119 -5
  47. package/dist/lib/execution.d.ts.map +1 -1
  48. package/dist/lib/execution.js +216 -10
  49. package/dist/lib/execution.js.map +1 -1
  50. package/dist/lib/index.d.ts +1 -1
  51. package/dist/lib/index.d.ts.map +1 -1
  52. package/dist/lib/index.js +1 -1
  53. package/dist/lib/index.js.map +1 -1
  54. package/dist/lib/mcp-client.d.ts +29 -5
  55. package/dist/lib/mcp-client.d.ts.map +1 -1
  56. package/dist/lib/mcp-client.js +88 -88
  57. package/dist/lib/mcp-client.js.map +1 -1
  58. package/dist/lib/tool-cache.d.ts +86 -0
  59. package/dist/lib/tool-cache.d.ts.map +1 -0
  60. package/dist/lib/tool-cache.js +123 -0
  61. package/dist/lib/tool-cache.js.map +1 -0
  62. package/dist/providers/minimax/adapter.d.ts +6 -4
  63. package/dist/providers/minimax/adapter.d.ts.map +1 -1
  64. package/dist/providers/minimax/adapter.js +64 -57
  65. package/dist/providers/minimax/adapter.js.map +1 -1
  66. package/dist/providers/minimax/coding-plan-client.d.ts +60 -0
  67. package/dist/providers/minimax/coding-plan-client.d.ts.map +1 -0
  68. package/dist/providers/minimax/coding-plan-client.js +204 -0
  69. package/dist/providers/minimax/coding-plan-client.js.map +1 -0
  70. package/dist/providers/minimax/media.d.ts +60 -6
  71. package/dist/providers/minimax/media.d.ts.map +1 -1
  72. package/dist/providers/minimax/media.js +147 -7
  73. package/dist/providers/minimax/media.js.map +1 -1
  74. package/dist/providers/minimax/quota-client.d.ts +13 -6
  75. package/dist/providers/minimax/quota-client.d.ts.map +1 -1
  76. package/dist/providers/minimax/quota-client.js +5 -0
  77. package/dist/providers/minimax/quota-client.js.map +1 -1
  78. package/dist/providers/minimax/vision-attestations.d.ts +23 -0
  79. package/dist/providers/minimax/vision-attestations.d.ts.map +1 -1
  80. package/dist/providers/minimax/vision-attestations.js +35 -10
  81. package/dist/providers/minimax/vision-attestations.js.map +1 -1
  82. package/dist/providers/minimax/vision-conformance.d.ts +8 -6
  83. package/dist/providers/minimax/vision-conformance.d.ts.map +1 -1
  84. package/dist/providers/minimax/vision-conformance.js +8 -6
  85. package/dist/providers/minimax/vision-conformance.js.map +1 -1
  86. package/dist/providers/minimax/vision-revisions.d.ts +8 -1
  87. package/dist/providers/minimax/vision-revisions.d.ts.map +1 -1
  88. package/dist/providers/minimax/vision-revisions.js +13 -6
  89. package/dist/providers/minimax/vision-revisions.js.map +1 -1
  90. package/dist/providers/selection.d.ts +3 -3
  91. package/dist/providers/selection.js +3 -3
  92. package/dist/providers/types.d.ts +66 -32
  93. package/dist/providers/types.d.ts.map +1 -1
  94. package/dist/providers/types.js.map +1 -1
  95. package/dist/providers/zai/adapter.d.ts.map +1 -1
  96. package/dist/providers/zai/adapter.js +71 -5
  97. package/dist/providers/zai/adapter.js.map +1 -1
  98. package/dist/providers/zai/encoded-error.d.ts +90 -0
  99. package/dist/providers/zai/encoded-error.d.ts.map +1 -0
  100. package/dist/providers/zai/encoded-error.js +169 -0
  101. package/dist/providers/zai/encoded-error.js.map +1 -0
  102. package/dist/providers/zai/reader.d.ts +82 -0
  103. package/dist/providers/zai/reader.d.ts.map +1 -0
  104. package/dist/providers/zai/reader.js +490 -0
  105. package/dist/providers/zai/reader.js.map +1 -0
  106. package/dist/providers/zai/repository.d.ts +76 -0
  107. package/dist/providers/zai/repository.d.ts.map +1 -0
  108. package/dist/providers/zai/repository.js +715 -0
  109. package/dist/providers/zai/repository.js.map +1 -0
  110. package/package.json +3 -3
@@ -0,0 +1,715 @@
1
+ /**
2
+ * Z.AI Repository Adapter (DESIGN.md §18, PRD FR-081, FR-087,
3
+ * FR-089–FR-091, FR-093; NFR-004, NFR-006).
4
+ *
5
+ * Owns the Provider-facing half of the provider-neutral Repository
6
+ * Capability defined in `src/capabilities/repository.ts`:
7
+ *
8
+ * - total parsers for the characterized ZRead Search, File, and
9
+ * Directory Listing responses (`<excerpt>`, `<file_content>`,
10
+ * `<structure>`);
11
+ * - per-operation Z.AI descriptors with `validate`, `cacheIdentity`,
12
+ * `decodeCached`, and `invoke`;
13
+ * - a single resolved-credential fingerprint per cache identity and
14
+ * exact per-operation legacy cache candidates/decoders;
15
+ * - encoded MCP error classification BEFORE success parsing
16
+ * (exhausted quota is terminal `QUOTA_ERROR`; the rest of the
17
+ * taxonomy uses the shared retry/terminal classification);
18
+ * - a fresh transport per invocation attempt and exactly one
19
+ * best-effort close in `finally`; close failure never replaces
20
+ * success nor masks the primary failure;
21
+ * - no leakage of raw ZRead response types outside this module.
22
+ *
23
+ * Boundary rules (ARCHITECTURE.md §2):
24
+ * - May import capability types, normalized errors, the Z.AI MCP
25
+ * tool-name helpers, the legacy cache-key helper, and the
26
+ * shared `ZaiAdapterClientPort` typed client port.
27
+ * - Must NOT import another Provider's Adapter, command
28
+ * presentation, BFS/Explorer logic, or path canonicalization.
29
+ *
30
+ * Scope:
31
+ * - implements Search, File, and Directory Listing only. Tree
32
+ * projection, BFS, depth/path policy, and `--max-chars` all
33
+ * belong to the Explorer layer (P6-05+) or the command layer.
34
+ * - descriptor metadata sequencing: P6-04 introduced this Adapter
35
+ * handle and wired it through `ProviderAdapter.repository`
36
+ * WITHOUT advertising `repository-exploration` on
37
+ * `createZaiDescriptor.capabilities()`; P6-06 then flipped the
38
+ * descriptor to advertise `repository-exploration` so Provider
39
+ * selection and Doctor inventory derive from a single source of
40
+ * truth. This module itself still owns no registry, selection,
41
+ * or command cutover — `commands/repo.ts` continues to dispatch
42
+ * through the legacy `ZReadMcpClient` until P6-07 exclusively
43
+ * owns that dispatch cutover.
44
+ */
45
+ import crypto from "node:crypto";
46
+ import { ApiError, ScoutlineError, ValidationError } from "../../lib/errors.js";
47
+ import { getMcpToolName } from "../../lib/mcp-config.js";
48
+ import { buildLegacyRepositoryCacheKey } from "../../lib/cache.js";
49
+ import { requireZaiApiKey } from "./credentials.js";
50
+ import { classifyEncodedMcpError, looksLikeEncodedMcpError } from "./encoded-error.js";
51
+ import { decodeRepositoryDirectoryListing, decodeRepositoryFile, decodeRepositorySearch, } from "../../capabilities/repository.js";
52
+ /**
53
+ * Production close bound (ms). Matches the existing
54
+ * `ZaiMcpClient.close(timeoutMs = 2000)` semantic; the Adapter races
55
+ * the close against a 2 second timer that resolves silently so a stuck
56
+ * close cannot stall the attempt. Tests may inject a shorter bound via
57
+ * {@link ZaiRepositoryCapabilityOptions.closeTimeoutMs}.
58
+ */
59
+ export const ZAI_REPOSITORY_CLOSE_BOUND_MS = 2000;
60
+ // ---------------------------------------------------------------------------
61
+ // Public dotted MCP tool names — the Adapter invokes through these so the
62
+ // `ZaiMcpClient.callToolRaw` path resolves the discovered internal identity
63
+ // on a miss, exactly as it does for Search/Vision/Reader.
64
+ // ---------------------------------------------------------------------------
65
+ const SEARCH_TOOL_PUBLIC_NAME = getMcpToolName("zread", "search_doc");
66
+ const FILE_TOOL_PUBLIC_NAME = getMcpToolName("zread", "read_file");
67
+ const DIRECTORY_TOOL_PUBLIC_NAME = getMcpToolName("zread", "get_repo_structure");
68
+ // ---------------------------------------------------------------------------
69
+ // Operation label passed to the shared encoded-error classifier. The label
70
+ // is the only operation-specific input — it becomes part of the sanitized
71
+ // outward ApiError / QuotaError message ("Z.AI repository request failed",
72
+ // "Z.AI repository quota has been exhausted"). Auth messages (401, 403) do
73
+ // not carry the label.
74
+ // ---------------------------------------------------------------------------
75
+ const ENCODED_ERROR_LABEL = "repository";
76
+ // ---------------------------------------------------------------------------
77
+ // Encoded MCP error envelope (DESIGN.md §18, ZRead response
78
+ // characterization). The Adapter recognises the string BEFORE parsing
79
+ // any success grammar. Raw Provider body, message, and help fields are
80
+ // discarded; outward messages are sanitized.
81
+ //
82
+ // The classification logic (status extraction, code extraction, phrase
83
+ // matching, status mapping) is centralized in
84
+ // `src/providers/zai/encoded-error.ts` so the Reader Adapter (Ticket 03)
85
+ // can reuse it without duplication. Each Adapter supplies an operation
86
+ // label that becomes part of the sanitized outward message.
87
+ // ---------------------------------------------------------------------------
88
+ // ---------------------------------------------------------------------------
89
+ // Total parsers for characterized ZRead responses (DESIGN.md §18, ZRead
90
+ // response characterization). Each parser is total over its expected
91
+ // shape: malformed responses throw a sanitized retryable ApiError 502.
92
+ //
93
+ // The parsers also accept a `request` so the normalized result can carry
94
+ // the validated request fields (`repository`, `query`, `language`, `path`).
95
+ // ---------------------------------------------------------------------------
96
+ /**
97
+ * Parse a ZRead Search response into a normalized
98
+ * `RepositorySearchResult`. The grammar is balanced `<excerpt>` framing
99
+ * with at least one well-formed top-level block; arbitrary inner
100
+ * Markdown, code, and HTML-like markup is preserved verbatim inside
101
+ * each excerpt text.
102
+ *
103
+ * Malformed responses (no wrapper, unbalanced tags, non-string return)
104
+ * throw `ApiError` with status 502 — the outward retryable envelope.
105
+ *
106
+ * Framing hardening (P6-04A): equal tag counts cannot admit nested,
107
+ * reversed, or stray `<excerpt>` framing. The parser walks tokens in
108
+ * source order with depth tracking so it can reject:
109
+ * - nested openings (`<excerpt>...<excerpt>...</excerpt>...</excerpt>`);
110
+ * - reversed/reversed openings (`</excerpt>...<excerpt>...</excerpt>`);
111
+ * - stray closings (`<excerpt>...</excerpt></excerpt>`).
112
+ * Inner non-framing text between an open and its matching close is
113
+ * preserved verbatim; Provider order is preserved.
114
+ */
115
+ function parseZaiSearch(raw, request) {
116
+ if (typeof raw !== "string") {
117
+ throw new ApiError("Z.AI search returned a malformed response", 502);
118
+ }
119
+ if (looksLikeEncodedMcpError(raw)) {
120
+ throw classifyEncodedMcpError(raw, ENCODED_ERROR_LABEL);
121
+ }
122
+ const openTag = "<excerpt>";
123
+ const closeTag = "</excerpt>";
124
+ const excerpts = [];
125
+ let depth = 0;
126
+ let pos = 0;
127
+ let sawOpening = false;
128
+ let originalTextLength = 0;
129
+ while (pos < raw.length) {
130
+ const nextOpen = raw.indexOf(openTag, pos);
131
+ const nextClose = raw.indexOf(closeTag, pos);
132
+ if (nextOpen === -1 && nextClose === -1)
133
+ break;
134
+ if (nextOpen !== -1 && (nextClose === -1 || nextOpen < nextClose)) {
135
+ // Next token is an opening tag. Reject if depth != 0 (nested or
136
+ // reversed framing).
137
+ if (depth !== 0) {
138
+ throw new ApiError("Z.AI search returned a malformed response", 502);
139
+ }
140
+ depth = 1;
141
+ sawOpening = true;
142
+ pos = nextOpen + openTag.length;
143
+ }
144
+ else {
145
+ // Next token is a closing tag. Reject if depth != 1 (stray
146
+ // closing before any open, or after a previous close).
147
+ if (depth !== 1) {
148
+ throw new ApiError("Z.AI search returned a malformed response", 502);
149
+ }
150
+ const innerText = raw.slice(pos, nextClose);
151
+ excerpts.push({ text: innerText });
152
+ originalTextLength += innerText.length;
153
+ depth = 0;
154
+ pos = nextClose + closeTag.length;
155
+ }
156
+ }
157
+ if (depth !== 0 || !sawOpening) {
158
+ throw new ApiError("Z.AI search returned a malformed response", 502);
159
+ }
160
+ return {
161
+ schemaVersion: 1,
162
+ repository: request.repository,
163
+ query: request.query,
164
+ language: request.language,
165
+ excerpts,
166
+ truncated: false,
167
+ originalTextLength,
168
+ };
169
+ }
170
+ /**
171
+ * Count non-overlapping occurrences of a literal substring. Used by
172
+ * the File and Directory parsers to enforce exactly one outer
173
+ * Provider wrapper (P6-04B): duplicate or nested `<file_content>` /
174
+ * `<structure>` framing is malformed, not content/entry data.
175
+ */
176
+ function countOccurrences(haystack, needle) {
177
+ let count = 0;
178
+ let from = 0;
179
+ while (true) {
180
+ const idx = haystack.indexOf(needle, from);
181
+ if (idx === -1)
182
+ return count;
183
+ count += 1;
184
+ from = idx + needle.length;
185
+ }
186
+ }
187
+ /**
188
+ * Parse a ZRead File response into a normalized `RepositoryFileResult`.
189
+ * The grammar is a single whole-response `<file_content>...</file_content>`
190
+ * wrapper; only characterized whitespace may appear outside the wrapper.
191
+ * Exactly one outer opening and one outer closing wrapper tag are
192
+ * required (P6-04B): duplicate or nested framing is malformed.
193
+ */
194
+ function parseZaiFile(raw, request) {
195
+ if (typeof raw !== "string") {
196
+ throw new ApiError("Z.AI file returned a malformed response", 502);
197
+ }
198
+ if (looksLikeEncodedMcpError(raw)) {
199
+ throw classifyEncodedMcpError(raw, ENCODED_ERROR_LABEL);
200
+ }
201
+ // Enforce exactly one outer wrapper pair (P6-04B). Duplicate or
202
+ // nested `<file_content>` framing is malformed, not content data.
203
+ if (countOccurrences(raw, "<file_content>") !== 1 ||
204
+ countOccurrences(raw, "</file_content>") !== 1) {
205
+ throw new ApiError("Z.AI file returned a malformed response", 502);
206
+ }
207
+ const m = raw.match(/^\s*<file_content>([\s\S]*)<\/file_content>\s*$/);
208
+ if (!m) {
209
+ throw new ApiError("Z.AI file returned a malformed response", 502);
210
+ }
211
+ const content = m[1];
212
+ return {
213
+ schemaVersion: 1,
214
+ repository: request.repository,
215
+ path: request.path,
216
+ content,
217
+ truncated: false,
218
+ originalContentLength: content.length,
219
+ };
220
+ }
221
+ /**
222
+ * Parse a ZRead Directory Listing response into a normalized
223
+ * `RepositoryDirectoryListing`. The grammar is a single whole-response
224
+ * `<structure>...</structure>` wrapper. The FIRST non-blank line inside
225
+ * the wrapper MUST be a non-empty glyph-less root label (typically
226
+ * `owner-repo/`). Every SUBSEQUENT non-blank line is either:
227
+ *
228
+ * - a level-zero immediate entry: `├── name` or `└── name` (branch
229
+ * glyph at column 0, no leading whitespace); or
230
+ * - a well-formed nested descendant: one or more exact four-column
231
+ * indentation groups (`│ ` pipe+3spaces or ` ` 4 spaces),
232
+ * then `├── ` or `└── `, then a non-empty name.
233
+ *
234
+ * Only immediate entries are emitted. Valid descendants are silently
235
+ * skipped (P6-04B). Glyph-bearing lines with arbitrary or misaligned
236
+ * prefixes (e.g. `garbage├──`, `│ ├──` with only 2 spaces),
237
+ * glyph-less lines, and descendants with empty names are rejected
238
+ * (P6-04C).
239
+ *
240
+ * A trailing `/` on an entry marks a directory. Sibling order is
241
+ * preserved verbatim.
242
+ *
243
+ * Child paths are repository-relative (P6-04A): for a listing at
244
+ * `packages`, child `react/` yields
245
+ * `{ name: "react", path: "packages/react", kind: "directory" }`.
246
+ * Root listings (request.path === "") project the child name as the
247
+ * entry path. The Adapter performs only this deterministic
248
+ * parent/child projection — canonical safety validation and rejection
249
+ * remain the Explorer's responsibility (P6-05).
250
+ */
251
+ function parseZaiDirectory(raw, request) {
252
+ if (typeof raw !== "string") {
253
+ throw new ApiError("Z.AI directory returned a malformed response", 502);
254
+ }
255
+ if (looksLikeEncodedMcpError(raw)) {
256
+ throw classifyEncodedMcpError(raw, ENCODED_ERROR_LABEL);
257
+ }
258
+ // Enforce exactly one outer wrapper pair (P6-04B). Duplicate or
259
+ // nested `<structure>` framing is malformed, not entry data.
260
+ if (countOccurrences(raw, "<structure>") !== 1 || countOccurrences(raw, "</structure>") !== 1) {
261
+ throw new ApiError("Z.AI directory returned a malformed response", 502);
262
+ }
263
+ const wrapper = raw.match(/^\s*<structure>([\s\S]*)<\/structure>\s*$/);
264
+ if (!wrapper) {
265
+ throw new ApiError("Z.AI directory returned a malformed response", 502);
266
+ }
267
+ const body = wrapper[1];
268
+ // Level-0 immediate entries: branch glyph at column 0 (no leading
269
+ // whitespace).
270
+ const immediateEntryRe = /^[├└]──\s(.+)$/;
271
+ // Well-formed nested descendant: one or more exact four-column
272
+ // indentation groups (`│ ` pipe+3spaces or ` ` 4 spaces),
273
+ // then `├── `/`└── `, then the name (P6-04C). Misaligned prefixes
274
+ // (`│ ├──`, ` ├──`, `garbage├──`) do not match and are rejected.
275
+ const descendantRe = /^(?:│ | )+[├└]──\s(.+)$/;
276
+ const entries = [];
277
+ let sawImmediate = false;
278
+ let isFirstNonBlank = true;
279
+ for (const line of body.split("\n")) {
280
+ if (!line.trim())
281
+ continue;
282
+ if (isFirstNonBlank) {
283
+ // The first non-blank line MUST be a glyph-less root label
284
+ // (P6-04C). A glyph-bearing first line means the root label
285
+ // is missing and the response is malformed.
286
+ isFirstNonBlank = false;
287
+ if (/[├└]──/.test(line)) {
288
+ throw new ApiError("Z.AI directory returned a malformed response", 502);
289
+ }
290
+ continue;
291
+ }
292
+ const m = line.match(immediateEntryRe);
293
+ if (m) {
294
+ // Level-0 immediate entry — parse and emit.
295
+ const name = m[1].trim();
296
+ if (!name) {
297
+ throw new ApiError("Z.AI directory returned a malformed response", 502);
298
+ }
299
+ sawImmediate = true;
300
+ const isDir = name.endsWith("/");
301
+ const cleanName = isDir ? name.slice(0, -1) : name;
302
+ if (!cleanName) {
303
+ throw new ApiError("Z.AI directory returned a malformed response", 502);
304
+ }
305
+ // Repository-relative child path: root listings project the
306
+ // name unchanged; non-root listings join `request.path` and the
307
+ // entry name with a single `/`. The Explorer later applies
308
+ // canonical safety validation.
309
+ const childPath = request.path === "" ? cleanName : `${request.path}/${cleanName}`;
310
+ entries.push({ name: cleanName, path: childPath, kind: isDir ? "directory" : "file" });
311
+ continue;
312
+ }
313
+ // Not a level-0 entry. Check whether the line is a well-formed
314
+ // descendant with exact four-column indentation groups (P6-04C).
315
+ const d = line.match(descendantRe);
316
+ if (d) {
317
+ // Valid descendant indentation — but a descendant with an empty
318
+ // name is still malformed. Silently skip only if the name is
319
+ // non-empty; otherwise reject.
320
+ if (d[1].trim()) {
321
+ continue;
322
+ }
323
+ }
324
+ // Glyph-less, arbitrary/misaligned prefix, or empty descendant
325
+ // name — reject.
326
+ throw new ApiError("Z.AI directory returned a malformed response", 502);
327
+ }
328
+ if (!sawImmediate) {
329
+ // A wrapper with no glyph-prefixed entries is malformed (the
330
+ // characterization requires at least one documented immediate
331
+ // entry). Reject uniformly rather than returning an empty list
332
+ // so the Explorer never sees a silently empty normalized result.
333
+ throw new ApiError("Z.AI directory returned a malformed response", 502);
334
+ }
335
+ return {
336
+ repository: request.repository,
337
+ path: request.path,
338
+ entries,
339
+ };
340
+ }
341
+ // ---------------------------------------------------------------------------
342
+ // Adapter-owned credential fingerprint (DESIGN.md §18). Identical
343
+ // algorithm to the Search Capability: full lowercase SHA-256 hex digest
344
+ // of the active credential; the cache key uses this verbatim.
345
+ // ---------------------------------------------------------------------------
346
+ function credentialFingerprint(apiKey) {
347
+ return crypto.createHash("sha256").update(apiKey).digest("hex");
348
+ }
349
+ function createZaiSearchOperation(options) {
350
+ const { env, clientFactory } = options;
351
+ function resolveApiKey() {
352
+ return requireZaiApiKey(env);
353
+ }
354
+ const operation = {
355
+ kind: "repository-search",
356
+ validate(request) {
357
+ assertRepository(request.repository);
358
+ assertQuery(request.query);
359
+ assertLanguage(request.language);
360
+ },
361
+ cacheIdentity(request) {
362
+ const apiKey = resolveApiKey();
363
+ // Legacy argument insertion order is fixed (DESIGN.md §18):
364
+ // Search -> repo_name, query, language
365
+ const legacyArgs = {
366
+ repo_name: request.repository,
367
+ query: request.query,
368
+ language: request.language,
369
+ };
370
+ return {
371
+ provider: "zai",
372
+ capability: "repository-exploration",
373
+ operation: "repository-search",
374
+ credentialFingerprint: credentialFingerprint(apiKey),
375
+ request,
376
+ legacyCandidates: [
377
+ {
378
+ key: buildLegacyRepositoryCacheKey(apiKey, SEARCH_TOOL_PUBLIC_NAME, legacyArgs),
379
+ decode: (raw) => decodeLegacyZaiSearch(raw, {
380
+ repository: request.repository,
381
+ query: request.query,
382
+ language: request.language,
383
+ }),
384
+ },
385
+ ],
386
+ };
387
+ },
388
+ decodeCached(value) {
389
+ // Total normalized cache decoder: delegate to the shared P6-02
390
+ // `decodeRepositorySearch` (DESIGN.md §18). Shared decoder
391
+ // rejects fractional `originalTextLength` values, missing
392
+ // required scalars, malformed `excerpts`, and unknown
393
+ // `language` values. Any malformed shape is a cache miss.
394
+ return decodeRepositorySearch(value);
395
+ },
396
+ async invoke(request) {
397
+ // Validate before any transport access (DESIGN.md §18 / §2).
398
+ operation.validate(request);
399
+ const args = {
400
+ repo_name: request.repository,
401
+ query: request.query,
402
+ language: request.language,
403
+ };
404
+ return invokeRepositoryOperation(clientFactory, SEARCH_TOOL_PUBLIC_NAME, args, (raw) => parseZaiSearch(raw, request), options.closeTimeoutMs);
405
+ },
406
+ };
407
+ return operation;
408
+ }
409
+ // ---------------------------------------------------------------------------
410
+ // Operation factory — File
411
+ // ---------------------------------------------------------------------------
412
+ function createZaiReadFileOperation(options) {
413
+ const { env, clientFactory } = options;
414
+ function resolveApiKey() {
415
+ return requireZaiApiKey(env);
416
+ }
417
+ const operation = {
418
+ kind: "repository-read-file",
419
+ validate(request) {
420
+ assertRepository(request.repository);
421
+ assertNonRootPath(request.path, "File");
422
+ },
423
+ cacheIdentity(request) {
424
+ const apiKey = resolveApiKey();
425
+ // File uses a fixed insertion order (DESIGN.md §18):
426
+ // File -> repo_name, file_path
427
+ const legacyArgs = {
428
+ repo_name: request.repository,
429
+ file_path: request.path,
430
+ };
431
+ return {
432
+ provider: "zai",
433
+ capability: "repository-exploration",
434
+ operation: "repository-read-file",
435
+ credentialFingerprint: credentialFingerprint(apiKey),
436
+ request,
437
+ legacyCandidates: [
438
+ {
439
+ key: buildLegacyRepositoryCacheKey(apiKey, FILE_TOOL_PUBLIC_NAME, legacyArgs),
440
+ decode: (raw) => decodeLegacyZaiFile(raw, {
441
+ repository: request.repository,
442
+ path: request.path,
443
+ }),
444
+ },
445
+ ],
446
+ };
447
+ },
448
+ decodeCached(value) {
449
+ // Total normalized cache decoder: delegate to the shared P6-02
450
+ // `decodeRepositoryFile`. Shared decoder rejects fractional
451
+ // `originalContentLength`, non-string `content`, empty `path`,
452
+ // and primitives/arrays at the top level.
453
+ return decodeRepositoryFile(value);
454
+ },
455
+ async invoke(request) {
456
+ operation.validate(request);
457
+ const args = {
458
+ repo_name: request.repository,
459
+ file_path: request.path,
460
+ };
461
+ return invokeRepositoryOperation(clientFactory, FILE_TOOL_PUBLIC_NAME, args, (raw) => parseZaiFile(raw, request), options.closeTimeoutMs);
462
+ },
463
+ };
464
+ return operation;
465
+ }
466
+ // ---------------------------------------------------------------------------
467
+ // Operation factory — Directory
468
+ // ---------------------------------------------------------------------------
469
+ function createZaiListDirectoryOperation(options) {
470
+ const { env, clientFactory } = options;
471
+ function resolveApiKey() {
472
+ return requireZaiApiKey(env);
473
+ }
474
+ const operation = {
475
+ kind: "repository-list-directory",
476
+ validate(request) {
477
+ assertRepository(request.repository);
478
+ assertDirectoryPath(request.path);
479
+ // Directory allows `path: ""` (root) AND non-root paths. The
480
+ // Adapter performs only the deterministic parent/child
481
+ // projection; canonical child-path safety validation belongs to
482
+ // the Explorer layer (P6-05).
483
+ },
484
+ cacheIdentity(request) {
485
+ const apiKey = resolveApiKey();
486
+ // Directory argument insertion order is fixed (DESIGN.md §18):
487
+ // root -> repo_name only
488
+ // non-root -> repo_name, dir_path
489
+ const legacyArgs = { repo_name: request.repository };
490
+ if (request.path !== "") {
491
+ legacyArgs.dir_path = request.path;
492
+ }
493
+ return {
494
+ provider: "zai",
495
+ capability: "repository-exploration",
496
+ operation: "repository-list-directory",
497
+ credentialFingerprint: credentialFingerprint(apiKey),
498
+ request,
499
+ legacyCandidates: [
500
+ {
501
+ key: buildLegacyRepositoryCacheKey(apiKey, DIRECTORY_TOOL_PUBLIC_NAME, legacyArgs),
502
+ decode: (raw) => decodeLegacyZaiDirectory(raw, {
503
+ repository: request.repository,
504
+ path: request.path,
505
+ }),
506
+ },
507
+ ],
508
+ };
509
+ },
510
+ decodeCached(value) {
511
+ // Total normalized cache decoder: delegate to the shared P6-02
512
+ // `decodeRepositoryDirectoryListing`. Shared decoder rejects
513
+ // malformed entries (empty name/path, unknown kind) and
514
+ // primitives/arrays at the top level.
515
+ return decodeRepositoryDirectoryListing(value);
516
+ },
517
+ async invoke(request) {
518
+ operation.validate(request);
519
+ const args = { repo_name: request.repository };
520
+ if (request.path !== "") {
521
+ args.dir_path = request.path;
522
+ }
523
+ return invokeRepositoryOperation(clientFactory, DIRECTORY_TOOL_PUBLIC_NAME, args, (raw) => parseZaiDirectory(raw, request), options.closeTimeoutMs);
524
+ },
525
+ };
526
+ return operation;
527
+ }
528
+ // ---------------------------------------------------------------------------
529
+ // Legacy decoder — total over `unknown`, returns normalized Result or
530
+ // `null`. Each legacy decoder runs the raw Provider string through the
531
+ // same production parser so the read-through path validates the same
532
+ // grammar the cache decoder does. `JSON.stringify(raw)` is used only
533
+ // when `raw` is already an object (the normalized shape); raw string
534
+ // values are passed through unchanged.
535
+ // ---------------------------------------------------------------------------
536
+ function decodeLegacyZaiSearch(raw, request) {
537
+ try {
538
+ return parseZaiSearch(raw, request);
539
+ }
540
+ catch {
541
+ return null;
542
+ }
543
+ }
544
+ function decodeLegacyZaiFile(raw, request) {
545
+ try {
546
+ return parseZaiFile(raw, request);
547
+ }
548
+ catch {
549
+ return null;
550
+ }
551
+ }
552
+ function decodeLegacyZaiDirectory(raw, request) {
553
+ try {
554
+ return parseZaiDirectory(raw, request);
555
+ }
556
+ catch {
557
+ return null;
558
+ }
559
+ }
560
+ // ---------------------------------------------------------------------------
561
+ // Validation helpers
562
+ //
563
+ // Request validation throws `ValidationError` (P6-04A) for invalid
564
+ // repository, query, language, File path, and Directory path types —
565
+ // distinct from parser/envelope failures, which remain retryable
566
+ // `ApiError` 502. `ValidationError` is terminal at the request layer
567
+ // (the retry classifier treats `VALIDATION_ERROR` as non-retryable)
568
+ // and never reaches a transport.
569
+ // ---------------------------------------------------------------------------
570
+ function assertRepository(repository) {
571
+ if (typeof repository !== "string" || !containsSlash(repository)) {
572
+ throw new ValidationError("Z.AI repository must be a string of the form 'owner/name'");
573
+ }
574
+ }
575
+ function containsSlash(value) {
576
+ for (let i = 0; i < value.length; i += 1) {
577
+ if (value.charCodeAt(i) === 47 /* "/" */)
578
+ return true;
579
+ }
580
+ return false;
581
+ }
582
+ function assertQuery(query) {
583
+ if (typeof query !== "string" || query.trim().length === 0) {
584
+ throw new ValidationError("Z.AI search query must contain at least one non-whitespace character");
585
+ }
586
+ }
587
+ function assertLanguage(language) {
588
+ if (language !== "en" && language !== "zh") {
589
+ throw new ValidationError("Z.AI search language must be 'en' or 'zh'");
590
+ }
591
+ }
592
+ function assertNonRootPath(path, label) {
593
+ if (typeof path !== "string" || path.length === 0) {
594
+ throw new ValidationError(`Z.AI ${label} path must be a non-empty string`);
595
+ }
596
+ }
597
+ function assertDirectoryPath(path) {
598
+ // Directory allows both `path: ""` (root) and a non-root repository-
599
+ // relative POSIX path. The Adapter only validates the type here;
600
+ // canonical child-path safety validation belongs to the Explorer.
601
+ if (typeof path !== "string") {
602
+ throw new ValidationError("Z.AI directory path must be a string");
603
+ }
604
+ }
605
+ // ---------------------------------------------------------------------------
606
+ // Per-invocation transport lifecycle (DESIGN.md §18 "Transport close
607
+ // semantics"). Each uncached attempt constructs a fresh client and
608
+ // closes it exactly once in `finally`. Close rejection or timeout
609
+ // never replaces success and never masks the primary operation failure.
610
+ //
611
+ // The Adapter NEVER retries internally — shared execution owns retry
612
+ // policy. A retry constructs a fresh Adapter attempt and therefore a
613
+ // fresh client.
614
+ // ---------------------------------------------------------------------------
615
+ async function invokeRepositoryOperation(clientFactory, publicToolName, args, parse, closeTimeoutMs) {
616
+ const clientOptions = {
617
+ enableVision: false,
618
+ noCache: true,
619
+ disableRetry: true,
620
+ };
621
+ const client = clientFactory(clientOptions);
622
+ // The success and primary-failure paths must survive a close
623
+ // rejection or timeout. We capture both outcomes separately so a
624
+ // failing close cannot replace a successful result and cannot mask a
625
+ // primary Provider failure.
626
+ let primaryError;
627
+ let result;
628
+ try {
629
+ try {
630
+ // Invoke through the public dotted tool name so the underlying
631
+ // client resolves the discovered internal identity (P6-01A fix
632
+ // path). No retry is performed inside this Adapter attempt;
633
+ // shared execution owns the retry policy.
634
+ result = parse(await client.callToolRaw(publicToolName, args));
635
+ }
636
+ catch (error) {
637
+ // Wrap any thrown MCP error into the normalized Adapter error.
638
+ primaryError = normalizeMcpInvokeError(error);
639
+ throw primaryError;
640
+ }
641
+ return result;
642
+ }
643
+ finally {
644
+ // Best-effort close. Matches the existing `ZaiMcpClient.close`
645
+ // semantic: race the close against a timeout that resolves
646
+ // silently so a stuck close cannot stall the Adapter attempt.
647
+ // Close rejection is also silently swallowed.
648
+ await closeWithBound(client, closeTimeoutMs);
649
+ }
650
+ }
651
+ /**
652
+ * Close the client with a bounded timeout that matches the existing
653
+ * `ZaiMcpClient.close(timeoutMs = 2000)` semantic. The timeout
654
+ * resolves silently (not rejects) so the close never throws and the
655
+ * Adapter attempt never stalls on a stuck close. Any close rejection
656
+ * is also silently swallowed.
657
+ */
658
+ async function closeWithBound(client, timeoutMs) {
659
+ let timer;
660
+ const timeoutPromise = new Promise((resolve) => {
661
+ timer = setTimeout(() => resolve(), timeoutMs);
662
+ if (timer && typeof timer === "object" && "unref" in timer) {
663
+ timer.unref();
664
+ }
665
+ });
666
+ try {
667
+ await Promise.race([client.close().catch(() => undefined), timeoutPromise]);
668
+ }
669
+ finally {
670
+ if (timer !== undefined)
671
+ clearTimeout(timer);
672
+ }
673
+ }
674
+ /**
675
+ * Normalize a Provider failure surfaced through `callToolRaw`. The
676
+ * underlying client already normalizes typed transport errors
677
+ * (`AuthError`, `NetworkError`, `TimeoutError`, `ApiError`,
678
+ * `QuotaError`); this function preserves ANY normalized
679
+ * `ScoutlineError` so the shared retry classifier sees the original
680
+ * `code` and `statusCode`. This base-class check also covers the
681
+ * repository-local `ScoutlineError` constructed for an encoded 403
682
+ * (which intentionally does not widen the global `AuthError`
683
+ * constructor). Untyped throwables surface as sanitized retryable
684
+ * `ApiError` 502 — the Adapter never embeds a raw Provider string
685
+ * into the public envelope.
686
+ */
687
+ function normalizeMcpInvokeError(error) {
688
+ if (error instanceof ScoutlineError) {
689
+ return error;
690
+ }
691
+ // Defensive: never let a raw Provider string reach the public
692
+ // envelope. An untyped throwable is a sanitized 502-equivalent.
693
+ return new ApiError("Z.AI repository request failed", 502);
694
+ }
695
+ /**
696
+ * Build the Z.AI Repository Capability. The capability is composed of
697
+ * three typed `RepositoryOperation` descriptors; the Adapter owns
698
+ * credentials, transport lifecycle, raw request/response mapping, and
699
+ * error normalization. No transport, credential resolution, or I/O
700
+ * happens during construction.
701
+ */
702
+ export function createZaiRepositoryCapability(options) {
703
+ const closeTimeoutMs = options.closeTimeoutMs ?? ZAI_REPOSITORY_CLOSE_BOUND_MS;
704
+ const sharedOptions = {
705
+ env: options.env,
706
+ clientFactory: options.clientFactory,
707
+ closeTimeoutMs,
708
+ };
709
+ return {
710
+ search: createZaiSearchOperation(sharedOptions),
711
+ readFile: createZaiReadFileOperation(sharedOptions),
712
+ listDirectory: createZaiListDirectoryOperation(sharedOptions),
713
+ };
714
+ }
715
+ //# sourceMappingURL=repository.js.map