dsh-openwrite 0.2.27 → 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.
- package/LICENSE +1 -1
- package/README.md +55 -6
- package/THIRD_PARTY_NOTICES.md +4 -3
- package/companions.mjs +508 -0
- package/cordis.patch.yml +0 -2
- package/docs/CLOSED_ISSUE_FOLLOWUPS.md +5 -3
- package/docs/COMPANION_PLUGINS.md +142 -0
- package/docs/CONTEXT_SLOTS.md +60 -0
- package/docs/COST_MEASUREMENT.md +2 -2
- package/docs/DEVELOPMENT.md +6 -11
- package/docs/DSH_STORE.md +14 -2
- package/docs/EDITOR_SAFETY_REPAIRS.md +17 -0
- package/docs/INSTALL.md +112 -4
- package/docs/ISSUES_121_123.md +97 -0
- package/docs/ISSUES_140_142.md +58 -0
- package/docs/ISSUES_149_150_151.md +77 -0
- package/docs/ISSUES_98_100.md +2 -0
- package/docs/ISSUE_123_FOLLOWUP.md +55 -0
- package/docs/ISSUE_146_REVIEW_EVIDENCE.md +53 -0
- package/docs/LEARNING.md +1 -1
- package/docs/MOBILE_READINESS.md +62 -0
- package/docs/PLUGIN_MAINTENANCE.md +1 -3
- package/docs/RELEASE_ACCEPTANCE.md +90 -3
- package/docs/REVIEW_ROUND_22.md +67 -0
- package/docs/REVIEW_ROUND_23.md +48 -0
- package/docs/REVIEW_ROUND_25.md +51 -0
- package/docs/SELECTION_UPGRADE_REPAIRS.md +16 -0
- package/docs/TOOLS.md +933 -0
- package/docs/WORKFLOWS.md +5 -5
- package/package.json +18 -21
- package/packages/openwrite-bridge/lib/client.d.ts +0 -1
- package/packages/openwrite-bridge/lib/client.js +90 -9
- package/packages/openwrite-bridge/lib/contracts-generated.js +2 -1
- package/packages/openwrite-bridge/lib/dog-delivery.d.ts +1 -0
- package/packages/openwrite-bridge/lib/dog-delivery.js +10 -4
- package/packages/openwrite-bridge/lib/dog-review.js +15 -3
- package/packages/openwrite-bridge/lib/domain.d.ts +27 -0
- package/packages/openwrite-bridge/lib/domain.js +250 -29
- package/packages/openwrite-bridge/lib/host-llm.d.ts +188 -0
- package/packages/openwrite-bridge/lib/host-llm.js +657 -0
- package/packages/openwrite-bridge/lib/host-web.d.ts +61 -0
- package/packages/openwrite-bridge/lib/host-web.js +166 -0
- package/packages/openwrite-bridge/lib/index.d.ts +4 -0
- package/packages/openwrite-bridge/lib/index.js +150 -6
- package/packages/openwrite-bridge/lib/managed-runtime.d.ts +47 -2
- package/packages/openwrite-bridge/lib/managed-runtime.js +179 -35
- package/packages/openwrite-bridge/lib/tool-policy.js +39 -12
- package/packages/openwrite-bridge/lib/tools.js +1238 -1219
- package/packages/openwrite-bridge/package.json +7 -5
- package/packages/studio-panel/lib/client.js +116667 -114773
- package/packages/studio-panel/lib/client.js.map +1 -1
- package/packages/studio-panel/lib/types/client/LaunchOpenWrite.d.ts +10 -0
- package/packages/studio-panel/lib/types/client/ModelView.d.ts +28 -4
- package/packages/studio-panel/lib/types/client/OpenWriteSettings.d.ts +64 -0
- package/packages/studio-panel/lib/types/client/SceneWorkbench.d.ts +2 -0
- package/packages/studio-panel/lib/types/client/SearchSettings.d.ts +2 -0
- package/packages/studio-panel/lib/types/client/SerialWritingView.d.ts +2 -1
- package/packages/studio-panel/lib/types/client/TurnMutationSummary.d.ts +2 -0
- package/packages/studio-panel/lib/types/client/VditorBody.d.ts +8 -2
- package/packages/studio-panel/lib/types/client/api.d.ts +41 -2
- package/packages/studio-panel/lib/types/client/browser-download.d.ts +4 -0
- package/packages/studio-panel/lib/types/client/companions.d.ts +46 -0
- package/packages/studio-panel/lib/types/client/dto.d.ts +22 -0
- package/packages/studio-panel/lib/types/client/locales.d.ts +1 -1
- package/packages/studio-panel/lib/types/client/manuscript-annotations.d.ts +2 -1
- package/packages/studio-panel/lib/types/client/manuscript-overlay.d.ts +2 -1
- package/packages/studio-panel/lib/types/client/manuscript-selection.d.ts +18 -5
- package/packages/studio-panel/lib/types/client/selection-coordinates.d.ts +15 -0
- package/packages/studio-panel/lib/types/client/workspace-chrome.d.ts +41 -0
- package/packages/studio-panel/package.json +5 -3
- package/plugin.mjs +159 -20
- package/preset-definition.mjs +144 -4
- package/presets/openwrite/agent.cordis.yml +7 -9
- package/presets/openwrite/ops-integration.mjs +28 -0
- package/presets/openwrite/preset.yml +2 -1
- package/presets/openwrite/skills/canon/SKILL.md +3 -3
- package/presets/openwrite/skills/delivery-artifact-query/SKILL.md +41 -0
- package/presets/openwrite/skills/dialoguequality/SKILL.md +16 -17
- package/presets/openwrite/skills/dog-delivery-verification/SKILL.md +113 -0
- package/presets/openwrite/skills/export-book/SKILL.md +3 -3
- package/presets/openwrite/skills/foreshadow/SKILL.md +1 -1
- package/presets/openwrite/skills/foreshadowing-system/SKILL.md +16 -25
- package/presets/openwrite/skills/import-artifact-query/SKILL.md +30 -0
- package/presets/openwrite/skills/learn/SKILL.md +3 -3
- package/presets/openwrite/skills/novel-creator/SKILL.md +10 -10
- package/presets/openwrite/skills/novel-reviewer/SKILL.md +11 -11
- package/presets/openwrite/skills/progress/SKILL.md +1 -1
- package/presets/openwrite/skills/review-artifact-query/SKILL.md +41 -0
- package/presets/openwrite/skills/review-chapter/SKILL.md +2 -2
- package/presets/openwrite/skills/revise-span/SKILL.md +5 -5
- package/presets/openwrite/skills/style-system/SKILL.md +13 -6
- package/presets/openwrite/skills/truth-validation/SKILL.md +9 -12
- package/presets/openwrite/skills/workflow-manager/SKILL.md +13 -13
- package/presets/openwrite/skills/world-query/SKILL.md +16 -11
- package/presets/openwrite/skills/write-next/SKILL.md +1 -1
- package/release/{openwrite-5.8.16-py3-none-any.whl → openwrite-5.8.30-py3-none-any.whl} +0 -0
- package/release/runtime-manifest.json +4 -12
- package/release/source.json +21 -21
- package/release/wheels/licenses/OPENSSL-3.6.3-LICENSE.txt +225 -0
- package/scripts/maintenance.mjs +67 -7
- package/scripts/plugin-doctor.mjs +179 -0
- package/scripts/prepare.mjs +11 -2
- package/scripts/safe-upgrade.mjs +141 -13
- package/dog-tools.mjs +0 -11
- package/presets/openwrite/skills/dog-delivery-query/SKILL.md +0 -42
- package/presets/openwrite/skills/dog-import-query/SKILL.md +0 -29
- package/presets/openwrite/skills/dog-review-query/SKILL.md +0 -42
- package/scripts/dog/delivery-stage.js +0 -32
- package/scripts/dog/import-chapter.js +0 -20
- package/scripts/dog/import-record.js +0 -35
- package/scripts/dog/review-dimension.js +0 -41
- package/scripts/dog/review-record.js +0 -42
- package/vendor/dsh-dog/LICENSE +0 -29
- package/vendor/dsh-dog/README.md +0 -114
- package/vendor/dsh-dog/UPSTREAM.md +0 -30
- package/vendor/dsh-dog/cordis.patch.yml +0 -14
- package/vendor/dsh-dog/lib/client.js +0 -2960
- package/vendor/dsh-dog/lib/client.js.map +0 -1
- package/vendor/dsh-dog/lib/core-BAlgZpLq.js +0 -2074
- package/vendor/dsh-dog/lib/core-BiEBMLz3.d.ts +0 -495
- package/vendor/dsh-dog/lib/core.d.ts +0 -2
- package/vendor/dsh-dog/lib/core.js +0 -2
- package/vendor/dsh-dog/lib/index.d.ts +0 -143
- package/vendor/dsh-dog/lib/index.js +0 -1279
- package/vendor/dsh-dog/package.json +0 -110
- package/vendor/dsh-dog/schemas/schema-0.2/graph.schema.json +0 -139
- package/vendor/dsh-dog/schemas/schema-0.2/report.schema.json +0 -87
- package/vendor/dsh-dog/schemas/schema-0.2/run.schema.json +0 -259
- package/vendor/dsh-dog/schemas/schema-0.2/runtime-event.schema.json +0 -105
- package/vendor/dsh-dog/schemas/schema-0.2/verification.schema.json +0 -40
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Service } from '@deepseek-ai/cordis';
|
|
2
|
-
import { StudioClient, workspaceRootHeaders, workspaceRootsEqual, mutationAffectsState } from './client.js';
|
|
2
|
+
import { StudioClient, StudioError, workspaceRootHeaders, workspaceRootsEqual, mutationAffectsState } from './client.js';
|
|
3
3
|
import { workspaceContextFromExec } from './tools.js';
|
|
4
4
|
export const CONFIG_ROUTE = '/studio-panel/config.json';
|
|
5
5
|
export const API_PROXY_ROUTE = '/studio-panel/api';
|
|
@@ -8,7 +8,8 @@ export const INVALIDATION_ROUTE = '/studio-panel/invalidation.json';
|
|
|
8
8
|
const PROXY_TIMEOUT_MS = 60_000;
|
|
9
9
|
/** Cap for the best-effort upstream context_epoch read; never blocks a snapshot longer. */
|
|
10
10
|
const CONTEXT_EPOCH_TIMEOUT_MS = 5_000;
|
|
11
|
-
|
|
11
|
+
/** Every browser write path the proxy forwards. Exported for the regression test. */
|
|
12
|
+
export const WRITABLE_PATHS = new Set([
|
|
12
13
|
'writing-assets',
|
|
13
14
|
'arc-reconcile',
|
|
14
15
|
'assets',
|
|
@@ -56,11 +57,44 @@ const WRITABLE_PATHS = new Set([
|
|
|
56
57
|
'model/embedding/delete',
|
|
57
58
|
'model/profiles',
|
|
58
59
|
'model/profiles/delete',
|
|
60
|
+
// The two-step delete the panel drives: preview first, then the real delete.
|
|
61
|
+
'model/profiles/delete-preview',
|
|
59
62
|
'model/routes',
|
|
63
|
+
// One profile that calls a model of the host's own directory.
|
|
64
|
+
'model/host-profiles',
|
|
60
65
|
'chapter/delete-batch',
|
|
61
66
|
'research/settings',
|
|
67
|
+
'research/preferences',
|
|
68
|
+
]);
|
|
69
|
+
export const WRITABLE_PATH_RE = /^(?:tasks\/[A-Za-z0-9_-]+\/(?:cancel|retry|confirm)|revisions\/(?:selection|from-review|rev_[A-Za-z0-9_-]+\/(?:apply|reject|regenerate)))$/;
|
|
70
|
+
/**
|
|
71
|
+
* Paths whose payload is global, not project-scoped.
|
|
72
|
+
*
|
|
73
|
+
* The model profile library lives outside every project — Studio keeps it in
|
|
74
|
+
* the user's preferences directory (`model-profiles.json`), and every model
|
|
75
|
+
* route is registered with `requires_project=False`. The host settings dialog
|
|
76
|
+
* is the one surface that is opened *without* a bound workspace, so these
|
|
77
|
+
* paths must be reachable without a workspace id: the browser sends no
|
|
78
|
+
* `X-Dsh-Workspace-Id` and the proxy forwards the call without a project
|
|
79
|
+
* root, exactly as the contract's project-free routes expect.
|
|
80
|
+
*
|
|
81
|
+
* Every other proxied path stays fail-closed on a missing workspace id.
|
|
82
|
+
*/
|
|
83
|
+
export const GLOBAL_PATHS = new Set([
|
|
84
|
+
'model',
|
|
85
|
+
'model/test',
|
|
86
|
+
'model/embedding',
|
|
87
|
+
'model/embedding/test',
|
|
88
|
+
'model/embedding/select',
|
|
89
|
+
'model/embedding/delete',
|
|
90
|
+
'model/profiles',
|
|
91
|
+
'model/profiles/delete',
|
|
92
|
+
'model/profiles/delete-preview',
|
|
93
|
+
'model/routes',
|
|
94
|
+
'model/host-models',
|
|
95
|
+
'model/host-profiles',
|
|
96
|
+
'research/preferences',
|
|
62
97
|
]);
|
|
63
|
-
const WRITABLE_PATH_RE = /^(?:tasks\/[A-Za-z0-9_-]+\/(?:cancel|retry|confirm)|revisions\/(?:selection|from-review|rev_[A-Za-z0-9_-]+\/(?:apply|reject|regenerate)))$/;
|
|
64
98
|
/** Mutation/SSE state bucket key for context-less (legacy) mutations. */
|
|
65
99
|
const LEGACY_ROOT_KEY = '';
|
|
66
100
|
function sendJson(res, status, payload) {
|
|
@@ -72,13 +106,33 @@ function sendJson(res, status, payload) {
|
|
|
72
106
|
});
|
|
73
107
|
res.end(body);
|
|
74
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Ceiling for one proxied request body. The runtime route already caps its
|
|
111
|
+
* uploads at 1 KiB; without a cap here an authenticated browser session can
|
|
112
|
+
* push an unbounded body into the host process's memory.
|
|
113
|
+
*/
|
|
114
|
+
const MAX_PROXY_BODY_BYTES = 32 * 1024 * 1024;
|
|
115
|
+
/** Ceiling for one proxied download (export / project archive). */
|
|
116
|
+
const MAX_PROXY_DOWNLOAD_BYTES = 64 * 1024 * 1024;
|
|
117
|
+
/** Per-stream ceiling on undelivered SSE bytes before the stream is dropped. */
|
|
118
|
+
const MAX_SSE_PENDING_BYTES = 4 * 1024 * 1024;
|
|
119
|
+
/** Concurrent invalidation streams allowed per workspace root. */
|
|
120
|
+
const MAX_SSE_STREAMS_PER_ROOT = 64;
|
|
121
|
+
/** Reap an SSE connection that has been silent this long (half-open peers). */
|
|
122
|
+
const SSE_IDLE_TIMEOUT_MS = 120_000;
|
|
75
123
|
async function readBody(req) {
|
|
76
124
|
const iterator = req[Symbol.asyncIterator];
|
|
77
125
|
if (iterator === undefined)
|
|
78
126
|
return Buffer.alloc(0);
|
|
79
127
|
const chunks = [];
|
|
128
|
+
let total = 0;
|
|
80
129
|
for await (const chunk of iterator.call(req)) {
|
|
81
|
-
|
|
130
|
+
const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
131
|
+
total += bytes.byteLength;
|
|
132
|
+
if (total > MAX_PROXY_BODY_BYTES) {
|
|
133
|
+
throw new StudioError(`Proxied request body exceeds ${MAX_PROXY_BODY_BYTES} bytes`, 413, 'PAYLOAD_TOO_LARGE');
|
|
134
|
+
}
|
|
135
|
+
chunks.push(bytes);
|
|
82
136
|
}
|
|
83
137
|
return Buffer.concat(chunks);
|
|
84
138
|
}
|
|
@@ -214,6 +268,8 @@ export class NovelDomainService extends Service {
|
|
|
214
268
|
connect;
|
|
215
269
|
baseUrl;
|
|
216
270
|
resolveWorkspace;
|
|
271
|
+
/** Undelivered SSE bytes per stream, for backpressure (see notifyMutation). */
|
|
272
|
+
streamPending = new Map();
|
|
217
273
|
/** Per-root invalidation state, keyed by canonical workspace root ('' = legacy). */
|
|
218
274
|
roots = new Map();
|
|
219
275
|
/** Open SSE connections bound to the root key they subscribed to. */
|
|
@@ -247,9 +303,41 @@ export class NovelDomainService extends Service {
|
|
|
247
303
|
state.lastMutation = { revision: state.revision, resource: resourceForPath(path), path };
|
|
248
304
|
this.roots.set(root, state);
|
|
249
305
|
const event = JSON.stringify({ workspace_root: root, ...state.lastMutation });
|
|
306
|
+
const payload = `event: invalidate\ndata: ${event}\n\n`;
|
|
250
307
|
for (const [stream, key] of this.streams) {
|
|
251
|
-
if (key
|
|
252
|
-
|
|
308
|
+
if (key !== root)
|
|
309
|
+
continue;
|
|
310
|
+
// Honour backpressure. `write()` returning false means the socket is not
|
|
311
|
+
// draining; without a `drain` listener the host buffers every event for a
|
|
312
|
+
// stalled client without bound. Track pending bytes and drop a stream that
|
|
313
|
+
// falls too far behind — EventSource reconnects and catches up from the
|
|
314
|
+
// revision in the ready event.
|
|
315
|
+
const pending = (this.streamPending.get(stream) ?? 0) + Buffer.byteLength(payload);
|
|
316
|
+
if (pending > MAX_SSE_PENDING_BYTES) {
|
|
317
|
+
this.streamPending.delete(stream);
|
|
318
|
+
this.streams.delete(stream);
|
|
319
|
+
try {
|
|
320
|
+
stream.end();
|
|
321
|
+
}
|
|
322
|
+
catch { /* already disconnected */ }
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const drained = stream.write(payload);
|
|
326
|
+
if (drained === false) {
|
|
327
|
+
this.streamPending.set(stream, pending);
|
|
328
|
+
const onDrain = () => {
|
|
329
|
+
stream.on?.('drain', onDrain);
|
|
330
|
+
const left = Math.max(0, (this.streamPending.get(stream) ?? 0) - Buffer.byteLength(payload));
|
|
331
|
+
if (left === 0)
|
|
332
|
+
this.streamPending.delete(stream);
|
|
333
|
+
else
|
|
334
|
+
this.streamPending.set(stream, left);
|
|
335
|
+
};
|
|
336
|
+
stream.on?.('drain', onDrain);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
this.streamPending.delete(stream);
|
|
340
|
+
}
|
|
253
341
|
}
|
|
254
342
|
}
|
|
255
343
|
/** Snapshot of one root's invalidation state (zero revision when never mutated). */
|
|
@@ -283,6 +371,21 @@ export class NovelDomainService extends Service {
|
|
|
283
371
|
* closed per the contract: missing id → 400 WORKSPACE_CONTEXT_MISSING, no
|
|
284
372
|
* registry → 503 WORKSPACE_REGISTRY_UNAVAILABLE, unknown id → 400 WORKSPACE_UNKNOWN.
|
|
285
373
|
*/
|
|
374
|
+
/**
|
|
375
|
+
* The workspace a browser view belongs to, for invalidation only.
|
|
376
|
+
*
|
|
377
|
+
* An unknown or absent workspace id resolves to the global bucket rather
|
|
378
|
+
* than to a rejection: the payload of a global path does not depend on the
|
|
379
|
+
* workspace, so a stale id must not turn a readable answer into an error.
|
|
380
|
+
*/
|
|
381
|
+
viewerRoot(id) {
|
|
382
|
+
if (id === undefined || id === '')
|
|
383
|
+
return LEGACY_ROOT_KEY;
|
|
384
|
+
if (this.resolveWorkspace === undefined)
|
|
385
|
+
return LEGACY_ROOT_KEY;
|
|
386
|
+
const root = this.resolveWorkspace(id);
|
|
387
|
+
return root === undefined || root === null ? LEGACY_ROOT_KEY : root;
|
|
388
|
+
}
|
|
286
389
|
resolveBrowserWorkspace(id) {
|
|
287
390
|
if (id === undefined) {
|
|
288
391
|
return { rejection: { status: 400, code: 'WORKSPACE_CONTEXT_MISSING', message: 'missing workspace id (x-dsh-workspace-id header or ?workspace= param)' } };
|
|
@@ -315,6 +418,8 @@ export class NovelDomainService extends Service {
|
|
|
315
418
|
// route does not close requests already handled by it; end them explicitly
|
|
316
419
|
// so EventSource reconnects to the newly mounted plugin after a reload.
|
|
317
420
|
const responses = new Set();
|
|
421
|
+
/** Responses the dispose cleanup already answered; handlers must not write again. */
|
|
422
|
+
const finalized = new Set();
|
|
318
423
|
const lifetime = new AbortController();
|
|
319
424
|
let disposed = false;
|
|
320
425
|
ctx.effect(() => () => {
|
|
@@ -322,24 +427,59 @@ export class NovelDomainService extends Service {
|
|
|
322
427
|
lifetime.abort();
|
|
323
428
|
for (const response of responses) {
|
|
324
429
|
this.streams.delete(response);
|
|
430
|
+
this.streamPending.delete(response);
|
|
431
|
+
finalized.add(response);
|
|
432
|
+
// A response the handler has not started must not be ended with no
|
|
433
|
+
// status and no body: Node silently drops the later writeHead/end, so
|
|
434
|
+
// the browser sees an empty 200 and pollInvalidation throws on .json(),
|
|
435
|
+
// flipping the panel to "offline" on a mere plugin reload.
|
|
325
436
|
try {
|
|
326
|
-
response.
|
|
437
|
+
if (response.headersSent !== true) {
|
|
438
|
+
response.writeHead(503, { 'content-type': 'application/json', 'cache-control': 'no-cache' });
|
|
439
|
+
response.end(JSON.stringify({ error: 'novel-domain is reloading', code: 'NOVEL_DOMAIN_DISPOSED' }));
|
|
440
|
+
}
|
|
441
|
+
else if (response.writableEnded !== true) {
|
|
442
|
+
response.end();
|
|
443
|
+
}
|
|
327
444
|
}
|
|
328
445
|
catch { /* already disconnected */ }
|
|
329
446
|
}
|
|
330
447
|
responses.clear();
|
|
448
|
+
// Do NOT clear `finalized`: a handler suspended on its upstream epoch read
|
|
449
|
+
// resumes after this cleanup and must still see that its response was
|
|
450
|
+
// already answered, or it writes a second status into a closed socket.
|
|
331
451
|
}, 'novel-domain: close live invalidation responses');
|
|
332
452
|
ctx.effect(() => register({
|
|
333
453
|
kind: 'exact',
|
|
334
454
|
path: CONFIG_ROUTE,
|
|
335
|
-
handler: (req, res) => {
|
|
455
|
+
handler: async (req, res) => {
|
|
336
456
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
337
457
|
res.writeHead(405);
|
|
338
458
|
res.end();
|
|
339
459
|
return;
|
|
340
460
|
}
|
|
341
461
|
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-cache' });
|
|
342
|
-
|
|
462
|
+
// In managed mode `this.baseUrl` is only the configured default (often
|
|
463
|
+
// the never-bound 127.0.0.1:4567); the real backend is the one `connect()`
|
|
464
|
+
// resolves. Report the live URL when there is one, and say so when the
|
|
465
|
+
// mode makes a static URL meaningless.
|
|
466
|
+
const managed = this.connect !== undefined;
|
|
467
|
+
// Await the resolution so the route answers once, with the real URL.
|
|
468
|
+
let studioUrl = this.baseUrl;
|
|
469
|
+
let available = true;
|
|
470
|
+
if (this.connect !== undefined) {
|
|
471
|
+
try {
|
|
472
|
+
const connection = await this.connect();
|
|
473
|
+
if (connection !== undefined)
|
|
474
|
+
studioUrl = connection.baseUrl;
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
available = false;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
res.end(JSON.stringify(available
|
|
481
|
+
? { studioUrl, managed }
|
|
482
|
+
: { studioUrl, managed, available: false }));
|
|
343
483
|
},
|
|
344
484
|
}), 'novel-domain: config route');
|
|
345
485
|
ctx.effect(() => register({
|
|
@@ -370,19 +510,35 @@ export class NovelDomainService extends Service {
|
|
|
370
510
|
// how polling clients observe background task transitions that never
|
|
371
511
|
// touch the bridge-local revision.
|
|
372
512
|
responses.add(res);
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
513
|
+
try {
|
|
514
|
+
const contextEpoch = await this.fetchContextEpoch(resolved.root, resolved.id, lifetime.signal);
|
|
515
|
+
// Check `disposed` while still registered: deleting `res` first made
|
|
516
|
+
// this guard unreachable, and the cleanup then ended an unstarted
|
|
517
|
+
// response as an empty 200.
|
|
518
|
+
if (disposed || finalized.has(res)) {
|
|
519
|
+
// Already answered by the dispose cleanup: its 503 notice stands.
|
|
520
|
+
if (!finalized.has(res)) {
|
|
521
|
+
sendJson(res, 503, { error: 'novel-domain is reloading', code: 'NOVEL_DOMAIN_DISPOSED' });
|
|
522
|
+
}
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
sendJson(res, 200, contextEpoch === null ? snapshot : { ...snapshot, context_epoch: contextEpoch });
|
|
526
|
+
}
|
|
527
|
+
finally {
|
|
528
|
+
responses.delete(res);
|
|
529
|
+
}
|
|
378
530
|
},
|
|
379
531
|
}), 'novel-domain: invalidation snapshot');
|
|
380
532
|
ctx.effect(() => register({
|
|
381
533
|
kind: 'exact',
|
|
382
534
|
path: EVENTS_ROUTE,
|
|
383
535
|
handler: async (req, res) => {
|
|
384
|
-
if (disposed) {
|
|
385
|
-
|
|
536
|
+
if (disposed || finalized.has(res)) {
|
|
537
|
+
// A reload ends every live response with a 503 notice; a handler that
|
|
538
|
+
// resumes afterwards must not write to it again.
|
|
539
|
+
if (!finalized.has(res)) {
|
|
540
|
+
sendJson(res, 503, { error: 'novel-domain is reloading', code: 'NOVEL_DOMAIN_DISPOSED' });
|
|
541
|
+
}
|
|
386
542
|
return;
|
|
387
543
|
}
|
|
388
544
|
if (req.method !== 'GET') {
|
|
@@ -395,6 +551,17 @@ export class NovelDomainService extends Service {
|
|
|
395
551
|
NovelDomainService.sendRejection(res, resolved.rejection);
|
|
396
552
|
return;
|
|
397
553
|
}
|
|
554
|
+
// Cap concurrent streams per root, and reap connections whose peer
|
|
555
|
+
// vanished without FIN/RST (sleep, Wi-Fi handoff) — those never emit
|
|
556
|
+
// 'close', so both maps and the socket leak until OS keepalive.
|
|
557
|
+
let live = 0;
|
|
558
|
+
for (const key of this.streams.values())
|
|
559
|
+
if (key === resolved.root)
|
|
560
|
+
live += 1;
|
|
561
|
+
if (live >= MAX_SSE_STREAMS_PER_ROOT) {
|
|
562
|
+
sendJson(res, 429, { error: 'Too many live invalidation streams for this workspace', code: 'SSE_LIMIT' });
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
398
565
|
res.writeHead(200, {
|
|
399
566
|
'content-type': 'text/event-stream',
|
|
400
567
|
'cache-control': 'no-cache',
|
|
@@ -402,16 +569,23 @@ export class NovelDomainService extends Service {
|
|
|
402
569
|
});
|
|
403
570
|
responses.add(res);
|
|
404
571
|
this.streams.set(res, resolved.root);
|
|
405
|
-
const dispose = () => { this.streams.delete(res); responses.delete(res); };
|
|
572
|
+
const dispose = () => { this.streams.delete(res); this.streamPending.delete(res); responses.delete(res); };
|
|
406
573
|
res.on?.('close', dispose);
|
|
407
574
|
res.on?.('error', dispose);
|
|
575
|
+
res.setTimeout?.(SSE_IDLE_TIMEOUT_MS, () => { dispose(); try {
|
|
576
|
+
res.end();
|
|
577
|
+
}
|
|
578
|
+
catch { /* already gone */ } });
|
|
408
579
|
// The ready event carries the upstream context epoch when it is
|
|
409
580
|
// readable; the stream waits for the fetch to settle (capped at
|
|
410
581
|
// CONTEXT_EPOCH_TIMEOUT_MS), never longer. A client gone by then
|
|
411
582
|
// simply never gets the ready event — polling carries epochs anyway.
|
|
412
583
|
const revision = this.roots.get(resolved.root)?.revision ?? 0;
|
|
413
584
|
const contextEpoch = await this.fetchContextEpoch(resolved.root, resolved.id, lifetime.signal);
|
|
414
|
-
|
|
585
|
+
// The dispose cleanup may have ended this response already (with its
|
|
586
|
+
// 503 notice); writing again would throw or be silently dropped. The
|
|
587
|
+
// entry guard above already covers `writableEnded`.
|
|
588
|
+
if (disposed || !this.streams.has(res) || finalized.has(res))
|
|
415
589
|
return;
|
|
416
590
|
const ready = { workspace_root: resolved.root, revision };
|
|
417
591
|
if (contextEpoch !== null)
|
|
@@ -433,14 +607,28 @@ export class NovelDomainService extends Service {
|
|
|
433
607
|
}
|
|
434
608
|
// Every browser request carries only a workspace_id; the trusted host
|
|
435
609
|
// registry resolves it to the canonical root (contract §2.2).
|
|
436
|
-
const
|
|
610
|
+
const method = req.method ?? 'GET';
|
|
611
|
+
const pathPart = sub.split('?')[0]?.slice(1) ?? '';
|
|
612
|
+
const isWrite = method === 'POST' || method === 'PUT';
|
|
613
|
+
// A global path (the model library) is answered without a workspace:
|
|
614
|
+
// its payload lives outside every project, and the settings dialog has
|
|
615
|
+
// no workspace bound to send.
|
|
616
|
+
const isGlobal = GLOBAL_PATHS.has(pathPart);
|
|
617
|
+
const resolved = isGlobal
|
|
618
|
+
? { root: '', id: '' }
|
|
619
|
+
: this.resolveBrowserWorkspace(headerValue(req, 'x-dsh-workspace-id'));
|
|
437
620
|
if ('rejection' in resolved) {
|
|
438
621
|
NovelDomainService.sendRejection(res, resolved.rejection);
|
|
439
622
|
return;
|
|
440
623
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
624
|
+
// A global path is answered without a workspace: its payload lives
|
|
625
|
+
// outside every project. The view that asked is still a view, though —
|
|
626
|
+
// its own workspace has to learn that the model library moved. The
|
|
627
|
+
// upstream call stays workspace-free; only the invalidation follows the
|
|
628
|
+
// requester, and a request that named no workspace stays global.
|
|
629
|
+
const mutationRoot = isGlobal
|
|
630
|
+
? this.viewerRoot(headerValue(req, 'x-dsh-workspace-id'))
|
|
631
|
+
: resolved.root;
|
|
444
632
|
if (!isWrite && method !== 'GET' && method !== 'HEAD') {
|
|
445
633
|
sendJson(res, 405, { error: 'novel-domain proxy allows only GET/HEAD and allowlisted writes' });
|
|
446
634
|
return;
|
|
@@ -453,8 +641,10 @@ export class NovelDomainService extends Service {
|
|
|
453
641
|
try {
|
|
454
642
|
const headers = {
|
|
455
643
|
accept: 'application/json',
|
|
456
|
-
|
|
457
|
-
|
|
644
|
+
// A global path carries no workspace identity at all: the Core reads
|
|
645
|
+
// a blank root header as "legacy mode" and serves the request from
|
|
646
|
+
// the root app, whose model library is the user's global one.
|
|
647
|
+
...(isGlobal ? {} : { ...workspaceRootHeaders(resolved.root, true), 'x-openwrite-workspace-id': resolved.id }),
|
|
458
648
|
};
|
|
459
649
|
const sessionId = headerValue(req, 'x-dsh-session-id');
|
|
460
650
|
if (sessionId !== undefined)
|
|
@@ -477,12 +667,27 @@ export class NovelDomainService extends Service {
|
|
|
477
667
|
if (lifetime.aborted)
|
|
478
668
|
return;
|
|
479
669
|
const actualRoot = upstream.headers.get('X-OpenWrite-Project-Path');
|
|
480
|
-
|
|
670
|
+
// A global path asked for no project, so the root the upstream reports
|
|
671
|
+
// is the only one it can have: comparing it against an empty request
|
|
672
|
+
// root would reject every answer.
|
|
673
|
+
if (!isGlobal && upstream.ok && actualRoot && !workspaceRootsEqual(decodeURIComponent(actualRoot), resolved.root)) {
|
|
481
674
|
await upstream.body?.cancel();
|
|
482
675
|
sendJson(res, 409, { error: 'Connected workspace differs from requested project', code: 'WORKSPACE_CONTEXT_MISMATCH', details: { requested_path: resolved.root, project_path: decodeURIComponent(actualRoot) } });
|
|
483
676
|
return;
|
|
484
677
|
}
|
|
678
|
+
// Bound the buffer: an export or project archive is read fully into
|
|
679
|
+
// memory, and `content-length` is computed from it afterwards.
|
|
680
|
+
const declaredLength = Number(upstream.headers.get('content-length') ?? '');
|
|
681
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_PROXY_DOWNLOAD_BYTES) {
|
|
682
|
+
await upstream.body?.cancel();
|
|
683
|
+
sendJson(res, 502, { error: 'The download is too large to proxy', code: 'DOWNLOAD_TOO_LARGE', details: { limit: MAX_PROXY_DOWNLOAD_BYTES } });
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
485
686
|
const upstreamBytes = Buffer.from(await upstream.arrayBuffer());
|
|
687
|
+
if (upstreamBytes.byteLength > MAX_PROXY_DOWNLOAD_BYTES) {
|
|
688
|
+
sendJson(res, 502, { error: 'The download is too large to proxy', code: 'DOWNLOAD_TOO_LARGE', details: { limit: MAX_PROXY_DOWNLOAD_BYTES } });
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
486
691
|
const bytes = compactTaskList(upstreamBytes, pathPart, method);
|
|
487
692
|
const responseHeaders = {
|
|
488
693
|
'content-type': upstream.headers.get('content-type') ?? 'application/json',
|
|
@@ -500,17 +705,33 @@ export class NovelDomainService extends Service {
|
|
|
500
705
|
res.writeHead(upstream.status, responseHeaders);
|
|
501
706
|
res.end(bytes);
|
|
502
707
|
if (isWrite && upstream.ok && mutationAffectsState(pathPart, body, upstream.headers)) {
|
|
503
|
-
this.notifyMutation(`/api/${pathPart}`, { workspaceRoot:
|
|
708
|
+
this.notifyMutation(`/api/${pathPart}`, { workspaceRoot: mutationRoot, workspaceId: resolved.id });
|
|
504
709
|
}
|
|
505
710
|
}
|
|
506
711
|
catch (error) {
|
|
507
|
-
|
|
712
|
+
// A browser that disconnects mid-upload makes readBody reject; writing a
|
|
713
|
+
// response into a dead socket throws again and masks the real cause.
|
|
714
|
+
if (!lifetime.aborted && !res.headersSent && res.writableEnded !== true) {
|
|
508
715
|
const failure = error;
|
|
509
716
|
if (failure.code === 'RUNTIME_PLATFORM_UNSUPPORTED') {
|
|
510
717
|
sendJson(res, 503, { error: failure.message, code: failure.code });
|
|
511
718
|
}
|
|
512
|
-
else
|
|
513
|
-
sendJson(res,
|
|
719
|
+
else if (failure.code === 'PAYLOAD_TOO_LARGE') {
|
|
720
|
+
sendJson(res, 413, { error: failure.message, code: failure.code });
|
|
721
|
+
}
|
|
722
|
+
else if (failure.code === 'DOWNLOAD_TOO_LARGE') {
|
|
723
|
+
sendJson(res, 502, { error: failure.message, code: failure.code });
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
// Preserve the upstream identity: the panel maps specific codes
|
|
727
|
+
// (DOCUMENT_CONFLICT, STALE_REVISION, WORKSPACE_NOT_INITIALIZED, …)
|
|
728
|
+
// to localized, actionable messages. Collapsing them into one 502
|
|
729
|
+
// turns a recoverable conflict into an opaque failure.
|
|
730
|
+
const status = typeof failure.status === 'number' && failure.status >= 400 && failure.status < 600 ? failure.status : 502;
|
|
731
|
+
const code = typeof failure.code === 'string' && failure.code !== '' ? failure.code : 'STUDIO_UNAVAILABLE';
|
|
732
|
+
const message = status === 502 ? '写作环境暂不可用,请打开环境与诊断重试' : (failure.message ?? 'Studio request failed');
|
|
733
|
+
sendJson(res, status, { error: message, code, ...(failure.details === undefined ? {} : { details: failure.details }) });
|
|
734
|
+
}
|
|
514
735
|
}
|
|
515
736
|
}
|
|
516
737
|
finally {
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A loopback-only, OpenAI-compatible relay onto the host's own model
|
|
3
|
+
* directory.
|
|
4
|
+
*
|
|
5
|
+
* OpenWrite's Core speaks the OpenAI chat-completions protocol (LiteLLM), so
|
|
6
|
+
* giving it a plain HTTP endpoint is enough to make every model dsh itself can
|
|
7
|
+
* reach — the host's own adapters and the models other plugins register into
|
|
8
|
+
* the same directory — usable from a writing task. The relay never sees a
|
|
9
|
+
* provider key: the host's `llm` service owns the credentials and performs the
|
|
10
|
+
* call, so the relay only translates a request shape.
|
|
11
|
+
*
|
|
12
|
+
* The relay is deliberately NOT mounted on the host's web server. That port is
|
|
13
|
+
* browser-authenticated and possibly reachable from the LAN; this one is bound
|
|
14
|
+
* to 127.0.0.1 on an ephemeral port and answers only to a machine token that
|
|
15
|
+
* is handed to the Core process over its private stdio handshake.
|
|
16
|
+
*/
|
|
17
|
+
/** Structural view of the host's `llm` service (`ctx.get('llm')`). */
|
|
18
|
+
export interface HostLlmService {
|
|
19
|
+
listProviders(): Array<{
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
}>;
|
|
23
|
+
listConfigurableProviders?(): Array<{
|
|
24
|
+
provider: string;
|
|
25
|
+
displayName: string;
|
|
26
|
+
settingsNs: string;
|
|
27
|
+
}>;
|
|
28
|
+
listModels(provider: string): Promise<Array<{
|
|
29
|
+
provider: string;
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
}>>;
|
|
34
|
+
resolveModelInfo?(provider: string, model: string, signal?: AbortSignal): Promise<{
|
|
35
|
+
context?: {
|
|
36
|
+
contextWindow?: number;
|
|
37
|
+
};
|
|
38
|
+
}>;
|
|
39
|
+
stream(options: HostGenerateOptions): AsyncIterable<HostStreamChunk>;
|
|
40
|
+
}
|
|
41
|
+
/** Why the host's model call stopped. A failure carries the provider's words. */
|
|
42
|
+
export interface HostFinishReason {
|
|
43
|
+
kind: 'stop' | 'tool-calls' | 'max-tokens' | 'aborted' | 'error' | string;
|
|
44
|
+
failure?: {
|
|
45
|
+
message?: string;
|
|
46
|
+
code?: string;
|
|
47
|
+
status?: number;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export interface HostGenerateOptions {
|
|
51
|
+
provider: string;
|
|
52
|
+
model: string;
|
|
53
|
+
messages: unknown[];
|
|
54
|
+
temperature?: number;
|
|
55
|
+
maxTokens?: number;
|
|
56
|
+
stop?: string[];
|
|
57
|
+
tools?: unknown[];
|
|
58
|
+
reasoningEffort?: string;
|
|
59
|
+
signal?: AbortSignal;
|
|
60
|
+
}
|
|
61
|
+
export type HostStreamChunk = {
|
|
62
|
+
type: 'block-start';
|
|
63
|
+
index: number;
|
|
64
|
+
blockType: string;
|
|
65
|
+
} | {
|
|
66
|
+
type: 'text-delta';
|
|
67
|
+
index: number;
|
|
68
|
+
text: string;
|
|
69
|
+
} | {
|
|
70
|
+
type: 'reasoning-delta';
|
|
71
|
+
index: number;
|
|
72
|
+
text: string;
|
|
73
|
+
} | {
|
|
74
|
+
type: 'tool-call-delta';
|
|
75
|
+
index: number;
|
|
76
|
+
id: string;
|
|
77
|
+
name?: string;
|
|
78
|
+
argumentsDelta: string;
|
|
79
|
+
} | {
|
|
80
|
+
type: 'block-end';
|
|
81
|
+
index: number;
|
|
82
|
+
block: {
|
|
83
|
+
type: string;
|
|
84
|
+
text?: string;
|
|
85
|
+
id?: string;
|
|
86
|
+
name?: string;
|
|
87
|
+
arguments?: string;
|
|
88
|
+
};
|
|
89
|
+
} | {
|
|
90
|
+
type: 'usage';
|
|
91
|
+
usage: {
|
|
92
|
+
inputTokens?: number;
|
|
93
|
+
outputTokens?: number;
|
|
94
|
+
totalTokens?: number;
|
|
95
|
+
};
|
|
96
|
+
} | {
|
|
97
|
+
type: 'finish';
|
|
98
|
+
reason: string | HostFinishReason;
|
|
99
|
+
};
|
|
100
|
+
/** The host's own default-model selection, when the host composes that service. */
|
|
101
|
+
export type HostDefaultSelection = () => {
|
|
102
|
+
provider: string;
|
|
103
|
+
model: string;
|
|
104
|
+
reasoningEffort?: string;
|
|
105
|
+
} | undefined;
|
|
106
|
+
/** Pseudo model id meaning "whatever dsh itself would use right now". */
|
|
107
|
+
export declare const HOST_CURRENT_MODEL = "host/current";
|
|
108
|
+
/** One directory entry the Core side renders as a selectable model. */
|
|
109
|
+
export interface HostModelEntry {
|
|
110
|
+
/** `provider/model` route, or {@link HOST_CURRENT_MODEL}. */
|
|
111
|
+
id: string;
|
|
112
|
+
provider: string;
|
|
113
|
+
model: string;
|
|
114
|
+
label: string;
|
|
115
|
+
description: string;
|
|
116
|
+
/** The route's own context budget, when the adapter declares one. */
|
|
117
|
+
contextTokens: number;
|
|
118
|
+
}
|
|
119
|
+
export interface HostModelCatalog {
|
|
120
|
+
available: boolean;
|
|
121
|
+
providers: Array<{
|
|
122
|
+
id: string;
|
|
123
|
+
name: string;
|
|
124
|
+
}>;
|
|
125
|
+
models: HostModelEntry[];
|
|
126
|
+
/** Present when the host composes a default-model service. */
|
|
127
|
+
current: {
|
|
128
|
+
provider: string;
|
|
129
|
+
model: string;
|
|
130
|
+
} | null;
|
|
131
|
+
error: string;
|
|
132
|
+
}
|
|
133
|
+
export interface HostLlmRelayOptions {
|
|
134
|
+
llm: HostLlmService;
|
|
135
|
+
/** Machine token; every request must present it as a bearer credential. */
|
|
136
|
+
token: string;
|
|
137
|
+
/** The host's default-model selection, when the host composes that service. */
|
|
138
|
+
defaultSelection?: HostDefaultSelection;
|
|
139
|
+
/**
|
|
140
|
+
* How long one provider's directory may take before the catalog gives up on
|
|
141
|
+
* it. Overridable so the gate can be exercised without waiting for it.
|
|
142
|
+
*/
|
|
143
|
+
providerDeadlineMs?: number;
|
|
144
|
+
/**
|
|
145
|
+
* How long one model's context lookup may take. It is optional metadata, so
|
|
146
|
+
* a slow lookup must cost the entry its context window, never the entry.
|
|
147
|
+
*/
|
|
148
|
+
modelDeadlineMs?: number;
|
|
149
|
+
}
|
|
150
|
+
export declare class HostLlmRelay {
|
|
151
|
+
private readonly options;
|
|
152
|
+
private server?;
|
|
153
|
+
private port;
|
|
154
|
+
private closed;
|
|
155
|
+
constructor(options: HostLlmRelayOptions);
|
|
156
|
+
/** Bind the loopback port. Idempotent. */
|
|
157
|
+
listen(): Promise<void>;
|
|
158
|
+
/** The OpenAI-compatible base URL Core should use. */
|
|
159
|
+
baseUrl(): string;
|
|
160
|
+
/** The machine token Core must present. */
|
|
161
|
+
token(): string;
|
|
162
|
+
close(): Promise<void>;
|
|
163
|
+
/** The host's model directory, including the models other plugins registered. */
|
|
164
|
+
catalog(): Promise<HostModelCatalog>;
|
|
165
|
+
/** One provider's models, or none when that provider did not answer in time. */
|
|
166
|
+
private providerModels;
|
|
167
|
+
/** The route's own context budget, or 0 when the adapter declares none. */
|
|
168
|
+
private contextTokens;
|
|
169
|
+
/** Split `provider/model`; the first segment is the provider route. */
|
|
170
|
+
private resolveRoute;
|
|
171
|
+
private handle;
|
|
172
|
+
private authorized;
|
|
173
|
+
private chatCompletions;
|
|
174
|
+
/**
|
|
175
|
+
* Retry once without the reasoning effort when the host refuses it.
|
|
176
|
+
*
|
|
177
|
+
* A model that declares no reasoning support rejects an explicit effort
|
|
178
|
+
* (the host reports `UNSUPPORTED_REASONING_EFFORT` as a terminal finish).
|
|
179
|
+
* Asking for "no reasoning" must never turn into a failed call, so the
|
|
180
|
+
* second attempt drops the effort and lets the provider decide. The retry
|
|
181
|
+
* only happens before any content was produced: the refusal arrives as the
|
|
182
|
+
* first chunk, so nothing has been written to the client yet.
|
|
183
|
+
*/
|
|
184
|
+
private withEffortFallback;
|
|
185
|
+
private writeChunk;
|
|
186
|
+
}
|
|
187
|
+
/** A fresh machine token for one relay instance. */
|
|
188
|
+
export declare function newRelayToken(): string;
|