@geraldmaron/construct 1.2.1 → 1.2.3

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 (208) hide show
  1. package/README.md +6 -6
  2. package/apps/chat/engine/ai-sdk-agent.mjs +183 -0
  3. package/apps/chat/engine/loop-driver.mjs +211 -0
  4. package/apps/chat/engine/models.mjs +122 -0
  5. package/apps/chat/engine/provider-adapters.mjs +171 -0
  6. package/apps/chat/engine/tools/permission.mjs +54 -0
  7. package/apps/chat/engine/tools/primitives.mjs +180 -0
  8. package/apps/chat/engine/tools/registry.mjs +122 -0
  9. package/apps/chat/engine/turn-controls.mjs +70 -0
  10. package/bin/construct +93 -79
  11. package/commands/plan/decide.md +1 -1
  12. package/commands/remember/runbook.md +1 -1
  13. package/examples/README.md +1 -1
  14. package/examples/distribution/README.md +1 -1
  15. package/examples/distribution/sources/adr.md +2 -2
  16. package/examples/distribution/sources/deck-one-pager.md +1 -1
  17. package/examples/distribution/sources/prd-platform.md +1 -1
  18. package/examples/distribution/sources/research-brief.md +2 -2
  19. package/examples/distribution/sources/rfc-platform.md +1 -1
  20. package/examples/distribution/sources/runbook.md +2 -2
  21. package/examples/distribution/sources/strategy.md +1 -1
  22. package/lib/agent-instructions/inject.mjs +1 -1
  23. package/lib/artifact-type-from-path.mjs +9 -5
  24. package/lib/audit-specialists.mjs +1 -1
  25. package/lib/audit-trail.mjs +3 -3
  26. package/lib/auto-docs.mjs +10 -10
  27. package/lib/bootstrap/built-ins.mjs +3 -3
  28. package/lib/boundary.mjs +3 -2
  29. package/lib/brand-prose.mjs +1 -1
  30. package/lib/cache-governor.js +4 -4
  31. package/lib/certification/document-io-fixtures.mjs +1 -1
  32. package/lib/chat/config.mjs +2 -2
  33. package/lib/chat/desktop-binary.mjs +5 -3
  34. package/lib/cli-commands.mjs +4 -6
  35. package/lib/cli-service-inventory.mjs +1 -1
  36. package/lib/comment-lint.mjs +9 -5
  37. package/lib/config/alias.mjs +3 -2
  38. package/lib/config/intake-policy.mjs +8 -78
  39. package/lib/config/legacy-config-migration.mjs +59 -0
  40. package/lib/config/project-config.mjs +95 -5
  41. package/lib/config/schema.mjs +9 -19
  42. package/lib/config/xdg.mjs +63 -0
  43. package/lib/contracts/violation-log.mjs +2 -2
  44. package/lib/cost-ledger.mjs +4 -3
  45. package/lib/cost.mjs +3 -2
  46. package/lib/decisions/registry.mjs +3 -3
  47. package/lib/distill.mjs +2 -2
  48. package/lib/docs-verify.mjs +2 -0
  49. package/lib/doctor/audit.mjs +4 -3
  50. package/lib/doctor/cli.mjs +0 -1
  51. package/lib/doctor/index.mjs +4 -4
  52. package/lib/doctor/report.mjs +3 -3
  53. package/lib/doctor/watchers/bd-watch.mjs +5 -5
  54. package/lib/doctor/watchers/cost.mjs +4 -4
  55. package/lib/doctor/watchers/disk.mjs +4 -3
  56. package/lib/doctor/watchers/service-health.mjs +5 -5
  57. package/lib/document-export.mjs +1 -1
  58. package/lib/document-extract.mjs +3 -3
  59. package/lib/document-ingest.mjs +13 -3
  60. package/lib/efficiency.mjs +2 -1
  61. package/lib/embed/approval-queue.mjs +3 -2
  62. package/lib/embed/cli.mjs +7 -5
  63. package/lib/embed/config.mjs +6 -5
  64. package/lib/embed/daemon.mjs +6 -5
  65. package/lib/embed/inbox-live-watcher.mjs +1 -1
  66. package/lib/embed/inbox.mjs +52 -37
  67. package/lib/embed/intake-metrics.mjs +3 -2
  68. package/lib/embed/reconcile.mjs +1 -1
  69. package/lib/embed/semantic.mjs +3 -2
  70. package/lib/embed/supervision.mjs +4 -2
  71. package/lib/engine/registry.mjs +3 -3
  72. package/lib/env-config.mjs +34 -12
  73. package/lib/features.mjs +4 -3
  74. package/lib/health-check.mjs +7 -6
  75. package/lib/hook-health.mjs +5 -4
  76. package/lib/hooks/_lib/log.mjs +4 -3
  77. package/lib/hooks/_lib/output-mode.mjs +2 -1
  78. package/lib/hooks/agent-tracker.mjs +3 -4
  79. package/lib/hooks/audit-reads.mjs +2 -2
  80. package/lib/hooks/audit-trail.mjs +2 -2
  81. package/lib/hooks/bash-output-logger.mjs +3 -3
  82. package/lib/hooks/brand-prose-lint.mjs +1 -1
  83. package/lib/hooks/ci-status-check.mjs +3 -2
  84. package/lib/hooks/context-watch.mjs +2 -2
  85. package/lib/hooks/context-window-recovery.mjs +5 -5
  86. package/lib/hooks/dep-audit.mjs +2 -2
  87. package/lib/hooks/doc-coupling-check.mjs +3 -2
  88. package/lib/hooks/edit-accumulator.mjs +3 -2
  89. package/lib/hooks/edit-guard.mjs +3 -3
  90. package/lib/hooks/guard-bash.mjs +2 -2
  91. package/lib/hooks/model-fallback.mjs +2 -1
  92. package/lib/hooks/policy-engine.mjs +4 -3
  93. package/lib/hooks/pre-compact.mjs +7 -7
  94. package/lib/hooks/readme-age-check.mjs +2 -2
  95. package/lib/hooks/session-optimize.mjs +4 -3
  96. package/lib/hooks/session-start.mjs +6 -5
  97. package/lib/hooks/stop-notify.mjs +9 -8
  98. package/lib/hooks/stop-typecheck.mjs +4 -3
  99. package/lib/hooks/test-watch.mjs +2 -2
  100. package/lib/host-disposition.mjs +1 -1
  101. package/lib/ingest/provider-extract.mjs +2 -1
  102. package/lib/init/detect-existing-structure.mjs +2 -2
  103. package/lib/init/doc-lanes.mjs +3 -10
  104. package/lib/init-docs.mjs +12 -54
  105. package/lib/init-unified.mjs +35 -81
  106. package/lib/init.mjs +7 -5
  107. package/lib/install/desktop-binary-download.mjs +5 -2
  108. package/lib/intake/daemon.mjs +31 -8
  109. package/lib/intake/intake-config.mjs +5 -32
  110. package/lib/integrations/intake-integrations.mjs +8 -7
  111. package/lib/knowledge/rag.mjs +2 -2
  112. package/lib/knowledge/search.mjs +15 -15
  113. package/lib/maintenance/cleanup.mjs +14 -10
  114. package/lib/mcp/server.mjs +1 -1
  115. package/lib/mcp/tools/project.mjs +2 -1
  116. package/lib/model-cheapest-provider.mjs +4 -3
  117. package/lib/model-pricing.mjs +3 -2
  118. package/lib/model-router.mjs +4 -3
  119. package/lib/models/catalog.mjs +2 -1
  120. package/lib/models/execution-capability-profile.mjs +2 -1
  121. package/lib/models/provider-poll.mjs +2 -1
  122. package/lib/ollama-manager.mjs +0 -1
  123. package/lib/op-log.mjs +2 -1
  124. package/lib/opencode-runtime-plugin.mjs +3 -2
  125. package/lib/oracle/artifact-gate.mjs +3 -0
  126. package/lib/oracle/cli.mjs +2 -1
  127. package/lib/oracle/execute.mjs +3 -2
  128. package/lib/oracle/index.mjs +2 -1
  129. package/lib/oracle/read-model.mjs +3 -2
  130. package/lib/performance/generate.mjs +4 -3
  131. package/lib/platforms/capabilities.mjs +1 -1
  132. package/lib/plugin-registry.mjs +2 -1
  133. package/lib/profiles/lifecycle.mjs +3 -3
  134. package/lib/project-profile.mjs +2 -1
  135. package/lib/project-root.mjs +9 -7
  136. package/lib/provider-capabilities.js +4 -3
  137. package/lib/providers/auth-manager.mjs +2 -1
  138. package/lib/providers/copilot-auth.mjs +3 -2
  139. package/lib/providers/creds.mjs +3 -2
  140. package/lib/providers/registry.mjs +3 -3
  141. package/lib/providers/secret-resolver.mjs +3 -2
  142. package/lib/read-tracker-store.mjs +2 -1
  143. package/lib/reconcile/mcp-entry-reconcile.mjs +2 -2
  144. package/lib/reflect.mjs +2 -1
  145. package/lib/registry/generate-docs.mjs +2 -2
  146. package/lib/roles/approval-surface.mjs +5 -4
  147. package/lib/roles/event-bus.mjs +0 -1
  148. package/lib/roles/gateway.mjs +6 -2
  149. package/lib/rules-delivery.mjs +1 -1
  150. package/lib/runtime-env.mjs +1 -1
  151. package/lib/runtime-pressure.mjs +5 -3
  152. package/lib/sandbox.mjs +3 -2
  153. package/lib/scheduler/solo.mjs +6 -4
  154. package/lib/server/auth.mjs +4 -3
  155. package/lib/server/index.mjs +24 -26
  156. package/lib/server/insights.mjs +5 -4
  157. package/lib/server/webhook.mjs +2 -1
  158. package/lib/service-manager.mjs +5 -3
  159. package/lib/setup.mjs +21 -11
  160. package/lib/status.mjs +6 -5
  161. package/lib/storage/embeddings-local.mjs +3 -2
  162. package/lib/storage/sync.mjs +2 -2
  163. package/lib/telemetry/client.mjs +0 -1
  164. package/lib/telemetry/intent-verifications.mjs +6 -6
  165. package/lib/telemetry/model-pricing-catalog.mjs +4 -3
  166. package/lib/telemetry/rule-calls.mjs +3 -3
  167. package/lib/telemetry/skill-calls.mjs +3 -3
  168. package/lib/template-registry.mjs +1 -0
  169. package/lib/templates/visual-requirements.mjs +1 -1
  170. package/lib/test-corpus-inventory.mjs +1 -1
  171. package/lib/uninstall/uninstall.mjs +12 -10
  172. package/package.json +4 -3
  173. package/platforms/claude/settings.template.json +43 -43
  174. package/rules/common/no-fabrication.md +1 -1
  175. package/rules/common/release-gates.md +1 -1
  176. package/rules/common/research.md +1 -1
  177. package/rules/common/review-before-change.md +1 -1
  178. package/scripts/sync-specialists.mjs +11 -8
  179. package/skills/docs/adr-workflow.md +3 -3
  180. package/skills/docs/init-docs.md +9 -9
  181. package/skills/docs/prd-workflow.md +5 -5
  182. package/skills/docs/product-intelligence-review.md +1 -1
  183. package/skills/docs/product-intelligence-workflow.md +2 -2
  184. package/skills/docs/product-signal-workflow.md +1 -1
  185. package/skills/docs/runbook-workflow.md +4 -4
  186. package/skills/exploration/unknown-codebase-onboarding.md +1 -1
  187. package/skills/operating/orchestration-reference.md +1 -1
  188. package/skills/routing.md +3 -3
  189. package/specialists/prompts/cx-architect.md +1 -1
  190. package/specialists/prompts/cx-docs-keeper.md +1 -1
  191. package/specialists/prompts/cx-researcher.md +1 -1
  192. package/specialists/prompts/cx-sre.md +1 -1
  193. package/specialists/role-manifests.json +6 -6
  194. package/templates/docs/README.md +125 -0
  195. package/templates/docs/construct_guide.md +3 -4
  196. package/templates/docs/persona-artifact.md +1 -1
  197. package/templates/docs/prds/README.md +25 -0
  198. package/templates/docs/prds/templates/_template.md +206 -0
  199. package/templates/docs/prds/templates/meta-prd.template.md +177 -0
  200. package/templates/docs/prds/templates/prd-business.template.md +61 -0
  201. package/templates/docs/prds/templates/prd-platform.template.md +81 -0
  202. package/templates/docs/prds/templates/prfaq.template.md +38 -0
  203. package/templates/docs/rfcs/README.md +22 -0
  204. package/templates/docs/rfcs/templates/_template.md +58 -0
  205. package/templates/docs/rfcs/templates/rfc-platform.template.md +72 -0
  206. package/templates/homebrew/construct.rb +1 -1
  207. package/templates/workflows/new-feature.yml +8 -8
  208. package/lib/intake/legacy-paths.mjs +0 -5
package/README.md CHANGED
@@ -28,7 +28,7 @@ Bootstrap local services (once per machine, opt-in to machine-scope writes):
28
28
  construct install --scope=user --yes
29
29
  ```
30
30
 
31
- `construct install` defaults to `--scope=project`, which writes nothing and prints scope guidance — see the [footprint contract](#footprint-contract) below or [ADR 0029](docs/adr/0029-install-scopes-and-hook-budgets.md). Use `--scope=user` for machine setup, `--scope=both` for both.
31
+ `construct install` defaults to `--scope=project`, which writes nothing and prints scope guidance — see the [footprint contract](#footprint-contract) below or [ADR 0029](docs/decisions/adr/0029-install-scopes-and-hook-budgets.md). Use `--scope=user` for machine setup, `--scope=both` for both.
32
32
 
33
33
  Initialize a project:
34
34
 
@@ -87,7 +87,7 @@ Pick or change modes with `construct config mode [solo|team|enterprise]`. [Deplo
87
87
 
88
88
  ## Intake
89
89
 
90
- Anything dropped into `.cx/inbox/` (a bug report, a customer comment, a competitor PDF, a postmortem draft) is classified by the active profile's intake taxonomy. The default `rnd` profile uses bug, user-signal, experiment, architecture, incident, security, requirement, research, ops, eval-finding, launch-asset, legal-compliance. The `operations` profile uses request, incident, ops, security, docs. The `creative` profile uses brief, content-request, asset, experiment, report. The `research` profile uses question, study, synthesis, report.
90
+ Anything dropped into `inbox/` (a bug report, a customer comment, a competitor PDF, a postmortem draft) is classified by the active profile's intake taxonomy. The default `rnd` profile uses bug, user-signal, experiment, architecture, incident, security, requirement, research, ops, eval-finding, launch-asset, legal-compliance. The `operations` profile uses request, incident, ops, security, docs. The `creative` profile uses brief, content-request, asset, experiment, report. The `research` profile uses question, study, synthesis, report.
91
91
 
92
92
  Each signal gets a primary owner and a recommended handoff chain. Inspect with `construct intake list` and `construct intake show <id>`. Generate a task graph with `construct graph from-intake <id>`. The classifier runs in the daemon and is deterministic. The agent in your editor does the actual analysis. [Intake and triage](https://geraldmaron.github.io/construct/concepts/intake-and-triage).
93
93
 
@@ -111,11 +111,11 @@ Construct's writes are scoped and disclosed up front. The default `construct ins
111
111
  | Machine | `construct install --scope=user` | `~/.construct/config.env`, `~/.construct/lib` (symlink), `~/.construct/services/`, `~/Library/LaunchAgents/` (macOS), MCP entries in `~/.config/opencode/opencode.json` and `~/.codex/config.toml`, marker block in `~/.claude/CLAUDE.md`, hook injection in `~/.claude/settings.json` (last two require interactive consent or `--yes`) |
112
112
  | Never touched | — | Shell rc files (`~/.bashrc`, `~/.zshrc`), npm global config, `git config --global` |
113
113
 
114
- Full table with file:line citations and the per-hook performance budget contract: [Architecture — Footprint contract](https://geraldmaron.github.io/construct/concepts/architecture#footprint-contract) and [ADR 0029](docs/adr/0029-install-scopes-and-hook-budgets.md).
114
+ Full table with file:line citations and the per-hook performance budget contract: [Architecture — Footprint contract](https://geraldmaron.github.io/construct/concepts/architecture#footprint-contract) and [ADR 0029](docs/decisions/adr/0029-install-scopes-and-hook-budgets.md).
115
115
 
116
116
  ## Learning loops
117
117
 
118
- Construct gets smarter on its own. Every session ends with an automatic capture: tools used, files touched, what the final reply said. That goes into `.cx/observations/` and is searchable from the next session. See [`docs/concepts/learning-loops.mdx`](./docs/concepts/learning-loops.mdx) for what's wired, what's coming, and how to turn pieces off.
118
+ Construct gets smarter on its own. Every session ends with an automatic capture: tools used, files touched, what the final reply said. That goes into `.cx/observations/` and is searchable from the next session. See [`docs/guides/concepts/learning-loops.mdx`](./docs/guides/concepts/learning-loops.mdx) for what's wired, what's coming, and how to turn pieces off.
119
119
 
120
120
  ## `.cx/` is local-only runtime state
121
121
 
@@ -226,7 +226,7 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
226
226
  | `construct doc` | Verify or inspect auditability stamps on Construct-generated markdown files |
227
227
  | `construct docs:check` | Check for missing how-to guides (alias for `docs check`) |
228
228
  | `construct docs:reconcile` | Reconcile docs against the registry |
229
- | `construct docs:site` | Regenerate generated reference pages under docs/reference/ |
229
+ | `construct docs:site` | Regenerate generated reference pages under docs/guides/reference/ |
230
230
  | `construct docs:update` | Regenerate AUTO-managed doc regions (alias for `docs update`) |
231
231
  | `construct docs:verify` | Validate documentation quality (alias for `docs verify`) |
232
232
  | `construct impact` | Change-impact analysis — map changed files to affected tests, capabilities, and workflows |
@@ -269,7 +269,7 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
269
269
 
270
270
  - [`CONTRIBUTING.md`](./CONTRIBUTING.md). Branch workflow, gates, review expectations.
271
271
  - [`CHANGELOG.md`](./CHANGELOG.md). Release history.
272
- - [`docs/concepts/architecture.mdx`](./docs/concepts/architecture.mdx). Canonical architecture.
272
+ - [`docs/guides/concepts/architecture.mdx`](./docs/guides/concepts/architecture.mdx). Canonical architecture.
273
273
  - [`AGENTS.md`](./AGENTS.md). Agent operating contract.
274
274
 
275
275
  ## Project structure
@@ -0,0 +1,183 @@
1
+ /**
2
+ * apps/chat/engine/ai-sdk-agent.mjs — the real owned-loop engine (Vercel AI SDK).
3
+ *
4
+ * Lazy-loaded by the launcher only when `construct chat` runs the rich/owned path,
5
+ * so the optional dependencies (`ai`, `@ai-sdk/*`, `zod`) never load in the zero-dep
6
+ * core or in tests of the mapping layer. It resolves a Construct model id (the
7
+ * router's `provider/model` form) to an AI SDK language model through the adapter
8
+ * registry (provider-adapters.mjs), builds the agent tool set from the tool
9
+ * registry, and runs streamText under the turn's compiled execution policy
10
+ * (turn-controls.mjs): the step cap, tool-group / schema budget, output cap, and
11
+ * caching eligibility all derive from the resolved capability profile, so the loop
12
+ * adapts to the model while staying behavior-preserving for hosted-direct. It
13
+ * yields the SDK fullStream parts unchanged; loop-driver.mjs owns the
14
+ * normalization into the event union.
15
+ */
16
+
17
+ import { listChatModels } from './models.mjs';
18
+ import { resolveLanguageModel } from './provider-adapters.mjs';
19
+ import { resolveTurnControls } from './turn-controls.mjs';
20
+ import { buildSystemPrompt } from '../../../lib/chat/system-prompt.mjs';
21
+ import { applyToolBudget } from '../../../lib/mcp/tool-budget.mjs';
22
+ import { recordPolicyTelemetry } from '../../../lib/chat/policy-telemetry.mjs';
23
+ import { maybeCompact, estimateTextTokens, messageText } from '../../../lib/chat/context-compactor.mjs';
24
+
25
+ // Real summarization of the compactible layers (tool results, prior assistant
26
+ // reasoning) the contract elides, run on the same language model as the turn.
27
+ // Bounded output and zero retries keep the once-per-compaction cost small; a throw
28
+ // is caught upstream and falls back to the deterministic extractive summary, so a
29
+ // summarizer failure degrades fidelity without breaking the turn.
30
+
31
+ function makeSummarizer({ generateText, languageModel, signal }) {
32
+ return async (text, meta = {}) => {
33
+ const { text: summary } = await generateText({
34
+ model: languageModel,
35
+ system: 'Compress earlier agent work into a faithful, terse recap for context continuation. Preserve decisions, findings, file paths, identifiers, and unresolved threads. Never invent facts. No preamble.',
36
+ prompt: `Summarize these ${meta.segmentCount || 'earlier'} turn(s) of an agent working in a repository. Use compact bullets, keep concrete identifiers, stay under 200 words:\n\n${text}`,
37
+ maxOutputTokens: 512,
38
+ maxRetries: 0,
39
+ abortSignal: signal,
40
+ });
41
+ return summary;
42
+ };
43
+ }
44
+
45
+ export async function createAiSdkAgent({ env = process.env, cwd = process.cwd(), model = null, handlers = {}, systemPrompt = '', tools = null, onTelemetry = recordPolicyTelemetry } = {}) {
46
+ const { streamText, stepCountIs, generateText } = await import('ai');
47
+
48
+ const { buildAgentTools } = await import('./tools/registry.mjs');
49
+ const sdkTools = await buildAgentTools({ env, cwd, handlers, only: tools });
50
+
51
+ const messages = [];
52
+ const languageModels = new Map();
53
+ let activeModelId = model;
54
+ let contextTokens = 0;
55
+
56
+ async function languageModelFor(modelId) {
57
+ const id = modelId || activeModelId;
58
+ if (!id) {
59
+ const err = new Error('No model selected and no configured provider found. Run `construct models` or set CX_MODEL_STANDARD.');
60
+ err.code = 'PROVIDER_MODEL_UNRESOLVED';
61
+ throw err;
62
+ }
63
+ if (!languageModels.has(id)) {
64
+ languageModels.set(id, await resolveLanguageModel(id, env));
65
+ }
66
+ return languageModels.get(id);
67
+ }
68
+
69
+ return {
70
+ sessionId: `construct-${Date.now()}`,
71
+ model,
72
+ listModels: () => listChatModels({ env }),
73
+ async *streamTurn(text, { signal, model: turnModel = null, turnOverlay = null } = {}) {
74
+ if (turnModel) activeModelId = turnModel;
75
+ const languageModel = await languageModelFor(activeModelId);
76
+
77
+ const controls = resolveTurnControls({ model: activeModelId, turnOverlay, env });
78
+ if (controls.degraded) {
79
+ onTelemetry({
80
+ kind: 'execution-policy-degraded',
81
+ model: activeModelId,
82
+ capabilityClass: controls.policy?.source?.capabilityClass || 'unknown',
83
+ reasons: controls.policy?.telemetry?.reasons || [],
84
+ });
85
+ }
86
+ const turnTools = applyToolBudget(sdkTools, {
87
+ allowedToolGroups: controls.allowedToolGroups,
88
+ maxToolSchemas: controls.maxToolSchemas,
89
+ });
90
+
91
+ // Per-turn routing policy belongs in the SYSTEM role (via buildSystemPrompt),
92
+ // never prepended to the user message: a user-role policy gets echoed in model
93
+ // reasoning and compounds in persisted history.
94
+ messages.push({ role: 'user', content: String(text) });
95
+ const systemText = buildSystemPrompt({ base: systemPrompt || undefined, overlay: turnOverlay });
96
+
97
+ const request = {
98
+ model: languageModel,
99
+ tools: turnTools,
100
+ stopWhen: stepCountIs(controls.iterations),
101
+ abortSignal: signal,
102
+ maxRetries: 0,
103
+ };
104
+ if (controls.outputCap) request.maxOutputTokens = controls.outputCap;
105
+
106
+ // A cache-eligible provider marks the stable system prefix as a cache
107
+ // breakpoint: the AI SDK maps a single leading system message onto the
108
+ // provider's system field, so the request stays output-identical and only the
109
+ // cache_control annotation is added. Every other provider keeps the plain
110
+ // system string — today's exact request shape.
111
+ if (controls.cacheEligible) {
112
+ const providerNs = String(activeModelId || '').split('/')[0] || 'anthropic';
113
+ request.messages = [
114
+ { role: 'system', content: systemText, providerOptions: { [providerNs]: { cacheControl: { type: 'ephemeral' } } } },
115
+ ...messages,
116
+ ];
117
+ } else {
118
+ request.system = systemText;
119
+ request.messages = messages;
120
+ }
121
+
122
+ const result = streamText(request);
123
+
124
+ // The compaction trigger needs the current context SIZE, which is the last
125
+ // model call's per-step input (system + full history), not the turn's
126
+ // aggregate input — totalUsage sums input across tool steps and would
127
+ // over-count a multi-step turn, tripping compaction below the real budget.
128
+ // Prefer the last finish-step's per-call usage; fall back to the aggregate
129
+ // only when the host reports no per-step usage. Host numbers only, no split.
130
+ let lastStepUsage = null;
131
+ let aggregateUsage = null;
132
+ for await (const part of result.fullStream) {
133
+ if (part.type === 'finish-step' && part.usage) lastStepUsage = part.usage;
134
+ if (part.type === 'finish') aggregateUsage = part.totalUsage || part.usage || aggregateUsage;
135
+ yield part;
136
+ }
137
+ const turnUsage = lastStepUsage || aggregateUsage;
138
+
139
+ // Persist the assistant turn (incl. tool exchanges) so the next prompt has history.
140
+ try {
141
+ const response = await result.response;
142
+ if (Array.isArray(response?.messages)) messages.push(...response.messages);
143
+ // Surface the model that actually answered — the OpenRouter free router
144
+ // resolves to an underlying model id, not the "openrouter/free" alias.
145
+ const resolved = response?.modelId;
146
+ if (resolved && resolved !== activeModelId && !String(activeModelId).endsWith(resolved)) {
147
+ yield { type: 'model-resolved', model: resolved };
148
+ }
149
+ } catch { /* history append is best-effort */ }
150
+
151
+ // The next turn's context ≈ this turn's input (system + full history) plus the
152
+ // output just appended. When that crosses the policy's continuation trigger,
153
+ // compact without silent loss. Behavior-preserving: hosted only crosses near
154
+ // its ~150k budget, so short conversations are never touched. Compaction is
155
+ // best-effort — any failure leaves the live history intact.
156
+ const input = Number(turnUsage?.inputTokens) || 0;
157
+ const output = Number(turnUsage?.outputTokens) || 0;
158
+ if (input || output) contextTokens = input + output;
159
+
160
+ const trigger = controls.continuation?.triggerTokens || null;
161
+ if (trigger && contextTokens >= trigger) {
162
+ try {
163
+ const outcome = await maybeCompact({
164
+ messages,
165
+ systemText,
166
+ triggerTokens: trigger,
167
+ contextTokens,
168
+ summarize: makeSummarizer({ generateText, languageModel, signal }),
169
+ });
170
+ if (outcome.packet) yield { type: 'context-continuation', packet: outcome.packet, compacted: outcome.compacted };
171
+ if (outcome.notice) {
172
+ yield { type: 'context-notice', level: outcome.blocker ? 'warn' : 'info', code: outcome.blocker ? 'context-blocker' : 'context-compacted', message: outcome.notice };
173
+ }
174
+ if (outcome.compacted && Array.isArray(outcome.messages)) {
175
+ messages.length = 0;
176
+ messages.push(...outcome.messages);
177
+ contextTokens = estimateTextTokens(systemText) + messages.reduce((n, m) => n + estimateTextTokens(messageText(m)), 0);
178
+ }
179
+ } catch { /* compaction must never break a turn */ }
180
+ }
181
+ },
182
+ };
183
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * apps/chat/engine/loop-driver.mjs — Construct's owned agent loop, exposed as a
3
+ * driver implementing the normalized event union (lib/chat/harness/driver.mjs).
4
+ *
5
+ * ADR-0041 reverses the delegate-the-loop posture: Construct now runs the loop
6
+ * (prompt -> model -> tool calls -> tool results -> repeat) on a provider-agnostic
7
+ * engine (Vercel AI SDK), so every token, tool result, and routing choice is
8
+ * first-party data. This module is the seam that keeps the rest of the surface
9
+ * host-agnostic: it owns the lifecycle (start/prompt/cancel/stop), maps the
10
+ * engine's fullStream parts onto the driver event union, and accumulates per-turn
11
+ * usage from the host's own numbers (no fabricated splits).
12
+ *
13
+ * The engine itself is injected as `createAgent` so this mapping layer is tested
14
+ * against a scripted mock with no network or API key — the same dependency-
15
+ * injection discipline the retired host adapters used (fetchImpl / spawnFn). The
16
+ * real engine lives in ai-sdk-agent.mjs and is lazy-imported only when chat runs.
17
+ */
18
+
19
+ import { AsyncEventQueue } from '../../../lib/chat/harness/driver.mjs';
20
+ import { listChatModels, recommendChatModel } from './models.mjs';
21
+
22
+ // Vercel AI SDK fullStream parts use a few delta field names across versions;
23
+ // read text from any of them so the mapping survives a minor SDK bump.
24
+
25
+ function partText(part) {
26
+ if (typeof part.text === 'string') return part.text;
27
+ if (typeof part.textDelta === 'string') return part.textDelta;
28
+ if (typeof part.delta === 'string') return part.delta;
29
+ return '';
30
+ }
31
+
32
+ // Usage arrives on finish parts as inputTokens/outputTokens/etc.; normalize to
33
+ // the union's token shape, emitting only fields the engine actually reported.
34
+
35
+ function normalizeUsage(part) {
36
+ const u = part.totalUsage || part.usage || null;
37
+ if (!u) return null;
38
+ const tokens = {};
39
+ if (Number.isFinite(u.inputTokens)) tokens.input = u.inputTokens;
40
+ if (Number.isFinite(u.outputTokens)) tokens.output = u.outputTokens;
41
+ if (Number.isFinite(u.reasoningTokens)) tokens.reasoning = u.reasoningTokens;
42
+ if (Number.isFinite(u.cachedInputTokens)) tokens.cacheRead = u.cachedInputTokens;
43
+ if (Number.isFinite(u.totalTokens)) tokens.total = u.totalTokens;
44
+ else if (Number.isFinite(tokens.input) || Number.isFinite(tokens.output)) {
45
+ tokens.total = (tokens.input || 0) + (tokens.output || 0) + (tokens.reasoning || 0);
46
+ }
47
+ if (!Object.keys(tokens).length) return null;
48
+ const event = { type: 'usage', tokens };
49
+ if (part.providerMetadata?.cost && Number.isFinite(part.providerMetadata.cost.amount)) {
50
+ event.cost = { amount: part.providerMetadata.cost.amount, currency: part.providerMetadata.cost.currency || 'USD' };
51
+ }
52
+ return event;
53
+ }
54
+
55
+ // One fullStream part -> zero or more normalized driver events on the turn queue.
56
+ // Tool ids are tracked so a tool_call is emitted once and tool_update carries the
57
+ // terminal state, matching the union the renderers already consume.
58
+
59
+ function mapPart(part, queue, state) {
60
+ switch (part.type) {
61
+ case 'text-delta':
62
+ case 'text': {
63
+ const t = partText(part);
64
+ if (t) queue.push({ type: 'text', text: t, messageId: part.id || null });
65
+ break;
66
+ }
67
+ case 'reasoning-delta':
68
+ case 'reasoning': {
69
+ const t = partText(part);
70
+ if (t) queue.push({ type: 'thinking', text: t, messageId: part.id || null });
71
+ break;
72
+ }
73
+ case 'model-resolved': {
74
+ if (part.model) queue.push({ type: 'model_resolved', model: part.model });
75
+ break;
76
+ }
77
+ case 'context-notice': {
78
+ queue.push({ type: 'notice', level: part.level || 'info', code: part.code || 'context', message: part.message || '' });
79
+ break;
80
+ }
81
+ case 'context-continuation': {
82
+ queue.push({ type: 'context_continuation', packet: part.packet || null, compacted: part.compacted === true });
83
+ break;
84
+ }
85
+ case 'tool-call': {
86
+ const id = part.toolCallId || part.id;
87
+ state.tools.add(id);
88
+ queue.push({ type: 'tool_call', id, title: part.toolName || 'tool', kind: part.toolName || 'other', status: 'pending', input: part.input ?? part.args ?? null });
89
+ break;
90
+ }
91
+ case 'tool-result': {
92
+ const id = part.toolCallId || part.id;
93
+ queue.push({ type: 'tool_update', id, status: 'completed', content: part.output ?? part.result ?? null });
94
+ break;
95
+ }
96
+ case 'tool-error': {
97
+ const id = part.toolCallId || part.id;
98
+ queue.push({ type: 'tool_update', id, status: 'failed', content: part.error ? String(part.error.message || part.error) : null });
99
+ break;
100
+ }
101
+ case 'finish-step':
102
+ case 'finish': {
103
+ const usage = normalizeUsage(part);
104
+ if (usage) queue.push(usage);
105
+ break;
106
+ }
107
+ case 'error':
108
+ state.errored = true;
109
+ queue.push({ type: 'error', message: part.error ? String(part.error.message || part.error) : 'engine error' });
110
+ break;
111
+ case 'abort':
112
+ state.aborted = true;
113
+ break;
114
+ default:
115
+ break;
116
+ }
117
+ }
118
+
119
+ export function createOwnedLoopDriver({
120
+ env = process.env,
121
+ cwd = process.cwd(),
122
+ model = null,
123
+ handlers = {},
124
+ systemPrompt = '',
125
+ tools = null,
126
+ createAgent,
127
+ } = {}) {
128
+ if (typeof createAgent !== 'function') {
129
+ throw new Error('createOwnedLoopDriver requires a `createAgent` factory (real engine or test mock)');
130
+ }
131
+
132
+ let agent = null;
133
+ let activeTurn = null;
134
+ let currentModel = model || null;
135
+
136
+ async function start() {
137
+ agent = await createAgent({ env, cwd, model: currentModel, handlers, systemPrompt, tools });
138
+ if (agent?.model && !currentModel) currentModel = agent.model;
139
+ return { sessionId: agent?.sessionId || `owned-${Date.now()}`, capabilities: { host: 'construct', ownedLoop: true } };
140
+ }
141
+
142
+ function prompt(text, opts = {}) {
143
+ if (!agent) throw new Error('prompt() called before start()');
144
+ const queue = new AsyncEventQueue();
145
+ const controller = new AbortController();
146
+ const turn = { queue, controller, tools: new Set(), aborted: false, errored: false };
147
+ activeTurn = turn;
148
+ if (opts.model) currentModel = opts.model;
149
+
150
+ (async () => {
151
+ try {
152
+ const stream = await agent.streamTurn(text, {
153
+ signal: controller.signal,
154
+ model: currentModel,
155
+ turnOverlay: opts.turnOverlay ?? null,
156
+ });
157
+ for await (const part of stream) {
158
+ if (queue.closed) break;
159
+ mapPart(part, queue, turn);
160
+ }
161
+ queue.push({ type: 'done', stopReason: turn.aborted ? 'cancelled' : turn.errored ? 'error' : 'end_turn' });
162
+ } catch (err) {
163
+ if (err?.name === 'AbortError' || turn.aborted) {
164
+ queue.push({ type: 'done', stopReason: 'cancelled' });
165
+ } else {
166
+ queue.push({ type: 'error', message: err?.message || String(err) });
167
+ queue.push({ type: 'done', stopReason: 'error' });
168
+ }
169
+ } finally {
170
+ queue.close();
171
+ if (activeTurn === turn) activeTurn = null;
172
+ }
173
+ })();
174
+
175
+ return queue;
176
+ }
177
+
178
+ function cancel() {
179
+ if (activeTurn) {
180
+ activeTurn.aborted = true;
181
+ try { activeTurn.controller.abort(); } catch { /* already aborted */ }
182
+ }
183
+ }
184
+
185
+ function stop() {
186
+ try { agent?.dispose?.(); } catch { /* nothing to dispose */ }
187
+ }
188
+
189
+ async function listModels() {
190
+ if (agent?.listModels) {
191
+ try { return await agent.listModels(); } catch { /* fall through to catalog */ }
192
+ }
193
+ return listChatModels({ env });
194
+ }
195
+
196
+ async function recommendModel() {
197
+ if (currentModel) return null;
198
+ return recommendChatModel({ env });
199
+ }
200
+
201
+ return {
202
+ start,
203
+ prompt,
204
+ cancel,
205
+ stop,
206
+ listModels,
207
+ recommendModel,
208
+ get model() { return currentModel; },
209
+ get sessionId() { return agent?.sessionId || null; },
210
+ };
211
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * apps/chat/engine/models.mjs — model and provider resolution for the owned loop.
3
+ *
4
+ * The owned loop is provider-agnostic (ADR-0041/ADR-0003): model choice is never
5
+ * hardcoded. This module reuses the core router (lib/model-router.mjs) so the chat
6
+ * surface, the orchestration worker, and the embedded contract all resolve models
7
+ * the same way. resolveChatModelSelection validates CX_MODEL pins against live
8
+ * credential detection; resolveChatModelSelectionAsync also probes Copilot session
9
+ * exchange so a bad token falls through before the first turn.
10
+ */
11
+
12
+ import {
13
+ getProviderModelCatalog,
14
+ describeModelFamily,
15
+ resolveValidatedChatModel,
16
+ isChatModelAvailable,
17
+ } from '../../../lib/model-router.mjs';
18
+ import { resolveFirstSecret } from '../../../lib/providers/secret-resolver.mjs';
19
+
20
+ export function listChatModels({ env = process.env, cwd = process.cwd(), activeModelId = null } = {}) {
21
+ const { providers } = getProviderModelCatalog({ env, cwd, activeModelId });
22
+ const models = [];
23
+ const seen = new Set();
24
+ for (const provider of providers) {
25
+ for (const tier of ['reasoning', 'standard', 'fast']) {
26
+ for (const id of provider.options?.[tier] || []) {
27
+ if (seen.has(id)) continue;
28
+ seen.add(id);
29
+ models.push({
30
+ id,
31
+ label: id,
32
+ provider: provider.id,
33
+ configured: provider.configured,
34
+ local: provider.local === true,
35
+ suitable: true,
36
+ tier,
37
+ available: isChatModelAvailable(id, { env }).ok,
38
+ });
39
+ }
40
+ }
41
+ }
42
+ return models.sort((a, b) => Number(b.configured) - Number(a.configured) || a.id.localeCompare(b.id));
43
+ }
44
+
45
+ export { refreshLiveOpenRouterCatalog } from '../../../lib/models/catalog.mjs';
46
+
47
+ export function recommendChatModel({ env = process.env } = {}) {
48
+ const { providers } = getProviderModelCatalog({ env });
49
+ const configured = providers.find((p) => p.configured);
50
+ if (!configured) return null;
51
+ const id = configured.tiers?.standard || configured.tiers?.fast || null;
52
+ if (!id) return null;
53
+ const check = isChatModelAvailable(id, { env });
54
+ if (!check.ok) return null;
55
+ return { id, reason: `configured provider ${configured.label}` };
56
+ }
57
+
58
+ export function resolveChatModelSelection({ env = process.env, requested = null, excludeFamilies = [] } = {}) {
59
+ return resolveValidatedChatModel({ env, requested, excludeFamilies });
60
+ }
61
+
62
+ export async function resolveFreeOpenRouterModel({ env = process.env, tier = 'standard', exclude = [] } = {}) {
63
+ const apiKey = resolveFirstSecret(['OPENROUTER_API_KEY', 'OPEN_ROUTER_API_KEY'], { env });
64
+ if (!apiKey) return null;
65
+ const excludeSet = new Set(Array.isArray(exclude) ? exclude : []);
66
+ const { pollFreeModels, topForTier } = await import('../../../lib/model-free-selector.mjs');
67
+ const freeModels = await pollFreeModels(apiKey);
68
+ for (const candidate of topForTier(freeModels, tier, 20)) {
69
+ const modelId = candidate.id.startsWith('openrouter/') ? candidate.id : `openrouter/${candidate.id}`;
70
+ if (excludeSet.has(modelId)) continue;
71
+ if (isChatModelAvailable(modelId, { env }).ok) return modelId;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ export async function resolveSessionModel(session, { env = process.env, exclude = [], tier = 'standard' } = {}) {
77
+ if (session?.modelMode === 'free-router') {
78
+ const merged = [...new Set([...getExcludeFromSession(session), ...exclude])];
79
+ return resolveFreeOpenRouterModel({ env, tier, exclude: merged });
80
+ }
81
+ return session?.model || session?.savedModel || null;
82
+ }
83
+
84
+ function getExcludeFromSession(session) {
85
+ if (!session?.failedModels) return [];
86
+ return session.failedModels instanceof Set ? [...session.failedModels] : [];
87
+ }
88
+
89
+ export async function resolveChatModelSelectionAsync({
90
+ env = process.env,
91
+ requested = null,
92
+ fetchImpl = fetch,
93
+ } = {}) {
94
+ let resolution = resolveValidatedChatModel({ env, requested });
95
+ if (!resolution.id?.startsWith('github-copilot/')) return resolution;
96
+
97
+ const { preflightCopilotSession } = await import('../../../lib/providers/copilot-auth.mjs');
98
+ const probe = await preflightCopilotSession({ fetchImpl });
99
+ if (probe.ok) return resolution;
100
+
101
+ const fallback = resolveValidatedChatModel({ env, requested: null, excludeFamilies: ['github-copilot'] });
102
+ if (fallback.id) {
103
+ return {
104
+ ...fallback,
105
+ notice: `GitHub Copilot session failed (${probe.message}). Using ${fallback.id}.`,
106
+ };
107
+ }
108
+ return {
109
+ id: null,
110
+ source: null,
111
+ notice: `GitHub Copilot session failed: ${probe.message}`,
112
+ rejected: resolution.rejected,
113
+ };
114
+ }
115
+
116
+ export function resolveChatModel(opts = {}) {
117
+ return resolveChatModelSelection(opts).id;
118
+ }
119
+
120
+ export function describeChatModel(modelId, { env = process.env } = {}) {
121
+ return describeModelFamily(modelId, { env });
122
+ }