stagent 0.10.0 → 0.11.1
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 +44 -31
- 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/getting-started.md +1 -1
- 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-07-instance-bootstrap.md +2 -2
- 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 +3 -3
- 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/book/content-blocks.tsx +1 -1
- 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/playbook/playbook-detail-view.tsx +1 -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 +97 -22
- 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 +56 -2
- 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__/detect.test.ts +1 -1
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
- package/src/lib/instance/fingerprint.ts +8 -10
- package/src/lib/instance/upgrade-poller.ts +53 -1
- package/src/lib/schedules/scheduler.ts +4 -4
- package/src/lib/utils/stagent-paths.ts +4 -0
- 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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { detectSkillConflicts } from "../skill-conflict";
|
|
3
|
+
|
|
4
|
+
describe("detectSkillConflicts", () => {
|
|
5
|
+
it("returns no conflicts for two unrelated skills", () => {
|
|
6
|
+
const a = { id: "a", name: "code-reviewer", content: "Always run ESLint before reviewing code." };
|
|
7
|
+
const b = { id: "b", name: "haiku-poet", content: "Use 5-7-5 syllable structure." };
|
|
8
|
+
expect(detectSkillConflicts(a, b)).toEqual([]);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("flags directive divergence on a shared topic", () => {
|
|
12
|
+
const a = { id: "a", name: "tdd", content: "Always write the test first. Never write production code without a failing test." };
|
|
13
|
+
const b = { id: "b", name: "spike", content: "Never write tests during a spike. Prefer exploratory code." };
|
|
14
|
+
const conflicts = detectSkillConflicts(a, b);
|
|
15
|
+
expect(conflicts.length).toBeGreaterThan(0);
|
|
16
|
+
expect(conflicts[0]).toMatchObject({
|
|
17
|
+
skillA: "tdd",
|
|
18
|
+
skillB: "spike",
|
|
19
|
+
});
|
|
20
|
+
expect(conflicts[0].excerptA).toMatch(/test/i);
|
|
21
|
+
expect(conflicts[0].excerptB).toMatch(/test/i);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns no conflicts when both skills agree on a topic", () => {
|
|
25
|
+
const a = { id: "a", name: "tdd", content: "Always write tests first." };
|
|
26
|
+
const b = { id: "b", name: "qa-strict", content: "Always write tests first and add coverage gates." };
|
|
27
|
+
expect(detectSkillConflicts(a, b)).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("ignores non-directive lines", () => {
|
|
31
|
+
const a = { id: "a", name: "x", content: "This skill is for documentation tasks." };
|
|
32
|
+
const b = { id: "b", name: "y", content: "Documentation is important context." };
|
|
33
|
+
expect(detectSkillConflicts(a, b)).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getFeaturesForModel, getRuntimeForModel } from "@/lib/chat/types";
|
|
3
|
+
|
|
4
|
+
describe("getFeaturesForModel", () => {
|
|
5
|
+
it("returns Claude features for a Claude model id", () => {
|
|
6
|
+
const features = getFeaturesForModel("sonnet");
|
|
7
|
+
expect(features.hasNativeSkills).toBe(true);
|
|
8
|
+
expect(features.autoLoadsInstructions).toBe("CLAUDE.md");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns Ollama features for an ollama-prefixed model id", () => {
|
|
12
|
+
const features = getFeaturesForModel("ollama:llama3");
|
|
13
|
+
expect(features.stagentInjectsSkills).toBe(true);
|
|
14
|
+
expect(features.hasNativeSkills).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns Codex features for a GPT model id", () => {
|
|
18
|
+
const features = getFeaturesForModel("gpt-5.4");
|
|
19
|
+
expect(features.autoLoadsInstructions).toBe("AGENTS.md");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("falls back to claude-code features for an unknown model id", () => {
|
|
23
|
+
// getRuntimeForModel's fallback chain lands on claude-code for unknown ids.
|
|
24
|
+
const features = getFeaturesForModel("totally-made-up-model");
|
|
25
|
+
expect(features.hasNativeSkills).toBe(true);
|
|
26
|
+
expect(getRuntimeForModel("totally-made-up-model")).toBe("claude-code");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helper for combining the legacy `conversations.active_skill_id`
|
|
3
|
+
* column with the new `conversations.active_skill_ids` JSON array
|
|
4
|
+
* (`features/chat-skill-composition.md`).
|
|
5
|
+
*
|
|
6
|
+
* Lives in its own module (no DB imports) so client components can use
|
|
7
|
+
* it without pulling server-only code into the bundle. The original
|
|
8
|
+
* lived alongside the chat-tool definition in `tools/skill-tools.ts`,
|
|
9
|
+
* which can only run server-side.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function mergeActiveSkillIds(
|
|
13
|
+
legacyId: string | null | undefined,
|
|
14
|
+
composed: string[] | null | undefined
|
|
15
|
+
): string[] {
|
|
16
|
+
const out: string[] = [];
|
|
17
|
+
const seen = new Set<string>();
|
|
18
|
+
if (legacyId) {
|
|
19
|
+
out.push(legacyId);
|
|
20
|
+
seen.add(legacyId);
|
|
21
|
+
}
|
|
22
|
+
if (composed) {
|
|
23
|
+
for (const id of composed) {
|
|
24
|
+
if (id && !seen.has(id)) {
|
|
25
|
+
out.push(id);
|
|
26
|
+
seen.add(id);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize the filterInput we persist for a saved search.
|
|
3
|
+
*
|
|
4
|
+
* The chat popover input may include the mention trigger prefix
|
|
5
|
+
* (e.g. `@task: ` or `task: ` depending on what the trigger regex
|
|
6
|
+
* stripped). When the user "Saves this view", we want the persisted
|
|
7
|
+
* filterInput to contain ONLY the meaningful filter expression —
|
|
8
|
+
* `#key:value` clauses plus any free-text search the user typed —
|
|
9
|
+
* not the trigger residue.
|
|
10
|
+
*
|
|
11
|
+
* Pure function. Tested in isolation; called from
|
|
12
|
+
* `chat-command-popover.tsx` at the SaveViewFooter call site.
|
|
13
|
+
*
|
|
14
|
+
* See `features/saved-search-polish-v1.md` for the bug history.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { FilterClause } from "@/lib/filters/parse";
|
|
18
|
+
|
|
19
|
+
const TRIGGER_RESIDUE = /^@?[a-z]+:\s*/i;
|
|
20
|
+
|
|
21
|
+
export function cleanFilterInput(
|
|
22
|
+
clauses: FilterClause[],
|
|
23
|
+
rawQuery: string
|
|
24
|
+
): string {
|
|
25
|
+
const cleanRawQuery = rawQuery.replace(TRIGGER_RESIDUE, "").trim();
|
|
26
|
+
return [
|
|
27
|
+
...clauses.map((c) => `#${c.key}:${c.value}`),
|
|
28
|
+
...(cleanRawQuery ? [cleanRawQuery] : []),
|
|
29
|
+
].join(" ");
|
|
30
|
+
}
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
cleanupConversation,
|
|
35
35
|
} from "./permission-bridge";
|
|
36
36
|
import { getWorkspaceContext } from "@/lib/environment/workspace-context";
|
|
37
|
+
import type { ResolvedExecutionTarget } from "@/lib/agents/runtime/execution-target";
|
|
37
38
|
|
|
38
39
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
39
40
|
|
|
@@ -57,7 +58,8 @@ function asString(v: unknown): string | null {
|
|
|
57
58
|
export async function* sendCodexMessage(
|
|
58
59
|
conversationId: string,
|
|
59
60
|
userContent: string,
|
|
60
|
-
signal?: AbortSignal
|
|
61
|
+
signal?: AbortSignal,
|
|
62
|
+
targetOverride?: ResolvedExecutionTarget
|
|
61
63
|
): AsyncGenerator<ChatStreamEvent> {
|
|
62
64
|
const conversation = await getConversation(conversationId);
|
|
63
65
|
if (!conversation) {
|
|
@@ -65,7 +67,7 @@ export async function* sendCodexMessage(
|
|
|
65
67
|
return;
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
const runtimeId = conversation.runtimeId;
|
|
70
|
+
const runtimeId = targetOverride?.effectiveRuntimeId ?? conversation.runtimeId;
|
|
69
71
|
const providerId = getProviderForRuntime(runtimeId);
|
|
70
72
|
|
|
71
73
|
// Enforce budget
|
|
@@ -187,8 +189,10 @@ export async function* sendCodexMessage(
|
|
|
187
189
|
const availableIds = new Set(
|
|
188
190
|
(modelResponse.models ?? []).map((m: { id: string }) => m.id)
|
|
189
191
|
);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
+
const requestedModelId =
|
|
193
|
+
targetOverride?.effectiveModelId ?? conversation.modelId;
|
|
194
|
+
if (requestedModelId && availableIds.has(requestedModelId)) {
|
|
195
|
+
validatedModel = requestedModelId;
|
|
192
196
|
}
|
|
193
197
|
// If not available, validatedModel stays undefined → Codex uses its default
|
|
194
198
|
} catch {
|
|
@@ -376,7 +380,18 @@ export async function* sendCodexMessage(
|
|
|
376
380
|
|
|
377
381
|
// Save usage metadata
|
|
378
382
|
const metadata = JSON.stringify({
|
|
379
|
-
modelId:
|
|
383
|
+
modelId:
|
|
384
|
+
usage.modelId ??
|
|
385
|
+
targetOverride?.effectiveModelId ??
|
|
386
|
+
conversation.modelId,
|
|
387
|
+
runtimeId,
|
|
388
|
+
requestedRuntimeId:
|
|
389
|
+
targetOverride?.requestedRuntimeId ?? conversation.runtimeId,
|
|
390
|
+
requestedModelId:
|
|
391
|
+
targetOverride?.requestedModelId ?? conversation.modelId,
|
|
392
|
+
...(targetOverride?.fallbackReason
|
|
393
|
+
? { fallbackReason: targetOverride.fallbackReason }
|
|
394
|
+
: {}),
|
|
380
395
|
inputTokens: usage.inputTokens,
|
|
381
396
|
outputTokens: usage.outputTokens,
|
|
382
397
|
...(quickAccess.length > 0 ? { quickAccess } : {}),
|
|
@@ -393,7 +408,11 @@ export async function* sendCodexMessage(
|
|
|
393
408
|
activityType: "chat_turn",
|
|
394
409
|
runtimeId,
|
|
395
410
|
providerId,
|
|
396
|
-
modelId:
|
|
411
|
+
modelId:
|
|
412
|
+
usage.modelId ??
|
|
413
|
+
targetOverride?.effectiveModelId ??
|
|
414
|
+
conversation.modelId ??
|
|
415
|
+
null,
|
|
397
416
|
inputTokens: usage.inputTokens ?? null,
|
|
398
417
|
outputTokens: usage.outputTokens ?? null,
|
|
399
418
|
totalTokens: usage.totalTokens ?? null,
|
|
@@ -413,7 +432,11 @@ export async function* sendCodexMessage(
|
|
|
413
432
|
activityType: "chat_turn",
|
|
414
433
|
runtimeId,
|
|
415
434
|
providerId,
|
|
416
|
-
modelId:
|
|
435
|
+
modelId:
|
|
436
|
+
usage.modelId ??
|
|
437
|
+
targetOverride?.effectiveModelId ??
|
|
438
|
+
conversation.modelId ??
|
|
439
|
+
null,
|
|
417
440
|
inputTokens: usage.inputTokens ?? null,
|
|
418
441
|
outputTokens: usage.outputTokens ?? null,
|
|
419
442
|
totalTokens: usage.totalTokens ?? null,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ToolCatalogEntry, ToolGroup } from "./tool-catalog";
|
|
2
|
+
|
|
3
|
+
export const COMMAND_TAB_IDS = ["actions", "skills", "tools", "entities"] as const;
|
|
4
|
+
export type CommandTabId = (typeof COMMAND_TAB_IDS)[number];
|
|
5
|
+
|
|
6
|
+
export interface CommandTab {
|
|
7
|
+
id: CommandTabId;
|
|
8
|
+
label: string;
|
|
9
|
+
shortcut: string; // ⌘1..⌘4
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const COMMAND_TABS: CommandTab[] = [
|
|
13
|
+
{ id: "actions", label: "Actions", shortcut: "⌘1" },
|
|
14
|
+
{ id: "skills", label: "Skills", shortcut: "⌘2" },
|
|
15
|
+
{ id: "tools", label: "Tools", shortcut: "⌘3" },
|
|
16
|
+
{ id: "entities", label: "Entities", shortcut: "⌘4" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_COMMAND_TAB: CommandTabId = "actions";
|
|
20
|
+
|
|
21
|
+
export const GROUP_TO_TAB = {
|
|
22
|
+
// Stagent actions / session primitives
|
|
23
|
+
Session: "actions",
|
|
24
|
+
Tasks: "actions",
|
|
25
|
+
Projects: "actions",
|
|
26
|
+
Workflows: "actions",
|
|
27
|
+
Schedules: "actions",
|
|
28
|
+
Documents: "actions",
|
|
29
|
+
Tables: "actions",
|
|
30
|
+
Notifications: "actions",
|
|
31
|
+
Profiles: "actions",
|
|
32
|
+
Usage: "actions",
|
|
33
|
+
Settings: "actions",
|
|
34
|
+
Chat: "actions",
|
|
35
|
+
// Skills
|
|
36
|
+
Skills: "skills",
|
|
37
|
+
// Tools (filesystem / system / utility)
|
|
38
|
+
Browser: "tools",
|
|
39
|
+
Utility: "tools",
|
|
40
|
+
} satisfies Record<ToolGroup, CommandTabId>;
|
|
41
|
+
|
|
42
|
+
export function isCommandTabId(value: string): value is CommandTabId {
|
|
43
|
+
return (COMMAND_TAB_IDS as readonly string[]).includes(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PartitionedCatalog {
|
|
47
|
+
actions: ToolCatalogEntry[];
|
|
48
|
+
skills: ToolCatalogEntry[];
|
|
49
|
+
tools: ToolCatalogEntry[];
|
|
50
|
+
entities: ToolCatalogEntry[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function partitionCatalogByTab(
|
|
54
|
+
catalog: ToolCatalogEntry[]
|
|
55
|
+
): PartitionedCatalog {
|
|
56
|
+
const out: PartitionedCatalog = { actions: [], skills: [], tools: [], entities: [] };
|
|
57
|
+
for (const entry of catalog) {
|
|
58
|
+
out[GROUP_TO_TAB[entry.group]].push(entry);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
@@ -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
|
+
}
|