dsh-openwrite 0.2.28 → 0.2.33

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 (125) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +49 -8
  3. package/THIRD_PARTY_NOTICES.md +4 -3
  4. package/companions.mjs +508 -0
  5. package/cordis.patch.yml +0 -2
  6. package/docs/CLOSED_ISSUE_FOLLOWUPS.md +1 -1
  7. package/docs/COMPANION_PLUGINS.md +142 -0
  8. package/docs/CONTEXT_SLOTS.md +1 -1
  9. package/docs/COST_MEASUREMENT.md +2 -2
  10. package/docs/DEVELOPMENT.md +6 -11
  11. package/docs/DSH_STORE.md +14 -2
  12. package/docs/EDITOR_SAFETY_REPAIRS.md +2 -2
  13. package/docs/INSTALL.md +112 -4
  14. package/docs/ISSUES_121_123.md +4 -4
  15. package/docs/ISSUES_140_142.md +58 -0
  16. package/docs/ISSUES_149_150_151.md +77 -0
  17. package/docs/ISSUE_123_FOLLOWUP.md +3 -2
  18. package/docs/ISSUE_146_REVIEW_EVIDENCE.md +53 -0
  19. package/docs/LEARNING.md +1 -1
  20. package/docs/MOBILE_READINESS.md +62 -0
  21. package/docs/PLUGIN_MAINTENANCE.md +1 -3
  22. package/docs/RELEASE_ACCEPTANCE.md +20 -3
  23. package/docs/REVIEW_ROUND_22.md +67 -0
  24. package/docs/REVIEW_ROUND_23.md +48 -0
  25. package/docs/REVIEW_ROUND_25.md +51 -0
  26. package/docs/SELECTION_UPGRADE_REPAIRS.md +2 -2
  27. package/docs/TOOLS.md +181 -604
  28. package/docs/WORKFLOWS.md +5 -5
  29. package/package.json +17 -21
  30. package/packages/openwrite-bridge/lib/client.d.ts +0 -1
  31. package/packages/openwrite-bridge/lib/client.js +90 -9
  32. package/packages/openwrite-bridge/lib/contracts-generated.js +2 -1
  33. package/packages/openwrite-bridge/lib/dog-delivery.d.ts +1 -0
  34. package/packages/openwrite-bridge/lib/dog-delivery.js +3 -1
  35. package/packages/openwrite-bridge/lib/dog-review.js +15 -3
  36. package/packages/openwrite-bridge/lib/domain.d.ts +27 -0
  37. package/packages/openwrite-bridge/lib/domain.js +250 -29
  38. package/packages/openwrite-bridge/lib/host-llm.d.ts +188 -0
  39. package/packages/openwrite-bridge/lib/host-llm.js +657 -0
  40. package/packages/openwrite-bridge/lib/host-web.d.ts +61 -0
  41. package/packages/openwrite-bridge/lib/host-web.js +166 -0
  42. package/packages/openwrite-bridge/lib/index.d.ts +4 -0
  43. package/packages/openwrite-bridge/lib/index.js +150 -6
  44. package/packages/openwrite-bridge/lib/managed-runtime.d.ts +47 -2
  45. package/packages/openwrite-bridge/lib/managed-runtime.js +176 -34
  46. package/packages/openwrite-bridge/lib/tool-policy.js +37 -12
  47. package/packages/openwrite-bridge/lib/tools.js +1223 -1227
  48. package/packages/openwrite-bridge/package.json +7 -5
  49. package/packages/studio-panel/lib/client.js +123207 -121534
  50. package/packages/studio-panel/lib/client.js.map +1 -1
  51. package/packages/studio-panel/lib/types/client/LaunchOpenWrite.d.ts +10 -0
  52. package/packages/studio-panel/lib/types/client/ModelView.d.ts +28 -4
  53. package/packages/studio-panel/lib/types/client/OpenWriteSettings.d.ts +64 -0
  54. package/packages/studio-panel/lib/types/client/SceneWorkbench.d.ts +2 -0
  55. package/packages/studio-panel/lib/types/client/SearchSettings.d.ts +2 -0
  56. package/packages/studio-panel/lib/types/client/SerialWritingView.d.ts +2 -1
  57. package/packages/studio-panel/lib/types/client/TurnMutationSummary.d.ts +2 -0
  58. package/packages/studio-panel/lib/types/client/VditorBody.d.ts +1 -1
  59. package/packages/studio-panel/lib/types/client/api.d.ts +41 -2
  60. package/packages/studio-panel/lib/types/client/browser-download.d.ts +4 -0
  61. package/packages/studio-panel/lib/types/client/companions.d.ts +46 -0
  62. package/packages/studio-panel/lib/types/client/dto.d.ts +22 -0
  63. package/packages/studio-panel/lib/types/client/locales.d.ts +1 -1
  64. package/packages/studio-panel/lib/types/client/workspace-chrome.d.ts +41 -0
  65. package/packages/studio-panel/package.json +5 -3
  66. package/plugin.mjs +159 -20
  67. package/preset-definition.mjs +144 -4
  68. package/presets/openwrite/agent.cordis.yml +7 -9
  69. package/presets/openwrite/ops-integration.mjs +28 -0
  70. package/presets/openwrite/preset.yml +2 -1
  71. package/presets/openwrite/skills/canon/SKILL.md +3 -3
  72. package/presets/openwrite/skills/delivery-artifact-query/SKILL.md +41 -0
  73. package/presets/openwrite/skills/dialoguequality/SKILL.md +16 -17
  74. package/presets/openwrite/skills/dog-delivery-verification/SKILL.md +113 -0
  75. package/presets/openwrite/skills/export-book/SKILL.md +3 -3
  76. package/presets/openwrite/skills/foreshadow/SKILL.md +1 -1
  77. package/presets/openwrite/skills/foreshadowing-system/SKILL.md +16 -25
  78. package/presets/openwrite/skills/import-artifact-query/SKILL.md +30 -0
  79. package/presets/openwrite/skills/learn/SKILL.md +3 -3
  80. package/presets/openwrite/skills/novel-creator/SKILL.md +10 -10
  81. package/presets/openwrite/skills/novel-reviewer/SKILL.md +11 -11
  82. package/presets/openwrite/skills/progress/SKILL.md +1 -1
  83. package/presets/openwrite/skills/review-artifact-query/SKILL.md +41 -0
  84. package/presets/openwrite/skills/review-chapter/SKILL.md +2 -2
  85. package/presets/openwrite/skills/revise-span/SKILL.md +5 -5
  86. package/presets/openwrite/skills/style-system/SKILL.md +13 -6
  87. package/presets/openwrite/skills/truth-validation/SKILL.md +9 -12
  88. package/presets/openwrite/skills/workflow-manager/SKILL.md +13 -13
  89. package/presets/openwrite/skills/world-query/SKILL.md +16 -11
  90. package/presets/openwrite/skills/write-next/SKILL.md +1 -1
  91. package/release/{openwrite-5.8.20-py3-none-any.whl → openwrite-5.8.30-py3-none-any.whl} +0 -0
  92. package/release/runtime-manifest.json +4 -13
  93. package/release/source.json +21 -21
  94. package/release/wheels/licenses/OPENSSL-3.6.3-LICENSE.txt +225 -0
  95. package/scripts/maintenance.mjs +67 -7
  96. package/scripts/plugin-doctor.mjs +179 -0
  97. package/scripts/prepare.mjs +11 -2
  98. package/scripts/safe-upgrade.mjs +38 -5
  99. package/dog-tools.mjs +0 -13
  100. package/presets/openwrite/skills/dog-delivery-query/SKILL.md +0 -42
  101. package/presets/openwrite/skills/dog-import-query/SKILL.md +0 -29
  102. package/presets/openwrite/skills/dog-review-query/SKILL.md +0 -42
  103. package/scripts/dog/delivery-stage.js +0 -32
  104. package/scripts/dog/import-chapter.js +0 -20
  105. package/scripts/dog/import-record.js +0 -35
  106. package/scripts/dog/review-dimension.js +0 -41
  107. package/scripts/dog/review-record.js +0 -42
  108. package/vendor/dsh-dog/LICENSE +0 -29
  109. package/vendor/dsh-dog/README.md +0 -114
  110. package/vendor/dsh-dog/UPSTREAM.md +0 -30
  111. package/vendor/dsh-dog/cordis.patch.yml +0 -14
  112. package/vendor/dsh-dog/lib/client.js +0 -2960
  113. package/vendor/dsh-dog/lib/client.js.map +0 -1
  114. package/vendor/dsh-dog/lib/core--byUPJL8.js +0 -2191
  115. package/vendor/dsh-dog/lib/core-BiEBMLz3.d.ts +0 -495
  116. package/vendor/dsh-dog/lib/core.d.ts +0 -2
  117. package/vendor/dsh-dog/lib/core.js +0 -2
  118. package/vendor/dsh-dog/lib/index.d.ts +0 -143
  119. package/vendor/dsh-dog/lib/index.js +0 -1230
  120. package/vendor/dsh-dog/package.json +0 -110
  121. package/vendor/dsh-dog/schemas/schema-0.2/graph.schema.json +0 -139
  122. package/vendor/dsh-dog/schemas/schema-0.2/report.schema.json +0 -87
  123. package/vendor/dsh-dog/schemas/schema-0.2/run.schema.json +0 -259
  124. package/vendor/dsh-dog/schemas/schema-0.2/runtime-event.schema.json +0 -105
  125. package/vendor/dsh-dog/schemas/schema-0.2/verification.schema.json +0 -40
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A loopback-only relay onto the host's own `ctx.web` service.
3
+ *
4
+ * OpenWrite's deep research runs as a separate Node process. The host's search
5
+ * and fetch providers (including `web_search` / `web_fetch`) live in the host
6
+ * process, so the child cannot reach them directly. This relay gives it a
7
+ * machine-token HTTP face over exactly those two capabilities; no provider key
8
+ * and no browser session can reach it.
9
+ *
10
+ * `/bocha` exists because the vendored DeepResearch framework already speaks
11
+ * the Bocha search protocol. Mapping one search onto that protocol lets us use
12
+ * the host's search without forking the framework's search-provider union.
13
+ */
14
+ export interface HostWebSearchSource {
15
+ url: string;
16
+ title?: string;
17
+ snippet?: string;
18
+ publishedAt?: string;
19
+ }
20
+ export interface HostWebSearchResult {
21
+ content?: string;
22
+ sources: HostWebSearchSource[];
23
+ truncated?: boolean;
24
+ }
25
+ export interface HostWebFetchResult {
26
+ url: string;
27
+ statusCode: number;
28
+ body: {
29
+ kind: 'html' | 'text';
30
+ content: string;
31
+ };
32
+ truncated?: boolean;
33
+ }
34
+ /** Structural view of the host's `web` service (`ctx.get('web')`). */
35
+ export interface HostWebService {
36
+ search(request: {
37
+ query: string;
38
+ maxResults?: number;
39
+ }, signal?: AbortSignal): Promise<HostWebSearchResult>;
40
+ fetch(request: {
41
+ url: string;
42
+ }, signal?: AbortSignal): Promise<HostWebFetchResult>;
43
+ }
44
+ export interface HostWebRelayOptions {
45
+ web: HostWebService;
46
+ /** Machine token; every request must present it as a bearer credential. */
47
+ token: string;
48
+ }
49
+ export declare class HostWebRelay {
50
+ private readonly options;
51
+ private server?;
52
+ private port;
53
+ private closed;
54
+ constructor(options: HostWebRelayOptions);
55
+ listen(): Promise<void>;
56
+ baseUrl(): string;
57
+ token(): string;
58
+ close(): Promise<void>;
59
+ private authorized;
60
+ private handle;
61
+ }
@@ -0,0 +1,166 @@
1
+ import { createServer } from 'node:http';
2
+ const BODY_LIMIT_BYTES = 1 * 1024 * 1024;
3
+ const REQUEST_TIMEOUT_MS = 120_000;
4
+ function json(res, status, payload) {
5
+ const body = JSON.stringify(payload);
6
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
7
+ res.end(body);
8
+ }
9
+ async function readJson(req) {
10
+ const chunks = [];
11
+ let size = 0;
12
+ for await (const chunk of req) {
13
+ size += chunk.length;
14
+ if (size > BODY_LIMIT_BYTES)
15
+ throw new Error('request body too large');
16
+ chunks.push(Buffer.from(chunk));
17
+ }
18
+ if (chunks.length === 0)
19
+ return {};
20
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
21
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
22
+ throw new Error('request body must be a JSON object');
23
+ return parsed;
24
+ }
25
+ function nonEmptyString(value) {
26
+ return typeof value === 'string' ? value.trim() : '';
27
+ }
28
+ /** Normalize the host's search result to the wire shape. */
29
+ function searchWire(result) {
30
+ const sources = Array.isArray(result.sources)
31
+ ? result.sources
32
+ .filter(source => source !== null && typeof source === 'object' && nonEmptyString(source.url) !== '')
33
+ .map(source => ({
34
+ url: nonEmptyString(source.url),
35
+ ...(nonEmptyString(source.title) === '' ? {} : { title: nonEmptyString(source.title) }),
36
+ ...(nonEmptyString(source.snippet) === '' ? {} : { snippet: nonEmptyString(source.snippet) }),
37
+ ...(nonEmptyString(source.publishedAt) === '' ? {} : { publishedAt: nonEmptyString(source.publishedAt) }),
38
+ }))
39
+ : [];
40
+ return {
41
+ ...(nonEmptyString(result.content) === '' ? {} : { content: nonEmptyString(result.content) }),
42
+ sources,
43
+ truncated: result.truncated === true,
44
+ };
45
+ }
46
+ /** Normalize the host's fetch result; body kind and text are always present. */
47
+ function fetchWire(result) {
48
+ const kind = result.body?.kind === 'html' ? 'html' : 'text';
49
+ return {
50
+ url: nonEmptyString(result.url),
51
+ statusCode: Number.isInteger(result.statusCode) ? result.statusCode : 0,
52
+ body: { kind, content: typeof result.body?.content === 'string' ? result.body.content : '' },
53
+ truncated: result.truncated === true,
54
+ };
55
+ }
56
+ /** The Bocha response shape the vendored search provider parses. */
57
+ function bochaWire(sources) {
58
+ return {
59
+ code: 200,
60
+ data: {
61
+ webPages: {
62
+ value: sources.map(source => ({
63
+ name: nonEmptyString(source.title) || nonEmptyString(source.url),
64
+ url: nonEmptyString(source.url),
65
+ snippet: nonEmptyString(source.snippet),
66
+ summary: nonEmptyString(source.snippet),
67
+ })),
68
+ },
69
+ },
70
+ };
71
+ }
72
+ export class HostWebRelay {
73
+ options;
74
+ server;
75
+ port = 0;
76
+ closed = false;
77
+ constructor(options) {
78
+ this.options = options;
79
+ }
80
+ async listen() {
81
+ if (this.server !== undefined)
82
+ return;
83
+ const server = createServer((req, res) => {
84
+ void this.handle(req, res).catch((error) => {
85
+ if (res.headersSent) {
86
+ res.end();
87
+ return;
88
+ }
89
+ json(res, 502, { error: { message: error instanceof Error ? error.message : String(error) } });
90
+ });
91
+ });
92
+ await new Promise((resolve, reject) => {
93
+ server.once('error', reject);
94
+ server.listen(0, '127.0.0.1', () => {
95
+ server.removeListener('error', reject);
96
+ resolve();
97
+ });
98
+ });
99
+ const address = server.address();
100
+ this.port = typeof address === 'object' && address !== null ? address.port : 0;
101
+ this.server = server;
102
+ }
103
+ baseUrl() {
104
+ return `http://127.0.0.1:${this.port}`;
105
+ }
106
+ token() {
107
+ return this.options.token;
108
+ }
109
+ async close() {
110
+ if (this.closed)
111
+ return;
112
+ this.closed = true;
113
+ const server = this.server;
114
+ this.server = undefined;
115
+ if (server === undefined)
116
+ return;
117
+ await new Promise(resolve => server.close(() => resolve()));
118
+ }
119
+ authorized(req) {
120
+ return req.headers.authorization === `Bearer ${this.options.token}`;
121
+ }
122
+ async handle(req, res) {
123
+ if (!this.authorized(req)) {
124
+ json(res, 401, { error: { message: 'unauthorized' } });
125
+ return;
126
+ }
127
+ if (req.method !== 'POST') {
128
+ json(res, 405, { error: { message: 'method not allowed' } });
129
+ return;
130
+ }
131
+ const url = new URL(req.url ?? '/', 'http://relay');
132
+ const body = await readJson(req);
133
+ const controller = new AbortController();
134
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
135
+ try {
136
+ if (url.pathname === '/search') {
137
+ const query = nonEmptyString(body.query);
138
+ if (query === '')
139
+ throw new Error('query is required');
140
+ const maxResults = Number.isInteger(body.maxResults) && Number(body.maxResults) > 0 ? Number(body.maxResults) : undefined;
141
+ json(res, 200, searchWire(await this.options.web.search({ query, ...(maxResults === undefined ? {} : { maxResults }) }, controller.signal)));
142
+ return;
143
+ }
144
+ if (url.pathname === '/bocha') {
145
+ const query = nonEmptyString(body.query);
146
+ if (query === '')
147
+ throw new Error('query is required');
148
+ const count = Number.isInteger(body.count) && Number(body.count) > 0 ? Number(body.count) : 10;
149
+ const result = await this.options.web.search({ query, maxResults: Math.min(count, 50) }, controller.signal);
150
+ json(res, 200, bochaWire(result.sources ?? []));
151
+ return;
152
+ }
153
+ if (url.pathname === '/fetch') {
154
+ const target = nonEmptyString(body.url);
155
+ if (target === '')
156
+ throw new Error('url is required');
157
+ json(res, 200, fetchWire(await this.options.web.fetch({ url: target }, controller.signal)));
158
+ return;
159
+ }
160
+ json(res, 404, { error: { message: 'not found' } });
161
+ }
162
+ finally {
163
+ clearTimeout(timer);
164
+ }
165
+ }
166
+ }
@@ -6,6 +6,10 @@ export interface Config {
6
6
  mode?: 'managed' | 'external';
7
7
  runtimeRoot?: string;
8
8
  artifacts?: string;
9
+ /** Absolute path to a prepared Python >=3.10; skips uv and interpreter downloads. */
10
+ pythonPath?: string;
11
+ /** Optional absolute directories containing dependencies for the supplied interpreter. */
12
+ pythonModulePaths?: string[];
9
13
  /** Base URL of the local OpenWrite Studio server. */
10
14
  baseUrl: string;
11
15
  /** Optional instance token of an externally managed Studio server. */
@@ -7,21 +7,77 @@
7
7
  import { tmpdir, homedir } from 'node:os';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { ManagedRuntime } from './managed-runtime.js';
10
- import { join } from 'node:path';
10
+ import { HostLlmRelay, newRelayToken } from './host-llm.js';
11
+ import { HostWebRelay } from './host-web.js';
12
+ import { join, isAbsolute } from 'node:path';
13
+ import { existsSync } from 'node:fs';
11
14
  import Schema from '@deepseek-ai/schemastery';
12
15
  import { NovelDomainService } from './domain.js';
13
16
  export const name = '@dsh-novel/openwrite-bridge';
14
17
  export const inject = [];
15
18
  export const Config = Schema.object({
16
- mode: Schema.union(['managed', 'external']),
19
+ // Explicit default: the code relied on `undefined` falling through to external,
20
+ // which is invisible in the schema and easy to break.
21
+ mode: Schema.union(['managed', 'external']).default('external'),
17
22
  runtimeRoot: Schema.string(),
18
23
  artifacts: Schema.string(),
24
+ pythonPath: Schema.string(),
25
+ pythonModulePaths: Schema.array(Schema.string()),
19
26
  baseUrl: Schema.string().default('http://127.0.0.1:4567'),
20
27
  authToken: Schema.string().role('secret'),
21
28
  timeoutMs: Schema.number().default(600000),
22
29
  outputDir: Schema.string().default(join(tmpdir(), 'openwrite-exports')),
23
30
  });
31
+ /**
32
+ * Validate the configuration before any request is served. Every one of these
33
+ * used to surface as a first-request failure with a misleading cause: a bogus
34
+ * runtimeRoot or artifacts path only raised ENOENT inside `ensure()`, a
35
+ * non-URL baseUrl threw inside `new URL()` and was rewritten to NETWORK_ERROR,
36
+ * and `timeoutMs <= 0` made every request abort immediately.
37
+ */
38
+ function validateConfig(config) {
39
+ if (!Number.isFinite(config.timeoutMs) || config.timeoutMs <= 0) {
40
+ throw new Error(`openwrite-bridge: timeoutMs must be a positive number, got ${String(config.timeoutMs)}`);
41
+ }
42
+ if (config.mode === 'external') {
43
+ try {
44
+ const parsed = new URL(config.baseUrl);
45
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
46
+ throw new Error(`unsupported protocol ${parsed.protocol}`);
47
+ }
48
+ }
49
+ catch (cause) {
50
+ throw new Error(`openwrite-bridge: baseUrl must be an http(s) URL, got ${JSON.stringify(config.baseUrl)} (${cause instanceof Error ? cause.message : String(cause)})`);
51
+ }
52
+ // No authToken rule here: a tokenless Studio server is a legitimate
53
+ // external configuration, and there is no way to tell it from a forgotten
54
+ // token without asking the server. Only the *contradiction* below is a
55
+ // definite misconfiguration.
56
+ }
57
+ if (config.mode === 'managed') {
58
+ // The token is only used in external mode; managed mode authenticates through
59
+ // the runtime's own connection, so a supplied token is silently ignored.
60
+ if (config.authToken !== undefined && config.authToken !== '') {
61
+ throw new Error('openwrite-bridge: authToken is ignored in managed mode (the managed runtime issues its own connection token); remove it or switch to mode "external"');
62
+ }
63
+ const artifacts = config.artifacts ?? fileURLToPath(new URL('../../../release/', import.meta.url));
64
+ if (!existsSync(join(artifacts, 'runtime-manifest.json'))) {
65
+ throw new Error(`openwrite-bridge: artifacts must contain runtime-manifest.json, got ${JSON.stringify(artifacts)}`);
66
+ }
67
+ for (const [key, value] of [['pythonPath', config.pythonPath], ['runtimeRoot', config.runtimeRoot]]) {
68
+ if (value === undefined || value === '')
69
+ continue;
70
+ if (!isAbsolute(value))
71
+ throw new Error(`openwrite-bridge: ${key} must be an absolute path, got ${JSON.stringify(value)}`);
72
+ }
73
+ for (const value of config.pythonModulePaths ?? []) {
74
+ if (!isAbsolute(value))
75
+ throw new Error(`openwrite-bridge: pythonModulePaths entries must be absolute, got ${JSON.stringify(value)}`);
76
+ }
77
+ }
78
+ }
24
79
  export function apply(ctx, config) {
80
+ validateConfig(config);
25
81
  // The web host process mounts ctx.workspaceRegistry. Resolve it lazily on
26
82
  // every call: plugin start order must not strand the lookup, an absent
27
83
  // registry (null) fails closed in the domain, and ctx.inject(['webServer',
@@ -32,8 +88,90 @@ export function apply(ctx, config) {
32
88
  return null;
33
89
  return registry.get(workspaceId)?.path;
34
90
  };
91
+ // The relay onto the host's own model directory. It is created lazily: a
92
+ // host that composes no `llm` service still loads the plugin, it just offers
93
+ // no host models. The relay binds an ephemeral loopback port and answers
94
+ // only to a machine token, so no provider key and no browser session can
95
+ // reach it.
96
+ let relay;
97
+ const defaultSelection = () => {
98
+ let service;
99
+ try {
100
+ service = ctx.get('agentDefaultModel');
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ if (service === undefined || service === null)
106
+ return undefined;
107
+ try {
108
+ const selection = service.currentSelection();
109
+ const provider = typeof selection?.provider === 'string' ? selection.provider : '';
110
+ const model = typeof selection?.model === 'string' ? selection.model : '';
111
+ if (provider === '' || model === '')
112
+ return undefined;
113
+ const effort = typeof selection?.reasoningEffort === 'string' ? selection.reasoningEffort : '';
114
+ return { provider, model, ...(effort === '' ? {} : { reasoningEffort: effort }) };
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ };
120
+ const ensureRelay = async () => {
121
+ if (relay !== undefined)
122
+ return relay;
123
+ let llm;
124
+ try {
125
+ llm = ctx.get('llm');
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ if (llm === undefined || llm === null)
131
+ return undefined;
132
+ const created = new HostLlmRelay({
133
+ llm: llm,
134
+ token: newRelayToken(),
135
+ defaultSelection,
136
+ });
137
+ await created.listen();
138
+ relay = created;
139
+ return relay;
140
+ };
141
+ ctx.effect(() => () => { void relay?.close(); relay = undefined; }, 'openwrite: host llm relay cleanup');
142
+ // The relay onto the host's own web search/fetch. Only the managed runtime
143
+ // needs it; an external Studio keeps its own search providers.
144
+ let webRelay;
145
+ const ensureWebRelay = async () => {
146
+ if (webRelay !== undefined)
147
+ return webRelay;
148
+ let web;
149
+ try {
150
+ web = ctx.get('web');
151
+ }
152
+ catch {
153
+ return undefined;
154
+ }
155
+ if (web === undefined || web === null)
156
+ return undefined;
157
+ const created = new HostWebRelay({ web: web, token: newRelayToken() });
158
+ await created.listen();
159
+ webRelay = created;
160
+ return created;
161
+ };
162
+ ctx.effect(() => () => { void webRelay?.close(); webRelay = undefined; }, 'openwrite: host web relay cleanup');
35
163
  // A supplied legacy baseUrl remains external; the standard bundle selects managed explicitly.
36
- const runtime = config.mode === 'managed' ? new ManagedRuntime(config.runtimeRoot ?? join(process.env['DSH_HOME']?.trim() || join(homedir(), '.dsh'), 'openwrite'), config.artifacts ?? fileURLToPath(new URL('../../../release/', import.meta.url))) : undefined;
164
+ const runtime = config.mode === 'managed' ? new ManagedRuntime(config.runtimeRoot ?? join(process.env['DSH_HOME']?.trim() || join(homedir(), '.dsh'), 'openwrite'), config.artifacts ?? fileURLToPath(new URL('../../../release/', import.meta.url)), { pythonPath: config.pythonPath, pythonModulePaths: config.pythonModulePaths,
165
+ // The relay is resolved per backend start: its port is ephemeral, so a
166
+ // cached address would outlive the server it points at.
167
+ hostLlm: async () => {
168
+ const created = await ensureRelay();
169
+ return created === undefined ? undefined : { url: created.baseUrl(), token: created.token() };
170
+ },
171
+ hostWeb: async () => {
172
+ const created = await ensureWebRelay();
173
+ return created === undefined ? undefined : { url: created.baseUrl(), token: created.token() };
174
+ } }) : undefined;
37
175
  const authorizeRequest = (headers) => {
38
176
  const connection = ctx.get('connection');
39
177
  return connection ? connection.requestRejection({ headers }) : 503;
@@ -61,15 +199,21 @@ export function apply(ctx, config) {
61
199
  }
62
200
  const chunks = [];
63
201
  let size = 0;
202
+ // Answering 413 while still iterating `req` resets the connection, so the
203
+ // client sees a truncated response. Drain first, then respond.
204
+ let tooLarge = false;
64
205
  for await (const chunk of req) {
65
206
  size += chunk.length;
66
207
  if (size > 1024) {
67
- respond(413, { error: 'Request too large', message: 'Request too large', code: 'REQUEST_TOO_LARGE' });
68
- return;
208
+ tooLarge = true;
209
+ break;
69
210
  }
70
- ;
71
211
  chunks.push(Buffer.from(chunk));
72
212
  }
213
+ if (tooLarge) {
214
+ respond(413, { error: 'Request too large', message: 'Request too large', code: 'REQUEST_TOO_LARGE' });
215
+ return;
216
+ }
73
217
  try {
74
218
  const action = JSON.parse(Buffer.concat(chunks).toString()).action;
75
219
  if (action === 'cancel')
@@ -48,10 +48,34 @@ export declare class UnsupportedRuntimePlatformError extends Error {
48
48
  }
49
49
  export type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess;
50
50
  export interface RuntimeOptions {
51
+ /** Prepared Python >=3.10. Dependencies belong to the author; Core stays in our own directory. */
52
+ pythonPath?: string;
53
+ /** Explicit dependency directories, e.g. an Android author's prepared pyroot. */
54
+ pythonModulePaths?: string[];
51
55
  spawn?: SpawnFn;
52
56
  delay?: typeof delay;
53
57
  restartLimit?: number;
54
58
  backoffMs?: number[];
59
+ /**
60
+ * The host's OpenAI-compatible relay, handed to Core over the stdio
61
+ * handshake. Core needs it to call the models dsh itself can reach; the
62
+ * relay address is ephemeral, so it is resolved per start, never cached.
63
+ */
64
+ hostLlm?: () => Promise<HostLlmHandshake | undefined> | HostLlmHandshake | undefined;
65
+ /** The host's search/fetch relay, handed to Core over the same handshake. */
66
+ hostWeb?: () => Promise<HostWebHandshake | undefined> | HostWebHandshake | undefined;
67
+ }
68
+ /** The relay coordinates Core receives in its private startup handshake. */
69
+ export interface HostLlmHandshake {
70
+ /** OpenAI-compatible base URL of the relay. */
71
+ url: string;
72
+ /** Machine token the relay requires as a bearer credential. */
73
+ token: string;
74
+ }
75
+ /** The host's web search/fetch relay coordinates. */
76
+ export interface HostWebHandshake {
77
+ url: string;
78
+ token: string;
55
79
  }
56
80
  export declare const DEFAULT_RESTART_LIMIT = 5;
57
81
  export declare const DEFAULT_BACKOFF_MS: number[];
@@ -89,6 +113,10 @@ export declare class ManagedRuntime {
89
113
  private readonly wait;
90
114
  private readonly restartLimit;
91
115
  private readonly backoffMs;
116
+ private readonly pythonPath?;
117
+ private readonly pythonModulePaths;
118
+ private readonly hostLlm?;
119
+ private readonly hostWeb?;
92
120
  constructor(root: string, artifacts: string, options?: RuntimeOptions);
93
121
  status(): RuntimeStatus;
94
122
  private update;
@@ -101,8 +129,23 @@ export declare class ManagedRuntime {
101
129
  * forcefully and pushed through the normal recovery path.
102
130
  */
103
131
  private verifyConnection;
104
- /** Short-timeout liveness probe; false covers exit, refusal, hang, and auth drift. */
105
- private probeHealth;
132
+ /**
133
+ * The host relay coordinates, if the host offers one.
134
+ *
135
+ * A relay that cannot be reached is not a startup failure: Core still runs,
136
+ * it simply has no host models to offer. The failure is silent on purpose —
137
+ * the relay's own diagnostics belong to the host, not to the backend boot.
138
+ */
139
+ private resolveHostLlm;
140
+ private resolveHostWeb;
141
+ /**
142
+ * Liveness with three outcomes. `dead` is only returned on positive evidence —
143
+ * the child exited, or the connection was refused, or health answered
144
+ * non-ok (auth drift). A probe that timed out returns `unknown`, because a
145
+ * busy backend is indistinguishable from a hung one at a 1.5 s deadline and
146
+ * killing the former destroys in-flight work.
147
+ */
148
+ private probeLiveness;
106
149
  cancel(): Promise<void>;
107
150
  dispose(): Promise<void>;
108
151
  private exitDiagnostic;
@@ -130,6 +173,8 @@ export declare class ManagedRuntime {
130
173
  private downloadFrom;
131
174
  private extract;
132
175
  private command;
176
+ /** Shared Android/embedded Python may need its host's native-library search paths. */
177
+ private pythonLibraryEnv;
133
178
  private lock;
134
179
  private prepare;
135
180
  private start;