mulmoclaude 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/bin/mulmoclaude.js +7 -24
  2. package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
  3. package/client/assets/index-CubzmCVK.css +2 -0
  4. package/client/assets/{index-eHWB79u5.js → index-DtcyExH9.js} +80 -61
  5. package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
  6. package/client/index.html +2 -4
  7. package/package.json +13 -13
  8. package/server/agent/attachmentConverter.ts +2 -2
  9. package/server/agent/index.ts +9 -3
  10. package/server/agent/mcp-tools/index.ts +6 -6
  11. package/server/agent/mcp-tools/x.ts +2 -1
  12. package/server/agent/prompt.ts +187 -26
  13. package/server/agent/resumeFailover.ts +5 -5
  14. package/server/agent/sandboxMounts.ts +3 -3
  15. package/server/api/auth/bearerAuth.ts +3 -3
  16. package/server/api/auth/token.ts +2 -2
  17. package/server/api/routes/agent.ts +21 -3
  18. package/server/api/routes/config.ts +1 -1
  19. package/server/api/routes/files.ts +13 -12
  20. package/server/api/routes/html.ts +2 -2
  21. package/server/api/routes/image.ts +7 -7
  22. package/server/api/routes/mulmo-script.ts +33 -31
  23. package/server/api/routes/pdf.ts +2 -2
  24. package/server/api/routes/plugins.ts +16 -6
  25. package/server/api/routes/roles.ts +2 -2
  26. package/server/api/routes/scheduler.ts +8 -6
  27. package/server/api/routes/schedulerTasks.ts +5 -3
  28. package/server/api/routes/sessions.ts +2 -2
  29. package/server/api/routes/sessionsCursor.ts +4 -4
  30. package/server/api/routes/skills.ts +5 -5
  31. package/server/api/routes/sources.ts +3 -3
  32. package/server/api/routes/todosHandlers.ts +1 -1
  33. package/server/api/routes/todosItemsHandlers.ts +14 -14
  34. package/server/api/routes/wiki.ts +22 -8
  35. package/server/api/sandboxStatus.ts +1 -1
  36. package/server/events/notifications.ts +6 -6
  37. package/server/events/pub-sub/index.ts +3 -3
  38. package/server/events/relay-client.ts +17 -16
  39. package/server/index.ts +40 -46
  40. package/server/system/config.ts +5 -5
  41. package/server/system/credentials.ts +7 -5
  42. package/server/system/env.ts +5 -5
  43. package/server/utils/files/atomic.ts +11 -11
  44. package/server/utils/files/image-store.ts +17 -6
  45. package/server/utils/files/journal-io.ts +2 -2
  46. package/server/utils/files/json.ts +5 -5
  47. package/server/utils/files/markdown-store.ts +4 -4
  48. package/server/utils/files/reference-dirs-io.ts +3 -3
  49. package/server/utils/files/roles-io.ts +4 -4
  50. package/server/utils/files/safe.ts +14 -14
  51. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  52. package/server/utils/files/spreadsheet-store.ts +5 -5
  53. package/server/utils/files/workspace-io.ts +12 -12
  54. package/server/utils/gemini.ts +2 -2
  55. package/server/utils/gitignore.ts +9 -9
  56. package/server/utils/json.ts +5 -5
  57. package/server/utils/logBackgroundError.ts +12 -3
  58. package/server/utils/markdown.ts +5 -5
  59. package/server/utils/port.d.mts +6 -0
  60. package/server/utils/port.mjs +48 -0
  61. package/server/utils/request.ts +12 -6
  62. package/server/utils/spawn.ts +1 -1
  63. package/server/utils/types.ts +2 -2
  64. package/server/workspace/chat-index/summarizer.ts +4 -4
  65. package/server/workspace/custom-dirs.ts +5 -5
  66. package/server/workspace/journal/diff.ts +2 -2
  67. package/server/workspace/journal/index.ts +4 -4
  68. package/server/workspace/journal/optimizationPass.ts +2 -2
  69. package/server/workspace/journal/state.ts +6 -6
  70. package/server/workspace/paths.ts +3 -3
  71. package/server/workspace/reference-dirs.ts +3 -3
  72. package/server/workspace/skills/parser.ts +6 -6
  73. package/server/workspace/skills/scheduler.ts +3 -3
  74. package/server/workspace/skills/writer.ts +3 -3
  75. package/server/workspace/sources/arxivDiscovery.ts +2 -2
  76. package/server/workspace/sources/fetchers/rss.ts +5 -5
  77. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  78. package/server/workspace/sources/interests.ts +3 -3
  79. package/server/workspace/sources/paths.ts +6 -6
  80. package/server/workspace/sources/pipeline/fetch.ts +36 -13
  81. package/server/workspace/sources/pipeline/index.ts +2 -7
  82. package/server/workspace/sources/pipeline/notify.ts +3 -3
  83. package/server/workspace/sources/pipeline/plan.ts +11 -9
  84. package/server/workspace/sources/pipeline/write.ts +5 -5
  85. package/server/workspace/sources/rateLimiter.ts +1 -1
  86. package/server/workspace/sources/sourceState.ts +9 -4
  87. package/server/workspace/sources/types.ts +9 -0
  88. package/server/workspace/sources/urls.ts +1 -1
  89. package/server/workspace/tool-trace/classify.ts +4 -4
  90. package/server/workspace/workspace.ts +7 -7
  91. package/src/App.vue +286 -112
  92. package/src/components/CanvasViewToggle.vue +10 -7
  93. package/src/components/ChatInput.vue +60 -26
  94. package/src/components/FileContentHeader.vue +7 -4
  95. package/src/components/FileContentRenderer.vue +20 -6
  96. package/src/components/FileTree.vue +6 -3
  97. package/src/components/FileTreePane.vue +11 -8
  98. package/src/components/FilesView.vue +5 -3
  99. package/src/components/LockStatusPopup.vue +15 -12
  100. package/src/components/NotificationBell.vue +14 -5
  101. package/src/components/NotificationToast.vue +4 -1
  102. package/src/components/PluginLauncher.vue +19 -56
  103. package/src/components/RightSidebar.vue +13 -10
  104. package/src/components/SessionHistoryPanel.vue +33 -29
  105. package/src/components/SessionTabBar.vue +8 -10
  106. package/src/components/SettingsMcpTab.vue +43 -30
  107. package/src/components/SettingsModal.vue +21 -19
  108. package/src/components/SettingsReferenceDirsTab.vue +29 -24
  109. package/src/components/SettingsWorkspaceDirsTab.vue +32 -22
  110. package/src/components/SidebarHeader.vue +25 -4
  111. package/src/components/StackView.vue +4 -1
  112. package/src/components/SuggestionsPanel.vue +5 -2
  113. package/src/components/TodoExplorer.vue +26 -15
  114. package/src/components/ToolResultsPanel.vue +27 -13
  115. package/src/components/todo/TodoAddDialog.vue +17 -12
  116. package/src/components/todo/TodoEditDialog.vue +7 -2
  117. package/src/components/todo/TodoEditPanel.vue +15 -10
  118. package/src/components/todo/TodoKanbanView.vue +10 -5
  119. package/src/components/todo/TodoListView.vue +5 -2
  120. package/src/components/todo/TodoTableView.vue +5 -2
  121. package/src/composables/useAppApi.ts +9 -0
  122. package/src/composables/useDynamicFavicon.ts +172 -37
  123. package/src/composables/useEventListeners.ts +7 -8
  124. package/src/composables/useFaviconState.ts +13 -2
  125. package/src/composables/useFileSelection.ts +24 -6
  126. package/src/composables/useLayoutMode.ts +32 -0
  127. package/src/composables/useSessionHistory.ts +7 -17
  128. package/src/composables/useViewLayout.ts +20 -34
  129. package/src/lang/de.ts +536 -0
  130. package/src/lang/en.ts +558 -0
  131. package/src/lang/es.ts +543 -0
  132. package/src/lang/fr.ts +536 -0
  133. package/src/lang/ja.ts +536 -0
  134. package/src/lang/ko.ts +540 -0
  135. package/src/lang/pt-BR.ts +534 -0
  136. package/src/lang/zh.ts +537 -0
  137. package/src/lib/vue-i18n.ts +97 -0
  138. package/src/main.ts +2 -0
  139. package/src/plugins/canvas/View.vue +102 -186
  140. package/src/plugins/canvas/definition.ts +0 -8
  141. package/src/plugins/chart/Preview.vue +1 -1
  142. package/src/plugins/chart/View.vue +9 -4
  143. package/src/plugins/manageRoles/Preview.vue +4 -1
  144. package/src/plugins/manageRoles/View.vue +59 -43
  145. package/src/plugins/manageSkills/Preview.vue +8 -3
  146. package/src/plugins/manageSkills/View.vue +26 -22
  147. package/src/plugins/manageSource/Preview.vue +1 -1
  148. package/src/plugins/manageSource/View.vue +73 -52
  149. package/src/plugins/markdown/Preview.vue +1 -1
  150. package/src/plugins/markdown/View.vue +24 -34
  151. package/src/plugins/presentHtml/Preview.vue +1 -1
  152. package/src/plugins/presentHtml/View.vue +7 -4
  153. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  154. package/src/plugins/presentMulmoScript/View.vue +36 -26
  155. package/src/plugins/scheduler/Preview.vue +7 -4
  156. package/src/plugins/scheduler/TasksTab.vue +53 -24
  157. package/src/plugins/scheduler/View.vue +28 -19
  158. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  159. package/src/plugins/spreadsheet/Preview.vue +8 -3
  160. package/src/plugins/spreadsheet/View.vue +21 -12
  161. package/src/plugins/textResponse/Preview.vue +15 -58
  162. package/src/plugins/textResponse/View.vue +27 -7
  163. package/src/plugins/todo/Preview.vue +11 -6
  164. package/src/plugins/todo/View.vue +27 -13
  165. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  166. package/src/plugins/ui-image/ImageView.vue +7 -4
  167. package/src/plugins/wiki/Preview.vue +5 -2
  168. package/src/plugins/wiki/View.vue +202 -81
  169. package/src/plugins/wiki/route.ts +112 -0
  170. package/src/router/guards.ts +42 -24
  171. package/src/router/index.ts +41 -26
  172. package/src/types/vue-i18n.d.ts +20 -0
  173. package/src/utils/agent/request.ts +19 -0
  174. package/src/utils/canvas/layoutMode.ts +26 -0
  175. package/src/utils/image/cacheBust.ts +16 -0
  176. package/src/utils/image/resolve.ts +16 -0
  177. package/src/utils/path/workspaceLinkRouter.ts +81 -0
  178. package/src/vite-env.d.ts +9 -0
  179. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  180. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  181. package/client/assets/index-Bm70FDU2.css +0 -1
  182. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  183. package/src/composables/useCanvasViewMode.ts +0 -121
  184. package/src/utils/canvas/viewMode.ts +0 -46
  185. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
package/client/index.html CHANGED
@@ -17,10 +17,8 @@
17
17
  <title>MulmoClaude</title>
18
18
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='30' height='30' x='1' y='1' rx='6' fill='%236B7280'/><text x='16' y='17' text-anchor='middle' dominant-baseline='central' font-family='sans-serif' font-weight='bold' font-size='20' fill='white'>M</text></svg>" />
19
19
  <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
20
- <script type="module" crossorigin src="/assets/index-eHWB79u5.js"></script>
21
- <link rel="modulepreload" crossorigin href="/assets/chunk-vKJrgz-R-C_I3GbVV.js">
22
- <link rel="modulepreload" crossorigin href="/assets/typeof-DBp4T-Ny-BC0P-2DM.js">
23
- <link rel="stylesheet" crossorigin href="/assets/index-Bm70FDU2.css">
20
+ <script type="module" crossorigin src="/assets/index-DtcyExH9.js"></script>
21
+ <link rel="stylesheet" crossorigin href="/assets/index-CubzmCVK.css">
24
22
  </head>
25
23
  <body>
26
24
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmoclaude",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "MulmoClaude — GUI-chat with Claude Code + long-term memory. One command to start.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,20 +18,20 @@
18
18
  "src/"
19
19
  ],
20
20
  "dependencies": {
21
- "@mulmobridge/chat-service": "^0.1.1",
22
- "@mulmobridge/client": "^0.1.1",
23
- "@mulmobridge/protocol": "^0.1.3",
24
- "@receptron/task-scheduler": "^0.1.0",
25
21
  "@google/genai": "^1.50.1",
26
- "@mulmocast/types": "^2.6.7",
27
- "@gui-chat-plugin/mindmap": "^0.4.0",
28
- "@gui-chat-plugin/present3d": "^0.1.0",
29
22
  "@gui-chat-plugin/browse": "^0.2.0",
30
23
  "@gui-chat-plugin/camera": "^0.4.0",
24
+ "@gui-chat-plugin/mindmap": "^0.4.0",
25
+ "@gui-chat-plugin/present3d": "^0.1.0",
31
26
  "@gui-chat-plugin/weather": "^0.1.0",
27
+ "@mulmobridge/chat-service": "^0.1.1",
28
+ "@mulmobridge/client": "^0.1.1",
29
+ "@mulmobridge/protocol": "^0.1.3",
30
+ "@mulmocast/types": "^2.6.8",
32
31
  "@mulmochat-plugin/form": "0.5.0",
33
32
  "@mulmochat-plugin/quiz": "0.4.0",
34
33
  "@mulmochat-plugin/ui-image": "^0.3.0",
34
+ "@receptron/task-scheduler": "^0.1.0",
35
35
  "cors": "^2.8.6",
36
36
  "dotenv": "^17.4.2",
37
37
  "express": "^5.2.1",
@@ -40,15 +40,15 @@
40
40
  "ignore": "^7.0.5",
41
41
  "mammoth": "^1.12.0",
42
42
  "marked": "^18.0.2",
43
- "mulmocast": "^2.6.7",
44
- "puppeteer": "^24.41.0",
43
+ "mulmocast": "^2.6.8",
44
+ "puppeteer": "^24.42.0",
45
45
  "socket.io": "^4.8.3",
46
46
  "socket.io-client": "^4.8.3",
47
- "uuid": "^13.0.0",
47
+ "tsx": "^4.19.0",
48
+ "uuid": "^14.0.0",
48
49
  "ws": "^8.20.0",
49
50
  "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
50
- "zod": "^4.3.6",
51
- "tsx": "^4.19.0"
51
+ "zod": "^4.3.6"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=20"
@@ -17,7 +17,7 @@ import * as XLSX from "xlsx";
17
17
  import { execFile } from "child_process";
18
18
  import { mkdtemp, readFile, writeFile, rm } from "fs/promises";
19
19
  import path from "path";
20
- import os from "os";
20
+ import { tmpdir } from "os";
21
21
  import { promisify } from "util";
22
22
 
23
23
  const execFileAsync = promisify(execFile);
@@ -116,7 +116,7 @@ async function tryDockerLibreOffice(): Promise<boolean> {
116
116
  }
117
117
 
118
118
  async function convertPptxToPdf(data: string): Promise<Buffer | null> {
119
- const tmpDir = await mkdtemp(path.join(os.tmpdir(), "pptx-"));
119
+ const tmpDir = await mkdtemp(path.join(tmpdir(), "pptx-"));
120
120
  const inputPath = path.join(tmpDir, "input.pptx");
121
121
  const outputPath = path.join(tmpDir, "input.pdf");
122
122
 
@@ -1,5 +1,6 @@
1
1
  import { spawn, type ChildProcessByStdio } from "child_process";
2
- import { mkdir, writeFile, unlink } from "fs/promises";
2
+ import { mkdir, unlink } from "fs/promises";
3
+ import { writeJsonAtomic } from "../utils/files/json.js";
3
4
  import { dirname } from "path";
4
5
  import type { Readable, Writable } from "stream";
5
6
  import { isDockerAvailable } from "../system/docker.js";
@@ -157,6 +158,7 @@ export async function* runAgent(
157
158
  claudeSessionId?: string,
158
159
  abortSignal?: AbortSignal,
159
160
  attachments?: Attachment[],
161
+ userTimezone?: string,
160
162
  ): AsyncGenerator<AgentEvent> {
161
163
  const activePlugins = getActivePlugins(role);
162
164
  const useDocker = await isDockerAvailable();
@@ -180,6 +182,7 @@ export async function* runAgent(
180
182
  role,
181
183
  workspacePath: useDocker ? CONTAINER_WORKSPACE_PATH : workspacePath,
182
184
  useDocker,
185
+ userTimezone,
183
186
  });
184
187
 
185
188
  // In debug mode (--debug), dump the full system prompt on the first
@@ -202,11 +205,14 @@ export async function* runAgent(
202
205
  chatSessionId: sessionId,
203
206
  port,
204
207
  activePlugins,
205
- roleIds: loadAllRoles().map((r) => r.id),
208
+ roleIds: loadAllRoles().map((loadedRole) => loadedRole.id),
206
209
  useDocker,
207
210
  userServers,
208
211
  });
209
- await writeFile(mcpPaths.hostPath, JSON.stringify(mcpConfig, null, 2));
212
+ // Write atomically so a partially-written file can't be picked
213
+ // up by a concurrent claude spawn (they share the --mcp-config
214
+ // path under the session dir).
215
+ await writeJsonAtomic(mcpPaths.hostPath, mcpConfig);
210
216
  }
211
217
 
212
218
  // Fresh read on every invocation so the Settings UI can change
@@ -17,7 +17,7 @@ export interface McpTool {
17
17
 
18
18
  export const mcpTools: McpTool[] = [readXPost, searchX];
19
19
 
20
- const toolMap = new Map(mcpTools.map((t) => [t.definition.name, t]));
20
+ const toolMap = new Map(mcpTools.map((tool) => [tool.definition.name, tool]));
21
21
 
22
22
  export function isMcpToolEnabled(tool: McpTool): boolean {
23
23
  return (tool.requiredEnv ?? []).every((key) => !!process.env[key]);
@@ -34,11 +34,11 @@ interface McpToolParams {
34
34
  // GET /api/mcp-tools — returns { name, enabled, requiredEnv } for each tool (used by the role builder UI)
35
35
  mcpToolsRouter.get(API_ROUTES.mcpTools.list, (_req: Request, res: Response) => {
36
36
  res.json(
37
- mcpTools.map((t) => ({
38
- name: t.definition.name,
39
- enabled: isMcpToolEnabled(t),
40
- requiredEnv: t.requiredEnv ?? [],
41
- prompt: t.prompt,
37
+ mcpTools.map((tool) => ({
38
+ name: tool.definition.name,
39
+ enabled: isMcpToolEnabled(tool),
40
+ requiredEnv: tool.requiredEnv ?? [],
41
+ prompt: tool.prompt,
42
42
  })),
43
43
  );
44
44
  });
@@ -1,5 +1,6 @@
1
1
  import { errorMessage } from "../../utils/errors.js";
2
2
  import { safeResponseText } from "../../utils/http.js";
3
+ import { toUtcIsoDate } from "../../utils/date.js";
3
4
  import { env } from "../../system/env.js";
4
5
 
5
6
  const X_API_BASE = "https://api.twitter.com/2";
@@ -56,7 +57,7 @@ async function fetchX(path: string): Promise<XApiResponse> {
56
57
  }
57
58
 
58
59
  function formatTweet(tweet: XTweet, author?: XUser, url?: string): string {
59
- const date = tweet.created_at ? new Date(tweet.created_at).toISOString().split("T")[0] : "";
60
+ const date = tweet.created_at ? toUtcIsoDate(new Date(tweet.created_at)) : "";
60
61
  const dateSuffix = date ? ` · ${date}` : "";
61
62
  const byline = author ? `@${author.username} (${author.name})${dateSuffix}` : date;
62
63
  const metrics = tweet.public_metrics
@@ -7,6 +7,8 @@ import { WORKSPACE_DIRS, WORKSPACE_FILES } from "../workspace/paths.js";
7
7
  import { getCachedCustomDirs, buildCustomDirsPrompt } from "../workspace/custom-dirs.js";
8
8
  import { TOOL_NAMES } from "../../src/config/toolNames.js";
9
9
  import { getCachedReferenceDirs, buildReferenceDirsPrompt } from "../workspace/reference-dirs.js";
10
+ import { log } from "../system/logger/index.js";
11
+ import { toLocalIsoDate } from "../utils/date.js";
10
12
 
11
13
  export const SYSTEM_PROMPT = `You are MulmoClaude, a versatile assistant app with rich visual output.
12
14
 
@@ -242,6 +244,35 @@ export function buildNewsConciergeContext(role: Role): string | null {
242
244
  return NEWS_CONCIERGE_PROMPT;
243
245
  }
244
246
 
247
+ // Single-paragraph prompts up to this length collapse into a compact
248
+ // `- **name**: body` bullet instead of the old `### name\n\n body`
249
+ // heading. Saves ~25 chars of heading overhead per plugin and keeps the
250
+ // whole "Plugin Instructions" block scannable. Multi-paragraph or
251
+ // longer prompts keep the heading form so the structure is preserved.
252
+ const PLUGIN_COMPACT_MAX_CHARS = 400;
253
+
254
+ export function formatPluginSection(name: string, prompt: string): string {
255
+ // Normalize CRLF → LF first: a prompt authored on Windows would
256
+ // otherwise hide its paragraph break inside `\r\n\r\n` and the
257
+ // `includes("\n\n")` check would falsely classify it as single-paragraph,
258
+ // collapsing a multi-paragraph prompt into one bullet.
259
+ const normalized = prompt.replace(/\r\n/g, "\n");
260
+ const trimmed = normalized.trim();
261
+ const isSingleParagraph = !trimmed.includes("\n\n");
262
+ if (isSingleParagraph && trimmed.length <= PLUGIN_COMPACT_MAX_CHARS) {
263
+ // Flatten any single newlines inside the paragraph so the bullet
264
+ // stays on one visual line. Split-join avoids the super-linear
265
+ // backtracking that `\s*\n\s*` would bring (sonarjs/slow-regex).
266
+ const oneLine = trimmed
267
+ .split("\n")
268
+ .map((line) => line.trim())
269
+ .filter((line) => line.length > 0)
270
+ .join(" ");
271
+ return `- **${name}**: ${oneLine}`;
272
+ }
273
+ return `### ${name}\n\n${trimmed}`;
274
+ }
275
+
245
276
  export function buildPluginPromptSections(role: Role): string[] {
246
277
  // Widen to Set<string> so the `.has()` checks accept arbitrary
247
278
  // definition names (PLUGIN_DEFS entries and MCP tool names are
@@ -268,7 +299,7 @@ export function buildPluginPromptSections(role: Role): string[] {
268
299
 
269
300
  // MCP tool prompts override definition prompts if both exist
270
301
  const merged = { ...defPrompts, ...mcpToolPrompts };
271
- return Object.entries(merged).map(([name, prompt]) => `### ${name}\n\n${prompt}`);
302
+ return Object.entries(merged).map(([name, prompt]) => formatPluginSection(name, prompt));
272
303
  }
273
304
 
274
305
  export interface SystemPromptParams {
@@ -279,6 +310,61 @@ export interface SystemPromptParams {
279
310
  * environment has no such guarantees, so without Docker we stay
280
311
  * silent. */
281
312
  useDocker: boolean;
313
+ /** IANA timezone from the user's browser (e.g. "Asia/Tokyo"). When
314
+ * present, drives the time-section instruction that tells the
315
+ * agent to interpret bare times in that zone without asking the
316
+ * user every turn. Missing or invalid values fall back to
317
+ * server-local date only. */
318
+ userTimezone?: string;
319
+ }
320
+
321
+ // Accept IANA-looking strings only. Anything else (including
322
+ // line-break injection attempts from a malicious client) is rejected
323
+ // and the prompt falls back to the server-local form.
324
+ const IANA_TZ_RE = /^[A-Za-z][A-Za-z0-9_+/-]{0,63}$/;
325
+ function sanitizeUserTimezone(zoneId: string | undefined): string | undefined {
326
+ if (typeof zoneId !== "string") return undefined;
327
+ if (!IANA_TZ_RE.test(zoneId)) return undefined;
328
+ try {
329
+ // Throws a RangeError if the zone isn't recognized by the ICU
330
+ // data on this runtime.
331
+ new Intl.DateTimeFormat("en-US", { timeZone: zoneId });
332
+ return zoneId;
333
+ } catch {
334
+ return undefined;
335
+ }
336
+ }
337
+
338
+ function formatDateInTimezone(date: Date, zoneId: string): string | null {
339
+ try {
340
+ // en-CA gives us YYYY-MM-DD directly, matching the rest of the
341
+ // workspace's date convention.
342
+ return new Intl.DateTimeFormat("en-CA", {
343
+ timeZone: zoneId,
344
+ year: "numeric",
345
+ month: "2-digit",
346
+ day: "2-digit",
347
+ }).format(date);
348
+ } catch {
349
+ return null;
350
+ }
351
+ }
352
+
353
+ // Compact prompt section that tells the agent (a) today's date in the
354
+ // user's zone and (b) not to pester the user about timezones for every
355
+ // bare time expression. Falls back to server-local date (previous
356
+ // behaviour) when the browser didn't give us a valid zone.
357
+ export function buildTimeSection(now: Date, userTimezone: string | undefined): string {
358
+ const sanitized = sanitizeUserTimezone(userTimezone);
359
+ if (!sanitized) {
360
+ return `Today's date: ${toLocalIsoDate(now)}`;
361
+ }
362
+ const today = formatDateInTimezone(now, sanitized) ?? toLocalIsoDate(now);
363
+ return `## Time & Timezone
364
+
365
+ The user's browser timezone is ${sanitized}. Today's date in that timezone is ${today}.
366
+
367
+ When the user mentions a time without explicitly naming a city or timezone, assume their local timezone (${sanitized}) and proceed — do NOT ask for clarification. Only confirm when the user explicitly mentions another location or timezone (e.g. "3pm in New York", "JST", "UTC+5").`;
282
368
  }
283
369
 
284
370
  // Mirror the tool set installed by Dockerfile.sandbox. Kept here so a
@@ -295,7 +381,47 @@ The bash tool runs inside a Docker sandbox. The following tools are guaranteed p
295
381
 
296
382
  Runtime \`pip install\` / \`apt install\` are not available (no network-installed deps by design). Work within the list above; if something is missing, say so rather than attempting to install it.`;
297
383
 
298
- function buildInlinedHelpFiles(rolePrompt: string, workspacePath: string): string[] {
384
+ // Files ≤ this threshold stay inlined verbatim; above it, only a short
385
+ // summary + pointer reaches the system prompt and the full content is
386
+ // fetched on demand via the Read tool. 2000 chars keeps today's small
387
+ // helps (github.md ~1.2K, spreadsheet.md ~1.4K) inline, while wiki.md /
388
+ // mulmoscript.md / telegram.md (4–7K each) switch to summary mode. See
389
+ // plans/feat-help-pointer-threshold.md and issue #487.
390
+ const HELP_INLINE_THRESHOLD_CHARS = 2000;
391
+ const HELP_SUMMARY_PARAGRAPH_CAP = 200;
392
+
393
+ // Pull a short, prompt-friendly summary from a help file:
394
+ // - first H1 heading (identifies the file)
395
+ // - first non-empty, non-heading paragraph, truncated to ~200 chars
396
+ // No frontmatter required — the goal is zero ceremony for help authors.
397
+ export function summarizeHelpContent(content: string): string {
398
+ const lines = content.split("\n");
399
+ const heading = lines
400
+ .find((line) => /^#\s+\S/.test(line))
401
+ ?.replace(/^#\s+/, "")
402
+ .trim();
403
+
404
+ let paragraph = "";
405
+ for (const line of lines) {
406
+ const trimmed = line.trim();
407
+ if (!trimmed || trimmed.startsWith("#")) {
408
+ if (paragraph) break;
409
+ continue;
410
+ }
411
+ paragraph = paragraph ? `${paragraph} ${trimmed}` : trimmed;
412
+ if (paragraph.length >= HELP_SUMMARY_PARAGRAPH_CAP) break;
413
+ }
414
+ if (paragraph.length > HELP_SUMMARY_PARAGRAPH_CAP) {
415
+ paragraph = paragraph.slice(0, HELP_SUMMARY_PARAGRAPH_CAP).trimEnd() + "…";
416
+ }
417
+
418
+ const parts: string[] = [];
419
+ if (heading) parts.push(heading);
420
+ if (paragraph) parts.push(paragraph);
421
+ return parts.join(" — ");
422
+ }
423
+
424
+ export function buildInlinedHelpFiles(rolePrompt: string, workspacePath: string): string[] {
299
425
  // Match either legacy `helps/<name>.md` or post-#284
300
426
  // `config/helps/<name>.md` references in role prompts. Both
301
427
  // resolve to the same on-disk file under `config/helps/`.
@@ -310,10 +436,17 @@ function buildInlinedHelpFiles(rolePrompt: string, workspacePath: string): strin
310
436
  const fullPath = join(workspacePath, WORKSPACE_DIRS.helps, name);
311
437
  if (!existsSync(fullPath)) return null;
312
438
  const content = readFileSync(fullPath, "utf-8").trim();
313
- // Keep the heading anchored to the canonical post-#284 path
314
- // so the LLM reading the inlined block can't accidentally
315
- // Read() the stale legacy location.
316
- return content ? `### ${WORKSPACE_DIRS.helps}/${name}\n\n${content}` : null;
439
+ if (!content) return null;
440
+ // Keep the heading anchored to the canonical post-#284 path so
441
+ // the LLM can't accidentally Read() the stale legacy location.
442
+ const canonicalPath = `${WORKSPACE_DIRS.helps}/${name}`;
443
+ const header = `### ${canonicalPath}`;
444
+ if (content.length <= HELP_INLINE_THRESHOLD_CHARS) {
445
+ return `${header}\n\n${content}`;
446
+ }
447
+ const summary = summarizeHelpContent(content);
448
+ const pointer = `Detailed reference: use Read on \`${canonicalPath}\` when you need the full content.`;
449
+ return summary ? `${header}\n\n${summary}\n\n${pointer}` : `${header}\n\n${pointer}`;
317
450
  })
318
451
  .filter((section): section is string => section !== null);
319
452
  }
@@ -328,27 +461,55 @@ export function headingSection(heading: string, items: string[]): string | null
328
461
  return `## ${heading}\n\n${items.join("\n\n")}`;
329
462
  }
330
463
 
464
+ // Named sections so buildSystemPrompt can log a size breakdown
465
+ // without inventing labels at the call site.
466
+ interface NamedSection {
467
+ name: string;
468
+ content: string | null;
469
+ }
470
+
471
+ // System prompt above this total size gets a warning in the log —
472
+ // 20K chars is ~5K tokens, a noticeable slice of the context budget
473
+ // and a useful early-warning threshold. Doesn't block, just flags.
474
+ const SYSTEM_PROMPT_WARN_THRESHOLD_CHARS = 20000;
475
+
331
476
  export function buildSystemPrompt(params: SystemPromptParams): string {
332
- const { role, workspacePath, useDocker } = params;
333
-
334
- // Every section builder returns either its content or null. The
335
- // orchestrator just filters out nulls and joins — no per-section
336
- // `...(cond ? [x] : [])` ceremony at the bottom.
337
- const sections: Array<string | null> = [
338
- SYSTEM_PROMPT,
339
- role.prompt,
340
- `Workspace directory: ${workspacePath}`,
341
- `Today's date: ${new Date().toISOString().split("T")[0]}`,
342
- buildMemoryContext(workspacePath),
343
- useDocker ? SANDBOX_TOOLS_HINT : null,
344
- buildWikiContext(workspacePath),
345
- buildSourcesContext(workspacePath),
346
- buildNewsConciergeContext(role),
347
- buildCustomDirsPrompt(getCachedCustomDirs()),
348
- buildReferenceDirsPrompt(getCachedReferenceDirs(), useDocker),
349
- headingSection("Reference Files", buildInlinedHelpFiles(role.prompt, workspacePath)),
350
- headingSection("Plugin Instructions", buildPluginPromptSections(role)),
477
+ const { role, workspacePath, useDocker, userTimezone } = params;
478
+
479
+ const sections: NamedSection[] = [
480
+ { name: "base", content: SYSTEM_PROMPT },
481
+ { name: "role", content: role.prompt },
482
+ { name: "workspace", content: `Workspace directory: ${workspacePath}` },
483
+ { name: "time", content: buildTimeSection(new Date(), userTimezone) },
484
+ { name: "memory", content: buildMemoryContext(workspacePath) },
485
+ { name: "sandbox", content: useDocker ? SANDBOX_TOOLS_HINT : null },
486
+ { name: "wiki", content: buildWikiContext(workspacePath) },
487
+ { name: "sources", content: buildSourcesContext(workspacePath) },
488
+ { name: "news-concierge", content: buildNewsConciergeContext(role) },
489
+ { name: "custom-dirs", content: buildCustomDirsPrompt(getCachedCustomDirs()) },
490
+ { name: "reference-dirs", content: buildReferenceDirsPrompt(getCachedReferenceDirs(), useDocker) },
491
+ { name: "helps", content: headingSection("Reference Files", buildInlinedHelpFiles(role.prompt, workspacePath)) },
492
+ { name: "plugins", content: headingSection("Plugin Instructions", buildPluginPromptSections(role)) },
351
493
  ];
352
494
 
353
- return sections.filter((section): section is string => section !== null).join("\n\n");
495
+ const kept = sections.filter((section): section is NamedSection & { content: string } => section.content !== null);
496
+ const result = kept.map((section) => section.content).join("\n\n");
497
+
498
+ // Log a size breakdown so prompt-bloat regressions show up in
499
+ // normal run logs. Warn tier fires for outright large prompts;
500
+ // the debug tier gives the per-section counts for when the
501
+ // warning hits (or just when someone wants a baseline).
502
+ const breakdown = kept.map((section) => `${section.name}=${section.content.length}`).join(" ");
503
+ const total = result.length;
504
+ log.debug("prompt", "system-prompt size", { total, breakdown, roleId: role.id });
505
+ if (total >= SYSTEM_PROMPT_WARN_THRESHOLD_CHARS) {
506
+ log.warn("prompt", "system-prompt exceeds warn threshold", {
507
+ total,
508
+ threshold: SYSTEM_PROMPT_WARN_THRESHOLD_CHARS,
509
+ breakdown,
510
+ roleId: role.id,
511
+ });
512
+ }
513
+
514
+ return result;
354
515
  }
@@ -77,12 +77,12 @@ function parseTranscriptEntries(jsonlContent: string): TranscriptEntry[] {
77
77
  continue;
78
78
  }
79
79
  if (!isRecord(entry)) continue;
80
- const o = entry;
81
- if (o.type !== EVENT_TYPES.text) continue;
82
- if (o.source !== "user" && o.source !== "assistant") continue;
83
- const message = o.message;
80
+ const record = entry;
81
+ if (record.type !== EVENT_TYPES.text) continue;
82
+ if (record.source !== "user" && record.source !== "assistant") continue;
83
+ const message = record.message;
84
84
  if (typeof message !== "string" || message.length === 0) continue;
85
- out.push({ source: o.source, text: message });
85
+ out.push({ source: record.source, text: message });
86
86
  }
87
87
  return out;
88
88
  }
@@ -15,7 +15,7 @@
15
15
  // See docs/sandbox-credentials.md for the user-facing contract.
16
16
 
17
17
  import path from "node:path";
18
- import fs from "node:fs";
18
+ import { existsSync, statSync } from "node:fs";
19
19
  import { execFileSync } from "node:child_process";
20
20
  import { homedir } from "node:os";
21
21
  import { log } from "../system/logger/index.js";
@@ -105,7 +105,7 @@ export function resolveMountNames(names: readonly string[], allowed: Record<stri
105
105
 
106
106
  function hostPathExists(spec: SandboxMountSpec): boolean {
107
107
  try {
108
- const stat = fs.statSync(spec.hostPath);
108
+ const stat = statSync(spec.hostPath);
109
109
  return spec.kind === "dir" ? stat.isDirectory() : stat.isFile();
110
110
  } catch {
111
111
  return false;
@@ -181,7 +181,7 @@ export function sshAgentForwardArgs(
181
181
  skippedReason: "SSH_AUTH_SOCK not set on host",
182
182
  };
183
183
  }
184
- if (!fs.existsSync(sshAuthSock)) {
184
+ if (!existsSync(sshAuthSock)) {
185
185
  return {
186
186
  args: [],
187
187
  skippedReason: `SSH_AUTH_SOCK=${sshAuthSock} not found on host`,
@@ -1,8 +1,8 @@
1
1
  import { timingSafeEqual } from "crypto";
2
2
 
3
- function safeEqual(a: string, b: string): boolean {
4
- if (a.length !== b.length) return false;
5
- return timingSafeEqual(Buffer.from(a), Buffer.from(b));
3
+ function safeEqual(left: string, right: string): boolean {
4
+ if (left.length !== right.length) return false;
5
+ return timingSafeEqual(Buffer.from(left), Buffer.from(right));
6
6
  }
7
7
 
8
8
  // Bearer token middleware (#272). Reject any `/api/*` request whose
@@ -21,7 +21,7 @@
21
21
  // setting it once on both sides survives restarts.
22
22
 
23
23
  import { randomBytes } from "crypto";
24
- import fs from "fs";
24
+ import { promises } from "fs";
25
25
  import { writeFileAtomic } from "../../utils/files/index.js";
26
26
  import { log } from "../../system/logger/index.js";
27
27
  import { isNonEmptyString } from "../../utils/types.js";
@@ -83,7 +83,7 @@ function resolveToken(override: string | undefined): string {
83
83
  */
84
84
  export async function deleteTokenFile(tokenPath: string = WORKSPACE_PATHS.sessionToken): Promise<void> {
85
85
  try {
86
- await fs.promises.unlink(tokenPath);
86
+ await promises.unlink(tokenPath);
87
87
  } catch {
88
88
  /* already gone — nothing to do */
89
89
  }
@@ -105,12 +105,16 @@ export interface StartChatParams {
105
105
  /** Where this session originates (#486). Accepts string for
106
106
  * cross-package compatibility (chat-service passes string). */
107
107
  origin?: string;
108
+ /** IANA timezone the user's browser resolved (e.g. "Asia/Tokyo").
109
+ * Validated server-side before it reaches the system prompt — an
110
+ * invalid or missing value falls back to server-local time. */
111
+ userTimezone?: string;
108
112
  }
109
113
 
110
114
  export type StartChatResult = { kind: "started"; chatSessionId: string } | { kind: "error"; error: string; status?: number };
111
115
 
112
116
  export async function startChat(params: StartChatParams): Promise<StartChatResult> {
113
- const { message, roleId, chatSessionId, selectedImageData, attachments } = params;
117
+ const { message, roleId, chatSessionId, selectedImageData, attachments, userTimezone } = params;
114
118
 
115
119
  if (!message || !roleId || !chatSessionId) {
116
120
  return {
@@ -203,6 +207,7 @@ export async function startChat(params: StartChatParams): Promise<StartChatResul
203
207
  requestStartedAt,
204
208
  toolArgsCache: createArgsCache(),
205
209
  attachments: mergeAttachments(selectedImageData, attachments),
210
+ userTimezone,
206
211
  });
207
212
 
208
213
  return { kind: "started", chatSessionId };
@@ -239,6 +244,7 @@ interface AgentBody {
239
244
  roleId: string;
240
245
  chatSessionId: string;
241
246
  selectedImageData?: string;
247
+ userTimezone?: string;
242
248
  }
243
249
 
244
250
  interface ErrorResponse {
@@ -271,6 +277,7 @@ interface BackgroundRunParams {
271
277
  requestStartedAt: number;
272
278
  toolArgsCache: ReturnType<typeof createArgsCache>;
273
279
  attachments: Attachment[] | undefined;
280
+ userTimezone: string | undefined;
274
281
  }
275
282
 
276
283
  // Per-event side-effect context passed to `handleAgentEvent`.
@@ -358,7 +365,8 @@ async function flushTextAccumulator(ctx: EventContext): Promise<void> {
358
365
  }
359
366
 
360
367
  async function runAgentInBackground(params: BackgroundRunParams): Promise<void> {
361
- const { decoratedMessage, role, chatSessionId, claudeSessionId, abortSignal, resultsFilePath, requestStartedAt, toolArgsCache, attachments } = params;
368
+ const { decoratedMessage, role, chatSessionId, claudeSessionId, abortSignal, resultsFilePath, requestStartedAt, toolArgsCache, attachments, userTimezone } =
369
+ params;
362
370
 
363
371
  const eventCtx: EventContext = {
364
372
  chatSessionId,
@@ -378,7 +386,17 @@ async function runAgentInBackground(params: BackgroundRunParams): Promise<void>
378
386
  try {
379
387
  while (true) {
380
388
  let staleSessionDetected = false;
381
- for await (const event of runAgent(currentMessage, role, workspacePath, chatSessionId, PORT, currentClaudeSessionId, abortSignal, attachments)) {
389
+ for await (const event of runAgent(
390
+ currentMessage,
391
+ role,
392
+ workspacePath,
393
+ chatSessionId,
394
+ PORT,
395
+ currentClaudeSessionId,
396
+ abortSignal,
397
+ attachments,
398
+ userTimezone,
399
+ )) {
382
400
  if (failoverAttemptsRemaining > 0 && event.type === EVENT_TYPES.error && typeof event.message === "string" && isStaleSessionError(event.message)) {
383
401
  // Swallow the error — we're about to recover. `break`
384
402
  // abandons the current generator; since the event is only
@@ -43,7 +43,7 @@ function isMcpPutBody(value: unknown): value is { servers: McpServerEntry[] } {
43
43
  if (!Array.isArray(value.servers)) return false;
44
44
  // Full shape validation happens inside fromMcpEntries (throws on
45
45
  // anything malformed). Here we just confirm the envelope.
46
- return value.servers.every((e) => isRecord(e) && "id" in e && "spec" in e);
46
+ return value.servers.every((entry) => isRecord(entry) && "id" in entry && "spec" in entry);
47
47
  }
48
48
 
49
49
  // Parse an MCP payload through `fromMcpEntries` (which does the full