@opengeni/core 2.8.0-canary.0 → 2.8.2-canary.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.
- package/dist/dependencies.d.ts +4 -2
- package/dist/domain/github-skill-source.d.ts +5 -0
- package/dist/domain/packs.d.ts +2 -3
- package/dist/domain/pr-review.d.ts +2 -1
- package/dist/domain/remember.d.ts +2 -2
- package/dist/domain/sessions.d.ts +1 -0
- package/dist/domain/skill-search.d.ts +30 -0
- package/dist/domain/skills.d.ts +73 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +523 -30
- package/dist/index.js.map +1 -1
- package/dist/transcription.d.ts +12 -1
- package/package.json +12 -12
- package/src/application/session-tenancy.ts +1 -0
- package/src/dependencies.ts +4 -2
- package/src/domain/github-skill-source.ts +142 -0
- package/src/domain/packs.ts +16 -8
- package/src/domain/product-integration-pack.ts +1 -0
- package/src/domain/remember.ts +7 -0
- package/src/domain/scheduled-tasks.ts +28 -0
- package/src/domain/sessions.ts +17 -0
- package/src/domain/skill-imports.ts +99 -15
- package/src/domain/skill-search.ts +218 -0
- package/src/domain/skills.ts +87 -0
- package/src/index.ts +3 -0
- package/src/sandbox/routing.ts +40 -1
- package/src/sandbox/runtime-settings.ts +9 -2
- package/src/transcription.ts +13 -1
package/dist/transcription.d.ts
CHANGED
|
@@ -28,6 +28,9 @@ export type TranscriptionRequest = {
|
|
|
28
28
|
providerDeadlineAt?: Date | undefined;
|
|
29
29
|
/** Exact provider selected before a resumable segment is first sent upstream. */
|
|
30
30
|
providerId?: string | undefined;
|
|
31
|
+
preferredProvider?: string | null | undefined;
|
|
32
|
+
fallbackEnabled?: boolean | undefined;
|
|
33
|
+
excludedProviders?: readonly string[] | undefined;
|
|
31
34
|
};
|
|
32
35
|
export type TranscriptionResult = TranscribeAudioResponse & {
|
|
33
36
|
/** Server-private provider id for operational metrics only. Never returned to clients. */
|
|
@@ -39,23 +42,31 @@ export declare class TranscriptionServiceError extends Error {
|
|
|
39
42
|
readonly code: VoiceInputErrorCode;
|
|
40
43
|
readonly status: number;
|
|
41
44
|
readonly retryable: boolean;
|
|
45
|
+
/** Explicit rejection before any transcription result; safe to try another provider. */
|
|
46
|
+
readonly fallbackSafe: boolean;
|
|
42
47
|
constructor(input: {
|
|
43
48
|
code: VoiceInputErrorCode;
|
|
44
49
|
message: string;
|
|
45
50
|
status?: number;
|
|
46
51
|
retryable?: boolean;
|
|
52
|
+
fallbackSafe?: boolean;
|
|
47
53
|
});
|
|
48
54
|
}
|
|
49
55
|
export declare function statusForVoiceInputError(code: VoiceInputErrorCode): number;
|
|
50
56
|
/** Optional workspace scope for readiness checks during provider selection. */
|
|
51
57
|
export type TranscriptionAvailabilityContext = {
|
|
58
|
+
afterProvider?: string | undefined;
|
|
59
|
+
preferredProvider?: string | null | undefined;
|
|
60
|
+
fallbackEnabled?: boolean | undefined;
|
|
61
|
+
excludedProviders?: readonly string[] | undefined;
|
|
52
62
|
workspaceId?: string | undefined;
|
|
53
63
|
subjectId?: string | undefined;
|
|
54
64
|
};
|
|
55
65
|
/**
|
|
56
66
|
* Extensible transcription provider port. Implementations own credentials and
|
|
57
67
|
* upstream request shape. Selection happens before audio is sent; providers must
|
|
58
|
-
*
|
|
68
|
+
* only fall back after an explicit rejection and before any successful or
|
|
69
|
+
* uncertain provider attempt. Recording persistence owns that eligibility.
|
|
59
70
|
*/
|
|
60
71
|
export type TranscriptionProvider = {
|
|
61
72
|
readonly id: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/core",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.2-canary.0",
|
|
4
4
|
"description": "OpenGeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -54,17 +54,17 @@
|
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
57
|
-
"@opengeni/capabilities": "^0.3.3-canary.
|
|
58
|
-
"@opengeni/codex": "^0.2.22-canary.
|
|
59
|
-
"@opengeni/config": "^1.0.
|
|
60
|
-
"@opengeni/contracts": "^2.
|
|
61
|
-
"@opengeni/db": "^4.1
|
|
62
|
-
"@opengeni/documents": "^0.8.
|
|
63
|
-
"@opengeni/events": "^0.4.
|
|
64
|
-
"@opengeni/network": "^0.3.1-canary.
|
|
65
|
-
"@opengeni/observability": "^0.8.
|
|
66
|
-
"@opengeni/runtime": "^2.4.
|
|
67
|
-
"@opengeni/storage": "^0.2.
|
|
57
|
+
"@opengeni/capabilities": "^0.3.3-canary.2",
|
|
58
|
+
"@opengeni/codex": "^0.2.22-canary.2",
|
|
59
|
+
"@opengeni/config": "^1.0.3-canary.0",
|
|
60
|
+
"@opengeni/contracts": "^2.15.1-canary.0",
|
|
61
|
+
"@opengeni/db": "^4.2.1-canary.0",
|
|
62
|
+
"@opengeni/documents": "^0.8.22-canary.0",
|
|
63
|
+
"@opengeni/events": "^0.4.20-canary.0",
|
|
64
|
+
"@opengeni/network": "^0.3.1-canary.2",
|
|
65
|
+
"@opengeni/observability": "^0.8.22-canary.0",
|
|
66
|
+
"@opengeni/runtime": "^2.4.2-canary.0",
|
|
67
|
+
"@opengeni/storage": "^0.2.123-canary.0",
|
|
68
68
|
"hono": "^4.12.18",
|
|
69
69
|
"zod": "^4.2.1"
|
|
70
70
|
},
|
|
@@ -287,6 +287,7 @@ export async function forkManagedHumanSession(
|
|
|
287
287
|
const forkInput = {
|
|
288
288
|
sourceWorkspaceId: workspaceId,
|
|
289
289
|
sourceSessionId,
|
|
290
|
+
...(request.sourceEventId ? { sourceEventId: request.sourceEventId } : {}),
|
|
290
291
|
actorSubjectId: authorization.grant.subjectId,
|
|
291
292
|
destinationWorkspaceId: workspaceId,
|
|
292
293
|
destinationVisibility:
|
package/src/dependencies.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
SessionAuthorizationPort,
|
|
10
10
|
TurnInitiator,
|
|
11
11
|
} from "@opengeni/contracts";
|
|
12
|
-
import type { Database } from "@opengeni/db";
|
|
12
|
+
import type { Database, SessionWorkflowWakeDeliveryResult } from "@opengeni/db";
|
|
13
13
|
import type { DocumentServices } from "@opengeni/documents";
|
|
14
14
|
import type { EventBus } from "@opengeni/events";
|
|
15
15
|
import type { Observability } from "@opengeni/observability";
|
|
@@ -44,7 +44,9 @@ export type SessionWorkflowClient = {
|
|
|
44
44
|
workflowId: string;
|
|
45
45
|
wakeRevision: number;
|
|
46
46
|
interruptionRequested?: boolean;
|
|
47
|
-
|
|
47
|
+
/** Called after transport acceptance, before the fallible durable ACK. */
|
|
48
|
+
onSignalAccepted?: () => void;
|
|
49
|
+
}) => Promise<SessionWorkflowWakeDeliveryResult | void>;
|
|
48
50
|
/** Trigger one bounded drain of already-committed workflow-wake revisions. */
|
|
49
51
|
requestSessionWorkflowWakeDispatch: () => Promise<void>;
|
|
50
52
|
// Dedicated, revision-carrying nudge for a durable Codex capacity waiter.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import type { GitHubSkillSourceClient, GitHubSkillTreeEntry } from "./skill-imports";
|
|
3
|
+
import { pinnedFetch, readResponseJsonBounded, readResponseTextBounded } from "@opengeni/network";
|
|
4
|
+
|
|
5
|
+
const githubApiBase = "https://api.github.com";
|
|
6
|
+
const githubRequestTimeoutMs = 15_000;
|
|
7
|
+
const githubMetadataMaxBytes = 4 * 1024 * 1024;
|
|
8
|
+
const githubBlobResponseMaxBytes = 512 * 1024;
|
|
9
|
+
|
|
10
|
+
type GitHubJsonRequest = (path: string, maxBytes: number, label: string) => Promise<unknown>;
|
|
11
|
+
|
|
12
|
+
export function createGitHubSkillSourceClient(
|
|
13
|
+
settings: Settings,
|
|
14
|
+
requestJson: GitHubJsonRequest = (path, maxBytes, label) =>
|
|
15
|
+
githubJson(settings, path, maxBytes, label),
|
|
16
|
+
): GitHubSkillSourceClient {
|
|
17
|
+
return {
|
|
18
|
+
resolveCommit: async (owner, repository, ref) => {
|
|
19
|
+
const payload = recordValue(
|
|
20
|
+
await requestJson(
|
|
21
|
+
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/commits/${encodeURIComponent(ref)}`,
|
|
22
|
+
githubMetadataMaxBytes,
|
|
23
|
+
"GitHub Skill commit",
|
|
24
|
+
),
|
|
25
|
+
"GitHub commit",
|
|
26
|
+
);
|
|
27
|
+
const sha = stringValue(payload.sha);
|
|
28
|
+
if (!sha) throw new Error("GitHub commit response omitted sha");
|
|
29
|
+
return sha.toLowerCase();
|
|
30
|
+
},
|
|
31
|
+
listTree: async (owner, repository, commit) => {
|
|
32
|
+
const payload = recordValue(
|
|
33
|
+
await requestJson(
|
|
34
|
+
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/git/trees/${encodeURIComponent(commit)}?recursive=1`,
|
|
35
|
+
githubMetadataMaxBytes,
|
|
36
|
+
"GitHub Skill tree",
|
|
37
|
+
),
|
|
38
|
+
"GitHub tree",
|
|
39
|
+
);
|
|
40
|
+
if (payload.truncated === true) {
|
|
41
|
+
throw new Error("GitHub repository tree is too large to import safely");
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(payload.tree)) throw new Error("GitHub tree response omitted entries");
|
|
44
|
+
return payload.tree.map((entry, index): GitHubSkillTreeEntry => {
|
|
45
|
+
const record = recordValue(entry, `GitHub tree entry ${index}`);
|
|
46
|
+
const path = stringValue(record.path);
|
|
47
|
+
const type = stringValue(record.type);
|
|
48
|
+
const mode = stringValue(record.mode);
|
|
49
|
+
const sha = stringValue(record.sha);
|
|
50
|
+
const size = record.size;
|
|
51
|
+
if (
|
|
52
|
+
!path ||
|
|
53
|
+
(type !== "blob" && type !== "tree" && type !== "commit") ||
|
|
54
|
+
!mode ||
|
|
55
|
+
!sha ||
|
|
56
|
+
(size !== undefined &&
|
|
57
|
+
size !== null &&
|
|
58
|
+
(typeof size !== "number" || !Number.isSafeInteger(size) || size < 0))
|
|
59
|
+
) {
|
|
60
|
+
throw new Error(`GitHub tree entry ${index} is invalid`);
|
|
61
|
+
}
|
|
62
|
+
return { path, type, mode, sha, size: typeof size === "number" ? size : null };
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
readBlob: async (owner, repository, sha) => {
|
|
66
|
+
const payload = recordValue(
|
|
67
|
+
await requestJson(
|
|
68
|
+
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/git/blobs/${encodeURIComponent(sha)}`,
|
|
69
|
+
githubBlobResponseMaxBytes,
|
|
70
|
+
"GitHub Skill file",
|
|
71
|
+
),
|
|
72
|
+
"GitHub blob",
|
|
73
|
+
);
|
|
74
|
+
if (payload.encoding !== "base64" || typeof payload.content !== "string") {
|
|
75
|
+
throw new Error("GitHub Skill file did not use base64 encoding");
|
|
76
|
+
}
|
|
77
|
+
const normalized = payload.content.replace(/\s+/gu, "");
|
|
78
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(normalized)) {
|
|
79
|
+
throw new Error("GitHub Skill file contained invalid base64");
|
|
80
|
+
}
|
|
81
|
+
const bytes = Uint8Array.from(Buffer.from(normalized, "base64"));
|
|
82
|
+
if (
|
|
83
|
+
typeof payload.size === "number" &&
|
|
84
|
+
Number.isSafeInteger(payload.size) &&
|
|
85
|
+
payload.size !== bytes.byteLength
|
|
86
|
+
) {
|
|
87
|
+
throw new Error("GitHub Skill file size did not match its payload");
|
|
88
|
+
}
|
|
89
|
+
return bytes;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function githubJson(
|
|
95
|
+
settings: Settings,
|
|
96
|
+
path: string,
|
|
97
|
+
maxBytes: number,
|
|
98
|
+
label: string,
|
|
99
|
+
): Promise<unknown> {
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const timeout = setTimeout(() => controller.abort(), githubRequestTimeoutMs);
|
|
102
|
+
try {
|
|
103
|
+
const response = await pinnedFetch(
|
|
104
|
+
`${githubApiBase}${path}`,
|
|
105
|
+
{
|
|
106
|
+
method: "GET",
|
|
107
|
+
headers: {
|
|
108
|
+
accept: "application/vnd.github+json",
|
|
109
|
+
"user-agent": "OpenGeni-Capabilities",
|
|
110
|
+
"x-github-api-version": "2022-11-28",
|
|
111
|
+
},
|
|
112
|
+
signal: controller.signal,
|
|
113
|
+
},
|
|
114
|
+
settings,
|
|
115
|
+
{ label, requireHttpsOutsideLocalTest: true },
|
|
116
|
+
);
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
await readResponseTextBounded(response, 8_192, `${label} error`).catch(() => undefined);
|
|
119
|
+
if (response.status === 404) throw new Error(`${label} was not found or is not public`);
|
|
120
|
+
if (response.status === 403 || response.status === 429) {
|
|
121
|
+
throw new Error(`${label} is temporarily unavailable because GitHub limited the request`);
|
|
122
|
+
}
|
|
123
|
+
throw new Error(`${label} failed with HTTP ${response.status}`);
|
|
124
|
+
}
|
|
125
|
+
return await readResponseJsonBounded(response, maxBytes, label, {
|
|
126
|
+
signal: controller.signal,
|
|
127
|
+
});
|
|
128
|
+
} finally {
|
|
129
|
+
clearTimeout(timeout);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function recordValue(value: unknown, label: string): Record<string, unknown> {
|
|
134
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
135
|
+
throw new Error(`${label} response is invalid`);
|
|
136
|
+
}
|
|
137
|
+
return value as Record<string, unknown>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function stringValue(value: unknown): string | null {
|
|
141
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
142
|
+
}
|
package/src/domain/packs.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
CapabilityPack,
|
|
5
|
+
StoredCapabilityPack,
|
|
5
6
|
OPENGENI_PR_REVIEW_PACK_ID,
|
|
6
7
|
OPENGENI_PR_REVIEW_SESSION_ROLE,
|
|
7
8
|
stableJson,
|
|
@@ -298,9 +299,8 @@ export function isBuiltInCapabilityPack(packId: string): boolean {
|
|
|
298
299
|
|
|
299
300
|
/**
|
|
300
301
|
* Built-in packs plus the manifests registered for this workspace. Stored
|
|
301
|
-
* manifests
|
|
302
|
-
*
|
|
303
|
-
* the whole catalog.
|
|
302
|
+
* manifests retain exact historical bytes. Execution views derive Skill labels
|
|
303
|
+
* from their files, but malformed content needs explicit repair, not omission.
|
|
304
304
|
*/
|
|
305
305
|
export async function listWorkspaceCapabilityPacks(
|
|
306
306
|
db: Database,
|
|
@@ -310,9 +310,13 @@ export async function listWorkspaceCapabilityPacks(
|
|
|
310
310
|
const builtInIds = new Set(packs.map((pack) => pack.id));
|
|
311
311
|
const registeredPacks = registered
|
|
312
312
|
.filter((registration) => !builtInIds.has(registration.pack.id))
|
|
313
|
-
.
|
|
314
|
-
const parsed =
|
|
315
|
-
|
|
313
|
+
.map((registration) => {
|
|
314
|
+
const parsed = StoredCapabilityPack.safeParse(registration.pack);
|
|
315
|
+
if (!parsed.success)
|
|
316
|
+
throw new HTTPException(422, {
|
|
317
|
+
message: `Stored Pack ${registration.pack.id} requires repair before it can be used: ${parsed.error.message}`,
|
|
318
|
+
});
|
|
319
|
+
return parsed.data;
|
|
316
320
|
});
|
|
317
321
|
return [...packs, ...registeredPacks];
|
|
318
322
|
}
|
|
@@ -330,8 +334,12 @@ export async function resolveCapabilityPack(
|
|
|
330
334
|
if (!registration) {
|
|
331
335
|
return null;
|
|
332
336
|
}
|
|
333
|
-
const parsed =
|
|
334
|
-
|
|
337
|
+
const parsed = StoredCapabilityPack.safeParse(registration.pack);
|
|
338
|
+
if (!parsed.success)
|
|
339
|
+
throw new HTTPException(422, {
|
|
340
|
+
message: `Stored Pack ${packId} requires repair before it can be used: ${parsed.error.message}`,
|
|
341
|
+
});
|
|
342
|
+
return parsed.data;
|
|
335
343
|
}
|
|
336
344
|
|
|
337
345
|
export function capabilityPackManifestDigest(pack: CapabilityPack): string {
|
|
@@ -37,6 +37,7 @@ The desired outcome is a native-feeling product experience backed by a standalon
|
|
|
37
37
|
- When an unknown choice is reversible and low-risk, choose the best-fitting default, state the assumption, and continue. When it changes privacy, tenant authority, write access, cost exposure, or an external mutation, resolve it before crossing that boundary.
|
|
38
38
|
- Possession of a credential or access to a cloud, repository, or deployment is technical capability, not authorization. Match the user's requested delivery autonomy and the repository's stated workflow.
|
|
39
39
|
- Keep alternatives open until evidence eliminates them. Use strict rules only for actual security, privacy, protocol, or authorization invariants.
|
|
40
|
+
- For packaged React chat, use SessionConversation or compose MessageTimeline and ChatComposer with the normal SDK and authenticated session routes. For a custom or compatible frontend, use the optional backend chat handler.
|
|
40
41
|
|
|
41
42
|
Read the references selectively:
|
|
42
43
|
|
package/src/domain/remember.ts
CHANGED
|
@@ -50,6 +50,7 @@ export class RememberError extends Error {
|
|
|
50
50
|
| "proposal_unavailable"
|
|
51
51
|
| "proposal_not_confirmable"
|
|
52
52
|
| "human_confirmation_unavailable"
|
|
53
|
+
| "preference_retired"
|
|
53
54
|
| "baseline_stale",
|
|
54
55
|
message: string,
|
|
55
56
|
) {
|
|
@@ -327,6 +328,12 @@ export function createRememberRouter(options: RememberRouterOptions): {
|
|
|
327
328
|
async remember(input) {
|
|
328
329
|
const attempt = await attemptOf(input.attempt);
|
|
329
330
|
const request = RememberRequest.parse(input.request);
|
|
331
|
+
if (request.lane === "preference") {
|
|
332
|
+
throw new RememberError(
|
|
333
|
+
"preference_retired",
|
|
334
|
+
"The Remember preference lane is retired; use skill_save for shared Skill files. No note or proposal was created.",
|
|
335
|
+
);
|
|
336
|
+
}
|
|
330
337
|
const note = await createNote(options.db, {
|
|
331
338
|
...attempt,
|
|
332
339
|
operationId: derivedRememberOperationId(request.operationId, "note"),
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
|
|
22
22
|
OPENGENI_SLACK_BOT_SESSION_METADATA_KEY,
|
|
23
23
|
resolveWorkspaceSessionToolDefaults,
|
|
24
|
+
resolveBundledSkillSelection,
|
|
24
25
|
SessionAgentAccess,
|
|
25
26
|
SessionEndUser,
|
|
26
27
|
SessionMemoryScope,
|
|
@@ -480,6 +481,14 @@ export async function validateScheduledTaskTarget(input: {
|
|
|
480
481
|
if (!session || session.accountId !== input.grant.accountId) {
|
|
481
482
|
throw new HTTPException(404, { message: "target session not found" });
|
|
482
483
|
}
|
|
484
|
+
if (
|
|
485
|
+
input.agentConfig.bundledSkillIds !== undefined &&
|
|
486
|
+
!isDeepStrictEqual(input.agentConfig.bundledSkillIds, session.bundledSkillIds)
|
|
487
|
+
) {
|
|
488
|
+
throw new HTTPException(422, {
|
|
489
|
+
message: "An existing-session schedule cannot change that session's bundled Skill selection",
|
|
490
|
+
});
|
|
491
|
+
}
|
|
483
492
|
if (session.status === "cancelled") {
|
|
484
493
|
throw new HTTPException(409, {
|
|
485
494
|
message: "target session is cancelled; choose a revivable session",
|
|
@@ -1301,6 +1310,24 @@ async function validateScheduledTaskAgentConfig(input: {
|
|
|
1301
1310
|
workspaceId: string;
|
|
1302
1311
|
toolsProvided?: boolean;
|
|
1303
1312
|
}): Promise<ScheduledTaskAgentConfig> {
|
|
1313
|
+
const actor = creationInitiatorForGrant(input.grant).actor;
|
|
1314
|
+
const parent = actor ? await getSession(input.db, input.workspaceId, actor.sessionId) : null;
|
|
1315
|
+
if (actor && (!parent || parent.accountId !== input.grant.accountId)) {
|
|
1316
|
+
throw new HTTPException(403, {
|
|
1317
|
+
message: "Scheduled Skill selection requires the creating agent's session",
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
let bundledSkillIds: ScheduledTaskAgentConfig["bundledSkillIds"];
|
|
1321
|
+
try {
|
|
1322
|
+
bundledSkillIds = resolveBundledSkillSelection(
|
|
1323
|
+
input.payload.agentConfig.bundledSkillIds,
|
|
1324
|
+
parent?.bundledSkillIds,
|
|
1325
|
+
);
|
|
1326
|
+
} catch (error) {
|
|
1327
|
+
throw new HTTPException(422, {
|
|
1328
|
+
message: error instanceof Error ? error.message : "Invalid bundled Skill selection",
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1304
1331
|
// Reject a curated-out model before touching the DB: a scheduled task is a
|
|
1305
1332
|
// session the worker runs later, so it must pass the same allow-list as the
|
|
1306
1333
|
// session choke points (a `scheduled_tasks:manage` holder could otherwise set
|
|
@@ -1382,6 +1409,7 @@ async function validateScheduledTaskAgentConfig(input: {
|
|
|
1382
1409
|
}
|
|
1383
1410
|
const validated = {
|
|
1384
1411
|
...input.payload.agentConfig,
|
|
1412
|
+
...(bundledSkillIds !== undefined ? { bundledSkillIds } : {}),
|
|
1385
1413
|
...(model === undefined || model === null ? {} : { model }),
|
|
1386
1414
|
prompt,
|
|
1387
1415
|
resources,
|
package/src/domain/sessions.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
FIRST_PARTY_MCP_TOOL_NAMES,
|
|
23
23
|
OPENGENI_SLACK_BOT_SESSION_METADATA_KEY,
|
|
24
24
|
SessionSkills,
|
|
25
|
+
resolveBundledSkillSelection,
|
|
25
26
|
SessionSpawnDenial,
|
|
26
27
|
ServiceTurnInitiator,
|
|
27
28
|
ServiceTurnInitiatorContext,
|
|
@@ -747,6 +748,7 @@ export async function createAndStartSessionWithOutcome(input: {
|
|
|
747
748
|
modelContext?: string | null;
|
|
748
749
|
resources: ResourceRef[];
|
|
749
750
|
skills?: SessionSkill[];
|
|
751
|
+
bundledSkillIds?: import("@opengeni/contracts").BundledSkillId[] | undefined;
|
|
750
752
|
tools: ToolRef[];
|
|
751
753
|
// Public admission always supplies provenance; optional keeps internal
|
|
752
754
|
// callers that predate durable tool-policy provenance source-compatible
|
|
@@ -995,6 +997,7 @@ export async function createAndStartSessionWithOutcome(input: {
|
|
|
995
997
|
initialModelContext: input.modelContext ?? null,
|
|
996
998
|
resources: input.resources,
|
|
997
999
|
skills: input.skills ?? [],
|
|
1000
|
+
bundledSkillIds: input.bundledSkillIds,
|
|
998
1001
|
tools: input.tools,
|
|
999
1002
|
toolPolicy: input.toolPolicy,
|
|
1000
1003
|
metadata: sessionMetadata,
|
|
@@ -1088,6 +1091,7 @@ export async function createAndStartSessionWithOutcome(input: {
|
|
|
1088
1091
|
initialModelContext: input.modelContext ?? null,
|
|
1089
1092
|
resources: input.resources,
|
|
1090
1093
|
skills: input.skills ?? [],
|
|
1094
|
+
bundledSkillIds: input.bundledSkillIds,
|
|
1091
1095
|
tools: input.tools,
|
|
1092
1096
|
toolPolicy: input.toolPolicy,
|
|
1093
1097
|
metadata: sessionMetadata,
|
|
@@ -2038,6 +2042,17 @@ export async function createSessionForRequestWithOutcome(
|
|
|
2038
2042
|
message: error instanceof Error ? error.message : "invalid child visibility",
|
|
2039
2043
|
});
|
|
2040
2044
|
}
|
|
2045
|
+
let bundledSkillIds: import("@opengeni/contracts").BundledSkillId[] | undefined;
|
|
2046
|
+
try {
|
|
2047
|
+
bundledSkillIds = resolveBundledSkillSelection(
|
|
2048
|
+
payload.bundledSkillIds,
|
|
2049
|
+
parentSession?.bundledSkillIds,
|
|
2050
|
+
);
|
|
2051
|
+
} catch (error) {
|
|
2052
|
+
throw new HTTPException(422, {
|
|
2053
|
+
message: error instanceof Error ? error.message : "Invalid bundled Skill selection",
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2041
2056
|
// Agent-access/end-user/memory scope inherit and narrow exactly like
|
|
2042
2057
|
// visibility: presence is read from the raw request because the Zod
|
|
2043
2058
|
// defaults erase absent-vs-explicit, and the parent side comes from the
|
|
@@ -2092,6 +2107,7 @@ export async function createSessionForRequestWithOutcome(
|
|
|
2092
2107
|
) {
|
|
2093
2108
|
try {
|
|
2094
2109
|
const initializedReplay = await getInitializedSessionCreateReplay(db, {
|
|
2110
|
+
bundledSkillIds,
|
|
2095
2111
|
accountId: grant.accountId,
|
|
2096
2112
|
workspaceId,
|
|
2097
2113
|
subjectId: replayManagedHumanSubjectId ?? grant.subjectId,
|
|
@@ -2941,6 +2957,7 @@ export async function createSessionForRequestWithOutcome(
|
|
|
2941
2957
|
modelContext: payload.modelContext ?? null,
|
|
2942
2958
|
resources,
|
|
2943
2959
|
skills,
|
|
2960
|
+
bundledSkillIds,
|
|
2944
2961
|
tools,
|
|
2945
2962
|
toolPolicy,
|
|
2946
2963
|
...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
|
|
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import type { SkillImportPreview, SkillImportSource } from "@opengeni/contracts";
|
|
3
3
|
import {
|
|
4
4
|
buildPortableSkillArtifact,
|
|
5
|
+
parsePortableSkillFrontmatter,
|
|
6
|
+
PORTABLE_SKILL_MAX_FILE_BYTES,
|
|
5
7
|
PORTABLE_SKILL_MAX_FILES,
|
|
6
8
|
PORTABLE_SKILL_MAX_TOTAL_BYTES,
|
|
7
9
|
type SkillLibraryFile,
|
|
@@ -55,7 +57,18 @@ export async function resolveSkillImport(
|
|
|
55
57
|
throw new HTTPException(422, { message: "GitHub returned an invalid source commit" });
|
|
56
58
|
}
|
|
57
59
|
const tree = await client.listTree(parsed.owner, parsed.repository, sourceCommit);
|
|
58
|
-
|
|
60
|
+
// Metadata discovery and the selected artifact share immutable blobs. This
|
|
61
|
+
// avoids reading SKILL.md twice without caching mutable refs or remote URLs.
|
|
62
|
+
const blobs = new Map<string, Promise<Uint8Array>>();
|
|
63
|
+
const readBlob = (sha: string) => {
|
|
64
|
+
let pending = blobs.get(sha);
|
|
65
|
+
if (!pending) {
|
|
66
|
+
pending = client.readBlob(parsed.owner, parsed.repository, sha);
|
|
67
|
+
blobs.set(sha, pending);
|
|
68
|
+
}
|
|
69
|
+
return pending;
|
|
70
|
+
};
|
|
71
|
+
const sourcePath = await selectSkillRoot(parsed, tree, readBlob, sourceCommit);
|
|
59
72
|
const entries = skillFilesUnderRoot(tree, sourcePath);
|
|
60
73
|
const declaredBytes = entries.reduce((sum, entry) => sum + (entry.size ?? 0), 0);
|
|
61
74
|
if (entries.length > PORTABLE_SKILL_MAX_FILES) {
|
|
@@ -69,11 +82,11 @@ export async function resolveSkillImport(
|
|
|
69
82
|
});
|
|
70
83
|
}
|
|
71
84
|
const files = await mapConcurrent(entries, maxConcurrentBlobReads, async (entry) => {
|
|
85
|
+
// Keep provider/network failures distinct from UTF-8 validation failures.
|
|
86
|
+
const bytes = await readBlob(entry.sha);
|
|
72
87
|
let content: string;
|
|
73
88
|
try {
|
|
74
|
-
content = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
75
|
-
await client.readBlob(parsed.owner, parsed.repository, entry.sha),
|
|
76
|
-
);
|
|
89
|
+
content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
77
90
|
} catch {
|
|
78
91
|
throw new HTTPException(422, {
|
|
79
92
|
message: `Skill file is not valid UTF-8 text: ${relativeSkillPath(entry.path, sourcePath)}`,
|
|
@@ -159,7 +172,12 @@ export function parseSkillSource(rawUrl: string): ParsedSkillSource {
|
|
|
159
172
|
message: "Skill imports require a credential-free HTTPS URL without a fragment",
|
|
160
173
|
});
|
|
161
174
|
}
|
|
162
|
-
|
|
175
|
+
let segments: string[];
|
|
176
|
+
try {
|
|
177
|
+
segments = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
178
|
+
} catch {
|
|
179
|
+
throw new HTTPException(422, { message: "The Skill URL contains invalid encoding" });
|
|
180
|
+
}
|
|
163
181
|
if (url.hostname === "skills.sh" || url.hostname === "www.skills.sh") {
|
|
164
182
|
if (segments.length !== 3) {
|
|
165
183
|
throw new HTTPException(422, {
|
|
@@ -212,14 +230,14 @@ export function parseSkillSource(rawUrl: string): ParsedSkillSource {
|
|
|
212
230
|
const ref = segments[3];
|
|
213
231
|
if (!ref) throw new HTTPException(422, { message: "The GitHub URL is missing a revision" });
|
|
214
232
|
const pathSegments = segments.slice(4);
|
|
215
|
-
if (pathSegments.length === 0) {
|
|
233
|
+
if (pathSegments.length === 0 && mode === "blob") {
|
|
216
234
|
throw new HTTPException(422, { message: "The GitHub URL is missing a Skill folder" });
|
|
217
235
|
}
|
|
218
|
-
const
|
|
236
|
+
const folderSegments =
|
|
219
237
|
mode === "blob" && pathSegments.at(-1)?.toLowerCase() === "skill.md"
|
|
220
238
|
? pathSegments.slice(0, -1)
|
|
221
|
-
: pathSegments
|
|
222
|
-
);
|
|
239
|
+
: pathSegments;
|
|
240
|
+
const requestedPath = folderSegments.length === 0 ? "." : normalizeGitHubPath(folderSegments);
|
|
223
241
|
return {
|
|
224
242
|
source: "github",
|
|
225
243
|
owner,
|
|
@@ -231,7 +249,12 @@ export function parseSkillSource(rawUrl: string): ParsedSkillSource {
|
|
|
231
249
|
};
|
|
232
250
|
}
|
|
233
251
|
|
|
234
|
-
function selectSkillRoot(
|
|
252
|
+
async function selectSkillRoot(
|
|
253
|
+
source: ParsedSkillSource,
|
|
254
|
+
tree: readonly GitHubSkillTreeEntry[],
|
|
255
|
+
readBlob: (sha: string) => Promise<Uint8Array>,
|
|
256
|
+
sourceCommit: string,
|
|
257
|
+
): Promise<string> {
|
|
235
258
|
const skillFiles = tree
|
|
236
259
|
.filter(
|
|
237
260
|
(entry) =>
|
|
@@ -239,7 +262,7 @@ function selectSkillRoot(source: ParsedSkillSource, tree: readonly GitHubSkillTr
|
|
|
239
262
|
entry.mode !== "120000" &&
|
|
240
263
|
(entry.path === "SKILL.md" || entry.path.endsWith("/SKILL.md")),
|
|
241
264
|
)
|
|
242
|
-
.map((entry) => entry.path.slice(0, -"/SKILL.md".length)
|
|
265
|
+
.map((entry) => (entry.path === "SKILL.md" ? "." : entry.path.slice(0, -"/SKILL.md".length)))
|
|
243
266
|
.sort();
|
|
244
267
|
if (source.requestedPath) {
|
|
245
268
|
const root = source.requestedPath;
|
|
@@ -250,9 +273,61 @@ function selectSkillRoot(source: ParsedSkillSource, tree: readonly GitHubSkillTr
|
|
|
250
273
|
}
|
|
251
274
|
return root;
|
|
252
275
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
276
|
+
let candidates = skillFiles;
|
|
277
|
+
if (source.skillSlug) {
|
|
278
|
+
// skills.sh identifies frontmatter names, not necessarily folder basenames
|
|
279
|
+
// (e.g. vercel-react-best-practices lives in skills/react-best-practices).
|
|
280
|
+
// Bound discovery as well as the eventual artifact; exact folder URLs avoid
|
|
281
|
+
// this scan for very large repositories or duplicate names.
|
|
282
|
+
if (skillFiles.length > PORTABLE_SKILL_MAX_FILES) {
|
|
283
|
+
throw new HTTPException(422, {
|
|
284
|
+
message: "Too many Skill candidates; paste the exact GitHub folder URL",
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
let metadataBytes = 0;
|
|
288
|
+
const entriesByPath = new Map(tree.map((entry) => [entry.path, entry]));
|
|
289
|
+
const matches = await mapConcurrent(skillFiles, maxConcurrentBlobReads, async (root) => {
|
|
290
|
+
const entry = entriesByPath.get(root === "." ? "SKILL.md" : `${root}/SKILL.md`)!;
|
|
291
|
+
if ((entry.size ?? 0) > PORTABLE_SKILL_MAX_FILE_BYTES) {
|
|
292
|
+
throw new HTTPException(422, {
|
|
293
|
+
message: "Skill metadata is too large; paste the exact GitHub folder URL",
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
const bytes = await readBlob(entry.sha);
|
|
297
|
+
metadataBytes += bytes.byteLength;
|
|
298
|
+
if (
|
|
299
|
+
bytes.byteLength > PORTABLE_SKILL_MAX_FILE_BYTES ||
|
|
300
|
+
metadataBytes > PORTABLE_SKILL_MAX_TOTAL_BYTES
|
|
301
|
+
) {
|
|
302
|
+
throw new HTTPException(422, {
|
|
303
|
+
message: "Skill metadata is too large; paste the exact GitHub folder URL",
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
let markdown: string;
|
|
307
|
+
try {
|
|
308
|
+
markdown = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
309
|
+
} catch {
|
|
310
|
+
throw new HTTPException(422, {
|
|
311
|
+
message: "Skill metadata is not valid UTF-8; paste the exact GitHub folder URL",
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return (
|
|
315
|
+
parsePortableSkillFrontmatter(markdown).name?.toLowerCase() ===
|
|
316
|
+
source.skillSlug!.toLowerCase()
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
candidates = skillFiles.filter((_, index) => matches[index]);
|
|
320
|
+
if (candidates.length === 0) {
|
|
321
|
+
// A folder basename is not a skills.sh identity. Never silently import a
|
|
322
|
+
// different frontmatter name from a stale directory-shaped link.
|
|
323
|
+
const folders = skillFiles.filter((path) => path.split("/").at(-1) === source.skillSlug);
|
|
324
|
+
const exactPath =
|
|
325
|
+
folders.length === 1 ? encodeGitHubPath(folders[0]!) : "<exact-skill-folder-path>";
|
|
326
|
+
throw new HTTPException(422, {
|
|
327
|
+
message: `No Skill frontmatter name matches skills.sh slug "${source.skillSlug}"; the link may be stale. Check the current Skill name, or explicitly select the intended Skill using its exact GitHub folder URL: https://github.com/${source.owner}/${source.repository}/tree/${sourceCommit}/${exactPath}`,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
256
331
|
if (candidates.length === 0) {
|
|
257
332
|
throw new HTTPException(422, { message: "No Skill folder with SKILL.md was found" });
|
|
258
333
|
}
|
|
@@ -332,13 +407,22 @@ async function mapConcurrent<Input, Output>(
|
|
|
332
407
|
): Promise<Output[]> {
|
|
333
408
|
const output = new Array<Output>(values.length);
|
|
334
409
|
let next = 0;
|
|
410
|
+
let failed = false;
|
|
335
411
|
await Promise.all(
|
|
336
412
|
Array.from({ length: Math.min(concurrency, values.length) }, async () => {
|
|
337
413
|
for (;;) {
|
|
414
|
+
if (failed) return;
|
|
338
415
|
const index = next;
|
|
339
416
|
next += 1;
|
|
340
417
|
if (index >= values.length) return;
|
|
341
|
-
|
|
418
|
+
try {
|
|
419
|
+
output[index] = await map(values[index]!);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
// Let existing reads settle, but do not launch further provider calls
|
|
422
|
+
// after any peer detects a failed request or an exceeded byte budget.
|
|
423
|
+
failed = true;
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
342
426
|
}
|
|
343
427
|
}),
|
|
344
428
|
);
|