mulmoclaude 0.1.2 → 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.
- package/bin/mulmoclaude.js +7 -24
- package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
- package/client/assets/index-CubzmCVK.css +2 -0
- package/client/assets/{index-D8rhwXLq.js → index-DtcyExH9.js} +80 -61
- package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
- package/client/index.html +2 -4
- package/package.json +13 -13
- package/server/agent/attachmentConverter.ts +2 -2
- package/server/agent/config.ts +12 -12
- package/server/agent/index.ts +9 -3
- package/server/agent/mcp-server.ts +19 -19
- package/server/agent/mcp-tools/index.ts +6 -6
- package/server/agent/mcp-tools/x.ts +7 -6
- package/server/agent/prompt.ts +195 -29
- package/server/agent/resumeFailover.ts +5 -5
- package/server/agent/sandboxMounts.ts +10 -10
- package/server/agent/stream.ts +4 -4
- package/server/api/auth/bearerAuth.ts +3 -3
- package/server/api/auth/token.ts +2 -2
- package/server/api/routes/agent.ts +21 -3
- package/server/api/routes/config.ts +1 -1
- package/server/api/routes/files.ts +22 -21
- package/server/api/routes/html.ts +2 -2
- package/server/api/routes/image.ts +7 -7
- package/server/api/routes/mulmo-script.ts +33 -31
- package/server/api/routes/pdf.ts +2 -2
- package/server/api/routes/plugins.ts +16 -6
- package/server/api/routes/roles.ts +2 -2
- package/server/api/routes/scheduler.ts +14 -12
- package/server/api/routes/schedulerHandlers.ts +12 -12
- package/server/api/routes/schedulerTasks.ts +19 -17
- package/server/api/routes/sessions.ts +26 -26
- package/server/api/routes/sessionsCursor.ts +4 -4
- package/server/api/routes/skills.ts +5 -5
- package/server/api/routes/sources.ts +3 -3
- package/server/api/routes/todosColumnsHandlers.ts +30 -30
- package/server/api/routes/todosHandlers.ts +1 -1
- package/server/api/routes/todosItemsHandlers.ts +14 -14
- package/server/api/routes/wiki.ts +36 -22
- package/server/api/sandboxStatus.ts +1 -1
- package/server/events/notifications.ts +6 -6
- package/server/events/pub-sub/index.ts +3 -3
- package/server/events/relay-client.ts +17 -16
- package/server/events/scheduler-adapter.ts +20 -20
- package/server/events/session-store/index.ts +10 -10
- package/server/events/task-manager/index.ts +7 -7
- package/server/index.ts +59 -65
- package/server/system/config.ts +5 -5
- package/server/system/credentials.ts +7 -5
- package/server/system/env.ts +5 -5
- package/server/utils/date.ts +18 -18
- package/server/utils/files/atomic.ts +16 -16
- package/server/utils/files/html-io.ts +5 -5
- package/server/utils/files/image-store.ts +19 -8
- package/server/utils/files/journal-io.ts +4 -4
- package/server/utils/files/json.ts +5 -5
- package/server/utils/files/markdown-store.ts +4 -4
- package/server/utils/files/naming.ts +2 -2
- package/server/utils/files/reference-dirs-io.ts +3 -3
- package/server/utils/files/roles-io.ts +12 -12
- package/server/utils/files/safe.ts +14 -14
- package/server/utils/files/scheduler-io.ts +5 -5
- package/server/utils/files/scheduler-overrides-io.ts +2 -2
- package/server/utils/files/session-io.ts +35 -35
- package/server/utils/files/spreadsheet-store.ts +7 -7
- package/server/utils/files/todos-io.ts +9 -9
- package/server/utils/files/user-tasks-io.ts +5 -5
- package/server/utils/files/workspace-io.ts +12 -12
- package/server/utils/gemini.ts +2 -2
- package/server/utils/gitignore.ts +9 -9
- package/server/utils/json.ts +5 -5
- package/server/utils/logBackgroundError.ts +12 -3
- package/server/utils/markdown.ts +5 -5
- package/server/utils/port.d.mts +6 -0
- package/server/utils/port.mjs +48 -0
- package/server/utils/request.ts +12 -6
- package/server/utils/spawn.ts +1 -1
- package/server/utils/types.ts +2 -2
- package/server/workspace/chat-index/indexer.ts +15 -15
- package/server/workspace/chat-index/summarizer.ts +4 -4
- package/server/workspace/custom-dirs.ts +16 -16
- package/server/workspace/journal/archivist.ts +35 -35
- package/server/workspace/journal/dailyPass.ts +31 -28
- package/server/workspace/journal/diff.ts +2 -2
- package/server/workspace/journal/index.ts +4 -4
- package/server/workspace/journal/indexFile.ts +29 -25
- package/server/workspace/journal/optimizationPass.ts +2 -2
- package/server/workspace/journal/state.ts +6 -6
- package/server/workspace/paths.ts +3 -3
- package/server/workspace/reference-dirs.ts +20 -20
- package/server/workspace/roles.ts +6 -6
- package/server/workspace/skills/discovery.ts +4 -4
- package/server/workspace/skills/parser.ts +6 -6
- package/server/workspace/skills/scheduler.ts +3 -3
- package/server/workspace/skills/user-tasks.ts +34 -34
- package/server/workspace/skills/writer.ts +3 -3
- package/server/workspace/sources/arxivDiscovery.ts +10 -10
- package/server/workspace/sources/classifier.ts +7 -7
- package/server/workspace/sources/fetchers/arxiv.ts +7 -7
- package/server/workspace/sources/fetchers/githubIssues.ts +7 -7
- package/server/workspace/sources/fetchers/githubReleases.ts +7 -7
- package/server/workspace/sources/fetchers/rss.ts +5 -5
- package/server/workspace/sources/fetchers/rssParser.ts +4 -4
- package/server/workspace/sources/interests.ts +12 -12
- package/server/workspace/sources/paths.ts +6 -6
- package/server/workspace/sources/pipeline/fetch.ts +36 -13
- package/server/workspace/sources/pipeline/index.ts +8 -13
- package/server/workspace/sources/pipeline/notify.ts +3 -3
- package/server/workspace/sources/pipeline/plan.ts +15 -13
- package/server/workspace/sources/pipeline/write.ts +5 -5
- package/server/workspace/sources/rateLimiter.ts +1 -1
- package/server/workspace/sources/registry.ts +16 -16
- package/server/workspace/sources/robots.ts +14 -14
- package/server/workspace/sources/sourceState.ts +17 -10
- package/server/workspace/sources/types.ts +9 -0
- package/server/workspace/sources/urls.ts +1 -1
- package/server/workspace/tool-trace/classify.ts +4 -4
- package/server/workspace/tool-trace/index.ts +1 -1
- package/server/workspace/tool-trace/writeSearch.ts +26 -16
- package/server/workspace/wiki-backlinks/index.ts +8 -8
- package/server/workspace/wiki-backlinks/sessionBacklinks.ts +15 -15
- package/server/workspace/workspace.ts +7 -7
- package/src/App.vue +315 -141
- package/src/components/CanvasViewToggle.vue +10 -7
- package/src/components/ChatInput.vue +67 -33
- package/src/components/FileContentHeader.vue +7 -4
- package/src/components/FileContentRenderer.vue +20 -6
- package/src/components/FileTree.vue +6 -3
- package/src/components/FileTreePane.vue +11 -8
- package/src/components/FilesView.vue +5 -3
- package/src/components/LockStatusPopup.vue +17 -14
- package/src/components/NotificationBell.vue +14 -5
- package/src/components/NotificationToast.vue +6 -3
- package/src/components/PluginLauncher.vue +19 -56
- package/src/components/RightSidebar.vue +13 -10
- package/src/components/RoleSelector.vue +2 -2
- package/src/components/SessionHistoryPanel.vue +38 -34
- package/src/components/SessionTabBar.vue +8 -10
- package/src/components/SettingsMcpTab.vue +49 -36
- package/src/components/SettingsModal.vue +24 -22
- package/src/components/SettingsReferenceDirsTab.vue +39 -34
- package/src/components/SettingsWorkspaceDirsTab.vue +37 -27
- package/src/components/SidebarHeader.vue +25 -4
- package/src/components/StackView.vue +4 -1
- package/src/components/SuggestionsPanel.vue +7 -4
- package/src/components/TodoExplorer.vue +26 -15
- package/src/components/ToolResultsPanel.vue +27 -13
- package/src/components/todo/TodoAddDialog.vue +19 -14
- package/src/components/todo/TodoEditDialog.vue +7 -2
- package/src/components/todo/TodoEditPanel.vue +17 -12
- package/src/components/todo/TodoKanbanView.vue +10 -5
- package/src/components/todo/TodoListView.vue +10 -7
- package/src/components/todo/TodoTableView.vue +5 -2
- package/src/composables/useAppApi.ts +9 -0
- package/src/composables/useClickOutside.ts +2 -2
- package/src/composables/useDynamicFavicon.ts +172 -37
- package/src/composables/useEventListeners.ts +7 -8
- package/src/composables/useFaviconState.ts +13 -2
- package/src/composables/useFileSelection.ts +24 -6
- package/src/composables/useFreshPluginData.ts +3 -3
- package/src/composables/useKeyNavigation.ts +11 -11
- package/src/composables/useLayoutMode.ts +32 -0
- package/src/composables/useMcpTools.ts +2 -2
- package/src/composables/useNotifications.ts +3 -3
- package/src/composables/usePdfDownload.ts +4 -4
- package/src/composables/usePendingCalls.ts +1 -1
- package/src/composables/usePubSub.ts +10 -10
- package/src/composables/useRoles.ts +1 -1
- package/src/composables/useSandboxStatus.ts +1 -1
- package/src/composables/useSessionDerived.ts +3 -3
- package/src/composables/useSessionHistory.ts +7 -17
- package/src/composables/useSessionSync.ts +8 -8
- package/src/composables/useViewLayout.ts +20 -34
- package/src/config/roles.ts +2 -2
- package/src/lang/de.ts +536 -0
- package/src/lang/en.ts +558 -0
- package/src/lang/es.ts +543 -0
- package/src/lang/fr.ts +536 -0
- package/src/lang/ja.ts +536 -0
- package/src/lang/ko.ts +540 -0
- package/src/lang/pt-BR.ts +534 -0
- package/src/lang/zh.ts +537 -0
- package/src/lib/vue-i18n.ts +97 -0
- package/src/main.ts +2 -0
- package/src/plugins/canvas/View.vue +102 -186
- package/src/plugins/canvas/definition.ts +0 -8
- package/src/plugins/chart/Preview.vue +5 -5
- package/src/plugins/chart/View.vue +9 -4
- package/src/plugins/manageRoles/Preview.vue +4 -1
- package/src/plugins/manageRoles/View.vue +59 -43
- package/src/plugins/manageSkills/Preview.vue +8 -3
- package/src/plugins/manageSkills/View.vue +29 -25
- package/src/plugins/manageSource/Preview.vue +2 -2
- package/src/plugins/manageSource/View.vue +73 -52
- package/src/plugins/markdown/Preview.vue +1 -1
- package/src/plugins/markdown/View.vue +26 -36
- package/src/plugins/presentHtml/Preview.vue +1 -1
- package/src/plugins/presentHtml/View.vue +7 -4
- package/src/plugins/presentHtml/helpers.ts +8 -8
- package/src/plugins/presentMulmoScript/Preview.vue +1 -1
- package/src/plugins/presentMulmoScript/View.vue +40 -30
- package/src/plugins/presentMulmoScript/helpers.ts +1 -1
- package/src/plugins/scheduler/Preview.vue +13 -10
- package/src/plugins/scheduler/TasksTab.vue +57 -28
- package/src/plugins/scheduler/View.vue +28 -19
- package/src/plugins/scheduler/formatSchedule.ts +93 -0
- package/src/plugins/spreadsheet/Preview.vue +8 -3
- package/src/plugins/spreadsheet/View.vue +21 -12
- package/src/plugins/textResponse/Preview.vue +15 -58
- package/src/plugins/textResponse/View.vue +29 -9
- package/src/plugins/todo/Preview.vue +13 -8
- package/src/plugins/todo/View.vue +38 -24
- package/src/plugins/todo/composables/useTodos.ts +5 -5
- package/src/plugins/ui-image/ImagePreview.vue +6 -3
- package/src/plugins/ui-image/ImageView.vue +7 -4
- package/src/plugins/wiki/Preview.vue +10 -7
- package/src/plugins/wiki/View.vue +202 -81
- package/src/plugins/wiki/helpers.ts +4 -4
- package/src/plugins/wiki/route.ts +112 -0
- package/src/router/guards.ts +46 -28
- package/src/router/index.ts +41 -26
- package/src/types/session.ts +4 -3
- package/src/types/vue-i18n.d.ts +20 -0
- package/src/utils/agent/request.ts +22 -3
- package/src/utils/canvas/layoutMode.ts +26 -0
- package/src/utils/dom/scrollable.ts +2 -2
- package/src/utils/files/expandedDirs.ts +1 -1
- package/src/utils/files/sortChildren.ts +6 -6
- package/src/utils/format/frontmatter.ts +6 -6
- package/src/utils/image/cacheBust.ts +16 -0
- package/src/utils/image/resolve.ts +16 -0
- package/src/utils/image/rewriteMarkdownImageRefs.ts +5 -5
- package/src/utils/markdown/extractFirstH1.ts +2 -2
- package/src/utils/path/relativeLink.ts +15 -15
- package/src/utils/path/workspaceLinkRouter.ts +81 -0
- package/src/utils/role/icon.ts +2 -2
- package/src/utils/role/merge.ts +2 -2
- package/src/utils/role/plugins.ts +1 -1
- package/src/utils/session/sessionFactory.ts +2 -2
- package/src/utils/session/sessionHelpers.ts +2 -2
- package/src/utils/tools/dedup.ts +4 -4
- package/src/utils/tools/result.ts +3 -3
- package/src/utils/types.ts +2 -2
- package/src/vite-env.d.ts +9 -0
- package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
- package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
- package/client/assets/index-KNLBjwuh.css +0 -1
- package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
- package/src/composables/useCanvasViewMode.ts +0 -121
- package/src/utils/canvas/viewMode.ts +0 -46
- /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
package/server/agent/prompt.ts
CHANGED
|
@@ -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
|
|
@@ -253,17 +284,22 @@ export function buildPluginPromptSections(role: Role): string[] {
|
|
|
253
284
|
// Some package plugins use an older gui-chat-protocol without the `prompt`
|
|
254
285
|
// field, so access it via `in` check to keep TypeScript happy.
|
|
255
286
|
const defPrompts = Object.fromEntries(
|
|
256
|
-
PLUGIN_DEFS.filter((
|
|
287
|
+
PLUGIN_DEFS.filter((definition) => "prompt" in definition && definition.prompt && allowedPlugins.has(definition.name)).map((definition) => [
|
|
288
|
+
definition.name,
|
|
289
|
+
(definition as unknown as { prompt: string }).prompt,
|
|
290
|
+
]),
|
|
257
291
|
);
|
|
258
292
|
|
|
259
293
|
// Collect prompts from MCP tools
|
|
260
294
|
const mcpToolPrompts = Object.fromEntries(
|
|
261
|
-
mcpTools
|
|
295
|
+
mcpTools
|
|
296
|
+
.filter((toolDef) => toolDef.prompt && allowedPlugins.has(toolDef.definition.name) && isMcpToolEnabled(toolDef))
|
|
297
|
+
.map((toolDef) => [toolDef.definition.name, toolDef.prompt as string]),
|
|
262
298
|
);
|
|
263
299
|
|
|
264
300
|
// MCP tool prompts override definition prompts if both exist
|
|
265
301
|
const merged = { ...defPrompts, ...mcpToolPrompts };
|
|
266
|
-
return Object.entries(merged).map(([name, prompt]) =>
|
|
302
|
+
return Object.entries(merged).map(([name, prompt]) => formatPluginSection(name, prompt));
|
|
267
303
|
}
|
|
268
304
|
|
|
269
305
|
export interface SystemPromptParams {
|
|
@@ -274,6 +310,61 @@ export interface SystemPromptParams {
|
|
|
274
310
|
* environment has no such guarantees, so without Docker we stay
|
|
275
311
|
* silent. */
|
|
276
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").`;
|
|
277
368
|
}
|
|
278
369
|
|
|
279
370
|
// Mirror the tool set installed by Dockerfile.sandbox. Kept here so a
|
|
@@ -290,7 +381,47 @@ The bash tool runs inside a Docker sandbox. The following tools are guaranteed p
|
|
|
290
381
|
|
|
291
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.`;
|
|
292
383
|
|
|
293
|
-
|
|
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[] {
|
|
294
425
|
// Match either legacy `helps/<name>.md` or post-#284
|
|
295
426
|
// `config/helps/<name>.md` references in role prompts. Both
|
|
296
427
|
// resolve to the same on-disk file under `config/helps/`.
|
|
@@ -305,12 +436,19 @@ function buildInlinedHelpFiles(rolePrompt: string, workspacePath: string): strin
|
|
|
305
436
|
const fullPath = join(workspacePath, WORKSPACE_DIRS.helps, name);
|
|
306
437
|
if (!existsSync(fullPath)) return null;
|
|
307
438
|
const content = readFileSync(fullPath, "utf-8").trim();
|
|
308
|
-
|
|
309
|
-
//
|
|
310
|
-
// Read() the stale legacy location.
|
|
311
|
-
|
|
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}`;
|
|
312
450
|
})
|
|
313
|
-
.filter((
|
|
451
|
+
.filter((section): section is string => section !== null);
|
|
314
452
|
}
|
|
315
453
|
|
|
316
454
|
// Wrap a list of sub-entries under a single markdown heading, or
|
|
@@ -323,27 +461,55 @@ export function headingSection(heading: string, items: string[]): string | null
|
|
|
323
461
|
return `## ${heading}\n\n${items.join("\n\n")}`;
|
|
324
462
|
}
|
|
325
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
|
+
|
|
326
476
|
export function buildSystemPrompt(params: SystemPromptParams): string {
|
|
327
|
-
const { role, workspacePath, useDocker } = params;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
buildReferenceDirsPrompt(getCachedReferenceDirs(), useDocker),
|
|
344
|
-
headingSection("Reference Files", buildInlinedHelpFiles(role.prompt, workspacePath)),
|
|
345
|
-
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)) },
|
|
346
493
|
];
|
|
347
494
|
|
|
348
|
-
|
|
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;
|
|
349
515
|
}
|
|
@@ -77,12 +77,12 @@ function parseTranscriptEntries(jsonlContent: string): TranscriptEntry[] {
|
|
|
77
77
|
continue;
|
|
78
78
|
}
|
|
79
79
|
if (!isRecord(entry)) continue;
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
82
|
-
if (
|
|
83
|
-
const 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:
|
|
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
|
|
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";
|
|
@@ -48,7 +48,7 @@ export interface SandboxMountSpec {
|
|
|
48
48
|
*/
|
|
49
49
|
export function buildAllowedConfigMounts(home: string = homedir()): Record<string, SandboxMountSpec> {
|
|
50
50
|
return {
|
|
51
|
-
gh: {
|
|
51
|
+
["gh"]: {
|
|
52
52
|
name: "gh",
|
|
53
53
|
hostPath: path.join(home, ".config", "gh"),
|
|
54
54
|
containerPath: "/home/node/.config/gh",
|
|
@@ -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 =
|
|
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 (!
|
|
184
|
+
if (!existsSync(sshAuthSock)) {
|
|
185
185
|
return {
|
|
186
186
|
args: [],
|
|
187
187
|
skippedReason: `SSH_AUTH_SOCK=${sshAuthSock} not found on host`,
|
|
@@ -261,7 +261,7 @@ export function resolveSandboxAuth(params: ResolveSandboxAuthParams): ResolvedSa
|
|
|
261
261
|
const args = [...configMountArgs(parsed.resolved), ...sshResult.args, ...sshAllowedHostsArgs, ...ghTokenArgs.args];
|
|
262
262
|
const allowedHostsSuffix = sshResult.args.length > 0 && params.sshAllowedHosts ? ` → hosts: ${params.sshAllowedHosts}` : "";
|
|
263
263
|
const appliedDescriptions = [
|
|
264
|
-
...parsed.resolved.map((
|
|
264
|
+
...parsed.resolved.map((spec) => `${spec.name} (${spec.description})`),
|
|
265
265
|
...(sshResult.args.length > 0 ? [`ssh-agent forward${allowedHostsSuffix}`] : []),
|
|
266
266
|
...(ghTokenArgs.args.length > 0 ? ["gh CLI (GH_TOKEN fallback)"] : []),
|
|
267
267
|
];
|
|
@@ -284,7 +284,7 @@ export function resolveSandboxAuth(params: ResolveSandboxAuthParams): ResolvedSa
|
|
|
284
284
|
// GH_TOKEN env var. This only runs when "gh" was explicitly
|
|
285
285
|
// requested (#259 opt-in principle).
|
|
286
286
|
function resolveGhTokenFallback(requestedNames: readonly string[], parsed: ParsedMountList): { args: string[] } {
|
|
287
|
-
const ghRequested = requestedNames.some((
|
|
287
|
+
const ghRequested = requestedNames.some((name) => name.trim() === "gh");
|
|
288
288
|
if (!ghRequested) return { args: [] };
|
|
289
289
|
|
|
290
290
|
// If an explicit GH_TOKEN is already in the environment, pass it.
|
|
@@ -295,8 +295,8 @@ function resolveGhTokenFallback(requestedNames: readonly string[], parsed: Parse
|
|
|
295
295
|
// If the file mount resolved (hosts.yml exists), the token might
|
|
296
296
|
// be in the file. Check if it's keyring-based by looking for
|
|
297
297
|
// "oauth_token" in the hosts.yml — if missing, fall back.
|
|
298
|
-
const ghResolved = parsed.resolved.some((
|
|
299
|
-
const ghMissing = parsed.missing.some((
|
|
298
|
+
const ghResolved = parsed.resolved.some((spec) => spec.name === "gh");
|
|
299
|
+
const ghMissing = parsed.missing.some((spec) => spec.name === "gh");
|
|
300
300
|
|
|
301
301
|
// gh dir doesn't exist at all → try extracting from keyring
|
|
302
302
|
// gh dir exists (mounted) → still try, since keyring auth leaves
|
|
@@ -324,6 +324,6 @@ function resolveGhTokenFallback(requestedNames: readonly string[], parsed: Parse
|
|
|
324
324
|
// Docker accepts POSIX-style paths even on Windows when using
|
|
325
325
|
// Docker Desktop, and the rest of the codebase already uses this
|
|
326
326
|
// helper in buildDockerSpawnArgs.
|
|
327
|
-
function toDockerPath(
|
|
328
|
-
return
|
|
327
|
+
function toDockerPath(hostPath: string): string {
|
|
328
|
+
return hostPath.replace(/\\/g, "/");
|
|
329
329
|
}
|
package/server/agent/stream.ts
CHANGED
|
@@ -98,7 +98,7 @@ function extractTextDelta(event: RawStreamEvent): string | null {
|
|
|
98
98
|
// Filter assistant block events: when deltas already streamed the
|
|
99
99
|
// text, remove text-type events to prevent duplication.
|
|
100
100
|
function filterAssistantBlocks(blockEvents: AgentEvent[], deltaStreamed: boolean): AgentEvent[] {
|
|
101
|
-
return deltaStreamed ? blockEvents.filter((
|
|
101
|
+
return deltaStreamed ? blockEvents.filter((agentEvent) => agentEvent.type !== EVENT_TYPES.text) : blockEvents;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
// Stateful parser that deduplicates text across the three stages
|
|
@@ -151,11 +151,11 @@ export function createStreamParser(): {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
const content = event.message?.content;
|
|
154
|
-
const blockEvents = Array.isArray(content) ? content.map(blockToEvent).filter((
|
|
154
|
+
const blockEvents = Array.isArray(content) ? content.map(blockToEvent).filter((agentEvent): agentEvent is AgentEvent => agentEvent !== null) : [];
|
|
155
155
|
|
|
156
156
|
if (event.type === "assistant") {
|
|
157
157
|
const filtered = filterAssistantBlocks(blockEvents, textStreamedFromDeltas);
|
|
158
|
-
if (filtered.some((
|
|
158
|
+
if (filtered.some((agentEvent) => agentEvent.type === EVENT_TYPES.text)) {
|
|
159
159
|
textEmitted = true;
|
|
160
160
|
}
|
|
161
161
|
return [{ type: EVENT_TYPES.status, message: "Thinking..." }, ...filtered];
|
|
@@ -185,7 +185,7 @@ export function parseStreamEvent(event: RawStreamEvent): AgentEvent[] {
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
const content = event.message?.content;
|
|
188
|
-
const blockEvents = Array.isArray(content) ? content.map(blockToEvent).filter((
|
|
188
|
+
const blockEvents = Array.isArray(content) ? content.map(blockToEvent).filter((agentEvent): agentEvent is AgentEvent => agentEvent !== null) : [];
|
|
189
189
|
|
|
190
190
|
if (event.type === "assistant") {
|
|
191
191
|
return [{ type: EVENT_TYPES.status, message: "Thinking..." }, ...blockEvents];
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { timingSafeEqual } from "crypto";
|
|
2
2
|
|
|
3
|
-
function safeEqual(
|
|
4
|
-
if (
|
|
5
|
-
return timingSafeEqual(Buffer.from(
|
|
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
|
package/server/api/auth/token.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// setting it once on both sides survives restarts.
|
|
22
22
|
|
|
23
23
|
import { randomBytes } from "crypto";
|
|
24
|
-
import
|
|
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
|
|
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 } =
|
|
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(
|
|
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((
|
|
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
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Router, Request, Response } from "express";
|
|
2
|
-
import
|
|
2
|
+
import { ReadStream, Stats, createReadStream, readFileSync, realpathSync } from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { workspacePath } from "../../workspace/workspace.js";
|
|
5
5
|
import { statSafe, statSafeAsync, readDirSafeAsync, resolveWithinRoot, writeFileAtomic } from "../../utils/files/index.js";
|
|
6
6
|
import { errorMessage } from "../../utils/errors.js";
|
|
7
7
|
import { badRequest, notFound, sendError, serverError } from "../../utils/httpError.js";
|
|
8
|
+
import { getOptionalStringQuery } from "../../utils/request.js";
|
|
8
9
|
import { API_ROUTES } from "../../../src/config/apiRoutes.js";
|
|
9
10
|
import { GitignoreFilter } from "../../utils/gitignore.js";
|
|
10
11
|
import { getCachedReferenceDirs } from "../../workspace/reference-dirs.js";
|
|
@@ -187,7 +188,7 @@ export function classify(filename: string): ContentKind {
|
|
|
187
188
|
// Cached realpath of the workspace. Computed once at module load so
|
|
188
189
|
// every request avoids the syscall. resolveWithinRoot needs an
|
|
189
190
|
// already-realpath'd root.
|
|
190
|
-
const workspaceReal =
|
|
191
|
+
const workspaceReal = realpathSync(workspacePath);
|
|
191
192
|
|
|
192
193
|
// Wraps the shared resolveWithinRoot helper with the additional
|
|
193
194
|
// hidden-dir traversal check (e.g. `.git/config`). `buildTreeAsync`
|
|
@@ -230,12 +231,12 @@ function resolveRefPath(prefixedPath: string): string | null {
|
|
|
230
231
|
const remainder = slashIdx >= 0 ? afterPrefix.slice(slashIdx + 1) : "";
|
|
231
232
|
|
|
232
233
|
const entries = getCachedReferenceDirs();
|
|
233
|
-
const entry = entries.find((
|
|
234
|
+
const entry = entries.find((refEntry) => refEntry.label === label);
|
|
234
235
|
if (!entry) return null;
|
|
235
236
|
|
|
236
237
|
let rootReal: string;
|
|
237
238
|
try {
|
|
238
|
-
rootReal =
|
|
239
|
+
rootReal = realpathSync(entry.hostPath);
|
|
239
240
|
} catch {
|
|
240
241
|
return null;
|
|
241
242
|
}
|
|
@@ -276,7 +277,7 @@ export function parseRange(header: string, size: number): ByteRange | null {
|
|
|
276
277
|
// RFC 7233 §2.1: "A Range request on a representation whose current
|
|
277
278
|
// length is 0 cannot be satisfied". We also need this guard at the
|
|
278
279
|
// top because the naive suffix-range math below produces `end = -1`
|
|
279
|
-
// for zero-byte files, which then crashes `
|
|
280
|
+
// for zero-byte files, which then crashes `createReadStream`
|
|
280
281
|
// with `ERR_OUT_OF_RANGE`.
|
|
281
282
|
if (size <= 0) return null;
|
|
282
283
|
const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim());
|
|
@@ -325,7 +326,7 @@ function applyRawSecurityHeaders(res: Response): void {
|
|
|
325
326
|
// If the read stream errors mid-flight (file deleted, disk error,
|
|
326
327
|
// permissions changed), surface a clean failure to the client instead
|
|
327
328
|
// of leaving the connection hanging.
|
|
328
|
-
function pipeWithErrorHandling(stream:
|
|
329
|
+
function pipeWithErrorHandling(stream: ReadStream, res: Response<ErrorResponse>): void {
|
|
329
330
|
stream.on("error", (err) => {
|
|
330
331
|
if (res.headersSent) {
|
|
331
332
|
res.destroy(err);
|
|
@@ -340,7 +341,7 @@ function pipeWithErrorHandling(stream: fs.ReadStream, res: Response<ErrorRespons
|
|
|
340
341
|
// the same security filters as the original sync implementation
|
|
341
342
|
// (hidden dirs, sensitive files, symlinks all rejected) and the same
|
|
342
343
|
// ordering (dirs before files, alphabetical within type). Uses
|
|
343
|
-
// `
|
|
344
|
+
// `promises` throughout so the walk never blocks the event loop,
|
|
344
345
|
// and fans out each directory's children in parallel via
|
|
345
346
|
// `Promise.all`.
|
|
346
347
|
//
|
|
@@ -393,10 +394,10 @@ export async function buildTreeAsync(absPath: string, relPath: string, gitFilter
|
|
|
393
394
|
return buildTreeAsync(childAbs, childRel, localFilter);
|
|
394
395
|
});
|
|
395
396
|
const resolved = await Promise.all(childPromises);
|
|
396
|
-
const children = resolved.filter((
|
|
397
|
-
children.sort((
|
|
398
|
-
if (
|
|
399
|
-
return
|
|
397
|
+
const children = resolved.filter((childNode): childNode is TreeNode => childNode !== null);
|
|
398
|
+
children.sort((leftChild, rightChild) => {
|
|
399
|
+
if (leftChild.type !== rightChild.type) return leftChild.type === "dir" ? -1 : 1;
|
|
400
|
+
return leftChild.name.localeCompare(rightChild.name);
|
|
400
401
|
});
|
|
401
402
|
return {
|
|
402
403
|
name: relPath ? path.basename(relPath) : "",
|
|
@@ -460,10 +461,10 @@ export async function listDirShallow(absPath: string, relPath: string, gitFilter
|
|
|
460
461
|
};
|
|
461
462
|
});
|
|
462
463
|
const resolved = await Promise.all(childPromises);
|
|
463
|
-
const children = resolved.filter((
|
|
464
|
-
children.sort((
|
|
465
|
-
if (
|
|
466
|
-
return
|
|
464
|
+
const children = resolved.filter((childNode): childNode is TreeNode => childNode !== null);
|
|
465
|
+
children.sort((leftChild, rightChild) => {
|
|
466
|
+
if (leftChild.type !== rightChild.type) return leftChild.type === "dir" ? -1 : 1;
|
|
467
|
+
return leftChild.name.localeCompare(rightChild.name);
|
|
467
468
|
});
|
|
468
469
|
return {
|
|
469
470
|
name: relPath ? path.basename(relPath) : "",
|
|
@@ -493,7 +494,7 @@ router.get(API_ROUTES.files.tree, async (_req: Request<object, unknown, unknown,
|
|
|
493
494
|
// (no recursion) so the client can render the tree incrementally.
|
|
494
495
|
// `path` is optional; empty / missing = workspace root.
|
|
495
496
|
router.get(API_ROUTES.files.dir, async (req: Request<object, unknown, unknown, PathQuery>, res: Response<TreeNode | ErrorResponse>) => {
|
|
496
|
-
const relPath =
|
|
497
|
+
const relPath = getOptionalStringQuery(req, "path") ?? "";
|
|
497
498
|
|
|
498
499
|
// Reference directory branch — resolve against the registered ref dir
|
|
499
500
|
if (isRefPath(relPath)) {
|
|
@@ -567,8 +568,8 @@ interface PathQuery {
|
|
|
567
568
|
function resolveAndStatFile<T>(
|
|
568
569
|
req: Request<object, unknown, unknown, PathQuery>,
|
|
569
570
|
res: Response<T | ErrorResponse>,
|
|
570
|
-
): { relPath: string; absPath: string; stat:
|
|
571
|
-
const relPath =
|
|
571
|
+
): { relPath: string; absPath: string; stat: Stats } | null {
|
|
572
|
+
const relPath = getOptionalStringQuery(req, "path") ?? "";
|
|
572
573
|
if (!relPath) {
|
|
573
574
|
badRequest(res, "path required");
|
|
574
575
|
return null;
|
|
@@ -668,7 +669,7 @@ router.get(API_ROUTES.files.content, (req: Request<object, unknown, unknown, Pat
|
|
|
668
669
|
}
|
|
669
670
|
let content: string;
|
|
670
671
|
try {
|
|
671
|
-
content =
|
|
672
|
+
content = readFileSync(absPath, "utf-8");
|
|
672
673
|
} catch (err) {
|
|
673
674
|
res.status(500).json({ error: `Failed to read file: ${errorMessage(err)}` });
|
|
674
675
|
return;
|
|
@@ -779,12 +780,12 @@ router.get(API_ROUTES.files.raw, (req: Request<object, unknown, unknown, PathQue
|
|
|
779
780
|
res.status(206);
|
|
780
781
|
res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${stat.size}`);
|
|
781
782
|
res.setHeader("Content-Length", String(range.end - range.start + 1));
|
|
782
|
-
pipeWithErrorHandling(
|
|
783
|
+
pipeWithErrorHandling(createReadStream(absPath, { start: range.start, end: range.end }), res);
|
|
783
784
|
return;
|
|
784
785
|
}
|
|
785
786
|
|
|
786
787
|
res.setHeader("Content-Length", String(stat.size));
|
|
787
|
-
pipeWithErrorHandling(
|
|
788
|
+
pipeWithErrorHandling(createReadStream(absPath), res);
|
|
788
789
|
});
|
|
789
790
|
|
|
790
791
|
// ── Reference directory roots ───────────────────────────────────
|