@xfey/tutti 0.1.113 → 0.1.114

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 (29) hide show
  1. package/dist/server-shell/cli/relay-registration.js +1 -0
  2. package/dist/server-shell/http/routes/agent-context-skills-routes.js +2 -1
  3. package/dist/server-shell/http/routes/project-api/openapi-skills-routes.d.ts +23 -0
  4. package/dist/server-shell/http/routes/project-api/openapi-skills-routes.js +9 -1
  5. package/dist/server-shell/http/routes/project-api/openapi.d.ts +22 -0
  6. package/dist/server-shell/http/routes/project-api/project-timeline-projection.js +27 -33
  7. package/dist/server-shell/http/routes/project-api/skills-helpers.js +9 -0
  8. package/dist/server-shell/http/routes/project-api/skills-routes.js +8 -4
  9. package/dist/server-shell/http/validation.js +1 -0
  10. package/dist/skills/constants.d.ts +5 -0
  11. package/dist/skills/constants.js +6 -0
  12. package/dist/skills/errors.d.ts +1 -1
  13. package/dist/skills/file-reader.d.ts +14 -0
  14. package/dist/skills/file-reader.js +123 -0
  15. package/dist/skills/index.d.ts +5 -2
  16. package/dist/skills/index.js +36 -41
  17. package/dist/skills/metadata.d.ts +2 -0
  18. package/dist/skills/metadata.js +10 -1
  19. package/dist/skills/zip-import.js +2 -2
  20. package/node_modules/@tutti/relay-client/dist/host-control.d.ts +1 -0
  21. package/node_modules/@tutti/relay-client/dist/host-control.js +3 -0
  22. package/node_modules/@tutti/shared/dist/schemas/api/primitives.d.ts +2 -2
  23. package/node_modules/@tutti/shared/dist/schemas/api/primitives.js +1 -0
  24. package/package.json +1 -1
  25. package/web/assets/{homepage-motion-scene-BwdH6Iss.js → homepage-motion-scene-DwIRFWMo.js} +1 -1
  26. package/web/assets/{index-DvfZ57Nl.css → index-C9grZQCJ.css} +1 -1
  27. package/web/assets/index-Igpofaw2.js +69 -0
  28. package/web/index.html +2 -2
  29. package/web/assets/index-B29RuNsd.js +0 -69
@@ -39,6 +39,7 @@ export async function registerHostWithRelay(options) {
39
39
  integration_source_ingestion_v1: true,
40
40
  integration_development_bot_v1: true,
41
41
  integration_clarification_v1: true,
42
+ feishu_supporting_reads_v1: true,
42
43
  integration_external_references_v1: true,
43
44
  integration_attachment_ingestion_v1: true,
44
45
  integration_managed_reference_ingestion_v1: true,
@@ -1,5 +1,5 @@
1
1
  import { redactText } from "@tutti/shared/utils";
2
- import { listUserSkills, readUserSkill, SkillServiceError, } from "../../../skills/index.js";
2
+ import { listUserSkills, readUserSkill, SkillServiceError } from "../../../skills/index.js";
3
3
  import { authorizeAgentContextRequest } from "./agent-context-auth.js";
4
4
  import { AgentContextBadRequestError } from "./agent-context-errors.js";
5
5
  import { boundedLimit, pageByOffset, queryString, truncateText } from "./agent-context-query.js";
@@ -23,6 +23,7 @@ function handleSkillReadError(error, skillName) {
23
23
  case "validation_failed":
24
24
  return skillUnavailable({ skillName, reason: "invalid" });
25
25
  case "filesystem_error":
26
+ case "read_limit_exceeded":
26
27
  return skillUnavailable({ skillName, reason: "unavailable" });
27
28
  case "invalid_zip":
28
29
  return skillUnavailable({ skillName, reason: "invalid" });
@@ -17,6 +17,17 @@ export declare const HOST_PROJECT_SKILLS_OPENAPI_ROUTES: ({
17
17
  }>>;
18
18
  }>;
19
19
  };
20
+ 413: {
21
+ description: string;
22
+ schema: import("@sinclair/typebox").TObject<{
23
+ error: import("@sinclair/typebox").TObject<{
24
+ code: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"bad_request">, import("@sinclair/typebox").TLiteral<"unauthorized">, import("@sinclair/typebox").TLiteral<"forbidden">, import("@sinclair/typebox").TLiteral<"not_found">, import("@sinclair/typebox").TLiteral<"conflict">, import("@sinclair/typebox").TLiteral<"validation_failed">, import("@sinclair/typebox").TLiteral<"provider_not_configured">, import("@sinclair/typebox").TLiteral<"provider_auth_invalid">, import("@sinclair/typebox").TLiteral<"provider_quota_or_billing_required">, import("@sinclair/typebox").TLiteral<"provider_rate_limited">, import("@sinclair/typebox").TLiteral<"provider_model_unavailable">, import("@sinclair/typebox").TLiteral<"provider_network_error">, import("@sinclair/typebox").TLiteral<"relay_session_invalid">, import("@sinclair/typebox").TLiteral<"project_membership_required">, import("@sinclair/typebox").TLiteral<"command_replay_mismatch">, import("@sinclair/typebox").TLiteral<"repo_snapshot_unavailable">, import("@sinclair/typebox").TLiteral<"host_unavailable">, import("@sinclair/typebox").TLiteral<"skill_read_limit_exceeded">, import("@sinclair/typebox").TLiteral<"internal_error">]>;
25
+ message: import("@sinclair/typebox").TString;
26
+ retryable: import("@sinclair/typebox").TBoolean;
27
+ details: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnknown>;
28
+ }>;
29
+ }>;
30
+ };
20
31
  };
21
32
  parameters?: never;
22
33
  requestBody?: never;
@@ -41,6 +52,17 @@ export declare const HOST_PROJECT_SKILLS_OPENAPI_ROUTES: ({
41
52
  }>;
42
53
  }>;
43
54
  };
55
+ 413: {
56
+ description: string;
57
+ schema: import("@sinclair/typebox").TObject<{
58
+ error: import("@sinclair/typebox").TObject<{
59
+ code: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"bad_request">, import("@sinclair/typebox").TLiteral<"unauthorized">, import("@sinclair/typebox").TLiteral<"forbidden">, import("@sinclair/typebox").TLiteral<"not_found">, import("@sinclair/typebox").TLiteral<"conflict">, import("@sinclair/typebox").TLiteral<"validation_failed">, import("@sinclair/typebox").TLiteral<"provider_not_configured">, import("@sinclair/typebox").TLiteral<"provider_auth_invalid">, import("@sinclair/typebox").TLiteral<"provider_quota_or_billing_required">, import("@sinclair/typebox").TLiteral<"provider_rate_limited">, import("@sinclair/typebox").TLiteral<"provider_model_unavailable">, import("@sinclair/typebox").TLiteral<"provider_network_error">, import("@sinclair/typebox").TLiteral<"relay_session_invalid">, import("@sinclair/typebox").TLiteral<"project_membership_required">, import("@sinclair/typebox").TLiteral<"command_replay_mismatch">, import("@sinclair/typebox").TLiteral<"repo_snapshot_unavailable">, import("@sinclair/typebox").TLiteral<"host_unavailable">, import("@sinclair/typebox").TLiteral<"skill_read_limit_exceeded">, import("@sinclair/typebox").TLiteral<"internal_error">]>;
60
+ message: import("@sinclair/typebox").TString;
61
+ retryable: import("@sinclair/typebox").TBoolean;
62
+ details: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnknown>;
63
+ }>;
64
+ }>;
65
+ };
44
66
  };
45
67
  requestBody?: never;
46
68
  } | {
@@ -54,6 +76,7 @@ export declare const HOST_PROJECT_SKILLS_OPENAPI_ROUTES: ({
54
76
  description: string;
55
77
  schema: import("@sinclair/typebox").TSchema;
56
78
  };
79
+ 413?: never;
57
80
  };
58
81
  parameters?: never;
59
82
  })[];
@@ -1,4 +1,4 @@
1
- import { DeleteSkillBodySchema, DeleteSkillResponseSchema, GetSkillResponseSchema, GetSkillsResponseSchema, ImportSkillZipBodySchema, ImportSkillZipResponseSchema, } from "@tutti/shared/schemas/api";
1
+ import { ApiErrorResponseSchema, DeleteSkillBodySchema, DeleteSkillResponseSchema, GetSkillResponseSchema, GetSkillsResponseSchema, ImportSkillZipBodySchema, ImportSkillZipResponseSchema, } from "@tutti/shared/schemas/api";
2
2
  import { HOST_PROJECT_API_BASE_PATH } from "./constants.js";
3
3
  import { SkillNamePathParameter } from "./openapi-path-parameters.js";
4
4
  export const HOST_PROJECT_SKILLS_OPENAPI_ROUTES = [
@@ -9,6 +9,10 @@ export const HOST_PROJECT_SKILLS_OPENAPI_ROUTES = [
9
9
  tags: ["skills"],
10
10
  responses: {
11
11
  200: { description: "User skills projection.", schema: GetSkillsResponseSchema },
12
+ 413: {
13
+ description: "Feishu preview list budget exceeded; no partial list.",
14
+ schema: ApiErrorResponseSchema,
15
+ },
12
16
  },
13
17
  },
14
18
  {
@@ -19,6 +23,10 @@ export const HOST_PROJECT_SKILLS_OPENAPI_ROUTES = [
19
23
  parameters: [SkillNamePathParameter],
20
24
  responses: {
21
25
  200: { description: "User skill detail projection.", schema: GetSkillResponseSchema },
26
+ 413: {
27
+ description: "Skill read budget exceeded; no truncated skill.",
28
+ schema: ApiErrorResponseSchema,
29
+ },
22
30
  },
23
31
  },
24
32
  {
@@ -885,6 +885,17 @@ export declare const HOST_PROJECT_OPENAPI_ROUTES: ({
885
885
  }>>;
886
886
  }>;
887
887
  };
888
+ 413: {
889
+ description: string;
890
+ schema: import("@sinclair/typebox").TObject<{
891
+ error: import("@sinclair/typebox").TObject<{
892
+ code: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"bad_request">, import("@sinclair/typebox").TLiteral<"unauthorized">, import("@sinclair/typebox").TLiteral<"forbidden">, import("@sinclair/typebox").TLiteral<"not_found">, import("@sinclair/typebox").TLiteral<"conflict">, import("@sinclair/typebox").TLiteral<"validation_failed">, import("@sinclair/typebox").TLiteral<"provider_not_configured">, import("@sinclair/typebox").TLiteral<"provider_auth_invalid">, import("@sinclair/typebox").TLiteral<"provider_quota_or_billing_required">, import("@sinclair/typebox").TLiteral<"provider_rate_limited">, import("@sinclair/typebox").TLiteral<"provider_model_unavailable">, import("@sinclair/typebox").TLiteral<"provider_network_error">, import("@sinclair/typebox").TLiteral<"relay_session_invalid">, import("@sinclair/typebox").TLiteral<"project_membership_required">, import("@sinclair/typebox").TLiteral<"command_replay_mismatch">, import("@sinclair/typebox").TLiteral<"repo_snapshot_unavailable">, import("@sinclair/typebox").TLiteral<"host_unavailable">, import("@sinclair/typebox").TLiteral<"skill_read_limit_exceeded">, import("@sinclair/typebox").TLiteral<"internal_error">]>;
893
+ message: import("@sinclair/typebox").TString;
894
+ retryable: import("@sinclair/typebox").TBoolean;
895
+ details: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnknown>;
896
+ }>;
897
+ }>;
898
+ };
888
899
  };
889
900
  parameters?: never;
890
901
  requestBody?: never;
@@ -909,6 +920,17 @@ export declare const HOST_PROJECT_OPENAPI_ROUTES: ({
909
920
  }>;
910
921
  }>;
911
922
  };
923
+ 413: {
924
+ description: string;
925
+ schema: import("@sinclair/typebox").TObject<{
926
+ error: import("@sinclair/typebox").TObject<{
927
+ code: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"bad_request">, import("@sinclair/typebox").TLiteral<"unauthorized">, import("@sinclair/typebox").TLiteral<"forbidden">, import("@sinclair/typebox").TLiteral<"not_found">, import("@sinclair/typebox").TLiteral<"conflict">, import("@sinclair/typebox").TLiteral<"validation_failed">, import("@sinclair/typebox").TLiteral<"provider_not_configured">, import("@sinclair/typebox").TLiteral<"provider_auth_invalid">, import("@sinclair/typebox").TLiteral<"provider_quota_or_billing_required">, import("@sinclair/typebox").TLiteral<"provider_rate_limited">, import("@sinclair/typebox").TLiteral<"provider_model_unavailable">, import("@sinclair/typebox").TLiteral<"provider_network_error">, import("@sinclair/typebox").TLiteral<"relay_session_invalid">, import("@sinclair/typebox").TLiteral<"project_membership_required">, import("@sinclair/typebox").TLiteral<"command_replay_mismatch">, import("@sinclair/typebox").TLiteral<"repo_snapshot_unavailable">, import("@sinclair/typebox").TLiteral<"host_unavailable">, import("@sinclair/typebox").TLiteral<"skill_read_limit_exceeded">, import("@sinclair/typebox").TLiteral<"internal_error">]>;
928
+ message: import("@sinclair/typebox").TString;
929
+ retryable: import("@sinclair/typebox").TBoolean;
930
+ details: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnknown>;
931
+ }>;
932
+ }>;
933
+ };
912
934
  };
913
935
  requestBody?: never;
914
936
  } | {
@@ -1,3 +1,5 @@
1
+ import { clarificationAnswerRole } from "@tutti/shared/utils";
2
+ import { readSubmittedClarificationMessages } from "../../../../collaboration-state/index.js";
1
3
  import { readProviderUsageBreakdownForAnchors, readProviderUsageModelSummariesForAnchors, readProviderUsageProjection, readProviderUsageSourceSummariesForAnchors, } from "../../../../provider-usage/index.js";
2
4
  import { runGitRaw } from "../../../../workspace-ops/git.js";
3
5
  const TIMELINE_LIMIT = 50;
@@ -178,34 +180,24 @@ function clarificationChatLink(messageId) {
178
180
  message_id: messageId,
179
181
  };
180
182
  }
181
- function readClarificationAnswerSnippets(db, submittedRefsJson) {
182
- const submittedRefs = parseJsonStringArray(submittedRefsJson);
183
- if (submittedRefs.length === 0) {
184
- return { answer_count: 0, answers: [] };
185
- }
186
- const readMessage = db.prepare(`
187
- SELECT
188
- id AS message_id,
189
- author_kind,
190
- author_display_name,
191
- body,
192
- created_at
193
- FROM messages
194
- WHERE id = ?
195
- AND message_kind = 'user_text'
196
- AND author_kind = 'human'
197
- LIMIT 1
198
- `);
199
- const answers = submittedRefs.flatMap((messageId) => {
200
- const row = readMessage.get(messageId);
201
- return row === undefined ? [] : [row];
183
+ function readClarificationAnswerSnippets(db, roundId) {
184
+ const answers = readSubmittedClarificationMessages(db, roundId).filter((message) => clarificationAnswerRole(message) !== null);
185
+ const visibleAnswers = answers.slice(0, TRACE_SOURCE_SNIPPET_LIMIT).map((answer) => {
186
+ let author = authorLabel({
187
+ author_kind: answer.author.kind,
188
+ author_display_name: answer.author.display_name ?? null,
189
+ });
190
+ if (clarificationAnswerRole(answer) === "operator") {
191
+ const displayName = answer.author.display_name?.trim();
192
+ author = displayName ? `${displayName} (operator)` : "Development operator";
193
+ }
194
+ return {
195
+ message_id: answer.id,
196
+ author,
197
+ body: truncateSnippetBody(answer.body),
198
+ created_at: answer.created_at,
199
+ };
202
200
  });
203
- const visibleAnswers = answers.slice(0, TRACE_SOURCE_SNIPPET_LIMIT).map((answer) => ({
204
- message_id: answer.message_id,
205
- author: authorLabel(answer),
206
- body: truncateSnippetBody(answer.body),
207
- created_at: answer.created_at,
208
- }));
209
201
  return {
210
202
  answer_count: answers.length,
211
203
  answers: visibleAnswers,
@@ -213,7 +205,7 @@ function readClarificationAnswerSnippets(db, submittedRefsJson) {
213
205
  }
214
206
  function clarificationTraceFromRow(db, row) {
215
207
  const payload = parseJsonObject(row.request_payload_json);
216
- const answers = readClarificationAnswerSnippets(db, row.submitted_message_refs_json);
208
+ const answers = readClarificationAnswerSnippets(db, row.round_id);
217
209
  const visibleAnswers = answers.answers;
218
210
  const title = stringValue(payload, "title");
219
211
  const summary = stringValue(payload, "summary");
@@ -247,7 +239,6 @@ function readLatestTaskCompileClarificationTrace(db, workflowRef) {
247
239
  clarification_rounds.status AS round_status,
248
240
  clarification_rounds.request_payload_json,
249
241
  clarification_rounds.card_message_id,
250
- clarification_rounds.submitted_message_refs_json,
251
242
  clarification_rounds.submitted_at,
252
243
  clarification_rounds.updated_at
253
244
  FROM clarification_rounds
@@ -273,7 +264,6 @@ function readLatestClarificationTraceByThread(db, threadId) {
273
264
  clarification_rounds.status AS round_status,
274
265
  clarification_rounds.request_payload_json,
275
266
  clarification_rounds.card_message_id,
276
- clarification_rounds.submitted_message_refs_json,
277
267
  clarification_rounds.submitted_at,
278
268
  clarification_rounds.updated_at
279
269
  FROM clarification_threads
@@ -302,7 +292,6 @@ function readLatestResolvedTaskClarificationTrace(db, taskId, throughCreatedAt)
302
292
  clarification_rounds.status AS round_status,
303
293
  clarification_rounds.request_payload_json,
304
294
  clarification_rounds.card_message_id,
305
- clarification_rounds.submitted_message_refs_json,
306
295
  clarification_rounds.submitted_at,
307
296
  clarification_rounds.updated_at
308
297
  FROM clarification_rounds
@@ -533,7 +522,10 @@ function readPromotedCommitChanges(workspaceRoot, commitOid) {
533
522
  if (result.status !== 0) {
534
523
  return undefined;
535
524
  }
536
- const fields = result.stdout.toString("utf8").split("\0").filter((field) => field !== "");
525
+ const fields = result.stdout
526
+ .toString("utf8")
527
+ .split("\0")
528
+ .filter((field) => field !== "");
537
529
  const changes = [];
538
530
  for (let index = 0; index < fields.length;) {
539
531
  const status = fields[index];
@@ -843,7 +835,9 @@ export function readProjectTimelineProjection(input) {
843
835
  ].sort(compareTimelineItemsByTimeDesc);
844
836
  return {
845
837
  generated_at: now().toISOString(),
846
- usage_overview: input.db === undefined ? emptyUsageOverview() : readProviderUsageProjection(input.db, { now }),
838
+ usage_overview: input.db === undefined
839
+ ? emptyUsageOverview()
840
+ : readProviderUsageProjection(input.db, { now }),
847
841
  items: items.slice(0, TIMELINE_LIMIT),
848
842
  };
849
843
  }
@@ -1,5 +1,12 @@
1
1
  import { getProjectUserSkillsRoot, SkillServiceError } from "../../../../skills/index.js";
2
2
  import { HostProjectBadRequestError, HostProjectNotFoundError, HostProjectStoreUnavailableError, HostProjectValidationError, } from "./errors.js";
3
+ class HostSkillReadLimitError extends Error {
4
+ statusCode = 413;
5
+ code = "skill_read_limit_exceeded";
6
+ constructor() {
7
+ super("Skill read exceeds the supported limit");
8
+ }
9
+ }
3
10
  export function skillsChangedInvalidates() {
4
11
  return [{ kind: "skills" }, { kind: "read_state" }, { kind: "bootstrap" }];
5
12
  }
@@ -24,6 +31,8 @@ function toHostProjectSkillError(error) {
24
31
  return new HostProjectValidationError(error.message);
25
32
  case "filesystem_error":
26
33
  return new HostProjectStoreUnavailableError();
34
+ case "read_limit_exceeded":
35
+ return new HostSkillReadLimitError();
27
36
  }
28
37
  }
29
38
  export async function withAsyncSkillServiceErrorMapping(fn) {
@@ -14,9 +14,11 @@ export function registerSkillRoutes(app, { options, workspaceEvents, }) {
14
14
  },
15
15
  },
16
16
  }, (request) => {
17
- requireHostProjectSession(request, options);
17
+ const session = requireHostProjectSession(request, options);
18
18
  return withSkillServiceErrorMapping(() => ({
19
- items: listUserSkills(requireProjectUserSkillsRoot(options)),
19
+ items: listUserSkills(requireProjectUserSkillsRoot(options), {
20
+ mode: session.client_session.transport === "feishu_platform_session" ? "preview" : "full",
21
+ }),
20
22
  }));
21
23
  });
22
24
  app.get(`${HOST_PROJECT_API_BASE_PATH}/skills/:skillName`, {
@@ -26,9 +28,11 @@ export function registerSkillRoutes(app, { options, workspaceEvents, }) {
26
28
  },
27
29
  },
28
30
  }, (request) => {
29
- requireHostProjectSession(request, options);
31
+ const session = requireHostProjectSession(request, options);
30
32
  return withSkillServiceErrorMapping(() => ({
31
- skill: readUserSkill(requireProjectUserSkillsRoot(options), request.params.skillName),
33
+ skill: readUserSkill(requireProjectUserSkillsRoot(options), request.params.skillName, {
34
+ mode: session.client_session.transport === "feishu_platform_session" ? "preview" : "full",
35
+ }),
32
36
  }));
33
37
  });
34
38
  app.post(`${HOST_PROJECT_API_BASE_PATH}/skills/import`, {
@@ -17,6 +17,7 @@ const API_ERROR_CODES = new Set([
17
17
  "command_replay_mismatch",
18
18
  "repo_snapshot_unavailable",
19
19
  "host_unavailable",
20
+ "skill_read_limit_exceeded",
20
21
  "internal_error",
21
22
  ]);
22
23
  function isApiErrorCode(value) {
@@ -5,4 +5,9 @@ export declare const MAX_SKILL_TOTAL_UNCOMPRESSED_BYTES: number;
5
5
  export declare const MAX_SKILL_FILE_BYTES: number;
6
6
  export declare const MAX_SKILL_FILE_COUNT = 1000;
7
7
  export declare const MAX_SKILL_DIRECTORY_DEPTH = 24;
8
+ export declare const MAX_SKILL_PREVIEW_BYTES: number;
9
+ export declare const MAX_SKILL_PREVIEW_METADATA_BYTES: number;
10
+ export declare const MAX_SKILL_PREVIEW_SCAN_ENTRIES = 512;
11
+ export declare const MAX_SKILL_PREVIEW_ITEMS = 256;
12
+ export declare const MAX_SKILL_PREVIEW_LIST_BYTES: number;
8
13
  //# sourceMappingURL=constants.d.ts.map
@@ -6,4 +6,10 @@ export const MAX_SKILL_TOTAL_UNCOMPRESSED_BYTES = 200 * 1024 * 1024;
6
6
  export const MAX_SKILL_FILE_BYTES = 50 * 1024 * 1024;
7
7
  export const MAX_SKILL_FILE_COUNT = 1_000;
8
8
  export const MAX_SKILL_DIRECTORY_DEPTH = 24;
9
+ // Browser preview budgets are independent of the full skill used by the runtime.
10
+ export const MAX_SKILL_PREVIEW_BYTES = 200 * 1024;
11
+ export const MAX_SKILL_PREVIEW_METADATA_BYTES = 32 * 1024;
12
+ export const MAX_SKILL_PREVIEW_SCAN_ENTRIES = 512;
13
+ export const MAX_SKILL_PREVIEW_ITEMS = 256;
14
+ export const MAX_SKILL_PREVIEW_LIST_BYTES = 512 * 1024;
9
15
  //# sourceMappingURL=constants.js.map
@@ -1,4 +1,4 @@
1
- export type SkillServiceErrorCode = "invalid_skill_name" | "skill_not_found" | "invalid_zip" | "validation_failed" | "filesystem_error";
1
+ export type SkillServiceErrorCode = "invalid_skill_name" | "skill_not_found" | "invalid_zip" | "validation_failed" | "read_limit_exceeded" | "filesystem_error";
2
2
  export declare class SkillServiceError extends Error {
3
3
  readonly code: SkillServiceErrorCode;
4
4
  constructor(code: SkillServiceErrorCode, message: string);
@@ -0,0 +1,14 @@
1
+ export declare function skillReadLimitExceeded(): never;
2
+ /** Read from one checked descriptor; never follow a SKILL.md symlink or block on a FIFO. */
3
+ export declare function readSkillFile(input: {
4
+ root: string;
5
+ name: string;
6
+ maxBytes: number;
7
+ metadataOnly?: boolean;
8
+ }): {
9
+ markdown: string;
10
+ updatedAt: string;
11
+ };
12
+ /** Bound enumeration before collecting names; a limit never returns a partial list. */
13
+ export declare function listSkillDirectories(root: string, maxEntries?: number): string[];
14
+ //# sourceMappingURL=file-reader.d.ts.map
@@ -0,0 +1,123 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, opendirSync, readSync, realpathSync, } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { SKILL_MD_FILE_NAME } from "./constants.js";
4
+ import { SkillServiceError } from "./errors.js";
5
+ import { decodeSkillMarkdown, isValidSkillName } from "./metadata.js";
6
+ function unsafePath() {
7
+ throw new SkillServiceError("validation_failed", "Skill path is not a regular project skill");
8
+ }
9
+ export function skillReadLimitExceeded() {
10
+ throw new SkillServiceError("read_limit_exceeded", "Skill read exceeds the supported limit");
11
+ }
12
+ function mapReadError(error) {
13
+ if (error instanceof SkillServiceError)
14
+ throw error;
15
+ const code = error.code;
16
+ if (code === "ENOENT" || code === "ENOTDIR") {
17
+ throw new SkillServiceError("skill_not_found", "Skill does not exist");
18
+ }
19
+ throw new SkillServiceError("filesystem_error", "Skill could not be read");
20
+ }
21
+ function realDirectory(path) {
22
+ if (!lstatSync(path).isDirectory())
23
+ unsafePath();
24
+ return realpathSync(path);
25
+ }
26
+ function resolveSkillFile(root, name) {
27
+ const realRoot = realDirectory(resolve(root));
28
+ const directory = join(realRoot, name);
29
+ if (realDirectory(directory) !== directory)
30
+ unsafePath();
31
+ const file = join(directory, SKILL_MD_FILE_NAME);
32
+ if (!lstatSync(file).isFile() || realpathSync(file) !== file)
33
+ unsafePath();
34
+ return file;
35
+ }
36
+ function sameFile(left, right) {
37
+ return (left.dev === right.dev &&
38
+ left.ino === right.ino &&
39
+ left.size === right.size &&
40
+ left.mtimeMs === right.mtimeMs &&
41
+ left.ctimeMs === right.ctimeMs);
42
+ }
43
+ /** Read from one checked descriptor; never follow a SKILL.md symlink or block on a FIFO. */
44
+ export function readSkillFile(input) {
45
+ if (!isValidSkillName(input.name)) {
46
+ throw new SkillServiceError("invalid_skill_name", "Skill name is invalid");
47
+ }
48
+ let fd;
49
+ try {
50
+ const file = resolveSkillFile(input.root, input.name);
51
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
52
+ const before = fstatSync(fd);
53
+ if (!before.isFile() || !sameFile(before, lstatSync(file)))
54
+ unsafePath();
55
+ if (!input.metadataOnly && before.size > input.maxBytes)
56
+ skillReadLimitExceeded();
57
+ const buffer = Buffer.alloc(Math.min(before.size, input.maxBytes) + 1);
58
+ let length = 0;
59
+ while (length < buffer.length) {
60
+ const read = readSync(fd, buffer, length, buffer.length - length, length);
61
+ if (read === 0)
62
+ break;
63
+ length += read;
64
+ }
65
+ if (!sameFile(before, fstatSync(fd)) ||
66
+ resolveSkillFile(input.root, input.name) !== file ||
67
+ !sameFile(before, lstatSync(file))) {
68
+ throw new SkillServiceError("filesystem_error", "Skill changed while being read");
69
+ }
70
+ const bytes = buffer.subarray(0, length);
71
+ const text = decodeSkillMarkdown(bytes, input.metadataOnly === true && before.size > length);
72
+ if (input.metadataOnly) {
73
+ // Frontmatter syntax stays owned by metadata.ts. Only its bounded prefix is decoded.
74
+ const prefix = text.replace(/\r\n/gu, "\n");
75
+ const end = prefix.startsWith("---\n") ? prefix.indexOf("\n---", 4) : -1;
76
+ if (end !== -1) {
77
+ const metadata = prefix.slice(0, end + 4);
78
+ if (Buffer.byteLength(metadata) > input.maxBytes)
79
+ skillReadLimitExceeded();
80
+ return { markdown: metadata, updatedAt: before.mtime.toISOString() };
81
+ }
82
+ }
83
+ if (length > input.maxBytes)
84
+ skillReadLimitExceeded();
85
+ return { markdown: text, updatedAt: before.mtime.toISOString() };
86
+ }
87
+ catch (error) {
88
+ return mapReadError(error);
89
+ }
90
+ finally {
91
+ if (fd !== undefined)
92
+ closeSync(fd);
93
+ }
94
+ }
95
+ /** Bound enumeration before collecting names; a limit never returns a partial list. */
96
+ export function listSkillDirectories(root, maxEntries = Infinity) {
97
+ try {
98
+ const realRoot = realDirectory(resolve(root));
99
+ const directory = opendirSync(realRoot);
100
+ const names = [];
101
+ let count = 0;
102
+ try {
103
+ for (let entry = directory.readSync(); entry !== null; entry = directory.readSync()) {
104
+ if (++count > maxEntries)
105
+ skillReadLimitExceeded();
106
+ if (entry.isDirectory() && isValidSkillName(entry.name))
107
+ names.push(entry.name);
108
+ }
109
+ }
110
+ finally {
111
+ directory.closeSync();
112
+ }
113
+ if (realDirectory(resolve(root)) !== realRoot)
114
+ unsafePath();
115
+ return names.sort();
116
+ }
117
+ catch (error) {
118
+ if (error.code === "ENOENT")
119
+ return [];
120
+ return mapReadError(error);
121
+ }
122
+ }
123
+ //# sourceMappingURL=file-reader.js.map
@@ -26,9 +26,12 @@ export type UserSkillImportResult = {
26
26
  disposition: UserSkillImportDisposition;
27
27
  skill: SkillDetailProjection;
28
28
  };
29
+ export type UserSkillReadOptions = {
30
+ mode?: "full" | "preview";
31
+ };
29
32
  export declare function getProjectUserSkillsRoot(tuttiHome: string, projectId: ProjectId): string;
30
- export declare function listUserSkills(userSkillsRoot: string): SkillProjection[];
31
- export declare function readUserSkill(userSkillsRoot: string, skillName: string): SkillDetailProjection;
33
+ export declare function listUserSkills(userSkillsRoot: string, options?: UserSkillReadOptions): SkillProjection[];
34
+ export declare function readUserSkill(userSkillsRoot: string, skillName: string, options?: UserSkillReadOptions): SkillDetailProjection;
32
35
  export declare function importUserSkillZip(options: {
33
36
  userSkillsRoot: string;
34
37
  zipBuffer: Buffer;
@@ -1,9 +1,10 @@
1
- import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, } from "node:fs";
1
+ import { existsSync, mkdtempSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
- import { SKILL_MD_FILE_NAME, USER_SKILLS_DIRECTORY_NAME } from "./constants.js";
4
+ import { MAX_SKILL_FILE_BYTES, MAX_SKILL_PREVIEW_BYTES, MAX_SKILL_PREVIEW_METADATA_BYTES, MAX_SKILL_PREVIEW_SCAN_ENTRIES, MAX_SKILL_PREVIEW_ITEMS, MAX_SKILL_PREVIEW_LIST_BYTES, USER_SKILLS_DIRECTORY_NAME, } from "./constants.js";
5
5
  import { SkillServiceError } from "./errors.js";
6
- import { isValidSkillName, parseSkillMarkdown, } from "./metadata.js";
6
+ import { listSkillDirectories, readSkillFile, skillReadLimitExceeded } from "./file-reader.js";
7
+ import { isValidSkillName, parseSkillMarkdown } from "./metadata.js";
7
8
  import { prepareUserSkillZipImport, writeCandidateFiles, } from "./zip-import.js";
8
9
  export { MAX_SKILL_DIRECTORY_DEPTH, MAX_SKILL_FILE_BYTES, MAX_SKILL_FILE_COUNT, MAX_SKILL_TOTAL_UNCOMPRESSED_BYTES, MAX_SKILL_ZIP_BYTES, SKILL_MD_FILE_NAME, USER_SKILLS_DIRECTORY_NAME, } from "./constants.js";
9
10
  export { SkillServiceError } from "./errors.js";
@@ -30,17 +31,6 @@ function skillDirectory(userSkillsRoot, skillName) {
30
31
  ensureInside(userSkillsRoot, directory);
31
32
  return directory;
32
33
  }
33
- function readSkillMarkdown(filePath) {
34
- let skillMd;
35
- try {
36
- skillMd = readFileSync(filePath, "utf8");
37
- }
38
- catch {
39
- throw new SkillServiceError("skill_not_found", "Skill does not exist");
40
- }
41
- const metadata = parseSkillMarkdown(skillMd);
42
- return { ...metadata, skill_md: skillMd };
43
- }
44
34
  function toSkillProjection(options) {
45
35
  return {
46
36
  name: options.metadata.name,
@@ -53,53 +43,58 @@ function toSkillProjection(options) {
53
43
  ...(options.updatedAt === undefined ? {} : { updated_at: options.updatedAt }),
54
44
  };
55
45
  }
56
- function readSkillProjection(directory) {
57
- const skillMdPath = join(directory, SKILL_MD_FILE_NAME);
58
- const parsed = readSkillMarkdown(skillMdPath);
59
- const stat = statSync(skillMdPath);
46
+ function readSkillProjection(root, name, preview) {
47
+ const file = readSkillFile({
48
+ root,
49
+ name,
50
+ maxBytes: preview ? MAX_SKILL_PREVIEW_METADATA_BYTES : MAX_SKILL_FILE_BYTES,
51
+ metadataOnly: preview,
52
+ });
60
53
  return toSkillProjection({
61
- metadata: parsed,
62
- updatedAt: stat.mtime.toISOString(),
54
+ metadata: parseSkillMarkdown(file.markdown),
55
+ updatedAt: file.updatedAt,
63
56
  });
64
57
  }
65
- export function listUserSkills(userSkillsRoot) {
66
- if (!existsSync(userSkillsRoot)) {
67
- return [];
68
- }
58
+ export function listUserSkills(userSkillsRoot, options = {}) {
59
+ const preview = options.mode === "preview";
69
60
  const items = [];
70
- for (const entry of readdirSync(userSkillsRoot, { withFileTypes: true })) {
71
- if (!entry.isDirectory() || !isValidSkillName(entry.name)) {
72
- continue;
73
- }
61
+ let responseBytes = Buffer.byteLength('{"items":[]}');
62
+ for (const name of listSkillDirectories(userSkillsRoot, preview ? MAX_SKILL_PREVIEW_SCAN_ENTRIES : undefined)) {
74
63
  try {
75
- const projection = readSkillProjection(join(userSkillsRoot, entry.name));
76
- if (projection.name === entry.name) {
64
+ const projection = readSkillProjection(userSkillsRoot, name, preview);
65
+ if (projection.name === name) {
66
+ responseBytes += Buffer.byteLength(JSON.stringify(projection)) + 1;
67
+ if (preview &&
68
+ (items.length >= MAX_SKILL_PREVIEW_ITEMS || responseBytes > MAX_SKILL_PREVIEW_LIST_BYTES)) {
69
+ skillReadLimitExceeded();
70
+ }
77
71
  items.push(projection);
78
72
  }
79
73
  }
80
- catch {
74
+ catch (error) {
75
+ if (preview && error instanceof SkillServiceError && error.code === "read_limit_exceeded")
76
+ throw error;
81
77
  // Keep the management surface available even if a stale local directory is malformed.
82
78
  }
83
79
  }
84
80
  return items.sort((left, right) => left.name.localeCompare(right.name));
85
81
  }
86
- export function readUserSkill(userSkillsRoot, skillName) {
87
- const directory = skillDirectory(userSkillsRoot, skillName);
88
- const skillMdPath = join(directory, SKILL_MD_FILE_NAME);
89
- if (!existsSync(skillMdPath)) {
90
- throw new SkillServiceError("skill_not_found", "Skill does not exist");
91
- }
92
- const parsed = readSkillMarkdown(skillMdPath);
82
+ export function readUserSkill(userSkillsRoot, skillName, options = {}) {
83
+ const file = readSkillFile({
84
+ root: userSkillsRoot,
85
+ name: skillName,
86
+ maxBytes: options.mode === "preview" ? MAX_SKILL_PREVIEW_BYTES : MAX_SKILL_FILE_BYTES,
87
+ });
88
+ const parsed = parseSkillMarkdown(file.markdown);
93
89
  if (parsed.name !== skillName) {
94
90
  throw new SkillServiceError("validation_failed", "Skill metadata is invalid");
95
91
  }
96
- const stat = statSync(skillMdPath);
97
92
  return {
98
93
  ...toSkillProjection({
99
94
  metadata: parsed,
100
- updatedAt: stat.mtime.toISOString(),
95
+ updatedAt: file.updatedAt,
101
96
  }),
102
- skill_md: parsed.skill_md,
97
+ skill_md: file.markdown,
103
98
  };
104
99
  }
105
100
  export async function importUserSkillZip(options) {
@@ -4,5 +4,7 @@ export type ParsedSkillMarkdown = {
4
4
  short_description?: string;
5
5
  };
6
6
  export declare function isValidSkillName(value: string): boolean;
7
+ /** Keep import preflight and runtime reads consistent; partial applies only to bounded prefixes. */
8
+ export declare function decodeSkillMarkdown(bytes: Uint8Array, partial?: boolean): string;
7
9
  export declare function parseSkillMarkdown(skillMd: string): ParsedSkillMarkdown;
8
10
  //# sourceMappingURL=metadata.d.ts.map
@@ -1,7 +1,16 @@
1
1
  import { SkillServiceError } from "./errors.js";
2
2
  const SKILL_NAME_PATTERN = /^[a-z0-9_-]+$/u;
3
3
  export function isValidSkillName(value) {
4
- return SKILL_NAME_PATTERN.test(value);
4
+ return value.length <= 96 && SKILL_NAME_PATTERN.test(value);
5
+ }
6
+ /** Keep import preflight and runtime reads consistent; partial applies only to bounded prefixes. */
7
+ export function decodeSkillMarkdown(bytes, partial = false) {
8
+ try {
9
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: partial });
10
+ }
11
+ catch {
12
+ throw new SkillServiceError("validation_failed", "Skill text is not valid UTF-8");
13
+ }
5
14
  }
6
15
  export function parseSkillMarkdown(skillMd) {
7
16
  const frontmatter = extractFrontmatter(skillMd);