@xfey/tutti 0.1.67 → 0.1.69

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 (52) hide show
  1. package/dist/chat-assistant/index.d.ts +1 -1
  2. package/dist/chat-assistant/index.js +1 -1
  3. package/dist/collaboration-state/index.d.ts +1 -1
  4. package/dist/collaboration-state/index.js +1 -1
  5. package/dist/collaboration-state/task-compile-context.d.ts +2 -1
  6. package/dist/collaboration-state/task-compile-context.js +30 -8
  7. package/dist/control-plane/index.d.ts +9 -2
  8. package/dist/control-plane/index.js +82 -4
  9. package/dist/control-plane/recent-references.d.ts +26 -0
  10. package/dist/control-plane/recent-references.js +94 -0
  11. package/dist/control-plane/reference-context-types.d.ts +12 -0
  12. package/dist/control-plane/reference-context-types.js +2 -0
  13. package/dist/control-plane/reference-summary-refresh.d.ts +5 -1
  14. package/dist/control-plane/reference-summary-refresh.js +181 -113
  15. package/dist/control-plane/run-start.js +12 -0
  16. package/dist/control-plane/task-compile-continuation.d.ts +2 -0
  17. package/dist/control-plane/task-compile-continuation.js +11 -0
  18. package/dist/control-plane/task-compile-start.d.ts +2 -0
  19. package/dist/control-plane/task-compile-start.js +113 -16
  20. package/dist/control-plane/types.d.ts +2 -0
  21. package/dist/control-plane/workflows/openai.js +7 -0
  22. package/dist/control-plane/workflows/types.d.ts +3 -0
  23. package/dist/providers/openai/app-server/json-rpc.js +13 -1
  24. package/dist/providers/openai/app-server/linux-sandbox-readiness.js +6 -3
  25. package/dist/providers/openai/app-server/read-only-procedure.js +2 -2
  26. package/dist/providers/openai/app-server/smoke.js +2 -2
  27. package/dist/providers/openai/app-server/workspace-write-run.js +2 -2
  28. package/dist/providers/openai/codex-app-server.d.ts +10 -0
  29. package/dist/providers/openai/codex-app-server.js +22 -5
  30. package/dist/run-pipeline/task-run-invocation.js +3 -0
  31. package/dist/server-shell/cli/host-server-runtime.js +1 -1
  32. package/dist/server-shell/http/routes/project-api/messages-routes.js +1 -1
  33. package/dist/server-shell/http/routes/project-api/reference-files-routes.js +1 -1
  34. package/dist/server-shell/local-console/invocation-context.d.ts +2 -0
  35. package/dist/server-shell/local-console/invocation-context.js +10 -1
  36. package/dist/workspace-ops/index.d.ts +1 -1
  37. package/dist/workspace-ops/index.js +1 -1
  38. package/dist/workspace-ops/reference-files.js +98 -34
  39. package/dist/workspace-ops/reference-summaries.d.ts +5 -0
  40. package/dist/workspace-ops/reference-summaries.js +6 -5
  41. package/package.json +2 -2
  42. package/prompts/README.md +1 -1
  43. package/prompts/procedures/README.md +1 -1
  44. package/prompts/procedures/task-compile.md +2 -1
  45. package/prompts/prompt-flow-map.md +9 -2
  46. package/prompts/runs/README.md +1 -1
  47. package/prompts/runs/task-continuation.md +3 -0
  48. package/prompts/runs/task-retry.md +3 -0
  49. package/prompts/runs/task-run.md +3 -0
  50. package/web/assets/{homepage-motion-scene-CblEWSta.js → homepage-motion-scene-D2GZ8Nix.js} +1 -1
  51. package/web/assets/{index-CH2ZN4W4.js → index-NsxRK6_d.js} +2 -2
  52. package/web/index.html +1 -1
@@ -1,4 +1,5 @@
1
1
  import type { ExecutionRuntimeSnapshot, MessageProjection, RunResultProjection, TaskDetailProjection, WorklistProjection } from "@tutti/shared/schemas/api";
2
+ import type { RecentReferenceContext } from "../reference-context-types.js";
2
3
  import type { ClarificationRequestPayload, TaskContractProjection } from "@tutti/shared/schemas/api";
3
4
  import type { ProjectDocKey } from "@tutti/shared/schemas/api";
4
5
  import type { ActivityRef, ClarificationRoundRef, ClarificationThreadRef, MessageId, TaskId, TaskModuleId, WorkflowInvocationRef } from "@tutti/shared/ids";
@@ -80,6 +81,7 @@ export type TaskCompileWorkflowInput = {
80
81
  workflow_ref: WorkflowInvocationRef;
81
82
  scratchpad: ScratchpadWorkflowSnapshot;
82
83
  worklist: WorklistProjection;
84
+ recent_references?: RecentReferenceContext[];
83
85
  clarification_rounds?: Array<{
84
86
  request_payload: ClarificationRequestPayload;
85
87
  answer_messages: TaskCompilePromptClarificationMessage[];
@@ -113,6 +115,7 @@ export type TaskCompilePromptInput = {
113
115
  }>;
114
116
  }>;
115
117
  };
118
+ recent_references?: RecentReferenceContext[];
116
119
  clarification_rounds?: Array<{
117
120
  request: {
118
121
  title?: string;
@@ -1,5 +1,6 @@
1
1
  import { StringDecoder } from "node:string_decoder";
2
2
  import { redactText } from "@tutti/shared/utils";
3
+ const PROCESS_EXIT_STDERR_EXCERPT_MAX_LENGTH = 500;
3
4
  export class CodexAppServerProtocolError extends Error {
4
5
  code;
5
6
  constructor(message, code) {
@@ -21,6 +22,16 @@ function normalizeProtocolError(method, error) {
21
22
  const message = typeof rawMessage === "string" ? redactText(rawMessage) : "protocol_error";
22
23
  return new CodexAppServerProtocolError(`Codex app-server request failed: ${method}: ${code}: ${message}`, "request_failed");
23
24
  }
25
+ function processExitStderrExcerpt(value) {
26
+ const normalized = redactText(value).replace(/\s+/gu, " ").trim();
27
+ if (normalized.length === 0) {
28
+ return "";
29
+ }
30
+ if (normalized.length <= PROCESS_EXIT_STDERR_EXCERPT_MAX_LENGTH) {
31
+ return normalized;
32
+ }
33
+ return `${normalized.slice(0, PROCESS_EXIT_STDERR_EXCERPT_MAX_LENGTH)}...`;
34
+ }
24
35
  export class CodexAppServerJsonRpcClient {
25
36
  child;
26
37
  options;
@@ -47,7 +58,8 @@ export class CodexAppServerJsonRpcClient {
47
58
  });
48
59
  child.on("exit", (code, signal) => {
49
60
  this.closed = true;
50
- this.rejectAll(new CodexAppServerProtocolError(`Codex app-server exited before request completed: code=${code ?? "null"} signal=${signal ?? "null"}`, "process_exited"));
61
+ const stderrExcerpt = processExitStderrExcerpt(this.stderrText);
62
+ this.rejectAll(new CodexAppServerProtocolError(`Codex app-server exited before request completed: code=${code ?? "null"} signal=${signal ?? "null"}${stderrExcerpt === "" ? "" : ` stderr=${stderrExcerpt}`}`, "process_exited"));
51
63
  });
52
64
  child.on("error", (error) => {
53
65
  this.closed = true;
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { CODEX_APP_SERVER_COMMAND } from "../codex-app-server.js";
5
+ import { buildCodexCliProcessPlan } from "../codex-app-server.js";
6
6
  import { createCodexSpawnEnv } from "./runtime-helpers.js";
7
7
  const DEFAULT_PROBE_TIMEOUT_MS = 10_000;
8
8
  export const CODEX_LINUX_SANDBOX_GUIDANCE = "Install bubblewrap. On Ubuntu 24.04, also install apparmor-profiles and apparmor-utils and load the distribution bwrap-userns-restrict profile. Then run `tutti doctor` again.";
@@ -74,9 +74,12 @@ export function inspectCodexLinuxSandboxReadiness(options = {}) {
74
74
  const codexHome = join(tempRoot, "codex-home");
75
75
  mkdirSync(codexHome, { recursive: true, mode: 0o700 });
76
76
  const runProbe = options.runProbe ?? defaultProbeRunner;
77
- return classifyProbeResult(runProbe({
78
- command: options.codexCommand ?? CODEX_APP_SERVER_COMMAND,
77
+ const processPlan = buildCodexCliProcessPlan({
79
78
  args: ["sandbox", "--", "/bin/true"],
79
+ ...(options.codexCommand === undefined ? {} : { codexCommand: options.codexCommand }),
80
+ });
81
+ return classifyProbeResult(runProbe({
82
+ ...processPlan,
80
83
  cwd: tempRoot,
81
84
  env: createCodexSpawnEnv(codexHome, options.baseEnv ?? process.env),
82
85
  timeoutMs: options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
@@ -120,9 +120,9 @@ export async function runCodexAppServerReadOnlyProcedure(options) {
120
120
  const processPlan = buildCodexAppServerProcessPlan({
121
121
  codexHome,
122
122
  configOverrides: buildCodexAppServerProviderConfigOverrides(options),
123
+ ...(options.codexCommand === undefined ? {} : { codexCommand: options.codexCommand }),
123
124
  });
124
- const command = options.codexCommand ?? processPlan.command;
125
- const child = spawn(command, processPlan.args, {
125
+ const child = spawn(processPlan.command, processPlan.args, {
126
126
  cwd: process.cwd(),
127
127
  env: createCodexSpawnEnv(codexHome, options.baseEnv ?? process.env),
128
128
  stdio: "pipe",
@@ -25,9 +25,9 @@ export async function runCodexAppServerNoWriteSmoke(options) {
25
25
  const processPlan = buildCodexAppServerProcessPlan({
26
26
  codexHome,
27
27
  configOverrides: buildCodexAppServerProviderConfigOverrides(options),
28
+ ...(options.codexCommand === undefined ? {} : { codexCommand: options.codexCommand }),
28
29
  });
29
- const command = options.codexCommand ?? processPlan.command;
30
- const child = spawn(command, processPlan.args, {
30
+ const child = spawn(processPlan.command, processPlan.args, {
31
31
  cwd: process.cwd(),
32
32
  env: createCodexSpawnEnv(codexHome, options.baseEnv ?? process.env),
33
33
  stdio: "pipe",
@@ -196,9 +196,9 @@ export async function runCodexAppServerWorkspaceWriteRun(options) {
196
196
  const processPlan = buildCodexAppServerProcessPlan({
197
197
  codexHome,
198
198
  configOverrides: buildCodexAppServerProviderConfigOverrides(options),
199
+ ...(options.codexCommand === undefined ? {} : { codexCommand: options.codexCommand }),
199
200
  });
200
- const command = options.codexCommand ?? processPlan.command;
201
- const child = spawn(command, processPlan.args, {
201
+ const child = spawn(processPlan.command, processPlan.args, {
202
202
  cwd: process.cwd(),
203
203
  env: createCodexSpawnEnv(codexHome, options.baseEnv ?? process.env),
204
204
  stdio: "pipe",
@@ -1,3 +1,4 @@
1
+ export declare const CODEX_APP_SERVER_ENTRYPOINT: string;
1
2
  export declare const CODEX_APP_SERVER_COMMAND: string;
2
3
  export declare const CODEX_APP_SERVER_LISTEN_URL: "stdio://";
3
4
  export declare const CODEX_APP_SERVER_CLIENT_NAME: "tutti_host";
@@ -73,8 +74,13 @@ export type CodexAppServerTurnStartTemplatePlan = {
73
74
  };
74
75
  export type BuildCodexAppServerProcessPlanOptions = {
75
76
  codexHome: string;
77
+ codexCommand?: string;
76
78
  configOverrides?: readonly CodexAppServerConfigOverride[];
77
79
  };
80
+ export type CodexCliProcessPlan = {
81
+ command: string;
82
+ args: string[];
83
+ };
78
84
  export type CodexAppServerNoWriteSmokePlan = {
79
85
  process: CodexAppServerProcessPlan;
80
86
  initialize: CodexAppServerRequestPlan<"initialize", CodexAppServerInitializeRequestParams>;
@@ -107,6 +113,10 @@ export declare const CODEX_NO_WRITE_SMOKE_OUTPUT_SCHEMA: {
107
113
  export declare function createCodexAppServerProcessEnv(options: {
108
114
  codexHome: string;
109
115
  }): Record<string, string>;
116
+ export declare function buildCodexCliProcessPlan(options: {
117
+ args: readonly string[];
118
+ codexCommand?: string;
119
+ }): CodexCliProcessPlan;
110
120
  export declare function buildCodexAppServerProcessPlan(options: BuildCodexAppServerProcessPlanOptions): CodexAppServerProcessPlan;
111
121
  export declare function createCodexAppServerNoWriteSmokePlan(options: CodexNoWriteSmokePlanOptions): CodexAppServerNoWriteSmokePlan;
112
122
  export declare class CodexAppServerJsonlParseError extends Error {
@@ -2,7 +2,8 @@ import { createRequire } from "node:module";
2
2
  import { dirname, isAbsolute, join } from "node:path";
3
3
  import { DEFAULT_OPENAI_MODEL } from "./model-config.js";
4
4
  const require = createRequire(import.meta.url);
5
- export const CODEX_APP_SERVER_COMMAND = join(dirname(require.resolve("@openai/codex/package.json")), "bin", "codex.js");
5
+ export const CODEX_APP_SERVER_ENTRYPOINT = join(dirname(require.resolve("@openai/codex/package.json")), "bin", "codex.js");
6
+ export const CODEX_APP_SERVER_COMMAND = process.execPath;
6
7
  export const CODEX_APP_SERVER_LISTEN_URL = "stdio://";
7
8
  export const CODEX_APP_SERVER_CLIENT_NAME = "tutti_host";
8
9
  export const CODEX_APP_SERVER_CLIENT_TITLE = "Tutti Host";
@@ -37,12 +38,28 @@ export function createCodexAppServerProcessEnv(options) {
37
38
  CODEX_HOME: options.codexHome,
38
39
  };
39
40
  }
40
- export function buildCodexAppServerProcessPlan(options) {
41
- const args = ["app-server", "--listen", CODEX_APP_SERVER_LISTEN_URL];
42
- pushConfigOverrides(args, options.configOverrides ?? []);
41
+ export function buildCodexCliProcessPlan(options) {
42
+ if (options.codexCommand !== undefined) {
43
+ assertNonEmpty(options.codexCommand, "codexCommand");
44
+ return {
45
+ command: options.codexCommand,
46
+ args: [...options.args],
47
+ };
48
+ }
43
49
  return {
44
50
  command: CODEX_APP_SERVER_COMMAND,
45
- args,
51
+ args: [CODEX_APP_SERVER_ENTRYPOINT, ...options.args],
52
+ };
53
+ }
54
+ export function buildCodexAppServerProcessPlan(options) {
55
+ const codexArgs = ["app-server", "--listen", CODEX_APP_SERVER_LISTEN_URL];
56
+ pushConfigOverrides(codexArgs, options.configOverrides ?? []);
57
+ const processPlan = buildCodexCliProcessPlan({
58
+ args: codexArgs,
59
+ ...(options.codexCommand === undefined ? {} : { codexCommand: options.codexCommand }),
60
+ });
61
+ return {
62
+ ...processPlan,
46
63
  env: createCodexAppServerProcessEnv({ codexHome: options.codexHome }),
47
64
  transport: {
48
65
  kind: "stdio",
@@ -6,6 +6,9 @@ function taskContractRunInput(input) {
6
6
  title: input.task.title,
7
7
  goal: input.task_detail.detail.contract.goal,
8
8
  scope: input.task_detail.detail.contract.scope,
9
+ ...(input.recent_references === undefined || input.recent_references.length === 0
10
+ ? {}
11
+ : { recent_references: input.recent_references }),
9
12
  };
10
13
  }
11
14
  function initialTaskRunInput(input) {
@@ -283,7 +283,7 @@ export async function startForegroundHostServer(options) {
283
283
  return await resolution.model.answer(input);
284
284
  },
285
285
  },
286
- onScratchpadSourceChanged: () => controlPlane.notifyScratchpadSourceChanged(),
286
+ onScratchpadSourceChanged: (message) => controlPlane.notifyScratchpadSourceChanged(message),
287
287
  now,
288
288
  });
289
289
  let close = async () => { };
@@ -71,7 +71,7 @@ export function registerMessagesRoutes(app, { options, workspaceEvents, }) {
71
71
  },
72
72
  invalidates: mainChatMessageInvalidates(),
73
73
  });
74
- options.controlPlane?.notifyScratchpadSourceChanged();
74
+ options.controlPlane?.notifyScratchpadSourceChanged(execution.result.message);
75
75
  options.chatAssistant?.enqueue({ message: execution.result.message });
76
76
  }
77
77
  return response;
@@ -137,7 +137,7 @@ export function registerReferenceFilesRoutes(app, { options, workspaceEvents, })
137
137
  },
138
138
  invalidates,
139
139
  });
140
- options.controlPlane?.notifyScratchpadSourceChanged();
140
+ options.controlPlane?.notifyScratchpadSourceChanged(execution.result.message);
141
141
  options.controlPlane?.startReferenceSummaryRefresh({
142
142
  paths: [execution.result.file.path],
143
143
  });
@@ -19,10 +19,12 @@ export declare function createDesktopLocalConsoleInvocationContext(options: {
19
19
  export declare function createLocalConsoleServiceEnvironment(options: {
20
20
  env: NodeJS.ProcessEnv;
21
21
  tuttiHome: string;
22
+ runtimeExecutablePath?: string;
22
23
  }): NodeJS.ProcessEnv;
23
24
  export declare function createLocalConsoleOperationEnvironment(options: {
24
25
  serviceEnvironment: NodeJS.ProcessEnv;
25
26
  invocationEnvironment: LocalConsoleInvocationEnvironment;
26
27
  tuttiHome: string;
28
+ runtimeExecutablePath?: string;
27
29
  }): NodeJS.ProcessEnv;
28
30
  //# sourceMappingURL=invocation-context.d.ts.map
@@ -1,4 +1,4 @@
1
- import { resolve } from "node:path";
1
+ import { delimiter, dirname, resolve } from "node:path";
2
2
  export const LOCAL_CONSOLE_INVOCATION_ENV_KEYS = [
3
3
  "DBUS_SESSION_BUS_ADDRESS",
4
4
  "DISPLAY",
@@ -12,6 +12,12 @@ export const LOCAL_CONSOLE_INVOCATION_ENV_KEYS = [
12
12
  "WAYLAND_DISPLAY",
13
13
  "XDG_CURRENT_DESKTOP",
14
14
  ];
15
+ function mergeExecutablePaths(values, runtimeExecutablePath) {
16
+ const entries = [dirname(runtimeExecutablePath), ...values.flatMap((value) => value?.split(delimiter) ?? [])]
17
+ .map((entry) => entry.trim())
18
+ .filter((entry) => entry !== "");
19
+ return [...new Set(entries)].join(delimiter);
20
+ }
15
21
  export function collectLocalConsoleInvocationEnvironment(env) {
16
22
  const environment = {};
17
23
  for (const key of LOCAL_CONSOLE_INVOCATION_ENV_KEYS) {
@@ -52,12 +58,15 @@ export function createLocalConsoleServiceEnvironment(options) {
52
58
  environment[key] = value;
53
59
  }
54
60
  }
61
+ environment.PATH = mergeExecutablePaths([environment.PATH], options.runtimeExecutablePath ?? process.execPath);
55
62
  return environment;
56
63
  }
57
64
  export function createLocalConsoleOperationEnvironment(options) {
65
+ const executablePath = mergeExecutablePaths([options.invocationEnvironment.PATH, options.serviceEnvironment.PATH], options.runtimeExecutablePath ?? process.execPath);
58
66
  return {
59
67
  ...options.serviceEnvironment,
60
68
  ...options.invocationEnvironment,
69
+ PATH: executablePath,
61
70
  TUTTI_HOME: options.tuttiHome,
62
71
  };
63
72
  }
@@ -5,7 +5,7 @@ export { collectProjectSourceDiff, createAcceptedCheckpoint, inspectProjectWorks
5
5
  export { initializeProjectDocs, readProjectDocsInitializationStatus, syncProjectDocs, } from "./project-docs.js";
6
6
  export { uploadReferenceFile } from "./reference-files.js";
7
7
  export { referenceFileCategoryForPath, referenceFileSourceForPath, referenceMediaTypeForPath, referenceTuttiGroupForPath, } from "./reference-metadata.js";
8
- export { readReferenceSummaryIndex, readReferenceSummaryTargets, referenceSummaryProjectionForEntry, updateReferenceSummaries, } from "./reference-summaries.js";
8
+ export { readReferenceSummaryEntries, readReferenceSummaryIndex, readReferenceSummaryTargets, referenceSummaryProjectionForEntry, updateReferenceSummaries, } from "./reference-summaries.js";
9
9
  export { readViewerAsset, readViewerFile, readViewerSnapshot, readViewerTree } from "./viewer.js";
10
10
  export type { WorkspaceOpsErrorCode } from "./errors.js";
11
11
  export type { AcceptedCheckpointRecoveryResult, AcceptedCheckpointResult, CreateAcceptedCheckpointOptions, InitializeProjectDocsOptions, InitializeProjectDocsResult, ProjectDocsInitializationContext, ProjectDocsInitializationDocument, ProjectDocsInitializationStatus, ProjectWorkspaceInspection, ReadViewerAssetOptions, ReadViewerFileOptions, ReadViewerTreeOptions, ReferenceFileCategory, ReferenceSummaryProjection, ReferenceSummaryRecord, ReferenceSummaryStatus, ReferenceSummaryTarget, RunDiffSummary, SyncProjectDocsResult, SyncProjectDocsUpdate, UpdateReferenceSummariesOptions, UpdateReferenceSummariesResult, UploadReferenceFileOptions, UploadReferenceFileResult, ViewerAssetResult, ViewerAssetVariant, ViewerEntryKind, ViewerFileContent, ViewerFileResult, ViewerRepoSnapshot, ViewerTreeEntry, ViewerTreeResult, WorkspaceViewerOptions, } from "./types.js";
@@ -5,6 +5,6 @@ export { collectProjectSourceDiff, createAcceptedCheckpoint, inspectProjectWorks
5
5
  export { initializeProjectDocs, readProjectDocsInitializationStatus, syncProjectDocs, } from "./project-docs.js";
6
6
  export { uploadReferenceFile } from "./reference-files.js";
7
7
  export { referenceFileCategoryForPath, referenceFileSourceForPath, referenceMediaTypeForPath, referenceTuttiGroupForPath, } from "./reference-metadata.js";
8
- export { readReferenceSummaryIndex, readReferenceSummaryTargets, referenceSummaryProjectionForEntry, updateReferenceSummaries, } from "./reference-summaries.js";
8
+ export { readReferenceSummaryEntries, readReferenceSummaryIndex, readReferenceSummaryTargets, referenceSummaryProjectionForEntry, updateReferenceSummaries, } from "./reference-summaries.js";
9
9
  export { readViewerAsset, readViewerFile, readViewerSnapshot, readViewerTree } from "./viewer.js";
10
10
  //# sourceMappingURL=index.js.map
@@ -6,40 +6,102 @@ import { WorkspaceOpsError } from "./errors.js";
6
6
  import { classifyReferenceUpload } from "./reference-metadata.js";
7
7
  import { commitOwnedPaths, requireOwnedPathsWriteReady } from "./system-commits.js";
8
8
  import { readViewerFile } from "./viewer.js";
9
- function commandSuffix(commandId) {
10
- return commandId.slice(-8).toLowerCase();
11
- }
12
- function sanitizeReferenceFileName(fileName, commandId) {
13
- const raw = fileName.trim();
14
- if (raw.length === 0) {
9
+ const MAX_REFERENCE_FILE_NAME_BYTES = 240;
10
+ const WINDOWS_RESERVED_FILE_STEMS = /^(?:con|prn|aux|nul|clock\$|com[1-9]|lpt[1-9])$/iu;
11
+ const WINDOWS_INVALID_FILE_NAME_CHARACTERS = /[<>:"|?*]/u;
12
+ function normalizeReferenceFileName(fileName) {
13
+ if (fileName.length === 0 || fileName.trim().length === 0) {
15
14
  throw new WorkspaceOpsError("validation_failed", "Reference file name cannot be empty");
16
15
  }
17
- if (raw.includes("/") || raw.includes("\\")) {
16
+ if (fileName !== fileName.trim()) {
17
+ throw new WorkspaceOpsError("validation_failed", "Reference file name must not have leading or trailing whitespace");
18
+ }
19
+ const normalized = fileName.normalize("NFC");
20
+ if (normalized.includes("/") || normalized.includes("\\")) {
18
21
  throw new WorkspaceOpsError("validation_failed", "Reference file name must not include directories");
19
22
  }
20
- const rawClassification = classifyViewerPath(raw);
23
+ if (WINDOWS_INVALID_FILE_NAME_CHARACTERS.test(normalized)) {
24
+ throw new WorkspaceOpsError("validation_failed", "Reference file name contains a platform-reserved character");
25
+ }
26
+ if (normalized.endsWith(".") || /^\.+$/u.test(normalized)) {
27
+ throw new WorkspaceOpsError("validation_failed", "Reference file name must not end with a dot or consist only of dots");
28
+ }
29
+ const reservedStem = normalized.split(".", 1)[0] ?? normalized;
30
+ if (WINDOWS_RESERVED_FILE_STEMS.test(reservedStem)) {
31
+ throw new WorkspaceOpsError("validation_failed", "Reference file name is reserved by the platform");
32
+ }
33
+ if (Buffer.byteLength(normalized, "utf8") > MAX_REFERENCE_FILE_NAME_BYTES) {
34
+ throw new WorkspaceOpsError("validation_failed", `Reference file name cannot exceed ${MAX_REFERENCE_FILE_NAME_BYTES} UTF-8 bytes`);
35
+ }
36
+ const rawClassification = classifyViewerPath(normalized);
21
37
  if (rawClassification.kind === "rejected") {
22
38
  throw new WorkspaceOpsError("validation_failed", `Reference file name is rejected: ${rawClassification.reason_code}`, { reason_code: rawClassification.reason_code });
23
39
  }
24
- const extension = extname(raw)
25
- .toLowerCase()
26
- .replace(/[^.a-z0-9]/gu, "");
27
- const rawStem = extension.length > 0 ? raw.slice(0, -extension.length) : raw;
28
- const stem = rawStem
29
- .replace(/[^A-Za-z0-9._-]+/gu, "-")
30
- .replace(/-{2,}/gu, "-")
31
- .replace(/^[.-]+/u, "")
32
- .replace(/[.-]+$/u, "");
33
- if (stem.length === 0) {
34
- throw new WorkspaceOpsError("validation_failed", "Reference file name cannot be normalized");
35
- }
36
- const safeName = `${stem}-${commandSuffix(commandId)}${extension}`;
37
- const path = `${REFERENCE_DIRECTORY_PATH}/${safeName}`;
40
+ return normalized;
41
+ }
42
+ function truncateUtf8(value, maxBytes) {
43
+ let result = "";
44
+ let bytes = 0;
45
+ for (const character of value) {
46
+ const characterBytes = Buffer.byteLength(character, "utf8");
47
+ if (bytes + characterBytes > maxBytes) {
48
+ break;
49
+ }
50
+ result += character;
51
+ bytes += characterBytes;
52
+ }
53
+ return result;
54
+ }
55
+ function conflictFileName(fileName, suffix) {
56
+ const extension = extname(fileName);
57
+ const stem = extension.length === 0 ? fileName : fileName.slice(0, -extension.length);
58
+ const marker = `-${suffix}`;
59
+ const maxStemBytes = MAX_REFERENCE_FILE_NAME_BYTES -
60
+ Buffer.byteLength(marker, "utf8") -
61
+ Buffer.byteLength(extension, "utf8");
62
+ if (maxStemBytes <= 0) {
63
+ throw new WorkspaceOpsError("validation_failed", "Reference file name is too long to resolve a path conflict");
64
+ }
65
+ const boundedStem = truncateUtf8(stem, maxStemBytes).replace(/[.\s]+$/u, "");
66
+ if (boundedStem.length === 0) {
67
+ throw new WorkspaceOpsError("validation_failed", "Reference file name cannot be disambiguated safely");
68
+ }
69
+ return `${boundedStem}${marker}${extension}`;
70
+ }
71
+ function commandSuffixes(commandId) {
72
+ const normalized = commandId.toLowerCase().replace(/[^a-z0-9]/gu, "");
73
+ const lengths = [8, 12, 16, 24];
74
+ return [...new Set(lengths.map((length) => normalized.slice(-length)).filter(Boolean))];
75
+ }
76
+ function sameFileContent(path, content) {
77
+ return existsSync(path) && readFileSync(path).equals(content);
78
+ }
79
+ function resolveReferenceFileName(options) {
80
+ const originalPath = join(options.workspaceRoot, ...options.directoryPath.split("/"), options.fileName);
81
+ if (!existsSync(originalPath) || sameFileContent(originalPath, options.content)) {
82
+ return options.fileName;
83
+ }
84
+ for (const suffix of commandSuffixes(options.commandId)) {
85
+ const candidate = conflictFileName(options.fileName, suffix);
86
+ const candidatePath = join(options.workspaceRoot, ...options.directoryPath.split("/"), candidate);
87
+ if (!existsSync(candidatePath) || sameFileContent(candidatePath, options.content)) {
88
+ return candidate;
89
+ }
90
+ }
91
+ throw new WorkspaceOpsError("conflict", "Reference file name conflicts with an existing upload");
92
+ }
93
+ function requireSafeReferencePath(directoryPath, fileName) {
94
+ const path = `${directoryPath}/${fileName}`;
38
95
  const classification = classifyViewerPath(path);
39
96
  if (classification.kind === "rejected") {
40
97
  throw new WorkspaceOpsError("validation_failed", `Reference file path is rejected: ${classification.reason_code}`, { reason_code: classification.reason_code });
41
98
  }
42
- return safeName;
99
+ }
100
+ function markdownLinkLabel(value) {
101
+ return value.replaceAll("[", "\\[").replaceAll("]", "\\]");
102
+ }
103
+ function markdownLinkPath(value) {
104
+ return value.split("/").map(encodeURIComponent).join("/");
43
105
  }
44
106
  function appendReferenceIndexEntry(options) {
45
107
  const indexPath = join(options.workspaceRoot, "docs", "reference", "README.md");
@@ -63,10 +125,10 @@ function appendReferenceIndexEntry(options) {
63
125
  content = `${content.trimEnd()}\n\n## Images\n\n`;
64
126
  }
65
127
  const relativePath = posix.relative(REFERENCE_DIRECTORY_PATH, options.repoPath);
66
- const link = `./${relativePath}`;
128
+ const link = `./${markdownLinkPath(relativePath)}`;
67
129
  const note = options.note?.trim();
68
130
  const description = note === undefined || note.length === 0 ? "" : ` - ${note}`;
69
- const line = `- [${options.fileName}](${link})${description}\n`;
131
+ const line = `- [${markdownLinkLabel(options.fileName)}](${link})${description}\n`;
70
132
  if (content.includes(`](${link})`)) {
71
133
  writeFileSync(indexPath, content, "utf8");
72
134
  return;
@@ -89,24 +151,26 @@ export function uploadReferenceFile(options) {
89
151
  if (options.content.byteLength > MAX_REFERENCE_UPLOAD_BYTES) {
90
152
  throw new WorkspaceOpsError("validation_failed", `Reference file content cannot exceed ${MAX_REFERENCE_UPLOAD_BYTES} bytes`);
91
153
  }
92
- const safeName = sanitizeReferenceFileName(options.fileName, options.commandId);
154
+ const normalizedName = normalizeReferenceFileName(options.fileName);
93
155
  const classification = classifyReferenceUpload({
94
- fileName: options.fileName,
156
+ fileName: normalizedName,
95
157
  content: options.content,
96
158
  ...(options.mediaType === undefined ? {} : { mediaType: options.mediaType }),
97
159
  });
160
+ const safeName = resolveReferenceFileName({
161
+ workspaceRoot: options.workspaceRoot,
162
+ directoryPath: classification.directoryPath,
163
+ fileName: normalizedName,
164
+ commandId: options.commandId,
165
+ content: options.content,
166
+ });
167
+ requireSafeReferencePath(classification.directoryPath, safeName);
98
168
  const referenceDirectory = join(options.workspaceRoot, ...classification.directoryPath.split("/"));
99
169
  const repoPath = `${classification.directoryPath}/${safeName}`;
100
170
  const filesystemPath = join(referenceDirectory, safeName);
101
171
  requireOwnedPathsWriteReady(options.workspaceRoot, [REFERENCE_INDEX_PATH, repoPath]);
102
172
  mkdirSync(referenceDirectory, { recursive: true });
103
- if (existsSync(filesystemPath)) {
104
- const existing = readFileSync(filesystemPath);
105
- if (!existing.equals(options.content)) {
106
- throw new WorkspaceOpsError("conflict", "Reference file path already exists with different content");
107
- }
108
- }
109
- else {
173
+ if (!existsSync(filesystemPath)) {
110
174
  writeFileSync(filesystemPath, options.content);
111
175
  }
112
176
  appendReferenceIndexEntry({
@@ -7,6 +7,11 @@ export declare function referenceSummaryProjectionForEntry(options: {
7
7
  entry: ViewerTreeEntry;
8
8
  summaries: ReferenceSummaryIndex;
9
9
  }): ReferenceSummaryProjection;
10
+ export declare function readReferenceSummaryEntries(options: {
11
+ workspaceRoot: string;
12
+ paths?: string[];
13
+ now?: () => Date;
14
+ }): ReferenceSummaryTarget[];
10
15
  export declare function readReferenceSummaryTargets(options: {
11
16
  workspaceRoot: string;
12
17
  paths?: string[];
@@ -138,8 +138,7 @@ export function referenceSummaryProjectionForEntry(options) {
138
138
  ...(record.source_blob_oid === undefined ? {} : { source_blob_oid: record.source_blob_oid }),
139
139
  ...(record.read_paths === undefined ? {} : { read_paths: record.read_paths }),
140
140
  };
141
- if (record.source_blob_oid !== undefined &&
142
- options.entry.blob_oid !== undefined &&
141
+ if (options.entry.blob_oid !== undefined &&
143
142
  record.source_blob_oid !== options.entry.blob_oid) {
144
143
  return {
145
144
  status: "stale",
@@ -179,7 +178,7 @@ function readReferenceFileEntries(options) {
179
178
  .filter((entry) => entry.blob_oid !== undefined && entry.size_bytes !== undefined)
180
179
  .sort((left, right) => left.path.localeCompare(right.path, "en", { sensitivity: "base" }));
181
180
  }
182
- export function readReferenceSummaryTargets(options) {
181
+ export function readReferenceSummaryEntries(options) {
183
182
  const summaries = readReferenceSummaryIndex({ workspaceRoot: options.workspaceRoot });
184
183
  const requested = options.paths === undefined ? undefined : new Set(options.paths);
185
184
  return readReferenceFileEntries(options)
@@ -198,8 +197,10 @@ export function readReferenceSummaryTargets(options) {
198
197
  : { media_type: referenceMediaTypeForPath(entry.path) }),
199
198
  summary,
200
199
  };
201
- })
202
- .filter((target) => target.summary.status !== "available");
200
+ });
201
+ }
202
+ export function readReferenceSummaryTargets(options) {
203
+ return readReferenceSummaryEntries(options).filter((target) => target.summary.status !== "available");
203
204
  }
204
205
  function removeSummarySection(content) {
205
206
  const headingPattern = /(^|\n)## Summaries\n/u;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfey/tutti",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -29,7 +29,7 @@
29
29
  "@fastify/cors": "^11.2.0",
30
30
  "@fastify/static": "^9.1.3",
31
31
  "@fastify/websocket": "^11.2.0",
32
- "@openai/codex": "^0.142.5",
32
+ "@openai/codex": "^0.147.0",
33
33
  "@sinclair/typebox": "^0.34.49",
34
34
  "@tutti/relay-client": "0.0.0",
35
35
  "@tutti/shared": "0.0.0",
package/prompts/README.md CHANGED
@@ -15,7 +15,7 @@ Prompt / skill 改造后的 live 实验计划与记录见 [prompt-flow-experimen
15
15
 
16
16
  - `procedures/`:`scratchpad_refresh`、`project_brief_refresh`、`task_compile`、`project_context_bootstrap`、`context_sync`、`reference_summary_refresh`、`follow_up_check` 等 Procedure / workflow prompt。
17
17
  - `artifacts/`:候选实现完成后的 transient Artifact applicability 判断;只接收 bounded task / diff / repo evidence,不写回 Task contract 或 repo。
18
- - `runs/`:正式 task `Run`、task-bound continuation 与 pipeline self-correction retry 等 Run pipeline prompt;provider transient retry 不新增 prompt,而是复用失败 attempt 的同一 prompt family。所有 Run prompt都以持久 project workspace为 cwd contract,不设计 promotion reconcile prompt。
18
+ - `runs/`:正式 task `Run`、task-bound continuation 与 pipeline self-correction retry 等 Run pipeline prompt;task contract 可附带 receipt-derived compact `recent_references`,两类 retry 都保留它。provider transient retry 不新增 prompt,而是复用失败 attempt 的同一 prompt family。所有 Run prompt都以持久 project workspace为 cwd contract,不设计 promotion reconcile prompt。
19
19
  - `codex/`:Codex app-server smoke 等 adapter / spike prompt。
20
20
  - `skills/`:Tutti packaged built-in Codex skills;由 server-side app-server skill selection 选择,不在 Web Skills 页面展示,也不保存 user skills。
21
21
  - `chat-assistant.md`:主群聊与 clarification round 内 `@tutti` 只读项目问答 prompt;它不是 Procedure / Run prompt。
@@ -14,7 +14,7 @@
14
14
 
15
15
  `project_context_bootstrap` 的 provider path 只让 Codex app-server read-only adapter 生成缺失文档内容;如果该计划不可用,Control Plane 会回退到本地保守模板。
16
16
  `project_context_bootstrap` 的 read-only 输入只包含项目名、非空时的 `missing_docs` 与刷新后的 Scratchpad;prompt 会要求 app-server 只读探索仓库,并可通过 `read-project-docs` / `read-references` skill 按需读取现有文档和参考文件。输出只包含 `documents[].doc_key/content`,固定 path 由 Control Plane 后处理生成。
17
- `task_compile` 的 read-only 输入只包含 durable context 固定的五字段 Scratchpad、当前 Worklist 和可选完整有序 `clarification_rounds`;workflow ref、activity ref、Scratchpad / Worklist 审计字段、排序字段、时间戳和完整 message projection 不进入 prompt。每次 continuation 仍读取同一 pinned Scratchpad,不读取并行变化的 Current Scratchpadlater round 可以纠正 earlier round,明确撤回时允许空 proposal 正常结束。prompt 暴露 `read-project-docs` / `read-worklist` / `read-references` skills 供 app-server 必要时读取项目上下文文件、既有任务 contract / latest result 或相关 reference 正文;Scratchpad 和 Worklist 紧凑视图仍以直接输入为准,不通过 skill 重读 Scratchpad。provider raw output 使用 `{ result: ... }` 互斥分支;proposal 分支用 `module_ref` 指向新模块或既有模块,task 只输出 `title / goal / scope`,runner adapter 再映射为 Control Plane 内部 proposal:`goal` 同时作为内部 task summary 和 contract goal,`scope` 作为内部 contract scope。
17
+ `task_compile` 的 read-only 输入只包含 durable context 固定的五字段 Scratchpad、当前 Worklist、可选 compact `recent_references` 和可选完整有序 `clarification_rounds`;reference 项只含 path、最终 name 与 available/unavailable summary,不含 blob/message/workflow audit fields。每次 continuation 仍读取同一 pinned Scratchpad 与 source set,不读取并行变化的 Current Scratchpad或 boundary 后的新上传;later round 可以纠正 earlier round,明确撤回时允许空 proposal 正常结束。prompt 暴露 `read-project-docs` / `read-worklist` / `read-references` skills 供 app-server 必要时读取项目上下文文件、既有任务 contract / latest result 或相关 reference 正文;Scratchpad 和 Worklist 紧凑视图仍以直接输入为准,不通过 skill 重读 Scratchpad。provider raw output 使用 `{ result: ... }` 互斥分支;proposal 分支用 `module_ref` 指向新模块或既有模块,task 只输出 `title / goal / scope`,runner adapter 再映射为 Control Plane 内部 proposal:`goal` 同时作为内部 task summary 和 contract goal,`scope` 作为内部 contract scope。
18
18
  `context_sync` 没有 provider-facing direct input;workflow ref、activity ref、触发原因、安全元数据、checked paths、skipped paths 和 open questions 都不进入 prompt,只用于运行时追踪、调度或审计。prompt 说明通用文档同步目标、目标项目文档落点和 canonical docs 职责,由 app-server 主动探索 repo,并通过 `read-project-docs` / `read-worklist` / `read-references` skill 按需读取现有文档、正式任务上下文和参考文件。Worklist / task result 只作为阶段性完成背景和稳定结果线索,不写成长文档里的逐任务流水。
19
19
  `context_sync` prompt 只返回 `summary` 与 `updates[].path/content`;空 `updates` 表示无需同步,非空 `updates` 由 Control Plane 调用 `workspace-ops` 做 path policy、mainline clean 检查、文件写入和 Git commit。
20
20
  `project_brief_refresh` 只走 OpenAI SDK structured output,不使用 Codex app-server,不读取 repo 或 skills。它接收四个 baseline Project Docs 的 bounded source 内容与状态,输出 `product_summary / tech_summary / structure_summary / principles_summary`,由 Control Plane 写入 host-local `project_brief` projection;Project Docs 创建、同步、被成功 Run promotion 修改,或 provider setup 后需要 backfill 时可以触发。provider 不可用、source 不可用、brief 已经 fresh 或刷新失败都不影响 Project Docs 写入结果。
@@ -20,13 +20,14 @@ You compile Tutti's Scratchpad into an append-only Worklist proposal for post-ba
20
20
 
21
21
  - `scratchpad` is the immutable five-field snapshot pinned when this task_compile workflow was submitted. It has already removed message ids, timestamps, activity refs, and refresh state.
22
22
  - `worklist.modules[]` is the current formal Worklist. Module `id` values are the only valid existing module keys. Module names and task titles are historical anchors for resolving short references in the current Scratchpad.
23
+ - `recent_references`, when present, identifies human-uploaded files from the same pinned Scratchpad cycle and provides a terminal summary status for each source.
23
24
  - `clarification_rounds`, when present, contains every sealed workflow-bound clarification round for this same pinned Scratchpad, ordered from earliest to latest. Each round keeps its request separate from its ordered submitted answer messages.
24
25
  - Later clarification rounds may add to or correct earlier rounds. Interpret them chronologically without discarding the earlier context.
25
26
  - Concurrent main-chat discussion and the mutable Current Scratchpad are intentionally absent. Never assume that a short clarification answer applies to a newer chat direction, and never use skills to find concurrent chat to complete this workflow.
26
27
  - Treat the provided Scratchpad and Worklist as authoritative. Do not use skills to reread them.
27
28
  - Use `read-worklist` only when an existing task's contract or latest result would clarify the current task's subject, boundary, or delivery relationship. Do not duplicate tasks already covered by the Worklist.
28
29
  - Use `read-project-docs` or `read-references` only when task scope, acceptance, product intent, technical stack, repo structure, agent working rules, or reference contents matter and are not clear from direct input.
29
- - Reference upload events, file names, paths, and media types do not prove file contents. Read the relevant reference when its contents affect the task contract.
30
+ - Use recent references when they materially inform the proposed work, and read the source when its summary is insufficient to freeze the task contract.
30
31
  - Preserve user-provided non-secret details when they are needed to execute or accept the task. Do not generalize them into vague labels.
31
32
  - Do not use web search, credentials, provider internals, raw logs, or Tutti runtime/internal paths.
32
33