stagent 0.10.0 → 0.11.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/README.md +15 -2
- package/dist/cli.js +24 -0
- package/docs/.coverage-gaps.json +154 -24
- package/docs/.last-generated +1 -1
- package/docs/features/agent-intelligence.md +12 -2
- package/docs/features/chat.md +40 -5
- package/docs/features/cost-usage.md +1 -1
- package/docs/features/documents.md +5 -2
- package/docs/features/inbox-notifications.md +10 -2
- package/docs/features/keyboard-navigation.md +12 -3
- package/docs/features/provider-runtimes.md +16 -2
- package/docs/features/settings.md +2 -2
- package/docs/features/shared-components.md +7 -3
- package/docs/features/tables.md +3 -1
- package/docs/features/tool-permissions.md +6 -2
- package/docs/features/workflows.md +6 -2
- package/docs/index.md +1 -1
- package/docs/journeys/developer.md +25 -2
- package/docs/journeys/personal-use.md +12 -5
- package/docs/journeys/power-user.md +45 -14
- package/docs/journeys/work-use.md +17 -8
- package/docs/manifest.json +15 -15
- package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
- package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
- package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
- package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
- package/next.config.mjs +1 -0
- package/package.json +1 -1
- package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
- package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
- package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
- package/src/app/api/chat/export/route.ts +52 -0
- package/src/app/api/chat/files/search/route.ts +50 -0
- package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
- package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
- package/src/app/api/environment/skills/route.ts +13 -0
- package/src/app/api/schedules/[id]/execute/route.ts +2 -2
- package/src/app/api/settings/chat/pins/route.ts +94 -0
- package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
- package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
- package/src/app/api/settings/environment/route.ts +26 -0
- package/src/app/api/tasks/[id]/execute/route.ts +52 -12
- package/src/app/api/tasks/[id]/respond/route.ts +31 -15
- package/src/app/api/tasks/[id]/resume/route.ts +24 -3
- package/src/app/documents/page.tsx +4 -1
- package/src/app/settings/page.tsx +2 -0
- package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
- package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
- package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
- package/src/components/chat/capability-banner.tsx +68 -0
- package/src/components/chat/chat-command-popover.tsx +668 -47
- package/src/components/chat/chat-input.tsx +103 -8
- package/src/components/chat/chat-message.tsx +12 -3
- package/src/components/chat/chat-session-provider.tsx +73 -3
- package/src/components/chat/chat-shell.tsx +62 -3
- package/src/components/chat/command-tab-bar.tsx +68 -0
- package/src/components/chat/conversation-template-picker.tsx +421 -0
- package/src/components/chat/help-dialog.tsx +39 -0
- package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
- package/src/components/chat/skill-row.tsx +147 -0
- package/src/components/documents/document-browser.tsx +37 -19
- package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
- package/src/components/notifications/permission-response-actions.tsx +155 -1
- package/src/components/settings/environment-section.tsx +102 -0
- package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
- package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
- package/src/components/shared/command-palette.tsx +262 -2
- package/src/components/shared/filter-hint.tsx +70 -0
- package/src/components/shared/filter-input.tsx +59 -0
- package/src/components/shared/saved-searches-manager.tsx +199 -0
- package/src/components/tasks/task-bento-grid.tsx +12 -2
- package/src/components/tasks/task-card.tsx +3 -0
- package/src/components/tasks/task-chip-bar.tsx +30 -1
- package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
- package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
- package/src/hooks/use-active-skills.ts +110 -0
- package/src/hooks/use-chat-autocomplete.ts +120 -7
- package/src/hooks/use-enriched-skills.ts +19 -0
- package/src/hooks/use-pinned-entries.ts +104 -0
- package/src/hooks/use-recent-user-messages.ts +19 -0
- package/src/hooks/use-saved-searches.ts +142 -0
- package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
- package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
- package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
- package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
- package/src/lib/agents/claude-agent.ts +105 -46
- package/src/lib/agents/handoff/bus.ts +2 -2
- package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
- package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
- package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
- package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
- package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
- package/src/lib/agents/profiles/registry.ts +18 -0
- package/src/lib/agents/profiles/types.ts +7 -1
- package/src/lib/agents/router.ts +3 -6
- package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
- package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
- package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
- package/src/lib/agents/runtime/catalog.ts +121 -0
- package/src/lib/agents/runtime/claude-sdk.ts +32 -0
- package/src/lib/agents/runtime/execution-target.ts +456 -0
- package/src/lib/agents/runtime/index.ts +4 -0
- package/src/lib/agents/runtime/launch-failure.ts +101 -0
- package/src/lib/agents/runtime/openai-codex.ts +35 -0
- package/src/lib/agents/runtime/openai-direct.ts +8 -0
- package/src/lib/agents/task-dispatch.ts +220 -0
- package/src/lib/agents/tool-permissions.ts +16 -1
- package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
- package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
- package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
- package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
- package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
- package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
- package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
- package/src/lib/chat/__tests__/types.test.ts +28 -0
- package/src/lib/chat/active-skills.ts +31 -0
- package/src/lib/chat/clean-filter-input.ts +30 -0
- package/src/lib/chat/codex-engine.ts +30 -7
- package/src/lib/chat/command-tabs.ts +61 -0
- package/src/lib/chat/context-builder.ts +141 -1
- package/src/lib/chat/dismissals.ts +73 -0
- package/src/lib/chat/engine.ts +109 -15
- package/src/lib/chat/files/__tests__/search.test.ts +135 -0
- package/src/lib/chat/files/expand-mention.ts +76 -0
- package/src/lib/chat/files/search.ts +99 -0
- package/src/lib/chat/skill-composition.ts +210 -0
- package/src/lib/chat/skill-conflict.ts +105 -0
- package/src/lib/chat/stagent-tools.ts +6 -19
- package/src/lib/chat/stream-telemetry.ts +9 -4
- package/src/lib/chat/system-prompt.ts +22 -0
- package/src/lib/chat/tool-catalog.ts +33 -3
- package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
- package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
- package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
- package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
- package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
- package/src/lib/chat/tools/blueprint-tools.ts +190 -0
- package/src/lib/chat/tools/helpers.ts +2 -0
- package/src/lib/chat/tools/profile-tools.ts +120 -23
- package/src/lib/chat/tools/skill-tools.ts +183 -0
- package/src/lib/chat/tools/task-tools.ts +6 -2
- package/src/lib/chat/tools/workflow-tools.ts +61 -20
- package/src/lib/chat/types.ts +15 -0
- package/src/lib/constants/settings.ts +2 -0
- package/src/lib/data/clear.ts +2 -6
- package/src/lib/db/bootstrap.ts +17 -0
- package/src/lib/db/schema.ts +26 -0
- package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
- package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
- package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
- package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
- package/src/lib/environment/data.ts +9 -0
- package/src/lib/environment/list-skills.ts +176 -0
- package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
- package/src/lib/environment/parsers/skill.ts +26 -5
- package/src/lib/environment/profile-generator.ts +54 -0
- package/src/lib/environment/skill-enrichment.ts +106 -0
- package/src/lib/environment/skill-recommendations.ts +66 -0
- package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
- package/src/lib/filters/__tests__/parse.test.ts +135 -0
- package/src/lib/filters/parse.ts +86 -0
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
- package/src/lib/instance/fingerprint.ts +7 -9
- package/src/lib/instance/upgrade-poller.ts +53 -1
- package/src/lib/schedules/scheduler.ts +4 -4
- package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
- package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
- package/src/lib/workflows/blueprints/types.ts +6 -0
- package/src/lib/workflows/engine.ts +5 -3
- package/src/test/setup.ts +10 -0
|
@@ -5,6 +5,8 @@ import { getMessages } from "@/lib/data/chat";
|
|
|
5
5
|
import { getProfile } from "@/lib/agents/profiles/registry";
|
|
6
6
|
import { STAGENT_SYSTEM_PROMPT } from "./system-prompt";
|
|
7
7
|
import type { WorkspaceContext } from "@/lib/environment/workspace-context";
|
|
8
|
+
import { expandFileMention } from "./files/expand-mention";
|
|
9
|
+
import { conversations } from "@/lib/db/schema";
|
|
8
10
|
|
|
9
11
|
// ── Token budget constants ─────────────────────────────────────────────
|
|
10
12
|
|
|
@@ -50,6 +52,121 @@ function buildTier0(
|
|
|
50
52
|
return parts.join("\n");
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
// ── Active skill injection (Ollama-first, runtime-agnostic) ────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Token budget for a conversation-bound skill's SKILL.md content.
|
|
59
|
+
*
|
|
60
|
+
* Per spec §7.1: 1000-4000 tokens typical, with 300 tokens of index/
|
|
61
|
+
* metadata on top. We cap at ~4000 tokens (≈16K chars) so a large skill
|
|
62
|
+
* can't blow out a small-context local model. Single-active-skill is
|
|
63
|
+
* enforced at the MCP-tool layer.
|
|
64
|
+
*/
|
|
65
|
+
const ACTIVE_SKILL_BUDGET = 4_000;
|
|
66
|
+
|
|
67
|
+
interface ActiveSkillSection {
|
|
68
|
+
name: string;
|
|
69
|
+
text: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderActiveSkillSections(
|
|
73
|
+
kept: ActiveSkillSection[],
|
|
74
|
+
omitted: ActiveSkillSection[]
|
|
75
|
+
): string {
|
|
76
|
+
if (kept.length === 0) return "";
|
|
77
|
+
|
|
78
|
+
const parts: string[] = [];
|
|
79
|
+
if (omitted.length > 0) {
|
|
80
|
+
const label = omitted.length === 1 ? "skill" : "skills";
|
|
81
|
+
parts.push(
|
|
82
|
+
`## Active Skill Note\nOmitted ${omitted.length} older active ${label} to fit the prompt budget: ${omitted
|
|
83
|
+
.map((section) => section.name)
|
|
84
|
+
.join(", ")}.`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
parts.push(...kept.map((section) => section.text));
|
|
88
|
+
return parts.join("\n\n---\n\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build the "Active Skill" section of the system prompt, if one is bound
|
|
93
|
+
* to the conversation via `conversations.active_skill_id`. Returns "" for
|
|
94
|
+
* conversations without an active skill.
|
|
95
|
+
*
|
|
96
|
+
* Primary use case: Ollama has no SDK-native skill support, so this is
|
|
97
|
+
* how SKILL.md reaches a local model. Claude and Codex runtimes can
|
|
98
|
+
* also bind a skill via this path alongside their native Skill tools.
|
|
99
|
+
*
|
|
100
|
+
* See `features/chat-ollama-native-skills.md`.
|
|
101
|
+
*/
|
|
102
|
+
async function buildActiveSkill(conversationId: string): Promise<string> {
|
|
103
|
+
const row = await db
|
|
104
|
+
.select({
|
|
105
|
+
activeSkillId: conversations.activeSkillId,
|
|
106
|
+
activeSkillIds: conversations.activeSkillIds,
|
|
107
|
+
runtimeId: conversations.runtimeId,
|
|
108
|
+
})
|
|
109
|
+
.from(conversations)
|
|
110
|
+
.where(eq(conversations.id, conversationId))
|
|
111
|
+
.get();
|
|
112
|
+
|
|
113
|
+
// Merge legacy single-active + new composed array. Dynamic import to
|
|
114
|
+
// avoid loading the chat tools module on the hot path / risk import
|
|
115
|
+
// cycles per the runtime-catalog smoke-test budget rule in MEMORY.md.
|
|
116
|
+
const { mergeActiveSkillIds } = await import("@/lib/chat/active-skills");
|
|
117
|
+
const merged = mergeActiveSkillIds(row?.activeSkillId, row?.activeSkillIds);
|
|
118
|
+
if (merged.length === 0) return "";
|
|
119
|
+
|
|
120
|
+
// Composition (any entry in the new activeSkillIds column) is an
|
|
121
|
+
// explicit user opt-in to override the SDK-native default. Without
|
|
122
|
+
// this carve-out, composed skills would silently no-op on Claude/
|
|
123
|
+
// Codex where stagentInjectsSkills=false. When only the legacy
|
|
124
|
+
// activeSkillId is set, fall back to the original capability gate
|
|
125
|
+
// (Ollama-only injection).
|
|
126
|
+
const isComposed = (row?.activeSkillIds?.length ?? 0) > 0;
|
|
127
|
+
|
|
128
|
+
if (!isComposed && row?.runtimeId) {
|
|
129
|
+
try {
|
|
130
|
+
const { getRuntimeFeatures } = await import("@/lib/agents/runtime/catalog");
|
|
131
|
+
const features = getRuntimeFeatures(
|
|
132
|
+
row.runtimeId as Parameters<typeof getRuntimeFeatures>[0]
|
|
133
|
+
);
|
|
134
|
+
if (!features.stagentInjectsSkills) return "";
|
|
135
|
+
} catch {
|
|
136
|
+
// Unknown runtime — fall through and inject (safer default than
|
|
137
|
+
// silently dropping the skill on an unrecognized runtime id).
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Dynamic import keeps the scanner + fs dependency off the hot path for
|
|
142
|
+
// conversations that don't have an active skill (the common case).
|
|
143
|
+
const { getSkill } = await import("@/lib/environment/list-skills");
|
|
144
|
+
const sections: ActiveSkillSection[] = [];
|
|
145
|
+
for (const id of merged) {
|
|
146
|
+
const skill = getSkill(id);
|
|
147
|
+
if (!skill) continue;
|
|
148
|
+
sections.push({
|
|
149
|
+
name: skill.name,
|
|
150
|
+
text: `## Active Skill: ${skill.name}\n\n${skill.content}`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (sections.length === 0) return "";
|
|
154
|
+
|
|
155
|
+
const kept = [...sections];
|
|
156
|
+
const omitted: ActiveSkillSection[] = [];
|
|
157
|
+
while (
|
|
158
|
+
kept.length > 1 &&
|
|
159
|
+
estimateTokens(renderActiveSkillSections(kept, omitted)) > ACTIVE_SKILL_BUDGET
|
|
160
|
+
) {
|
|
161
|
+
const oldest = kept.shift();
|
|
162
|
+
if (oldest) omitted.push(oldest);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const combined = renderActiveSkillSections(kept, omitted);
|
|
166
|
+
if (estimateTokens(combined) <= ACTIVE_SKILL_BUDGET) return combined;
|
|
167
|
+
return truncateToTokenBudget(combined, ACTIVE_SKILL_BUDGET);
|
|
168
|
+
}
|
|
169
|
+
|
|
53
170
|
// ── Tier 1: Conversation history ───────────────────────────────────────
|
|
54
171
|
|
|
55
172
|
interface HistoryMessage {
|
|
@@ -278,6 +395,23 @@ async function buildTier3(mentions: MentionReference[]): Promise<string> {
|
|
|
278
395
|
}
|
|
279
396
|
break;
|
|
280
397
|
}
|
|
398
|
+
case "file": {
|
|
399
|
+
// `entityId` is a relative path scoped to the active project's
|
|
400
|
+
// workingDirectory (preferred) or the stagent launch cwd (fallback).
|
|
401
|
+
// Security is enforced inside expandFileMention — the caller cannot
|
|
402
|
+
// influence cwd.
|
|
403
|
+
const { getLaunchCwd } = await import("@/lib/environment/workspace-context");
|
|
404
|
+
let cwd = getLaunchCwd();
|
|
405
|
+
// If the mention has a known project context in scope, prefer the
|
|
406
|
+
// project's workingDirectory. We don't have it at this scope today,
|
|
407
|
+
// so launch cwd is the safe default — matches the API route.
|
|
408
|
+
// (Future: plumb projectId into buildTier3 so file expansion honors
|
|
409
|
+
// per-project cwds exactly the same way as the search API.)
|
|
410
|
+
void cwd;
|
|
411
|
+
cwd = getLaunchCwd();
|
|
412
|
+
parts.push(...expandFileMention(mention.entityId, cwd));
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
281
415
|
}
|
|
282
416
|
}
|
|
283
417
|
|
|
@@ -304,16 +438,22 @@ export async function buildChatContext(opts: {
|
|
|
304
438
|
workspace?: WorkspaceContext | null;
|
|
305
439
|
mentions?: MentionReference[];
|
|
306
440
|
}): Promise<ChatContext> {
|
|
307
|
-
const [history, tier2, tier3] = await Promise.all([
|
|
441
|
+
const [history, tier2, tier3, activeSkill] = await Promise.all([
|
|
308
442
|
buildTier1(opts.conversationId),
|
|
309
443
|
buildTier2(opts.projectId),
|
|
310
444
|
buildTier3(opts.mentions ?? []),
|
|
445
|
+
buildActiveSkill(opts.conversationId),
|
|
311
446
|
]);
|
|
312
447
|
|
|
313
448
|
const tier0 = buildTier0(opts.projectName, opts.workspace);
|
|
314
449
|
|
|
315
450
|
const systemParts = [tier0];
|
|
316
451
|
|
|
452
|
+
// Active skill (from conversations.active_skill_id) sits right below
|
|
453
|
+
// Tier 0 so its instructions carry the most weight. Empty string when
|
|
454
|
+
// no skill is bound — common case.
|
|
455
|
+
if (activeSkill) systemParts.push(activeSkill);
|
|
456
|
+
|
|
317
457
|
if (tier3) systemParts.push(tier3);
|
|
318
458
|
if (tier2) systemParts.push(tier2);
|
|
319
459
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export const DISMISSAL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
2
|
+
|
|
3
|
+
export interface DismissalStore {
|
|
4
|
+
read(): string | null;
|
|
5
|
+
write(value: string): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type DismissalMap = Record<string, Record<string, number>>;
|
|
9
|
+
|
|
10
|
+
export function loadDismissals(store: DismissalStore): DismissalMap {
|
|
11
|
+
const raw = store.read();
|
|
12
|
+
if (!raw) return {};
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(raw);
|
|
15
|
+
if (parsed && typeof parsed === "object") return parsed as DismissalMap;
|
|
16
|
+
} catch {
|
|
17
|
+
// corrupt — fall through
|
|
18
|
+
}
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function saveDismissal(
|
|
23
|
+
store: DismissalStore,
|
|
24
|
+
conversationId: string,
|
|
25
|
+
skillId: string,
|
|
26
|
+
nowMs: number = Date.now()
|
|
27
|
+
): void {
|
|
28
|
+
const current = loadDismissals(store);
|
|
29
|
+
current[conversationId] = current[conversationId] ?? {};
|
|
30
|
+
current[conversationId][skillId] = nowMs;
|
|
31
|
+
try {
|
|
32
|
+
store.write(JSON.stringify(current));
|
|
33
|
+
} catch {
|
|
34
|
+
// silent — in-memory state won't persist
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function activeDismissedIds(
|
|
39
|
+
store: DismissalStore,
|
|
40
|
+
conversationId: string,
|
|
41
|
+
nowMs: number = Date.now()
|
|
42
|
+
): Set<string> {
|
|
43
|
+
const all = loadDismissals(store);
|
|
44
|
+
const conv = all[conversationId];
|
|
45
|
+
if (!conv) return new Set();
|
|
46
|
+
const out = new Set<string>();
|
|
47
|
+
for (const [skillId, ts] of Object.entries(conv)) {
|
|
48
|
+
if (nowMs - ts < DISMISSAL_TTL_MS) out.add(skillId);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Browser store adapter around localStorage for a given key. */
|
|
54
|
+
export function browserLocalStore(key: string): DismissalStore {
|
|
55
|
+
return {
|
|
56
|
+
read() {
|
|
57
|
+
if (typeof window === "undefined") return null;
|
|
58
|
+
try {
|
|
59
|
+
return window.localStorage.getItem(key);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
write(value) {
|
|
65
|
+
if (typeof window === "undefined") return;
|
|
66
|
+
try {
|
|
67
|
+
window.localStorage.setItem(key, value);
|
|
68
|
+
} catch {
|
|
69
|
+
// quota / disabled — silent
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/lib/chat/engine.ts
CHANGED
|
@@ -3,7 +3,12 @@ import { db } from "@/lib/db";
|
|
|
3
3
|
import { projects, chatMessages } from "@/lib/db/schema";
|
|
4
4
|
import { eq } from "drizzle-orm";
|
|
5
5
|
import { getAuthEnv } from "@/lib/settings/auth";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildClaudeSdkEnv,
|
|
8
|
+
CLAUDE_SDK_SETTING_SOURCES,
|
|
9
|
+
CLAUDE_SDK_ALLOWED_TOOLS,
|
|
10
|
+
CLAUDE_SDK_READ_ONLY_FS_TOOLS,
|
|
11
|
+
} from "@/lib/agents/runtime/claude-sdk";
|
|
7
12
|
import {
|
|
8
13
|
extractUsageSnapshot,
|
|
9
14
|
mergeUsageSnapshot,
|
|
@@ -42,7 +47,7 @@ import {
|
|
|
42
47
|
} from "./permission-bridge";
|
|
43
48
|
import { isToolAllowed } from "@/lib/settings/permissions";
|
|
44
49
|
import { getLaunchCwd, getWorkspaceContext } from "@/lib/environment/workspace-context";
|
|
45
|
-
import {
|
|
50
|
+
import { createToolServer } from "./stagent-tools";
|
|
46
51
|
import {
|
|
47
52
|
getBrowserMcpServers,
|
|
48
53
|
getBrowserAllowedToolPatterns,
|
|
@@ -53,6 +58,36 @@ import {
|
|
|
53
58
|
isExaTool,
|
|
54
59
|
isExaReadOnly,
|
|
55
60
|
} from "@/lib/agents/browser-mcp";
|
|
61
|
+
import { resolveChatExecutionTarget } from "@/lib/agents/runtime/execution-target";
|
|
62
|
+
|
|
63
|
+
// Re-exported from runtime/claude-sdk.ts so chat/engine.ts remains a stable
|
|
64
|
+
// import surface for the Phase 1a test suite. The canonical definitions
|
|
65
|
+
// live in the runtime module since task execution needs them too — see
|
|
66
|
+
// features/task-runtime-skill-parity.md Task 1.
|
|
67
|
+
export {
|
|
68
|
+
CLAUDE_SDK_SETTING_SOURCES,
|
|
69
|
+
CLAUDE_SDK_ALLOWED_TOOLS,
|
|
70
|
+
CLAUDE_SDK_READ_ONLY_FS_TOOLS,
|
|
71
|
+
} from "@/lib/agents/runtime/claude-sdk";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pure auto-allow policy for SDK filesystem + Skill tools. Exposed for tests.
|
|
75
|
+
* Returns `{ behavior: "allow" }` for auto-allowed tools, or
|
|
76
|
+
* `{ behavior: "pending" }` to signal "route through permission flow".
|
|
77
|
+
* The real canUseTool in query() options uses the full side-channel bridge.
|
|
78
|
+
*/
|
|
79
|
+
export async function canUseToolForTest(
|
|
80
|
+
toolName: string,
|
|
81
|
+
_input: Record<string, unknown>
|
|
82
|
+
): Promise<ToolPermissionResponse | { behavior: "pending" }> {
|
|
83
|
+
if (CLAUDE_SDK_READ_ONLY_FS_TOOLS.has(toolName)) {
|
|
84
|
+
return { behavior: "allow" };
|
|
85
|
+
}
|
|
86
|
+
if (toolName === "Skill") {
|
|
87
|
+
return { behavior: "allow" };
|
|
88
|
+
}
|
|
89
|
+
return { behavior: "pending" };
|
|
90
|
+
}
|
|
56
91
|
|
|
57
92
|
// ── Streaming input wrapper (required for MCP tools) ─────────────────
|
|
58
93
|
|
|
@@ -151,21 +186,43 @@ export async function* sendMessage(
|
|
|
151
186
|
return;
|
|
152
187
|
}
|
|
153
188
|
|
|
189
|
+
let target;
|
|
190
|
+
try {
|
|
191
|
+
target = await resolveChatExecutionTarget({
|
|
192
|
+
requestedRuntimeId: conversation.runtimeId,
|
|
193
|
+
requestedModelId: conversation.modelId,
|
|
194
|
+
});
|
|
195
|
+
} catch (error) {
|
|
196
|
+
yield {
|
|
197
|
+
type: "error",
|
|
198
|
+
message: error instanceof Error ? error.message : "No chat runtime is available",
|
|
199
|
+
};
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (target.fallbackApplied && target.fallbackReason) {
|
|
204
|
+
yield {
|
|
205
|
+
type: "status",
|
|
206
|
+
phase: "runtime_fallback",
|
|
207
|
+
message: target.fallbackReason,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
154
211
|
// Route to Codex App Server for OpenAI models
|
|
155
|
-
if (
|
|
212
|
+
if (target.effectiveRuntimeId === "openai-codex-app-server") {
|
|
156
213
|
const { sendCodexMessage } = await import("./codex-engine");
|
|
157
|
-
yield* sendCodexMessage(conversationId, userContent, signal);
|
|
214
|
+
yield* sendCodexMessage(conversationId, userContent, signal, target);
|
|
158
215
|
return;
|
|
159
216
|
}
|
|
160
217
|
|
|
161
218
|
// Route to Ollama for local models
|
|
162
|
-
if (
|
|
219
|
+
if (target.effectiveRuntimeId === "ollama") {
|
|
163
220
|
const { sendOllamaMessage } = await import("./ollama-engine");
|
|
164
221
|
yield* sendOllamaMessage(conversationId, userContent, signal);
|
|
165
222
|
return;
|
|
166
223
|
}
|
|
167
224
|
|
|
168
|
-
const runtimeId =
|
|
225
|
+
const runtimeId = target.effectiveRuntimeId;
|
|
169
226
|
const providerId = getProviderForRuntime(runtimeId);
|
|
170
227
|
|
|
171
228
|
// Enforce budget before the turn
|
|
@@ -277,10 +334,11 @@ export async function* sendMessage(
|
|
|
277
334
|
|
|
278
335
|
// Create in-process MCP server for Stagent CRUD tools
|
|
279
336
|
const toolResults: ToolResultCapture[] = [];
|
|
280
|
-
const stagentServer =
|
|
337
|
+
const stagentServer = createToolServer(
|
|
281
338
|
conversation.projectId,
|
|
282
|
-
(toolName, result) => { toolResults.push({ toolName, result }); }
|
|
283
|
-
|
|
339
|
+
(toolName, result) => { toolResults.push({ toolName, result }); },
|
|
340
|
+
projectCwd,
|
|
341
|
+
).asMcpServer();
|
|
284
342
|
|
|
285
343
|
yield { type: "status", phase: "connecting", message: "Connecting to model..." };
|
|
286
344
|
|
|
@@ -300,7 +358,7 @@ export async function* sendMessage(
|
|
|
300
358
|
const response = query({
|
|
301
359
|
prompt: generatePrompt(fullPrompt),
|
|
302
360
|
options: {
|
|
303
|
-
model: conversation.modelId || undefined,
|
|
361
|
+
model: target.effectiveModelId || conversation.modelId || undefined,
|
|
304
362
|
maxTurns,
|
|
305
363
|
abortController,
|
|
306
364
|
includePartialMessages: true,
|
|
@@ -312,7 +370,13 @@ export async function* sendMessage(
|
|
|
312
370
|
if (stderrChunks.length > 50) stderrChunks.shift();
|
|
313
371
|
},
|
|
314
372
|
mcpServers: { stagent: stagentServer, ...browserServers, ...externalServers },
|
|
315
|
-
allowedTools: [
|
|
373
|
+
allowedTools: [
|
|
374
|
+
"mcp__stagent__*",
|
|
375
|
+
...browserToolPatterns,
|
|
376
|
+
...externalToolPatterns,
|
|
377
|
+
...CLAUDE_SDK_ALLOWED_TOOLS,
|
|
378
|
+
],
|
|
379
|
+
settingSources: [...CLAUDE_SDK_SETTING_SOURCES],
|
|
316
380
|
// @ts-expect-error Agent SDK canUseTool types are incomplete — our async handler is compatible at runtime
|
|
317
381
|
canUseTool: async (
|
|
318
382
|
toolName: string,
|
|
@@ -369,6 +433,32 @@ export async function* sendMessage(
|
|
|
369
433
|
// Mutation browser tools fall through to permission check below
|
|
370
434
|
}
|
|
371
435
|
|
|
436
|
+
// SDK filesystem read-only tools: auto-allow (mirror browser/exa pattern)
|
|
437
|
+
if (CLAUDE_SDK_READ_ONLY_FS_TOOLS.has(toolName)) {
|
|
438
|
+
emitSideChannelEvent(conversationId, {
|
|
439
|
+
type: "status",
|
|
440
|
+
phase: "tool_use",
|
|
441
|
+
message: `Filesystem: ${toolName.toLowerCase()}...`,
|
|
442
|
+
});
|
|
443
|
+
return { behavior: "allow", updatedInput: input };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Skill tool: auto-allow. Rationale: the Skill tool loads skills from
|
|
447
|
+
// ~/.claude/skills/ and .claude/skills/ — the same sources the Claude Code
|
|
448
|
+
// CLI trusts unconditionally. Any tool the skill subsequently invokes
|
|
449
|
+
// (Bash, Edit, etc.) goes through this same canUseTool check. The trust
|
|
450
|
+
// assumption here is identical to using `claude` directly; no new attack
|
|
451
|
+
// surface is introduced. See: features/chat-claude-sdk-skills.md, Error
|
|
452
|
+
// & Rescue Registry row "settingSources loads hostile skill".
|
|
453
|
+
if (toolName === "Skill") {
|
|
454
|
+
emitSideChannelEvent(conversationId, {
|
|
455
|
+
type: "status",
|
|
456
|
+
phase: "tool_use",
|
|
457
|
+
message: `Skill: ${(input as { skill?: string }).skill ?? "unknown"}...`,
|
|
458
|
+
});
|
|
459
|
+
return { behavior: "allow", updatedInput: input };
|
|
460
|
+
}
|
|
461
|
+
|
|
372
462
|
const isQuestion = toolName === "AskUserQuestion";
|
|
373
463
|
|
|
374
464
|
// Layer 1: Check saved user permissions (skip for questions)
|
|
@@ -615,7 +705,11 @@ export async function* sendMessage(
|
|
|
615
705
|
|
|
616
706
|
// Save usage metadata + quick access links + screenshot attachments
|
|
617
707
|
const metadata = JSON.stringify({
|
|
618
|
-
modelId: usage.modelId ?? conversation.modelId,
|
|
708
|
+
modelId: usage.modelId ?? target.effectiveModelId ?? conversation.modelId,
|
|
709
|
+
runtimeId,
|
|
710
|
+
requestedRuntimeId: target.requestedRuntimeId ?? conversation.runtimeId,
|
|
711
|
+
requestedModelId: target.requestedModelId ?? conversation.modelId,
|
|
712
|
+
...(target.fallbackReason ? { fallbackReason: target.fallbackReason } : {}),
|
|
619
713
|
inputTokens: usage.inputTokens,
|
|
620
714
|
outputTokens: usage.outputTokens,
|
|
621
715
|
...(quickAccess.length > 0 ? { quickAccess } : {}),
|
|
@@ -632,7 +726,7 @@ export async function* sendMessage(
|
|
|
632
726
|
activityType: "chat_turn",
|
|
633
727
|
runtimeId,
|
|
634
728
|
providerId,
|
|
635
|
-
modelId: usage.modelId ?? conversation.modelId ?? null,
|
|
729
|
+
modelId: usage.modelId ?? target.effectiveModelId ?? conversation.modelId ?? null,
|
|
636
730
|
inputTokens: usage.inputTokens ?? null,
|
|
637
731
|
outputTokens: usage.outputTokens ?? null,
|
|
638
732
|
totalTokens: usage.totalTokens ?? null,
|
|
@@ -695,7 +789,7 @@ export async function* sendMessage(
|
|
|
695
789
|
activityType: "chat_turn",
|
|
696
790
|
runtimeId,
|
|
697
791
|
providerId,
|
|
698
|
-
modelId: usage.modelId ?? conversation.modelId ?? null,
|
|
792
|
+
modelId: usage.modelId ?? target.effectiveModelId ?? conversation.modelId ?? null,
|
|
699
793
|
inputTokens: usage.inputTokens ?? null,
|
|
700
794
|
outputTokens: usage.outputTokens ?? null,
|
|
701
795
|
totalTokens: usage.totalTokens ?? null,
|
|
@@ -722,7 +816,7 @@ export async function* sendMessage(
|
|
|
722
816
|
activityType: "chat_turn",
|
|
723
817
|
runtimeId,
|
|
724
818
|
providerId,
|
|
725
|
-
modelId: usage.modelId ?? conversation.modelId ?? null,
|
|
819
|
+
modelId: usage.modelId ?? target.effectiveModelId ?? conversation.modelId ?? null,
|
|
726
820
|
inputTokens: usage.inputTokens ?? null,
|
|
727
821
|
outputTokens: usage.outputTokens ?? null,
|
|
728
822
|
totalTokens: usage.totalTokens ?? null,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Hoist mutable state so the mock factories can read it.
|
|
4
|
+
const { mockState } = vi.hoisted(() => ({
|
|
5
|
+
mockState: {
|
|
6
|
+
stdout: "" as string,
|
|
7
|
+
execFileThrows: false as boolean | Error,
|
|
8
|
+
files: new Map<string, { size: number; mtimeMs: number }>(),
|
|
9
|
+
realpathMap: new Map<string, string>(),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("node:child_process", () => {
|
|
14
|
+
const execFileSync = vi.fn(() => {
|
|
15
|
+
if (mockState.execFileThrows) {
|
|
16
|
+
throw mockState.execFileThrows instanceof Error
|
|
17
|
+
? mockState.execFileThrows
|
|
18
|
+
: new Error("git not available");
|
|
19
|
+
}
|
|
20
|
+
return mockState.stdout;
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
default: { execFileSync },
|
|
24
|
+
execFileSync,
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
vi.mock("node:fs", () => {
|
|
29
|
+
const realpathSync = (p: string) => mockState.realpathMap.get(p) ?? p;
|
|
30
|
+
const statSync = (absPath: string) => {
|
|
31
|
+
const f = mockState.files.get(absPath);
|
|
32
|
+
if (!f) throw new Error(`ENOENT: ${absPath}`);
|
|
33
|
+
return { size: f.size, mtimeMs: f.mtimeMs };
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
default: { realpathSync, statSync },
|
|
37
|
+
realpathSync,
|
|
38
|
+
statSync,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
import { searchFiles } from "../search";
|
|
43
|
+
|
|
44
|
+
// Helper: all test files live under this fake cwd
|
|
45
|
+
const CWD = "/repo";
|
|
46
|
+
|
|
47
|
+
function file(relPath: string, size: number, mtimeMs: number) {
|
|
48
|
+
mockState.files.set(`${CWD}/${relPath}`, { size, mtimeMs });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
mockState.stdout = "";
|
|
53
|
+
mockState.execFileThrows = false;
|
|
54
|
+
mockState.files.clear();
|
|
55
|
+
mockState.realpathMap.clear();
|
|
56
|
+
mockState.realpathMap.set(CWD, CWD);
|
|
57
|
+
vi.clearAllMocks();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("searchFiles", () => {
|
|
61
|
+
it("returns all files when query is empty, mtime-sorted newest first", () => {
|
|
62
|
+
mockState.stdout = ["src/a.ts", "src/b.ts", "src/c.ts", ""].join("\n");
|
|
63
|
+
file("src/a.ts", 100, 1_000);
|
|
64
|
+
file("src/b.ts", 200, 3_000);
|
|
65
|
+
file("src/c.ts", 300, 2_000);
|
|
66
|
+
|
|
67
|
+
const hits = searchFiles(CWD, "", 10);
|
|
68
|
+
expect(hits.map((h) => h.path)).toEqual(["src/b.ts", "src/c.ts", "src/a.ts"]);
|
|
69
|
+
expect(hits[0].sizeBytes).toBe(200);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("ranks filename matches above directory-path matches", () => {
|
|
73
|
+
mockState.stdout = [
|
|
74
|
+
"src/schema/other.ts", // directory match for "schema"
|
|
75
|
+
"src/lib/db/schema.ts", // filename match for "schema"
|
|
76
|
+
""
|
|
77
|
+
].join("\n");
|
|
78
|
+
file("src/schema/other.ts", 100, 1_000);
|
|
79
|
+
file("src/lib/db/schema.ts", 100, 500); // older but should still rank first
|
|
80
|
+
|
|
81
|
+
const hits = searchFiles(CWD, "schema", 10);
|
|
82
|
+
expect(hits[0].path).toBe("src/lib/db/schema.ts");
|
|
83
|
+
expect(hits[1].path).toBe("src/schema/other.ts");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("performs case-insensitive substring match", () => {
|
|
87
|
+
mockState.stdout = ["src/Foo.TSX", "src/bar.ts", ""].join("\n");
|
|
88
|
+
file("src/Foo.TSX", 100, 1_000);
|
|
89
|
+
file("src/bar.ts", 100, 1_000);
|
|
90
|
+
|
|
91
|
+
const hits = searchFiles(CWD, "foo", 10);
|
|
92
|
+
expect(hits).toHaveLength(1);
|
|
93
|
+
expect(hits[0].path).toBe("src/Foo.TSX");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("respects limit cap", () => {
|
|
97
|
+
const lines: string[] = [];
|
|
98
|
+
for (let i = 0; i < 50; i++) {
|
|
99
|
+
const p = `src/file${i}.ts`;
|
|
100
|
+
lines.push(p);
|
|
101
|
+
file(p, 100, i * 10);
|
|
102
|
+
}
|
|
103
|
+
mockState.stdout = lines.join("\n");
|
|
104
|
+
|
|
105
|
+
const hits = searchFiles(CWD, "", 5);
|
|
106
|
+
expect(hits).toHaveLength(5);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns [] when execFileSync throws (not a git repo)", () => {
|
|
110
|
+
mockState.execFileThrows = new Error("not a git repository");
|
|
111
|
+
const hits = searchFiles(CWD, "anything", 10);
|
|
112
|
+
expect(hits).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("skips files that disappeared between ls-files and stat", () => {
|
|
116
|
+
mockState.stdout = ["src/exists.ts", "src/ghost.ts", ""].join("\n");
|
|
117
|
+
file("src/exists.ts", 100, 1_000);
|
|
118
|
+
// src/ghost.ts intentionally absent from the files map — statSync throws
|
|
119
|
+
|
|
120
|
+
const hits = searchFiles(CWD, "", 10);
|
|
121
|
+
expect(hits.map((h) => h.path)).toEqual(["src/exists.ts"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("excludes files that would resolve outside cwd (defense-in-depth)", () => {
|
|
125
|
+
// git ls-files should never emit such a path, but if it did we must reject.
|
|
126
|
+
mockState.stdout = ["../escape.ts", "src/ok.ts", ""].join("\n");
|
|
127
|
+
// Do NOT register the escape path in files — resolve() would point outside
|
|
128
|
+
// /repo, and the startsWith check in search.ts will discard it before
|
|
129
|
+
// statSync is even called.
|
|
130
|
+
file("src/ok.ts", 100, 1_000);
|
|
131
|
+
|
|
132
|
+
const hits = searchFiles(CWD, "", 10);
|
|
133
|
+
expect(hits.map((h) => h.path)).toEqual(["src/ok.ts"]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { realpathSync, statSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Format a single `entityType: "file"` mention for Tier 3.
|
|
6
|
+
*
|
|
7
|
+
* Security:
|
|
8
|
+
* - `cwd` is resolved by the caller from a trusted source (active project's
|
|
9
|
+
* workingDirectory, else `getLaunchCwd()`) — NEVER from the mention itself.
|
|
10
|
+
* - The mention's `relPath` is treated as a relative path; any path that
|
|
11
|
+
* resolves outside `cwd` is rejected without opening the file.
|
|
12
|
+
*
|
|
13
|
+
* Size semantics (matches spec §3 "tiered expansion"):
|
|
14
|
+
* - < 8 KB: inline content inside a fenced code block with path header.
|
|
15
|
+
* - >= 8 KB and < MAX_SIZE: emit a short reference line so agents with a
|
|
16
|
+
* `Read` tool can fetch the file on demand; agents without one degrade
|
|
17
|
+
* gracefully ("I can't read large files on this runtime").
|
|
18
|
+
* - >= MAX_SIZE (50 MB): skip silently — pathological.
|
|
19
|
+
*
|
|
20
|
+
* Non-crashing by design: any read/stat failure becomes a short note in
|
|
21
|
+
* the output, not a thrown error that would break the whole prompt build.
|
|
22
|
+
*/
|
|
23
|
+
export function expandFileMention(relPath: string, cwd: string): string[] {
|
|
24
|
+
const lines: string[] = [];
|
|
25
|
+
|
|
26
|
+
let cwdReal: string;
|
|
27
|
+
try {
|
|
28
|
+
cwdReal = realpathSync(cwd);
|
|
29
|
+
} catch {
|
|
30
|
+
lines.push(`\n### File: ${relPath}`);
|
|
31
|
+
lines.push("(cwd does not exist)");
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const abs = resolve(cwdReal, relPath);
|
|
36
|
+
if (!abs.startsWith(cwdReal)) {
|
|
37
|
+
lines.push(`\n### File: ${relPath}`);
|
|
38
|
+
lines.push("(invalid path — escapes working directory)");
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let stat: { size: number };
|
|
43
|
+
try {
|
|
44
|
+
stat = statSync(abs);
|
|
45
|
+
} catch {
|
|
46
|
+
lines.push(`\n### File: ${relPath}`);
|
|
47
|
+
lines.push("(file not found at context-build time)");
|
|
48
|
+
return lines;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const INLINE_LIMIT = 8 * 1024;
|
|
52
|
+
const MAX_SIZE = 50 * 1024 * 1024;
|
|
53
|
+
if (stat.size > MAX_SIZE) return []; // skip silently
|
|
54
|
+
|
|
55
|
+
if (stat.size < INLINE_LIMIT) {
|
|
56
|
+
let content: string;
|
|
57
|
+
try {
|
|
58
|
+
content = readFileSync(abs, "utf8");
|
|
59
|
+
} catch {
|
|
60
|
+
lines.push(`\n### File: ${relPath}`);
|
|
61
|
+
lines.push("(file could not be read as UTF-8)");
|
|
62
|
+
return lines;
|
|
63
|
+
}
|
|
64
|
+
const ext = relPath.split(".").pop() ?? "";
|
|
65
|
+
lines.push(`\n### File: ${relPath}`);
|
|
66
|
+
lines.push("```" + ext);
|
|
67
|
+
lines.push(content);
|
|
68
|
+
lines.push("```");
|
|
69
|
+
} else {
|
|
70
|
+
lines.push(
|
|
71
|
+
`\n### File (by reference): ${relPath} (${Math.round(stat.size / 1024)} KB)`
|
|
72
|
+
);
|
|
73
|
+
lines.push("Use the Read tool to load this file if you need its content.");
|
|
74
|
+
}
|
|
75
|
+
return lines;
|
|
76
|
+
}
|