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.
Files changed (176) hide show
  1. package/README.md +44 -31
  2. package/dist/cli.js +24 -0
  3. package/docs/.coverage-gaps.json +154 -24
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/agent-intelligence.md +12 -2
  6. package/docs/features/chat.md +40 -5
  7. package/docs/features/cost-usage.md +1 -1
  8. package/docs/features/documents.md +5 -2
  9. package/docs/features/inbox-notifications.md +10 -2
  10. package/docs/features/keyboard-navigation.md +12 -3
  11. package/docs/features/provider-runtimes.md +16 -2
  12. package/docs/features/settings.md +2 -2
  13. package/docs/features/shared-components.md +7 -3
  14. package/docs/features/tables.md +3 -1
  15. package/docs/features/tool-permissions.md +6 -2
  16. package/docs/features/workflows.md +6 -2
  17. package/docs/getting-started.md +1 -1
  18. package/docs/index.md +1 -1
  19. package/docs/journeys/developer.md +25 -2
  20. package/docs/journeys/personal-use.md +12 -5
  21. package/docs/journeys/power-user.md +45 -14
  22. package/docs/journeys/work-use.md +17 -8
  23. package/docs/manifest.json +15 -15
  24. package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +2 -2
  25. package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
  26. package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
  27. package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
  28. package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
  29. package/next.config.mjs +1 -0
  30. package/package.json +3 -3
  31. package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
  32. package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
  33. package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
  34. package/src/app/api/chat/export/route.ts +52 -0
  35. package/src/app/api/chat/files/search/route.ts +50 -0
  36. package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
  37. package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
  38. package/src/app/api/environment/skills/route.ts +13 -0
  39. package/src/app/api/schedules/[id]/execute/route.ts +2 -2
  40. package/src/app/api/settings/chat/pins/route.ts +94 -0
  41. package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
  42. package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
  43. package/src/app/api/settings/environment/route.ts +26 -0
  44. package/src/app/api/tasks/[id]/execute/route.ts +52 -12
  45. package/src/app/api/tasks/[id]/respond/route.ts +31 -15
  46. package/src/app/api/tasks/[id]/resume/route.ts +24 -3
  47. package/src/app/documents/page.tsx +4 -1
  48. package/src/app/settings/page.tsx +2 -0
  49. package/src/components/book/content-blocks.tsx +1 -1
  50. package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
  51. package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
  52. package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
  53. package/src/components/chat/capability-banner.tsx +68 -0
  54. package/src/components/chat/chat-command-popover.tsx +668 -47
  55. package/src/components/chat/chat-input.tsx +103 -8
  56. package/src/components/chat/chat-message.tsx +12 -3
  57. package/src/components/chat/chat-session-provider.tsx +73 -3
  58. package/src/components/chat/chat-shell.tsx +62 -3
  59. package/src/components/chat/command-tab-bar.tsx +68 -0
  60. package/src/components/chat/conversation-template-picker.tsx +421 -0
  61. package/src/components/chat/help-dialog.tsx +39 -0
  62. package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
  63. package/src/components/chat/skill-row.tsx +147 -0
  64. package/src/components/documents/document-browser.tsx +37 -19
  65. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
  66. package/src/components/notifications/permission-response-actions.tsx +155 -1
  67. package/src/components/playbook/playbook-detail-view.tsx +1 -1
  68. package/src/components/settings/environment-section.tsx +102 -0
  69. package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
  70. package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
  71. package/src/components/shared/command-palette.tsx +262 -2
  72. package/src/components/shared/filter-hint.tsx +70 -0
  73. package/src/components/shared/filter-input.tsx +59 -0
  74. package/src/components/shared/saved-searches-manager.tsx +199 -0
  75. package/src/components/tasks/task-bento-grid.tsx +12 -2
  76. package/src/components/tasks/task-card.tsx +3 -0
  77. package/src/components/tasks/task-chip-bar.tsx +30 -1
  78. package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
  79. package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
  80. package/src/hooks/use-active-skills.ts +110 -0
  81. package/src/hooks/use-chat-autocomplete.ts +120 -7
  82. package/src/hooks/use-enriched-skills.ts +19 -0
  83. package/src/hooks/use-pinned-entries.ts +104 -0
  84. package/src/hooks/use-recent-user-messages.ts +19 -0
  85. package/src/hooks/use-saved-searches.ts +142 -0
  86. package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
  87. package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
  88. package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
  89. package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
  90. package/src/lib/agents/claude-agent.ts +105 -46
  91. package/src/lib/agents/handoff/bus.ts +2 -2
  92. package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
  93. package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
  94. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
  95. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
  96. package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
  97. package/src/lib/agents/profiles/registry.ts +97 -22
  98. package/src/lib/agents/profiles/types.ts +7 -1
  99. package/src/lib/agents/router.ts +3 -6
  100. package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
  101. package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
  102. package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
  103. package/src/lib/agents/runtime/catalog.ts +121 -0
  104. package/src/lib/agents/runtime/claude-sdk.ts +32 -0
  105. package/src/lib/agents/runtime/execution-target.ts +456 -0
  106. package/src/lib/agents/runtime/index.ts +4 -0
  107. package/src/lib/agents/runtime/launch-failure.ts +101 -0
  108. package/src/lib/agents/runtime/openai-codex.ts +35 -0
  109. package/src/lib/agents/runtime/openai-direct.ts +8 -0
  110. package/src/lib/agents/task-dispatch.ts +220 -0
  111. package/src/lib/agents/tool-permissions.ts +16 -1
  112. package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
  113. package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
  114. package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
  115. package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
  116. package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
  117. package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
  118. package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
  119. package/src/lib/chat/__tests__/types.test.ts +28 -0
  120. package/src/lib/chat/active-skills.ts +31 -0
  121. package/src/lib/chat/clean-filter-input.ts +30 -0
  122. package/src/lib/chat/codex-engine.ts +30 -7
  123. package/src/lib/chat/command-tabs.ts +61 -0
  124. package/src/lib/chat/context-builder.ts +141 -1
  125. package/src/lib/chat/dismissals.ts +73 -0
  126. package/src/lib/chat/engine.ts +109 -15
  127. package/src/lib/chat/files/__tests__/search.test.ts +135 -0
  128. package/src/lib/chat/files/expand-mention.ts +76 -0
  129. package/src/lib/chat/files/search.ts +99 -0
  130. package/src/lib/chat/skill-composition.ts +210 -0
  131. package/src/lib/chat/skill-conflict.ts +105 -0
  132. package/src/lib/chat/stagent-tools.ts +6 -19
  133. package/src/lib/chat/stream-telemetry.ts +9 -4
  134. package/src/lib/chat/system-prompt.ts +22 -0
  135. package/src/lib/chat/tool-catalog.ts +33 -3
  136. package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
  137. package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
  138. package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
  139. package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
  140. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
  141. package/src/lib/chat/tools/blueprint-tools.ts +190 -0
  142. package/src/lib/chat/tools/helpers.ts +2 -0
  143. package/src/lib/chat/tools/profile-tools.ts +120 -23
  144. package/src/lib/chat/tools/skill-tools.ts +183 -0
  145. package/src/lib/chat/tools/task-tools.ts +6 -2
  146. package/src/lib/chat/tools/workflow-tools.ts +61 -20
  147. package/src/lib/chat/types.ts +15 -0
  148. package/src/lib/constants/settings.ts +2 -0
  149. package/src/lib/data/clear.ts +2 -6
  150. package/src/lib/db/bootstrap.ts +17 -0
  151. package/src/lib/db/schema.ts +26 -0
  152. package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
  153. package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
  154. package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
  155. package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
  156. package/src/lib/environment/data.ts +9 -0
  157. package/src/lib/environment/list-skills.ts +176 -0
  158. package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
  159. package/src/lib/environment/parsers/skill.ts +26 -5
  160. package/src/lib/environment/profile-generator.ts +56 -2
  161. package/src/lib/environment/skill-enrichment.ts +106 -0
  162. package/src/lib/environment/skill-recommendations.ts +66 -0
  163. package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
  164. package/src/lib/filters/__tests__/parse.test.ts +135 -0
  165. package/src/lib/filters/parse.ts +86 -0
  166. package/src/lib/instance/__tests__/detect.test.ts +1 -1
  167. package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
  168. package/src/lib/instance/fingerprint.ts +8 -10
  169. package/src/lib/instance/upgrade-poller.ts +53 -1
  170. package/src/lib/schedules/scheduler.ts +4 -4
  171. package/src/lib/utils/stagent-paths.ts +4 -0
  172. package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
  173. package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
  174. package/src/lib/workflows/blueprints/types.ts +6 -0
  175. package/src/lib/workflows/engine.ts +5 -3
  176. 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
- if (conversation.modelId && availableIds.has(conversation.modelId)) {
191
- validatedModel = conversation.modelId;
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: usage.modelId ?? conversation.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: usage.modelId ?? conversation.modelId ?? null,
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: usage.modelId ?? conversation.modelId ?? null,
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
+ }