@jini-ai/platform 0.6.15-rc.2 → 0.6.16

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 (77) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +7 -8
  3. package/dist/asset-cache.adapter.d.ts +2 -2
  4. package/dist/asset-cache.adapter.js +2 -2
  5. package/dist/asset-cache.d.ts +1 -3
  6. package/dist/asset-cache.js +2 -14
  7. package/dist/blob-storage.js +10 -9
  8. package/dist/child-environment.js +1 -1
  9. package/dist/command.js +4 -1
  10. package/dist/core/effective-port.d.ts +6 -0
  11. package/dist/core/effective-port.js +8 -0
  12. package/dist/download.js +25 -24
  13. package/dist/fetch-with-timeout.d.ts +27 -7
  14. package/dist/fetch-with-timeout.js +51 -11
  15. package/dist/fs/atomic-write.js +5 -2
  16. package/dist/fs/boot-identity.d.ts +0 -4
  17. package/dist/fs/boot-identity.js +9 -3
  18. package/dist/fs/directory-registry.js +2 -2
  19. package/dist/fs/directory-running-lock.js +9 -4
  20. package/dist/fs/directory-sync.js +2 -3
  21. package/dist/fs/directory-user-config.d.ts +9 -0
  22. package/dist/fs/directory-user-config.js +13 -6
  23. package/dist/fs/directory-workspace.js +11 -14
  24. package/dist/fs/durable-json.js +22 -16
  25. package/dist/fs/errno.d.ts +9 -0
  26. package/dist/fs/errno.js +21 -0
  27. package/dist/fs/file-lock.d.ts +1 -0
  28. package/dist/fs/file-lock.js +17 -19
  29. package/dist/fs/guarded-reader.d.ts +22 -1
  30. package/dist/fs/guarded-reader.js +85 -15
  31. package/dist/fs/index.d.ts +1 -0
  32. package/dist/fs/index.js +1 -0
  33. package/dist/fs/user-data-directories.js +5 -7
  34. package/dist/fs/workspace-directory.d.ts +7 -1
  35. package/dist/fs/workspace-directory.js +9 -2
  36. package/dist/fs.js +2 -1
  37. package/dist/http/guarded/client.js +2 -1
  38. package/dist/http.js +2 -1
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/mail/builtin-providers.js +4 -2
  42. package/dist/mail/feedback.js +1 -1
  43. package/dist/mail/prepare-sender-email.js +7 -1
  44. package/dist/mail/provider.js +14 -8
  45. package/dist/mail/rate-limit.js +3 -1
  46. package/dist/messages.js +1 -1
  47. package/dist/net/endpoint-policy.js +15 -7
  48. package/dist/net/index.d.ts +1 -0
  49. package/dist/net/index.js +1 -0
  50. package/dist/net/local-http-probe.d.ts +16 -0
  51. package/dist/net/local-http-probe.js +36 -0
  52. package/dist/object-store/local-fs.js +3 -1
  53. package/dist/process.js +10 -9
  54. package/dist/proxy-env.js +22 -14
  55. package/dist/secrets/credential-sets/index.d.ts +1 -0
  56. package/dist/secrets/credential-sets/index.js +1 -0
  57. package/dist/secrets/credential-sets/provider-matches.js +14 -3
  58. package/dist/secrets/credential-sets/vault-archive.d.ts +41 -0
  59. package/dist/secrets/credential-sets/vault-archive.js +64 -0
  60. package/dist/secrets/credential-sets/vault.meta.js +6 -2
  61. package/dist/secrets/credential-sets/vault.repo.memory.js +3 -5
  62. package/dist/secrets/credential-sets/vault.service.js +22 -17
  63. package/dist/secrets/credential-sets/vault.types.d.ts +6 -0
  64. package/dist/secrets/credential-sets/vault.types.js +7 -0
  65. package/dist/secrets/credential-token.js +9 -2
  66. package/dist/secrets/keyring.env.js +2 -4
  67. package/dist/shell.js +2 -1
  68. package/dist/terminal.js +4 -7
  69. package/dist/toolchain.js +4 -9
  70. package/dist/web/first-distinct.d.ts +11 -0
  71. package/dist/web/first-distinct.js +16 -0
  72. package/dist/web/html-document.js +35 -39
  73. package/dist/web/html-to-markdown.js +20 -9
  74. package/dist/web/web-page.js +28 -25
  75. package/dist/windows-system-tools.d.ts +15 -0
  76. package/dist/windows-system-tools.js +16 -0
  77. package/package.json +10 -6
@@ -0,0 +1,64 @@
1
+ import { VaultError, isInVaultQueryScope } from "./vault.types.js";
2
+ function assertPortable(kind, principalId) {
3
+ // Personal and MCP credentials require their own identity/configuration transport, outside v1.
4
+ if (principalId !== null || ["personal-execution", "mcp-env", "mcp-auth"].includes(kind))
5
+ throw new VaultError({ code: "context" });
6
+ }
7
+ /** Capture through the owning service's authorization and parsers. Hosts supply exact scopes and
8
+ * attachment-filtered repositories; this helper never discovers other workspaces or principals.
9
+ * Diagnostics contain only a count, because even labels/IDs may contain pasted secrets.
10
+ * @example const captured = await captureVaultArchive({ vault, repo, queries: [siteScope] });
11
+ * @complexity O(rows + payload bytes), bounded to 2,000 rows by the transport contract.
12
+ */
13
+ export async function captureVaultArchive({ vault, queries, repo }, _options = {}) {
14
+ const entries = [];
15
+ let omitted = 0, count = 0;
16
+ for (const query of queries) {
17
+ assertPortable(query.kind, query.principalId);
18
+ // Optional raw enumeration isolates unsupported metadata; every payload still passes get's
19
+ // exact identity, authorization, parser and AAD checks before it can enter the result.
20
+ for (const row of await (repo ?? vault).list(query)) {
21
+ if (++count > 2000)
22
+ throw new VaultError({ code: "invalid-record" });
23
+ try {
24
+ if (!isInVaultQueryScope({ record: row, query }))
25
+ throw new VaultError({ code: "context" });
26
+ const found = await vault.get(row);
27
+ if (!found)
28
+ throw new VaultError({ code: "conflict" });
29
+ const s = found.summary;
30
+ entries.push({ id: s.id, kind: s.kind, ownerKey: s.ownerKey, binding: s.binding, provider: s.provider,
31
+ label: s.label, isDefault: s.isDefault, meta: s.meta, payload: found.payload });
32
+ }
33
+ catch {
34
+ omitted++;
35
+ }
36
+ }
37
+ }
38
+ return { entries, notices: omitted ? [`${omitted} saved credential(s) could not be opened and were left out.`] : [] };
39
+ }
40
+ /** Validate and re-seal every entry before the host atomically commits any of them. Keeping the
41
+ * transaction in the host lets settings and vault rows join the same commit. Never call put in a
42
+ * loop: a bad later entry must leave the recipient completely unchanged.
43
+ * @throws VaultError or the host's policy error; callers expose a fixed optional-import notice.
44
+ * @example const records = await prepareVaultArchiveRestore({ vault, workspaceId, entries });
45
+ * @complexity O(entries + payload bytes), at most 2,000 entries.
46
+ */
47
+ export async function prepareVaultArchiveRestore({ vault, workspaceId, entries }, _options = {}) {
48
+ if (!Array.isArray(entries) || entries.length > 2000)
49
+ throw new VaultError({ code: "invalid-record" });
50
+ const ids = new Set();
51
+ const records = [];
52
+ for (const entry of entries) {
53
+ assertPortable(entry.kind, null);
54
+ if (ids.has(entry.id))
55
+ throw new VaultError({ code: "invalid-record" });
56
+ ids.add(entry.id);
57
+ // Enumerating fields prevents an archive from supplying a revision, principal or workspace.
58
+ records.push(await vault.prepare({ workspaceId, id: entry.id, kind: entry.kind, ownerKey: entry.ownerKey,
59
+ principalId: null, binding: entry.binding, provider: entry.provider, label: entry.label,
60
+ isDefault: entry.isDefault, meta: entry.meta, payload: entry.payload, expectedRevision: null }));
61
+ }
62
+ return records;
63
+ }
64
+ //# sourceMappingURL=vault-archive.js.map
@@ -39,16 +39,20 @@ function isJson(value) {
39
39
  * @throws VaultError when a supplied subtype or compatibility list is invalid.
40
40
  * @complexity O(worksFor characters); returns a fresh metadata object.
41
41
  */
42
+ /** Older vendor rows imply compatibility with their own provider; nothing else implies any. */
43
+ function defaultWorksFor({ credentialType, provider }) {
44
+ return credentialType === "vendor" && provider != null ? [provider] : [];
45
+ }
42
46
  function credentialMeta({ meta, kind, provider }) {
43
47
  const credentialType = meta.credentialType === undefined ? kind : meta.credentialType;
44
48
  if ((credentialType !== "vendor" && credentialType !== "custom") || (kind === "custom" && credentialType !== "custom"))
45
49
  throw new VaultError({ code: "invalid-meta" });
46
- const worksFor = meta.worksFor === undefined ? (credentialType === "vendor" && provider != null ? [provider] : []) : meta.worksFor;
50
+ const worksFor = meta.worksFor === undefined ? defaultWorksFor({ credentialType, provider }) : meta.worksFor;
47
51
  return { ...meta, credentialType, worksFor: normalizeCredentialWorksFor({ value: worksFor }) };
48
52
  }
49
53
  /** Plain JSON object with identifier-only top-level keys; checked on input and on override output. */
50
54
  function requireMetaShape({ value }) {
51
- if (!isObject(value) || !isJson(value) || Object.keys(value).some(key => !/^[A-Za-z0-9_]+$/.test(key)))
55
+ if (!isObject(value) || !isJson(value) || Object.keys(value).some(key => !/^\w+$/.test(key)))
52
56
  throw new VaultError({ code: "invalid-meta" });
53
57
  return value;
54
58
  }
@@ -1,4 +1,4 @@
1
- import { assertVaultReplacement, vaultGroupKey, VaultError } from "./vault.types.js";
1
+ import { assertVaultReplacement, isInVaultQueryScope, vaultGroupKey, VaultError } from "./vault.types.js";
2
2
  /** Single-turn mutations are atomic in JS; clones prevent callers bypassing CAS through aliases. */
3
3
  export class InMemoryVaultRepo {
4
4
  rows = new Map();
@@ -56,15 +56,13 @@ export class InMemoryVaultRepo {
56
56
  /** O(n) memory scan, n stored rows; clones only the default, never its siblings. */
57
57
  async findDefault(input, _options = {}) {
58
58
  for (const row of this.rows.values()) {
59
- if (row.isDefault && row.workspaceId === input.workspaceId && row.kind === input.kind && row.ownerKey === input.ownerKey &&
60
- row.principalId === input.principalId && row.provider === input.provider)
59
+ if (row.isDefault && row.provider === input.provider && isInVaultQueryScope({ record: row, query: input }))
61
60
  return structuredClone(row);
62
61
  }
63
62
  return null;
64
63
  }
65
64
  async list(input, _options = {}) {
66
- return structuredClone([...this.rows.values()].filter(row => row.workspaceId === input.workspaceId && row.kind === input.kind &&
67
- row.ownerKey === input.ownerKey && row.principalId === input.principalId && (input.provider === undefined || row.provider === input.provider))
65
+ return structuredClone([...this.rows.values()].filter(row => isInVaultQueryScope({ record: row, query: input }))
68
66
  // Rows are keyed by (workspaceId, id), so one workspace never lists two equal IDs.
69
67
  .sort((a, b) => a.id < b.id ? -1 : 1));
70
68
  }
@@ -1,18 +1,23 @@
1
1
  import { credentialTokenHint } from "../credential-token.js";
2
2
  import { buildVaultAad } from "./vault.aad.js";
3
3
  import { isCopyableKind } from "./vault.meta.js";
4
- import { VAULT_KINDS, VaultError } from "./vault.types.js";
4
+ import { VAULT_KINDS, VaultError, isInVaultQueryScope } from "./vault.types.js";
5
5
  /** Kinds whose payloads are addresses, environment maps or OAuth sessions, never single tokens. */
6
6
  const HINTLESS_KINDS = ["database-destination", "mcp-env", "mcp-auth"];
7
7
  function isNonEmptyString({ value }) {
8
8
  return typeof value === "string" && value !== "";
9
9
  }
10
+ /** The one error for malformed, missing or unsupported records and repo capabilities. */
11
+ function invalidRecord() {
12
+ return new VaultError({ code: "invalid-record" });
13
+ }
10
14
  function isNullableString({ value }) {
11
15
  return value === null || typeof value === "string";
12
16
  }
13
17
  function hasValidOwnership({ context }) {
18
+ const validBinding = !("binding" in context) || isNullableString({ value: context.binding });
14
19
  return isNonEmptyString({ value: context.workspaceId }) && isNonEmptyString({ value: context.ownerKey }) &&
15
- isNullableString({ value: context.principalId }) && (!("binding" in context) || isNullableString({ value: context.binding }));
20
+ isNullableString({ value: context.principalId }) && validBinding;
16
21
  }
17
22
  function hasValidKind({ context }) {
18
23
  if (!VAULT_KINDS.some(kind => kind === context.kind) && context.kind !== "custom")
@@ -25,19 +30,19 @@ function assertContext(context) {
25
30
  }
26
31
  /** Unified credential rows declare both subtype and compatibility; read defaults cover older rows. */
27
32
  function declaresCredential({ meta }) {
28
- return meta !== null && typeof meta === "object" && Object.hasOwn(meta, "credentialType") && Object.hasOwn(meta, "worksFor");
29
- }
30
- function isInQueryScope({ record, query }) {
31
- return record.workspaceId === query.workspaceId && record.kind === query.kind && record.ownerKey === query.ownerKey &&
32
- record.principalId === query.principalId && (query.provider === undefined || record.provider === query.provider);
33
+ if (meta === null || typeof meta !== "object")
34
+ return false;
35
+ return Object.hasOwn(meta, "credentialType") && Object.hasOwn(meta, "worksFor");
33
36
  }
34
37
  /** Copy changes the site/key, never the credential's owning feature or endpoint. Principal
35
38
  * identities are local to each installation and cannot be carried to the target.
36
39
  */
37
40
  function isCopyTarget({ sourceContext, targetContext, sameRepo }) {
38
- return sourceContext.kind === targetContext.kind && sourceContext.ownerKey === targetContext.ownerKey &&
39
- sourceContext.binding === targetContext.binding && sourceContext.principalId === null && targetContext.principalId === null &&
40
- !(sameRepo && sourceContext.workspaceId === targetContext.workspaceId);
41
+ const sameFeature = sourceContext.kind === targetContext.kind && sourceContext.ownerKey === targetContext.ownerKey &&
42
+ sourceContext.binding === targetContext.binding;
43
+ const noPrincipal = sourceContext.principalId === null && targetContext.principalId === null;
44
+ const sameSite = sameRepo && sourceContext.workspaceId === targetContext.workspaceId;
45
+ return sameFeature && noPrincipal && !sameSite;
41
46
  }
42
47
  function sourceNeedsUpgrade({ raw }) {
43
48
  return Object.hasOwn(raw.meta, "legacyAad") || (raw.kind === "vendor" && !declaresCredential({ meta: raw.meta }));
@@ -97,7 +102,7 @@ export class SecretsVaultService {
97
102
  // JSON round-trip makes the payload contract explicit; encryption never receives an object
98
103
  // coerced to '[object Object]' or a connection URI masquerading as an entire payload.
99
104
  if (typeof input.payload !== "object" || Array.isArray(input.payload))
100
- throw new VaultError({ code: "invalid-record" });
105
+ throw invalidRecord();
101
106
  const plaintext = JSON.stringify(input.payload);
102
107
  return this.deps.sealer.seal({ plaintext, key: await this.deps.keyring.activeKey({}), aad: buildVaultAad(input.context) });
103
108
  }
@@ -110,10 +115,10 @@ export class SecretsVaultService {
110
115
  payload = JSON.parse(plaintext);
111
116
  }
112
117
  catch {
113
- throw new VaultError({ code: "invalid-record" });
118
+ throw invalidRecord();
114
119
  }
115
120
  if (payload === null || typeof payload !== "object" || Array.isArray(payload))
116
- throw new VaultError({ code: "invalid-record" });
121
+ throw invalidRecord();
117
122
  return payload;
118
123
  }
119
124
  /** Create/replace configuration and a sealed JSON payload. Throws on policy, validation or CAS failure. */
@@ -167,7 +172,7 @@ export class SecretsVaultService {
167
172
  async putEnvelope({ input, identity, existing }) {
168
173
  if (input.payload === undefined) {
169
174
  if (!existing)
170
- throw new VaultError({ code: "invalid-record" });
175
+ throw invalidRecord();
171
176
  return { sealed: existing.sealed, last4: existing.last4, tokenLength: existing.tokenLength };
172
177
  }
173
178
  const sealed = await this.seal({ context: identity, payload: input.payload });
@@ -195,7 +200,7 @@ export class SecretsVaultService {
195
200
  assertContext(input);
196
201
  await this.authorize({ action: "get", context: input });
197
202
  if (!this.deps.repo.findOne)
198
- throw new VaultError({ code: "invalid-record" });
203
+ throw invalidRecord();
199
204
  const record = await this.deps.repo.findOne(input);
200
205
  if (!record)
201
206
  return null;
@@ -213,7 +218,7 @@ export class SecretsVaultService {
213
218
  await this.authorize({ action: "get", context: input });
214
219
  // Old injected ports retain their existing operations; default reads require this capability.
215
220
  if (!this.deps.repo.findDefault)
216
- throw new VaultError({ code: "invalid-record" });
221
+ throw invalidRecord();
217
222
  const record = await this.deps.repo.findDefault(input);
218
223
  if (!record)
219
224
  return null;
@@ -301,7 +306,7 @@ export class SecretsVaultService {
301
306
  assertContext(input);
302
307
  await this.authorize({ action: "list", context: input });
303
308
  return (await this.deps.repo.list(input)).map(record => {
304
- if (!isInQueryScope({ record, query: input }))
309
+ if (!isInVaultQueryScope({ record, query: input }))
305
310
  throw new VaultError({ code: "context" });
306
311
  return summary(this.parse(record));
307
312
  });
@@ -152,6 +152,12 @@ export interface VaultRepoPort {
152
152
  expectedRevision: number;
153
153
  }, options?: VaultOptions): Promise<boolean>;
154
154
  }
155
+ /** The record belongs to the query's exact workspace, kind, owner and principal; an omitted provider
156
+ * matches every provider. @complexity O(1). */
157
+ export declare function isInVaultQueryScope({ record, query }: {
158
+ record: Pick<VaultRecord, keyof VaultQuery>;
159
+ query: VaultQuery;
160
+ }, _options?: VaultOptions): boolean;
155
161
  /** Unambiguous scope encoding keeps adapter default/identity checks on the same contract. */
156
162
  export declare function vaultGroupKey({ record }: {
157
163
  record: VaultDefaultQuery;
@@ -9,6 +9,13 @@ export class VaultError extends Error {
9
9
  this.name = "VaultError";
10
10
  }
11
11
  }
12
+ /** The record belongs to the query's exact workspace, kind, owner and principal; an omitted provider
13
+ * matches every provider. @complexity O(1). */
14
+ export function isInVaultQueryScope({ record, query }, _options = {}) {
15
+ const sameOwner = record.workspaceId === query.workspaceId && record.kind === query.kind && record.ownerKey === query.ownerKey;
16
+ const sameProvider = query.provider === undefined || record.provider === query.provider;
17
+ return sameOwner && record.principalId === query.principalId && sameProvider;
18
+ }
12
19
  /** Unambiguous scope encoding keeps adapter default/identity checks on the same contract. */
13
20
  export function vaultGroupKey({ record }, _options = {}) {
14
21
  return JSON.stringify([record.workspaceId, record.kind, record.ownerKey, record.principalId, record.provider]);
@@ -60,10 +60,17 @@ export function credentialTokenHint({ token }, _optional = {}) {
60
60
  return { length: token.length, last4: token.length >= 12 ? token.slice(-4) : null };
61
61
  }
62
62
  const SECRET_NAME = /(?:^|[_-])(?:key|api[_-]?key|token|access[_-]?token|secret|password|auth|sig)$/i;
63
- const SECRET_ARG = /(?:^|\s)--[\w-]*(?:api[_-]?key|token|secret|password)(?:=|\s+)(?!\s*$)\S/i;
63
+ // A `--flag` followed by a value; the flag name is captured whole and its suffix checked separately,
64
+ // so the name run and the keyword can no longer backtrack against each other.
65
+ const CLI_FLAG_WITH_VALUE = /(?:^|\s)--([\w-]*)(?=(?:=|\s+)\S)/gi;
66
+ const SECRET_FLAG_SUFFIX = /(?:api[_-]?key|token|secret|password)$/i;
67
+ /** True when some `--…api-key/token/secret/password` flag carries a value. @complexity O(value length). */
68
+ function hasSecretCliArg(value) {
69
+ return [...value.matchAll(CLI_FLAG_WITH_VALUE)].some(match => SECRET_FLAG_SUFFIX.test(match[1]));
70
+ }
64
71
  /** Protect plaintext metadata, using Jini's detector plus structural URL/CLI rules. */
65
72
  export function assertCredentialFreeField({ value, field, containsSecret }, _optional = {}) {
66
- let credential = containsSecret({ text: value }) || SECRET_ARG.test(value);
73
+ let credential = containsSecret({ text: value }) || hasSecretCliArg(value);
67
74
  try {
68
75
  const url = new URL(value);
69
76
  credential ||= url.username !== '' || url.password !== '' || [...url.searchParams.keys()].some(key => SECRET_NAME.test(key));
@@ -206,8 +206,7 @@ function toActiveRootKeyMaterial(source, raw) {
206
206
  * @example inspectRootKeyMaterial({ env, envVarName, keyFilePath })
207
207
  */
208
208
  export function inspectRootKeyMaterial(options) {
209
- const envVarName = options.envVarName;
210
- const keyFilePath = options.keyFilePath;
209
+ const { envVarName, keyFilePath } = options;
211
210
  const raw = readActiveRootKeyMaterial({ env: options.env, envVarName, keyFilePath });
212
211
  if (raw.hex)
213
212
  return { active: true, source: raw.source, fingerprint: fingerprintRootKeyHex({ hex: raw.hex }), keyFilePath };
@@ -221,8 +220,7 @@ function invalidFields(raw) {
221
220
  * @example revealRootKeyMaterial({ env, envVarName, keyFilePath })
222
221
  */
223
222
  export function revealRootKeyMaterial(options) {
224
- const envVarName = options.envVarName;
225
- const keyFilePath = options.keyFilePath;
223
+ const { envVarName, keyFilePath } = options;
226
224
  const raw = readActiveRootKeyMaterial({ env: options.env, envVarName, keyFilePath });
227
225
  if (raw.hex)
228
226
  return { active: true, source: raw.source, hex: raw.hex, fingerprint: fingerprintRootKeyHex({ hex: raw.hex }), keyFilePath };
package/dist/shell.js CHANGED
@@ -41,7 +41,8 @@ export function execFileBuffered(command, args, opts = {}) {
41
41
  /** @internal Single-quote a POSIX shell argument, escaping embedded single quotes. */
42
42
  function quotePosixShellArg(value) {
43
43
  const text = String(value ?? "");
44
- return `'${text.replace(/'/g, `'\\''`)}'`;
44
+ const escapedQuote = "'\\''";
45
+ return `'${text.replace(/'/g, escapedQuote)}'`;
45
46
  }
46
47
  /** @internal Join a command and its arguments into one quoted POSIX shell command line. */
47
48
  function buildShellCommandLine(command, args) {
package/dist/terminal.js CHANGED
@@ -171,7 +171,8 @@ export function createTerminalService(options) {
171
171
  }
172
172
  };
173
173
  const emit = (session, event, data) => {
174
- const id = session.nextEventId++;
174
+ const id = session.nextEventId;
175
+ session.nextEventId += 1;
175
176
  const byteLength = recordByteLength(data);
176
177
  const record = { id, event, data, timestamp: Date.now(), byteLength };
177
178
  session.events.push(record);
@@ -282,12 +283,8 @@ export function createTerminalService(options) {
282
283
  return session ? toPublicSession(session) : null;
283
284
  };
284
285
  const list = (filter = {}) => Array.from(sessions.values())
285
- .filter((session) => {
286
- if (typeof filter.projectId === 'string' && filter.projectId && session.projectId !== filter.projectId) {
287
- return false;
288
- }
289
- return true;
290
- })
286
+ // Only a non-empty string projectId filters; anything else lists every session.
287
+ .filter((session) => !(typeof filter.projectId === 'string' && filter.projectId && session.projectId !== filter.projectId))
291
288
  .map(toPublicSession);
292
289
  const attach = (id, lastEventId, sink) => {
293
290
  const session = sessions.get(id);
package/dist/toolchain.js CHANGED
@@ -255,18 +255,13 @@ function compareVersionLikeDirNames(left, right) {
255
255
  const leftSemver = parseVersionLikeDirName(left);
256
256
  const rightSemver = parseVersionLikeDirName(right);
257
257
  if (leftSemver && rightSemver) {
258
- for (let index = 0; index < leftSemver.length; index += 1) {
259
- const difference = rightSemver[index] - leftSemver[index];
260
- if (difference !== 0)
261
- return difference;
262
- }
258
+ const difference = leftSemver.map((part, index) => rightSemver[index] - part).find((value) => value !== 0);
259
+ return difference ?? left.localeCompare(right);
263
260
  }
264
- else if (leftSemver) {
261
+ if (leftSemver)
265
262
  return -1;
266
- }
267
- else if (rightSemver) {
263
+ if (rightSemver)
268
264
  return 1;
269
- }
270
265
  return left.localeCompare(right);
271
266
  }
272
267
  /** @internal Parse a `vX.Y.Z` / `X.Y.Z` directory name into its numeric parts, or `null` when it does not match. */
@@ -0,0 +1,11 @@
1
+ /** Items with distinct non-empty keys, in order, capped at `max`; `truncated` reports a further
2
+ * distinct key beyond the cap (scanning stops there). @complexity O(items). */
3
+ export declare function firstDistinct<T>({ items, max, keyOf }: {
4
+ items: readonly T[];
5
+ max: number;
6
+ keyOf: (item: T) => string | null | undefined;
7
+ }): {
8
+ picked: [string, T][];
9
+ truncated: boolean;
10
+ };
11
+ //# sourceMappingURL=first-distinct.d.ts.map
@@ -0,0 +1,16 @@
1
+ /** Items with distinct non-empty keys, in order, capped at `max`; `truncated` reports a further
2
+ * distinct key beyond the cap (scanning stops there). @complexity O(items). */
3
+ export function firstDistinct({ items, max, keyOf }) {
4
+ const picked = new Map();
5
+ const truncated = items.some((item) => {
6
+ const key = keyOf(item);
7
+ if (!key || picked.has(key))
8
+ return false;
9
+ if (picked.size === max)
10
+ return true;
11
+ picked.set(key, item);
12
+ return false;
13
+ });
14
+ return { picked: [...picked], truncated };
15
+ }
16
+ //# sourceMappingURL=first-distinct.js.map
@@ -1,4 +1,5 @@
1
1
  import { parse, serialize } from "parse5";
2
+ import { firstDistinct } from "./first-distinct.js";
2
3
  export const MAX_LINKS = 500;
3
4
  export const MAX_IMAGES = 300;
4
5
  const MAX_STYLESHEETS = 100;
@@ -88,19 +89,25 @@ const COLLECTED = new Map([["a", "anchors"], ["img", "images"], ["link", "linkTa
88
89
  function collect(document) {
89
90
  const raw = { anchors: [], images: [], linkTags: [], metas: [] };
90
91
  for (const node of walk(document)) {
91
- if (!isElement(node))
92
- continue;
93
- const list = COLLECTED.get(node.tagName);
94
- if (list)
95
- raw[list].push(node);
96
- else if (node.tagName === "title" || node.tagName === "html")
97
- raw[node.tagName] ??= node;
98
- // The first base WITH an href wins, as in browsers.
99
- else if (node.tagName === "base")
100
- raw.base ??= attr({ element: node, name: "href" });
92
+ if (isElement(node))
93
+ collectElement(raw, node);
101
94
  }
102
95
  return raw;
103
96
  }
97
+ function collectElement(raw, node) {
98
+ const list = COLLECTED.get(node.tagName);
99
+ if (list) {
100
+ raw[list].push(node);
101
+ return;
102
+ }
103
+ if (node.tagName === "title" || node.tagName === "html") {
104
+ raw[node.tagName] ??= node;
105
+ return;
106
+ }
107
+ // The first base WITH an href wins, as in browsers.
108
+ if (node.tagName === "base")
109
+ raw.base ??= attr({ element: node, name: "href" });
110
+ }
104
111
  /** Meta keys whose values are URLs and are returned absolute. */
105
112
  const URL_META = new Set(["og:image", "og:url", "twitter:image"]);
106
113
  /** `<link rel>` tokens copied into meta, in emission order, under their meta key. */
@@ -127,11 +134,16 @@ function putMeta({ meta, key, value }, _optional = {}) {
127
134
  return;
128
135
  meta[key] = collapse(value).slice(0, MAX_META_VALUE);
129
136
  }
137
+ function metaKey({ property, name }) {
138
+ if (property?.startsWith("og:"))
139
+ return property;
140
+ return name && META_NAMES.has(name) ? name : undefined;
141
+ }
130
142
  /** Every `og:*` property, or an allowlisted `name`; URL-valued keys are resolved. */
131
143
  function metaTagEntry({ element, base }, _optional = {}) {
132
144
  const property = attr({ element, name: "property" })?.toLowerCase();
133
145
  const name = attr({ element, name: "name" })?.toLowerCase();
134
- const key = property?.startsWith("og:") ? property : name && META_NAMES.has(name) ? name : undefined;
146
+ const key = metaKey({ property, name });
135
147
  if (key === undefined)
136
148
  return undefined;
137
149
  const content = attr({ element, name: "content" });
@@ -141,37 +153,21 @@ function relTokens({ element }, _optional = {}) {
141
153
  return (attr({ element, name: "rel" }) ?? "").toLowerCase().split(/\s+/);
142
154
  }
143
155
  function readLinks(anchors, base, pageHost) {
144
- const seen = new Set();
145
- const links = [];
146
- let truncated = false;
147
- for (const anchor of anchors) {
148
- const href = absolute({ raw: attr({ element: anchor, name: "href" }), base, protocols: LINK_PROTOCOLS });
149
- if (!href || seen.has(href))
150
- continue;
151
- seen.add(href);
152
- if (links.length === MAX_LINKS) {
153
- truncated = true;
154
- break;
155
- }
156
- links.push({ href, text: linkText(anchor), internal: isInternal(href, pageHost) });
157
- }
156
+ const { picked, truncated } = firstDistinct({
157
+ items: anchors,
158
+ max: MAX_LINKS,
159
+ keyOf: (anchor) => absolute({ raw: attr({ element: anchor, name: "href" }), base, protocols: LINK_PROTOCOLS }),
160
+ });
161
+ const links = picked.map(([href, anchor]) => ({ href, text: linkText(anchor), internal: isInternal(href, pageHost) }));
158
162
  return { links, truncated };
159
163
  }
160
164
  function readImages(images, base) {
161
- const seen = new Set();
162
- const out = [];
163
- let truncated = false;
164
- for (const img of images) {
165
- const src = absolute({ raw: imageSource({ img }), base, protocols: ASSET_PROTOCOLS });
166
- if (!src || seen.has(src))
167
- continue;
168
- seen.add(src);
169
- if (out.length === MAX_IMAGES) {
170
- truncated = true;
171
- break;
172
- }
173
- out.push({ src, alt: collapse(attr({ element: img, name: "alt" }) ?? "") });
174
- }
165
+ const { picked, truncated } = firstDistinct({
166
+ items: images,
167
+ max: MAX_IMAGES,
168
+ keyOf: (img) => absolute({ raw: imageSource({ img }), base, protocols: ASSET_PROTOCOLS }),
169
+ });
170
+ const out = picked.map(([src, img]) => ({ src, alt: collapse(attr({ element: img, name: "alt" }) ?? "") }));
175
171
  return { images: out, truncated };
176
172
  }
177
173
  function readStylesheets(linkTags, base) {
@@ -12,18 +12,25 @@ const MAX_DEPTH = 300;
12
12
  function isElement(node) {
13
13
  return "tagName" in node;
14
14
  }
15
+ /** A node's children for {@link plainText}; skipped elements and leaves contribute none. */
16
+ function scannableChildren(node) {
17
+ if (!("childNodes" in node) || (isElement(node) && SKIPPED.has(node.tagName)))
18
+ return [];
19
+ return node.childNodes;
20
+ }
15
21
  /** Iterative so a degraded, very deep subtree cannot overflow the stack either. O(subtree nodes). */
16
22
  function plainText(root) {
17
23
  const pending = [root];
18
24
  const parts = [];
19
25
  while (pending.length) {
20
26
  const node = pending.pop();
21
- if (node.nodeName === "#text" && "value" in node)
27
+ if (node.nodeName === "#text" && "value" in node) {
22
28
  parts.push(node.value);
23
- else if ("childNodes" in node && !(isElement(node) && SKIPPED.has(node.tagName))) {
24
- for (let i = node.childNodes.length - 1; i >= 0; i--)
25
- pending.push(node.childNodes[i]);
29
+ continue;
26
30
  }
31
+ const children = scannableChildren(node);
32
+ for (let i = children.length - 1; i >= 0; i--)
33
+ pending.push(children[i]);
27
34
  }
28
35
  return parts.join("").replace(/\s+/g, " ");
29
36
  }
@@ -112,9 +119,11 @@ function takeEdgeWhitespace({ node, last }, _options = {}) {
112
119
  const edge = edgeText({ node, last });
113
120
  if (!edge)
114
121
  return "";
115
- const whitespace = (last ? /\s+$/ : /^\s+/).exec(edge.value)?.[0] ?? "";
116
- edge.value = last ? edge.value.slice(0, edge.value.length - whitespace.length) : edge.value.slice(whitespace.length);
117
- return whitespace;
122
+ const value = edge.value;
123
+ // trimStart/trimEnd strip exactly the characters `\s` matches.
124
+ const kept = last ? value.trimEnd() : value.trimStart();
125
+ edge.value = kept;
126
+ return last ? value.slice(kept.length) : value.slice(0, value.length - kept.length);
118
127
  }
119
128
  /** The first/last leaf of the subtree when it is a text node. */
120
129
  function edgeText({ node, last }, _options = {}) {
@@ -141,9 +150,11 @@ function hastText(root) {
141
150
  while (pending.length) {
142
151
  const node = pending.pop();
143
152
  // HAST text nodes always carry a value.
144
- if (node.type === "text")
153
+ if (node.type === "text") {
145
154
  parts.push(node.value);
146
- else if (node.children)
155
+ continue;
156
+ }
157
+ if (node.children)
147
158
  for (let i = node.children.length - 1; i >= 0; i--)
148
159
  pending.push(node.children[i]);
149
160
  }
@@ -1,4 +1,5 @@
1
1
  import { boundHtml } from "./html-resource.js";
2
+ import { firstDistinct } from "./first-distinct.js";
2
3
  import { ToolInputError } from "@jini-ai/core";
3
4
  import { EgressRefusedError } from "../http/guarded/errors.js";
4
5
  import { htmlToText, readHtmlDocument, stripActiveHtml, MAX_LINKS, MAX_IMAGES } from "./html-document.js";
@@ -145,28 +146,25 @@ const EMPTY_FACTS = { links: [], linksTruncated: false, images: [], imagesTrunca
145
146
  /** A sitemap's `<loc>` entries become links so an importer can enumerate pages from one call. */
146
147
  function sitemapFacts(xml, pageUrl) {
147
148
  const host = new URL(pageUrl).hostname.replace(/^www\./, "");
148
- const seen = new Set();
149
- const links = [];
150
- let linksTruncated = false;
151
- for (const match of xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)) {
152
- let url;
153
- try {
154
- url = new URL(match[1].replace(/&amp;/g, "&"), pageUrl);
155
- }
156
- catch {
157
- continue;
158
- }
159
- if ((url.protocol !== "http:" && url.protocol !== "https:") || seen.has(url.href))
160
- continue;
161
- seen.add(url.href);
162
- if (links.length === MAX_LINKS) {
163
- linksTruncated = true;
164
- break;
165
- }
166
- links.push({ href: url.href, text: "", internal: url.hostname.replace(/^www\./, "") === host });
167
- }
149
+ const { picked, truncated: linksTruncated } = firstDistinct({
150
+ items: [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)],
151
+ max: MAX_LINKS,
152
+ keyOf: (match) => sitemapHref(match[1], pageUrl),
153
+ });
154
+ const links = picked.map(([href]) => ({ href, text: "", internal: new URL(href).hostname.replace(/^www\./, "") === host }));
168
155
  return { ...EMPTY_FACTS, links, linksTruncated };
169
156
  }
157
+ /** The absolute http(s) href of one `<loc>` value, or undefined when it is unparsable or another scheme. */
158
+ function sitemapHref(loc, pageUrl) {
159
+ let url;
160
+ try {
161
+ url = new URL(loc.replace(/&amp;/g, "&"), pageUrl);
162
+ }
163
+ catch {
164
+ return undefined;
165
+ }
166
+ return url.protocol === "http:" || url.protocol === "https:" ? url.href : undefined;
167
+ }
170
168
  function render(format, body, isHtml, pageUrl, ports) {
171
169
  if (!isHtml || format === "raw")
172
170
  return body;
@@ -364,9 +362,9 @@ function projectRendered({ input, ports, url, page }) {
364
362
  }
365
363
  /** Any clipped or dropped fact, image, data or text marks the whole rendered result truncated. */
366
364
  function renderedFlags({ page, result, images, factsTruncated, textTruncated }) {
367
- const imagesTruncated = images.truncated || page.imagesTruncated || result.imagesTruncated || images.byUrl.size > MAX_IMAGES;
368
- const truncated = imagesTruncated || factsTruncated || page.title.length > MAX_RENDERED_TITLE || result.truncated
369
- || result.linksTruncated || page.dataTruncated || textTruncated;
365
+ const imagesTruncated = [images.truncated, page.imagesTruncated, result.imagesTruncated, images.byUrl.size > MAX_IMAGES].some(Boolean);
366
+ const truncated = [imagesTruncated, factsTruncated, page.title.length > MAX_RENDERED_TITLE, result.truncated,
367
+ result.linksTruncated, page.dataTruncated, textTruncated].some(Boolean);
370
368
  return { imagesTruncated, truncated };
371
369
  }
372
370
  /** Browser image facts come first, so their alt text wins over the parsed DOM's for the same src. */
@@ -391,8 +389,13 @@ function boundRenderedFacts({ result }, _optional = {}) {
391
389
  const stylesheets = result.stylesheets.filter(href => href.length <= MAX_URL_LENGTH);
392
390
  const meta = Object.fromEntries(Object.entries(result.meta).filter(([key]) => key.length <= MAX_RENDERED_META_KEY));
393
391
  const linksDropped = links.length < result.links.length;
394
- const truncated = linksDropped || stylesheets.length < result.stylesheets.length || Object.keys(meta).length < Object.keys(result.meta).length
395
- || (result.description?.length ?? 0) > MAX_RENDERED_DESCRIPTION || (result.lang?.length ?? 0) > MAX_RENDERED_LANG;
392
+ const truncated = [
393
+ linksDropped,
394
+ stylesheets.length < result.stylesheets.length,
395
+ Object.keys(meta).length < Object.keys(result.meta).length,
396
+ (result.description?.length ?? 0) > MAX_RENDERED_DESCRIPTION,
397
+ (result.lang?.length ?? 0) > MAX_RENDERED_LANG,
398
+ ].some(Boolean);
396
399
  return { links, stylesheets, meta, linksDropped, truncated };
397
400
  }
398
401
  /** `none` empties content; `summary` and text format use the browser's visible text instead of the projection. */