@promptbook/cli 0.114.0-12 → 0.114.0-13

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 (80) hide show
  1. package/apps/agents-server/src/app/agents/[agentName]/AgentChatWrapper.tsx +7 -4
  2. package/apps/agents-server/src/app/agents/[agentName]/chat/AgentGoalChatPlannedMessages.tsx +23 -8
  3. package/apps/agents-server/src/app/agents/[agentName]/chat/CanonicalAgentChatSurface.tsx +14 -3
  4. package/apps/agents-server/src/app/agents/[agentName]/chat/page.tsx +2 -20
  5. package/apps/agents-server/src/app/agents/[agentName]/page.tsx +2 -1
  6. package/apps/agents-server/src/app/agents/[agentName]/projectReferenceActions.ts +61 -0
  7. package/apps/agents-server/src/components/AgentProjects/resolveLatestCompletedAgentMessageKey.ts +32 -0
  8. package/apps/agents-server/src/components/AgentProjects/useAgentProjectMarkdownReferences.ts +124 -2
  9. package/apps/agents-server/src/languages/ServerTranslationKeys.ts +3 -0
  10. package/apps/agents-server/src/languages/translations/czech.yaml +3 -0
  11. package/apps/agents-server/src/languages/translations/english.yaml +3 -0
  12. package/apps/agents-server/src/tools/agentGoalChatTimeoutToolFunctions.ts +24 -2
  13. package/apps/agents-server/src/tools/agentGoalChatTimeoutTools.ts +5 -4
  14. package/apps/agents-server/src/utils/agentGoalChat/agentGoalChatPlannedMessageActions.ts +38 -9
  15. package/apps/agents-server/src/utils/agentGoalChat/createAgentGoalChatNoteContent.ts +15 -6
  16. package/apps/agents-server/src/utils/agentProjects/listAgentProjectNames.ts +35 -0
  17. package/apps/agents-server/src/utils/agentProjects/listAgentProjects.ts +6 -23
  18. package/apps/agents-server/src/utils/agentProjects/resolveAgentChatProjectReferences.ts +25 -0
  19. package/apps/agents-server/src/utils/chatMessageChips/createAnsweredMessageChipToolCalls.ts +37 -0
  20. package/apps/agents-server/src/utils/chatMessageChips/createPlannedMessageChipToolCalls.ts +39 -0
  21. package/apps/agents-server/src/utils/chatMessageChips/createTouchedProjectChipToolCalls.ts +75 -0
  22. package/apps/agents-server/src/utils/localChatRunner/applyLocalAgentPlannedMessageCommands.ts +212 -0
  23. package/apps/agents-server/src/utils/localChatRunner/prepareLocalAgentPlannedMessagesSidecar.ts +66 -0
  24. package/apps/agents-server/src/utils/localChatRunner/processLocalUserChatJob.ts +110 -22
  25. package/apps/agents-server/src/utils/userChat/persistUserChatJobTerminalState.ts +5 -0
  26. package/apps/agents-server/src/utils/userChat/userChatJobRunReport.ts +30 -0
  27. package/apps/agents-server/src/utils/userChatTimeout/createTimeoutWakeUpMessage.ts +9 -1
  28. package/apps/agents-server/src/utils/userChatTimeout/userChatTimeoutStore/repeatFiredUserChatTimeout.ts +74 -0
  29. package/apps/agents-server/src/utils/userChatTimeout/userChatTimeoutStore.ts +1 -0
  30. package/apps/agents-server/src/utils/userChatTimeout/userChatTimeoutWorker.ts +63 -54
  31. package/esm/index.es.js +639 -80
  32. package/esm/index.es.js.map +1 -1
  33. package/esm/scripts/run-agent-messages/messages/buildAgentGoalChatPromptSection.d.ts +22 -4
  34. package/esm/scripts/run-agent-messages/messages/buildAgentMessagePrompt.d.ts +3 -3
  35. package/esm/scripts/run-agent-messages/messages/loadAgentPlannedMessagesSidecar.d.ts +10 -0
  36. package/esm/scripts/run-agent-messages/messages/resolveTouchedAgentProjects.d.ts +15 -0
  37. package/esm/src/book-3.0/AgentMessageRunReport.d.ts +28 -0
  38. package/esm/src/book-3.0/AgentPlannedMessagesSidecar.d.ts +94 -0
  39. package/esm/src/book-3.0/AgentPlannedMessagesSidecar.test.d.ts +1 -0
  40. package/esm/src/book-3.0/createAgentMessageSidecarBaseName.d.ts +13 -0
  41. package/esm/src/book-components/Chat/utils/agentProjectToolCall.d.ts +46 -0
  42. package/esm/src/book-components/Chat/utils/timeoutToolCallPresentation.d.ts +24 -0
  43. package/esm/src/utils/agent-message-runtime/parseAgentMessageRuntimeLogEvents.d.ts +59 -0
  44. package/esm/src/utils/agent-message-runtime/resolveAgentMessageTouchedProjectNames.d.ts +19 -0
  45. package/esm/src/utils/agent-message-runtime/resolveAgentMessageTouchedProjectNames.test.d.ts +1 -0
  46. package/esm/src/version.d.ts +1 -1
  47. package/package.json +1 -1
  48. package/src/book-3.0/AgentMessageRunReport.ts +76 -0
  49. package/src/book-3.0/AgentPlannedMessagesSidecar.ts +229 -0
  50. package/src/book-3.0/AgentTeamConversationWorkspace.ts +3 -5
  51. package/src/book-3.0/createAgentMessageSidecarBaseName.ts +20 -0
  52. package/src/book-components/Chat/Chat/Chat.module.css +6 -1
  53. package/src/book-components/Chat/Chat/renderToolCallDetails.tsx +1 -12
  54. package/src/book-components/Chat/utils/agentProjectToolCall.ts +74 -0
  55. package/src/book-components/Chat/utils/getToolCallChipletInfo.ts +28 -0
  56. package/src/book-components/Chat/utils/timeoutToolCallPresentation.ts +23 -3
  57. package/src/other/templates/getTemplatesPipelineCollection.ts +784 -738
  58. package/src/utils/agent-message-runtime/parseAgentMessageRuntimeLogEvents.ts +105 -0
  59. package/src/utils/agent-message-runtime/resolveAgentMessageRuntimeActivity.ts +6 -54
  60. package/src/utils/agent-message-runtime/resolveAgentMessageTouchedProjectNames.ts +147 -0
  61. package/src/version.ts +2 -2
  62. package/src/versions.txt +1 -0
  63. package/umd/index.umd.js +639 -80
  64. package/umd/index.umd.js.map +1 -1
  65. package/umd/scripts/run-agent-messages/messages/buildAgentGoalChatPromptSection.d.ts +22 -4
  66. package/umd/scripts/run-agent-messages/messages/buildAgentMessagePrompt.d.ts +3 -3
  67. package/umd/scripts/run-agent-messages/messages/loadAgentPlannedMessagesSidecar.d.ts +10 -0
  68. package/umd/scripts/run-agent-messages/messages/resolveTouchedAgentProjects.d.ts +15 -0
  69. package/umd/src/book-3.0/AgentMessageRunReport.d.ts +28 -0
  70. package/umd/src/book-3.0/AgentPlannedMessagesSidecar.d.ts +94 -0
  71. package/umd/src/book-3.0/AgentPlannedMessagesSidecar.test.d.ts +1 -0
  72. package/umd/src/book-3.0/createAgentMessageSidecarBaseName.d.ts +13 -0
  73. package/umd/src/book-components/Chat/utils/agentProjectToolCall.d.ts +46 -0
  74. package/umd/src/book-components/Chat/utils/timeoutToolCallPresentation.d.ts +24 -0
  75. package/umd/src/utils/agent-message-runtime/parseAgentMessageRuntimeLogEvents.d.ts +59 -0
  76. package/umd/src/utils/agent-message-runtime/resolveAgentMessageTouchedProjectNames.d.ts +19 -0
  77. package/umd/src/utils/agent-message-runtime/resolveAgentMessageTouchedProjectNames.test.d.ts +1 -0
  78. package/umd/src/version.d.ts +1 -1
  79. package/esm/apps/agents-server/src/utils/agentGoalChat/agentGoalChatConstants.d.ts +0 -12
  80. package/umd/apps/agents-server/src/utils/agentGoalChat/agentGoalChatConstants.d.ts +0 -12
@@ -24,6 +24,14 @@ const MAX_LISTED_PLANNED_MESSAGES_LIMIT = 100;
24
24
  */
25
25
  const ACTIVE_PLANNED_MESSAGE_STATUSES: ReadonlyArray<UserChatTimeoutStatus> = ['QUEUED', 'RUNNING'];
26
26
 
27
+ /**
28
+ * Shortest repeat interval one agent may plan for itself.
29
+ *
30
+ * Planned messages repeat like `setInterval`, so an interval below one minute would turn the goal
31
+ * chat into an unattended self-invocation loop instead of a plan.
32
+ */
33
+ const MINIMUM_PLANNED_MESSAGE_INTERVAL_MS = 60_000;
34
+
27
35
  /**
28
36
  * Minimal goal-chat identity needed when scheduling a planned message.
29
37
  */
@@ -41,6 +49,11 @@ export type AgentGoalChatPlannedMessageItem = {
41
49
  readonly dueAt: string;
42
50
  readonly isPaused: boolean;
43
51
  readonly message: string | null;
52
+
53
+ /**
54
+ * Repeat interval in milliseconds, or `null` for a planned message that wakes the agent only once.
55
+ */
56
+ readonly intervalMs: number | null;
44
57
  };
45
58
 
46
59
  /**
@@ -52,6 +65,11 @@ export type SetAgentGoalChatPlannedMessageResult = {
52
65
  readonly timeoutId: string;
53
66
  readonly dueAt: string;
54
67
  readonly message: string | null;
68
+
69
+ /**
70
+ * Repeat interval in milliseconds of the scheduled planned message.
71
+ */
72
+ readonly intervalMs: number | null;
55
73
  };
56
74
 
57
75
  /**
@@ -76,8 +94,14 @@ export type CancelAgentGoalChatPlannedMessageResult = {
76
94
 
77
95
  /**
78
96
  * Shared operations used by both model tools and the coding-agent runtime API.
97
+ *
98
+ * Planned messages behave like `setInterval`: `set` starts a repeating wake-up and only `cancel`
99
+ * stops it, so an agent that is happy with its plan does not have to do anything.
79
100
  */
80
101
  export type AgentGoalChatPlannedMessageActions = {
102
+ /**
103
+ * Starts one repeating planned message, where `milliseconds` is its repeat interval.
104
+ */
81
105
  readonly set: (options: {
82
106
  readonly agentPermanentId: string;
83
107
  readonly milliseconds: unknown;
@@ -133,14 +157,16 @@ export function createAgentGoalChatPlannedMessageActions(
133
157
  return {
134
158
  async set(options): Promise<SetAgentGoalChatPlannedMessageResult> {
135
159
  const agentPermanentId = parseRequiredText(options.agentPermanentId, 'agentPermanentId');
136
- const milliseconds = parsePositiveMilliseconds(options.milliseconds);
160
+ const intervalMs = parsePlannedMessageIntervalMs(options.milliseconds);
137
161
  const message = parseRequiredText(options.message, 'message');
138
162
  const goalChat = await dependencies.ensureAgentGoalChat(agentPermanentId);
163
+ // Note: A planned message repeats like `setInterval`, so the requested delay is also its interval
139
164
  const plannedMessage = await dependencies.scheduleThreadScopedUserChatTimeout({
140
165
  userId: goalChat.userId,
141
166
  agentPermanentId,
142
167
  chatId: goalChat.id,
143
- durationMs: milliseconds,
168
+ durationMs: intervalMs,
169
+ recurrenceIntervalMs: intervalMs,
144
170
  message,
145
171
  });
146
172
 
@@ -150,6 +176,7 @@ export function createAgentGoalChatPlannedMessageActions(
150
176
  timeoutId: plannedMessage.timeoutId,
151
177
  dueAt: plannedMessage.dueAt,
152
178
  message: plannedMessage.message,
179
+ intervalMs: plannedMessage.recurrenceIntervalMs,
153
180
  };
154
181
  },
155
182
 
@@ -209,20 +236,21 @@ export function createAgentGoalChatPlannedMessageActions(
209
236
  export const AGENT_GOAL_CHAT_PLANNED_MESSAGE_ACTIONS = createAgentGoalChatPlannedMessageActions();
210
237
 
211
238
  /**
212
- * Parses one positive timeout duration.
239
+ * Parses one planned-message repeat interval.
213
240
  *
214
- * @param rawMilliseconds - Untrusted duration supplied by a model or runtime API.
215
- * @returns Positive whole milliseconds.
241
+ * @param rawMilliseconds - Untrusted interval supplied by a model or runtime API.
242
+ * @returns Repeat interval in whole milliseconds.
216
243
  */
217
- function parsePositiveMilliseconds(rawMilliseconds: unknown): number {
244
+ function parsePlannedMessageIntervalMs(rawMilliseconds: unknown): number {
218
245
  const milliseconds = Number(rawMilliseconds);
219
246
 
220
- if (!Number.isFinite(milliseconds) || milliseconds < 1) {
247
+ if (!Number.isFinite(milliseconds) || milliseconds < MINIMUM_PLANNED_MESSAGE_INTERVAL_MS) {
221
248
  throw new ParseError(
222
249
  spaceTrim(`
223
- Invalid planned-message delay.
250
+ Invalid planned-message interval.
224
251
 
225
- - \`milliseconds\` must be a positive number of at least \`1\`.
252
+ - A planned message **repeats** at this interval until it is cancelled.
253
+ - \`milliseconds\` must be a number of at least \`${MINIMUM_PLANNED_MESSAGE_INTERVAL_MS}\`.
226
254
  `),
227
255
  );
228
256
  }
@@ -289,6 +317,7 @@ function createPlannedMessageListItem(plannedMessage: UserChatTimeoutRecord): Ag
289
317
  dueAt: plannedMessage.dueAt,
290
318
  isPaused: Boolean(plannedMessage.pausedAt),
291
319
  message: plannedMessage.message,
320
+ intervalMs: plannedMessage.recurrenceIntervalMs,
292
321
  };
293
322
  }
294
323
 
@@ -1,4 +1,5 @@
1
1
  import { spaceTrim } from 'spacetrim';
2
+ import { formatTimeoutDurationHuman } from '../../../../../src/book-components/Chat/utils/timeoutToolCallPresentation';
2
3
 
3
4
  /**
4
5
  * Lifecycle events of an agent that are recorded in its goal chat.
@@ -35,8 +36,8 @@ export function createAgentGoalChatLifecycleNoteContent(options: {
35
36
  ${block(goalSection)}
36
37
 
37
38
  This is my own thread for acting towards that goal. I should work on it now when useful,
38
- inspect existing planned messages with \`list_timeouts\`, and use \`set_timeout\` or
39
- \`cancel_timeout\` to arrange the next useful wake-up without creating duplicates.
39
+ review the planned messages I already have, and plan a repeating wake-up with the
40
+ planned-message capability of my invocation when my goal needs one that is not planned yet.
40
41
  `,
41
42
  );
42
43
  }
@@ -47,8 +48,10 @@ export function createAgentGoalChatLifecycleNoteContent(options: {
47
48
 
48
49
  ${block(goalSection)}
49
50
 
50
- I should re-check my work and planned messages against this new goal, act on it now when useful,
51
- and arrange another goal-chat wake-up with \`set_timeout\` when future follow-up is needed.
51
+ I should re-check my work and planned messages against this new goal and act on it now when useful.
52
+ My planned messages keep repeating on their own, so I keep the ones that still match this goal,
53
+ and cancel and re-plan only the ones that do not — announcing a wake-up without planning it
54
+ changes nothing.
52
55
  `,
53
56
  );
54
57
  }
@@ -56,19 +59,25 @@ export function createAgentGoalChatLifecycleNoteContent(options: {
56
59
  /**
57
60
  * Builds the goal-chat note left when a planned message is scheduled.
58
61
  *
59
- * @param options - Planned-message identity, due time, and optional payload message.
62
+ * @param options - Planned-message identity, repeat interval, due time, and optional payload message.
60
63
  * @returns Note content stored as one agent message.
61
64
  */
62
65
  export function createAgentGoalChatPlannedMessageNoteContent(options: {
63
66
  readonly timeoutId: string;
64
67
  readonly dueAt: string;
68
+ readonly intervalMs?: number | null;
65
69
  readonly message?: string | null;
66
70
  }): string {
67
71
  const plannedMessage = options.message?.trim() || '';
72
+ const headline = options.intervalMs
73
+ ? `⏳ I planned a message that will wake me up every ${formatTimeoutDurationHuman(
74
+ options.intervalMs,
75
+ )}, for the first time at ${options.dueAt}.`
76
+ : `⏳ I planned a message that will wake me up at ${options.dueAt}.`;
68
77
 
69
78
  return spaceTrim(
70
79
  (block) => `
71
- ⏳ I planned a message that will wake me up at ${options.dueAt}.
80
+ ${headline}
72
81
 
73
82
  ${plannedMessage ? block(plannedMessage) : ''}
74
83
 
@@ -0,0 +1,35 @@
1
+ import { readdir } from 'fs/promises';
2
+ import { resolveAgentProjectsRootPath } from './agentProjectsPaths';
3
+ import { isMissingPathError } from './isMissingPathError';
4
+
5
+ /**
6
+ * Lists the directory names of all projects of one agent.
7
+ *
8
+ * This is the cheap half of `listAgentProjects` — it reads the `projects/` folder itself and never walks
9
+ * the project trees, so it can answer "did the set of projects change?" without measuring every project.
10
+ *
11
+ * A missing projects folder is reported as an empty list — the folder is created lazily
12
+ * when the agent (or the runner) first needs it.
13
+ *
14
+ * @param agentPermanentId - Permanent id of the agent.
15
+ * @returns Project directory names ordered by name.
16
+ */
17
+ export async function listAgentProjectNames(agentPermanentId: string): Promise<ReadonlyArray<string>> {
18
+ const projectsRootPath = resolveAgentProjectsRootPath(agentPermanentId);
19
+
20
+ let projectsRootEntries;
21
+ try {
22
+ projectsRootEntries = await readdir(projectsRootPath, { withFileTypes: true });
23
+ } catch (error) {
24
+ if (isMissingPathError(error)) {
25
+ return [];
26
+ }
27
+
28
+ throw error;
29
+ }
30
+
31
+ return projectsRootEntries
32
+ .filter((projectsRootEntry) => projectsRootEntry.isDirectory())
33
+ .map((projectDirectoryEntry) => projectDirectoryEntry.name)
34
+ .sort((firstProjectName, secondProjectName) => firstProjectName.localeCompare(secondProjectName));
35
+ }
@@ -1,7 +1,6 @@
1
- import { readdir } from 'fs/promises';
2
1
  import type { AgentProjectInfo } from './AgentProjectInfo';
3
2
  import { resolveAgentProjectsRootPath } from './agentProjectsPaths';
4
- import { isMissingPathError } from './isMissingPathError';
3
+ import { listAgentProjectNames } from './listAgentProjectNames';
5
4
  import { createAgentProjectInfo } from './resolveAgentProjectInfo';
6
5
 
7
6
  /**
@@ -15,27 +14,11 @@ import { createAgentProjectInfo } from './resolveAgentProjectInfo';
15
14
  */
16
15
  export async function listAgentProjects(agentPermanentId: string): Promise<ReadonlyArray<AgentProjectInfo>> {
17
16
  const projectsRootPath = resolveAgentProjectsRootPath(agentPermanentId);
17
+ const projectNames = await listAgentProjectNames(agentPermanentId);
18
18
 
19
- let projectsRootEntries;
20
- try {
21
- projectsRootEntries = await readdir(projectsRootPath, { withFileTypes: true });
22
- } catch (error) {
23
- if (isMissingPathError(error)) {
24
- return [];
25
- }
26
-
27
- throw error;
28
- }
29
-
30
- const projects = await Promise.all(
31
- projectsRootEntries
32
- .filter((projectsRootEntry) => projectsRootEntry.isDirectory())
33
- .map((projectDirectoryEntry): Promise<AgentProjectInfo> =>
34
- createAgentProjectInfo(projectsRootPath, projectDirectoryEntry.name),
35
- ),
36
- );
37
-
38
- return projects.sort((firstProject, secondProject) =>
39
- firstProject.projectName.localeCompare(secondProject.projectName),
19
+ return await Promise.all(
20
+ projectNames.map(
21
+ (projectName): Promise<AgentProjectInfo> => createAgentProjectInfo(projectsRootPath, projectName),
22
+ ),
40
23
  );
41
24
  }
@@ -0,0 +1,25 @@
1
+ import { resolveAgentProjectsAccess, type AgentProjectsAccessResolution } from './agentProjectAccess';
2
+ import type { AgentProjectReferenceInfo } from './AgentProjectReferenceInfo';
3
+ import { listAgentProjectChatReferences } from './listAgentProjectChatReferences';
4
+
5
+ /**
6
+ * Resolves the compact project references one chat surface may render as project chips.
7
+ *
8
+ * Every surface showing a chat — the agent profile, the chat page, and the action refreshing an open
9
+ * chat — gates the projects behind the same overview permission, so the decision lives here only.
10
+ *
11
+ * @param options - Owning agent and its already resolved project access, when the caller has it.
12
+ * @returns Display-only project references safe to send to the browser.
13
+ */
14
+ export async function resolveAgentChatProjectReferences(options: {
15
+ readonly agentPermanentId: string;
16
+ readonly projectsAccess?: AgentProjectsAccessResolution;
17
+ }): Promise<ReadonlyArray<AgentProjectReferenceInfo>> {
18
+ const projectsAccess = options.projectsAccess || (await resolveAgentProjectsAccess(options.agentPermanentId));
19
+
20
+ if (!projectsAccess.isProjectOverviewVisible) {
21
+ return [];
22
+ }
23
+
24
+ return await listAgentProjectChatReferences(options.agentPermanentId);
25
+ }
@@ -0,0 +1,37 @@
1
+ import type { ToolCall } from '@promptbook-local/types';
2
+ import type { AppliedAgentPlannedMessageCommand } from '../localChatRunner/applyLocalAgentPlannedMessageCommands';
3
+ import { createPlannedMessageChipToolCalls } from './createPlannedMessageChipToolCalls';
4
+ import { createTouchedProjectChipToolCalls } from './createTouchedProjectChipToolCalls';
5
+
6
+ /**
7
+ * Creates every extra chip shown below one answered chat message.
8
+ *
9
+ * This is the single place deciding what an answer shows besides its text: the wake-ups it
10
+ * planned and the projects it worked with. The chips travel as tool calls of the assistant
11
+ * message, so they are visible in every chat view — including external chats such as email,
12
+ * whose outbound reply is rendered from the message text alone and therefore stays unchanged.
13
+ *
14
+ * @param options - Owning agent plus what the answered message planned and touched.
15
+ * @returns Tool calls rendered as chips below the answer, in a stable order.
16
+ */
17
+ export async function createAnsweredMessageChipToolCalls(options: {
18
+ readonly agentPermanentId: string;
19
+ readonly appliedPlannedMessageCommands: ReadonlyArray<AppliedAgentPlannedMessageCommand>;
20
+ readonly touchedProjectNames: ReadonlyArray<string>;
21
+ readonly createdAt?: NonNullable<ToolCall['createdAt']>;
22
+ }): Promise<ReadonlyArray<ToolCall>> {
23
+ const createdAt = options.createdAt || (new Date().toISOString() as NonNullable<ToolCall['createdAt']>);
24
+ const projectChipToolCalls = await createTouchedProjectChipToolCalls({
25
+ agentPermanentId: options.agentPermanentId,
26
+ touchedProjectNames: options.touchedProjectNames,
27
+ createdAt,
28
+ });
29
+
30
+ return [
31
+ ...projectChipToolCalls,
32
+ ...createPlannedMessageChipToolCalls({
33
+ appliedCommands: options.appliedPlannedMessageCommands,
34
+ createdAt,
35
+ }),
36
+ ];
37
+ }
@@ -0,0 +1,39 @@
1
+ import type { ToolCall } from '@promptbook-local/types';
2
+ import {
3
+ CANCEL_TIMEOUT_TOOL_CALL_NAME,
4
+ SET_TIMEOUT_TOOL_CALL_NAME,
5
+ } from '../../../../../src/book-components/Chat/utils/timeoutToolCallPresentation';
6
+ import type { AppliedAgentPlannedMessageCommand } from '../localChatRunner/applyLocalAgentPlannedMessageCommands';
7
+
8
+ /**
9
+ * Prefix of the idempotency keys identifying planned-message chips.
10
+ */
11
+ const PLANNED_MESSAGE_CHIP_IDEMPOTENCY_PREFIX = 'planned-message';
12
+
13
+ /**
14
+ * Creates the timeout chips of the message that planned or cancelled a wake-up.
15
+ *
16
+ * The chat already renders `set_timeout` and `cancel_timeout` tool calls as timeout chips with a
17
+ * detail popup, so one planned message becomes exactly one such tool call instead of a second
18
+ * timeout presentation.
19
+ *
20
+ * @param options - Planned-message commands applied for one answered message.
21
+ * @returns Timeout tool calls shown as chips below the answer.
22
+ */
23
+ export function createPlannedMessageChipToolCalls(options: {
24
+ readonly appliedCommands: ReadonlyArray<AppliedAgentPlannedMessageCommand>;
25
+ readonly createdAt: NonNullable<ToolCall['createdAt']>;
26
+ }): ReadonlyArray<ToolCall> {
27
+ return options.appliedCommands.map(({ command, result }) => ({
28
+ name: result.action === 'set' ? SET_TIMEOUT_TOOL_CALL_NAME : CANCEL_TIMEOUT_TOOL_CALL_NAME,
29
+ arguments: {
30
+ ...(command.milliseconds === undefined ? {} : { milliseconds: command.milliseconds }),
31
+ ...(command.message === undefined ? {} : { message: command.message }),
32
+ ...(command.timeoutId === undefined ? {} : { timeoutId: command.timeoutId }),
33
+ },
34
+ result,
35
+ state: 'COMPLETE',
36
+ idempotencyKey: `${PLANNED_MESSAGE_CHIP_IDEMPOTENCY_PREFIX}-${result.action}-${result.timeoutId}`,
37
+ createdAt: options.createdAt,
38
+ }));
39
+ }
@@ -0,0 +1,75 @@
1
+ import type { ToolCall } from '@promptbook-local/types';
2
+ import { AGENT_PROJECT_TOOL_CALL_NAME } from '../../../../../src/book-components/Chat/utils/agentProjectToolCall';
3
+ import { buildAgentProjectProfileHref } from '../agentProjects/agentProjectHrefs';
4
+ import { isSafeAgentProjectPathSegment } from '../agentProjects/agentProjectsPaths';
5
+ import { resolveAgentProjectInfo } from '../agentProjects/resolveAgentProjectInfo';
6
+
7
+ /**
8
+ * Prefix of the idempotency keys identifying touched-project chips.
9
+ */
10
+ const TOUCHED_PROJECT_CHIP_IDEMPOTENCY_PREFIX = 'agent-project';
11
+
12
+ /**
13
+ * Creates the project chips of one answered message.
14
+ *
15
+ * A project is reported by the runner once the answering harness viewed or edited any of its
16
+ * files, and it is resolved here so the chip carries the same human-readable project name and
17
+ * link as every other project surface. Projects that no longer exist are skipped.
18
+ *
19
+ * @param options - Owning agent and the project directory names its answer touched.
20
+ * @returns Project tool calls shown as chips below the answer.
21
+ */
22
+ export async function createTouchedProjectChipToolCalls(options: {
23
+ readonly agentPermanentId: string;
24
+ readonly touchedProjectNames: ReadonlyArray<string>;
25
+ readonly createdAt: NonNullable<ToolCall['createdAt']>;
26
+ }): Promise<ReadonlyArray<ToolCall>> {
27
+ const projectChipToolCalls = await Promise.all(
28
+ options.touchedProjectNames
29
+ .filter((touchedProjectName) => isSafeAgentProjectPathSegment(touchedProjectName))
30
+ .map((touchedProjectName) =>
31
+ createTouchedProjectChipToolCall({
32
+ agentPermanentId: options.agentPermanentId,
33
+ projectName: touchedProjectName,
34
+ createdAt: options.createdAt,
35
+ }),
36
+ ),
37
+ );
38
+
39
+ return projectChipToolCalls.filter((projectChipToolCall): projectChipToolCall is ToolCall =>
40
+ Boolean(projectChipToolCall),
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Creates the chip of one touched project.
46
+ *
47
+ * @param options - Owning agent and one touched project directory name.
48
+ * @returns Project tool call, or `null` when the project no longer exists.
49
+ *
50
+ * @private function of `createTouchedProjectChipToolCalls`
51
+ */
52
+ async function createTouchedProjectChipToolCall(options: {
53
+ readonly agentPermanentId: string;
54
+ readonly projectName: string;
55
+ readonly createdAt: NonNullable<ToolCall['createdAt']>;
56
+ }): Promise<ToolCall | null> {
57
+ const project = await resolveAgentProjectInfo(options.agentPermanentId, options.projectName);
58
+
59
+ if (!project) {
60
+ return null;
61
+ }
62
+
63
+ return {
64
+ name: AGENT_PROJECT_TOOL_CALL_NAME,
65
+ arguments: { projectName: project.projectName },
66
+ result: {
67
+ projectName: project.projectName,
68
+ displayName: project.displayName,
69
+ projectHref: buildAgentProjectProfileHref(options.agentPermanentId, project.projectName),
70
+ },
71
+ state: 'COMPLETE',
72
+ idempotencyKey: `${TOUCHED_PROJECT_CHIP_IDEMPOTENCY_PREFIX}-${project.projectName.toLowerCase()}`,
73
+ createdAt: options.createdAt,
74
+ };
75
+ }
@@ -0,0 +1,212 @@
1
+ import { readFile, rm } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { serializeError } from '@promptbook-local/utils';
4
+ import {
5
+ createAgentPlannedMessagesSidecarPath,
6
+ parseAgentPlannedMessagesSidecar,
7
+ type AgentPlannedMessageCommand,
8
+ } from '../../../../../src/book-3.0/AgentPlannedMessagesSidecar';
9
+ import {
10
+ AGENT_GOAL_CHAT_PLANNED_MESSAGE_ACTIONS,
11
+ type CancelAgentGoalChatPlannedMessageResult,
12
+ type SetAgentGoalChatPlannedMessageResult,
13
+ } from '../agentGoalChat/agentGoalChatPlannedMessageActions';
14
+ import type { UserChatJobRecord } from '../userChat/UserChatJobRecord';
15
+ import type { LocalUserChatJobMetadata } from './LocalUserChatJobMetadata';
16
+
17
+ /**
18
+ * Planned-message operations used while applying one sidecar.
19
+ */
20
+ export type LocalAgentPlannedMessageActions = Pick<typeof AGENT_GOAL_CHAT_PLANNED_MESSAGE_ACTIONS, 'set' | 'cancel'>;
21
+
22
+ /**
23
+ * One planned-message command that was applied together with its result.
24
+ *
25
+ * The command carries what the agent asked for (for example the requested delay) and the result
26
+ * carries what really happened, so the chat can show the wake-up below the message that planned it.
27
+ */
28
+ export type AppliedAgentPlannedMessageCommand = {
29
+ readonly command: AgentPlannedMessageCommand;
30
+ readonly result: SetAgentGoalChatPlannedMessageResult | CancelAgentGoalChatPlannedMessageResult;
31
+ };
32
+
33
+ /**
34
+ * Applies every planned-message command a coding harness wrote while answering one message.
35
+ *
36
+ * The sidecar is consumed exactly once: it is removed afterwards so a replayed worker tick can never
37
+ * schedule the very same wake-up twice.
38
+ *
39
+ * @param options - Answered job, its agent folder path, and the local runner metadata of the message.
40
+ * @returns Planned-message commands that were applied successfully, in sidecar order.
41
+ */
42
+ export async function applyLocalAgentPlannedMessageCommands(options: {
43
+ readonly job: Pick<UserChatJobRecord, 'id' | 'chatId' | 'agentPermanentId'>;
44
+ readonly agentDirectoryPath: string;
45
+ readonly metadata: LocalUserChatJobMetadata;
46
+ readonly actions?: LocalAgentPlannedMessageActions;
47
+ }): Promise<ReadonlyArray<AppliedAgentPlannedMessageCommand>> {
48
+ const sidecarPath = resolveLocalAgentPlannedMessagesSidecarPath(options.agentDirectoryPath, options.metadata);
49
+ const sidecarContent = await readOptionalTextFile(sidecarPath);
50
+
51
+ await removeLocalAgentPlannedMessagesSidecar(options.agentDirectoryPath, options.metadata);
52
+
53
+ if (sidecarContent === null) {
54
+ return [];
55
+ }
56
+
57
+ const sidecar = parseAgentPlannedMessagesSidecar(sidecarContent);
58
+
59
+ if (!sidecar) {
60
+ console.warn('[local-chat-runner] planned_messages_sidecar_unreadable', {
61
+ chatId: options.job.chatId,
62
+ jobId: options.job.id,
63
+ });
64
+ return [];
65
+ }
66
+
67
+ const actions = options.actions || AGENT_GOAL_CHAT_PLANNED_MESSAGE_ACTIONS;
68
+ const appliedCommands: Array<AppliedAgentPlannedMessageCommand> = [];
69
+
70
+ for (const command of sidecar.commands) {
71
+ const result = await applyLocalAgentPlannedMessageCommand({
72
+ command,
73
+ actions,
74
+ agentPermanentId: options.job.agentPermanentId,
75
+ job: options.job,
76
+ });
77
+
78
+ if (result) {
79
+ appliedCommands.push({ command, result });
80
+ }
81
+ }
82
+
83
+ return appliedCommands;
84
+ }
85
+
86
+ /**
87
+ * Removes the planned-message sidecar of one queued message.
88
+ *
89
+ * @param agentDirectoryPath - Absolute path of the local agent folder.
90
+ * @param metadata - Local runner metadata of the queued message.
91
+ */
92
+ export async function removeLocalAgentPlannedMessagesSidecar(
93
+ agentDirectoryPath: string,
94
+ metadata: LocalUserChatJobMetadata,
95
+ ): Promise<void> {
96
+ await rm(resolveLocalAgentPlannedMessagesSidecarPath(agentDirectoryPath, metadata), { force: true }).catch(
97
+ () => undefined,
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Dispatches one planned-message command through the shared planned-message actions.
103
+ *
104
+ * One rejected command never blocks the remaining ones, because the agent already answered and the
105
+ * harness owns this sidecar.
106
+ *
107
+ * @param options - Command, actions, and the agent whose goal chat owns the planned message.
108
+ * @returns Result of the applied command, or `null` when it was rejected.
109
+ *
110
+ * @private function of `applyLocalAgentPlannedMessageCommands`
111
+ */
112
+ async function applyLocalAgentPlannedMessageCommand(options: {
113
+ readonly command: AgentPlannedMessageCommand;
114
+ readonly actions: LocalAgentPlannedMessageActions;
115
+ readonly agentPermanentId: string;
116
+ readonly job: Pick<UserChatJobRecord, 'id' | 'chatId'>;
117
+ }): Promise<AppliedAgentPlannedMessageCommand['result'] | null> {
118
+ try {
119
+ if (options.command.action === 'set') {
120
+ const plannedMessage = await options.actions.set({
121
+ agentPermanentId: options.agentPermanentId,
122
+ milliseconds: options.command.milliseconds,
123
+ message: options.command.message,
124
+ });
125
+
126
+ console.info('[local-chat-runner]', 'planned_message_set', {
127
+ chatId: options.job.chatId,
128
+ jobId: options.job.id,
129
+ timeoutId: plannedMessage.timeoutId,
130
+ dueAt: plannedMessage.dueAt,
131
+ });
132
+
133
+ return plannedMessage;
134
+ }
135
+
136
+ const cancelledPlannedMessage = await options.actions.cancel({
137
+ agentPermanentId: options.agentPermanentId,
138
+ timeoutId: options.command.timeoutId,
139
+ });
140
+
141
+ console.info('[local-chat-runner]', 'planned_message_cancel', {
142
+ chatId: options.job.chatId,
143
+ jobId: options.job.id,
144
+ timeoutId: cancelledPlannedMessage.timeoutId,
145
+ status: cancelledPlannedMessage.status,
146
+ });
147
+
148
+ return cancelledPlannedMessage.status === 'cancelled' ? cancelledPlannedMessage : null;
149
+ } catch (error) {
150
+ console.error('[local-chat-runner]', 'planned_message_command_failed', {
151
+ chatId: options.job.chatId,
152
+ jobId: options.job.id,
153
+ action: options.command.action,
154
+ error: serializeError(error as Error),
155
+ });
156
+
157
+ return null;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Resolves the absolute planned-message sidecar path of one queued message.
163
+ *
164
+ * @param agentDirectoryPath - Absolute path of the local agent folder.
165
+ * @param metadata - Local runner metadata of the queued message.
166
+ * @returns Absolute sidecar path.
167
+ *
168
+ * @private function of `applyLocalAgentPlannedMessageCommands`
169
+ */
170
+ function resolveLocalAgentPlannedMessagesSidecarPath(
171
+ agentDirectoryPath: string,
172
+ metadata: LocalUserChatJobMetadata,
173
+ ): string {
174
+ return join(agentDirectoryPath, createAgentPlannedMessagesSidecarPath(metadata.fileName));
175
+ }
176
+
177
+ /**
178
+ * Reads one text file and treats a missing planned-message sidecar as absent.
179
+ *
180
+ * @param path - Absolute sidecar path.
181
+ * @returns File content, or `null` when the sidecar does not exist.
182
+ *
183
+ * @private function of `applyLocalAgentPlannedMessageCommands`
184
+ */
185
+ async function readOptionalTextFile(path: string): Promise<string | null> {
186
+ try {
187
+ return await readFile(path, 'utf-8');
188
+ } catch (error) {
189
+ if (isFileNotFoundError(error)) {
190
+ return null;
191
+ }
192
+
193
+ throw error;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Returns true when one filesystem error indicates a missing path.
199
+ *
200
+ * @param error - Caught filesystem failure.
201
+ * @returns `true` for missing-path errors.
202
+ *
203
+ * @private function of `applyLocalAgentPlannedMessageCommands`
204
+ */
205
+ function isFileNotFoundError(error: unknown): boolean {
206
+ return Boolean(
207
+ error &&
208
+ typeof error === 'object' &&
209
+ 'code' in error &&
210
+ ((error as { code?: string }).code === 'ENOENT' || (error as { code?: string }).code === 'ENOTDIR'),
211
+ );
212
+ }