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.
Files changed (130) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +55 -6
  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 +5 -3
  7. package/docs/COMPANION_PLUGINS.md +142 -0
  8. package/docs/CONTEXT_SLOTS.md +60 -0
  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 +17 -0
  13. package/docs/INSTALL.md +112 -4
  14. package/docs/ISSUES_121_123.md +97 -0
  15. package/docs/ISSUES_140_142.md +58 -0
  16. package/docs/ISSUES_149_150_151.md +77 -0
  17. package/docs/ISSUES_98_100.md +2 -0
  18. package/docs/ISSUE_123_FOLLOWUP.md +55 -0
  19. package/docs/ISSUE_146_REVIEW_EVIDENCE.md +53 -0
  20. package/docs/LEARNING.md +1 -1
  21. package/docs/MOBILE_READINESS.md +62 -0
  22. package/docs/PLUGIN_MAINTENANCE.md +1 -3
  23. package/docs/RELEASE_ACCEPTANCE.md +90 -3
  24. package/docs/REVIEW_ROUND_22.md +67 -0
  25. package/docs/REVIEW_ROUND_23.md +48 -0
  26. package/docs/REVIEW_ROUND_25.md +51 -0
  27. package/docs/SELECTION_UPGRADE_REPAIRS.md +16 -0
  28. package/docs/TOOLS.md +933 -0
  29. package/docs/WORKFLOWS.md +5 -5
  30. package/package.json +18 -21
  31. package/packages/openwrite-bridge/lib/client.d.ts +0 -1
  32. package/packages/openwrite-bridge/lib/client.js +90 -9
  33. package/packages/openwrite-bridge/lib/contracts-generated.js +2 -1
  34. package/packages/openwrite-bridge/lib/dog-delivery.d.ts +1 -0
  35. package/packages/openwrite-bridge/lib/dog-delivery.js +10 -4
  36. package/packages/openwrite-bridge/lib/dog-review.js +15 -3
  37. package/packages/openwrite-bridge/lib/domain.d.ts +27 -0
  38. package/packages/openwrite-bridge/lib/domain.js +250 -29
  39. package/packages/openwrite-bridge/lib/host-llm.d.ts +188 -0
  40. package/packages/openwrite-bridge/lib/host-llm.js +657 -0
  41. package/packages/openwrite-bridge/lib/host-web.d.ts +61 -0
  42. package/packages/openwrite-bridge/lib/host-web.js +166 -0
  43. package/packages/openwrite-bridge/lib/index.d.ts +4 -0
  44. package/packages/openwrite-bridge/lib/index.js +150 -6
  45. package/packages/openwrite-bridge/lib/managed-runtime.d.ts +47 -2
  46. package/packages/openwrite-bridge/lib/managed-runtime.js +179 -35
  47. package/packages/openwrite-bridge/lib/tool-policy.js +39 -12
  48. package/packages/openwrite-bridge/lib/tools.js +1238 -1219
  49. package/packages/openwrite-bridge/package.json +7 -5
  50. package/packages/studio-panel/lib/client.js +116667 -114773
  51. package/packages/studio-panel/lib/client.js.map +1 -1
  52. package/packages/studio-panel/lib/types/client/LaunchOpenWrite.d.ts +10 -0
  53. package/packages/studio-panel/lib/types/client/ModelView.d.ts +28 -4
  54. package/packages/studio-panel/lib/types/client/OpenWriteSettings.d.ts +64 -0
  55. package/packages/studio-panel/lib/types/client/SceneWorkbench.d.ts +2 -0
  56. package/packages/studio-panel/lib/types/client/SearchSettings.d.ts +2 -0
  57. package/packages/studio-panel/lib/types/client/SerialWritingView.d.ts +2 -1
  58. package/packages/studio-panel/lib/types/client/TurnMutationSummary.d.ts +2 -0
  59. package/packages/studio-panel/lib/types/client/VditorBody.d.ts +8 -2
  60. package/packages/studio-panel/lib/types/client/api.d.ts +41 -2
  61. package/packages/studio-panel/lib/types/client/browser-download.d.ts +4 -0
  62. package/packages/studio-panel/lib/types/client/companions.d.ts +46 -0
  63. package/packages/studio-panel/lib/types/client/dto.d.ts +22 -0
  64. package/packages/studio-panel/lib/types/client/locales.d.ts +1 -1
  65. package/packages/studio-panel/lib/types/client/manuscript-annotations.d.ts +2 -1
  66. package/packages/studio-panel/lib/types/client/manuscript-overlay.d.ts +2 -1
  67. package/packages/studio-panel/lib/types/client/manuscript-selection.d.ts +18 -5
  68. package/packages/studio-panel/lib/types/client/selection-coordinates.d.ts +15 -0
  69. package/packages/studio-panel/lib/types/client/workspace-chrome.d.ts +41 -0
  70. package/packages/studio-panel/package.json +5 -3
  71. package/plugin.mjs +159 -20
  72. package/preset-definition.mjs +144 -4
  73. package/presets/openwrite/agent.cordis.yml +7 -9
  74. package/presets/openwrite/ops-integration.mjs +28 -0
  75. package/presets/openwrite/preset.yml +2 -1
  76. package/presets/openwrite/skills/canon/SKILL.md +3 -3
  77. package/presets/openwrite/skills/delivery-artifact-query/SKILL.md +41 -0
  78. package/presets/openwrite/skills/dialoguequality/SKILL.md +16 -17
  79. package/presets/openwrite/skills/dog-delivery-verification/SKILL.md +113 -0
  80. package/presets/openwrite/skills/export-book/SKILL.md +3 -3
  81. package/presets/openwrite/skills/foreshadow/SKILL.md +1 -1
  82. package/presets/openwrite/skills/foreshadowing-system/SKILL.md +16 -25
  83. package/presets/openwrite/skills/import-artifact-query/SKILL.md +30 -0
  84. package/presets/openwrite/skills/learn/SKILL.md +3 -3
  85. package/presets/openwrite/skills/novel-creator/SKILL.md +10 -10
  86. package/presets/openwrite/skills/novel-reviewer/SKILL.md +11 -11
  87. package/presets/openwrite/skills/progress/SKILL.md +1 -1
  88. package/presets/openwrite/skills/review-artifact-query/SKILL.md +41 -0
  89. package/presets/openwrite/skills/review-chapter/SKILL.md +2 -2
  90. package/presets/openwrite/skills/revise-span/SKILL.md +5 -5
  91. package/presets/openwrite/skills/style-system/SKILL.md +13 -6
  92. package/presets/openwrite/skills/truth-validation/SKILL.md +9 -12
  93. package/presets/openwrite/skills/workflow-manager/SKILL.md +13 -13
  94. package/presets/openwrite/skills/world-query/SKILL.md +16 -11
  95. package/presets/openwrite/skills/write-next/SKILL.md +1 -1
  96. package/release/{openwrite-5.8.16-py3-none-any.whl → openwrite-5.8.30-py3-none-any.whl} +0 -0
  97. package/release/runtime-manifest.json +4 -12
  98. package/release/source.json +21 -21
  99. package/release/wheels/licenses/OPENSSL-3.6.3-LICENSE.txt +225 -0
  100. package/scripts/maintenance.mjs +67 -7
  101. package/scripts/plugin-doctor.mjs +179 -0
  102. package/scripts/prepare.mjs +11 -2
  103. package/scripts/safe-upgrade.mjs +141 -13
  104. package/dog-tools.mjs +0 -11
  105. package/presets/openwrite/skills/dog-delivery-query/SKILL.md +0 -42
  106. package/presets/openwrite/skills/dog-import-query/SKILL.md +0 -29
  107. package/presets/openwrite/skills/dog-review-query/SKILL.md +0 -42
  108. package/scripts/dog/delivery-stage.js +0 -32
  109. package/scripts/dog/import-chapter.js +0 -20
  110. package/scripts/dog/import-record.js +0 -35
  111. package/scripts/dog/review-dimension.js +0 -41
  112. package/scripts/dog/review-record.js +0 -42
  113. package/vendor/dsh-dog/LICENSE +0 -29
  114. package/vendor/dsh-dog/README.md +0 -114
  115. package/vendor/dsh-dog/UPSTREAM.md +0 -30
  116. package/vendor/dsh-dog/cordis.patch.yml +0 -14
  117. package/vendor/dsh-dog/lib/client.js +0 -2960
  118. package/vendor/dsh-dog/lib/client.js.map +0 -1
  119. package/vendor/dsh-dog/lib/core-BAlgZpLq.js +0 -2074
  120. package/vendor/dsh-dog/lib/core-BiEBMLz3.d.ts +0 -495
  121. package/vendor/dsh-dog/lib/core.d.ts +0 -2
  122. package/vendor/dsh-dog/lib/core.js +0 -2
  123. package/vendor/dsh-dog/lib/index.d.ts +0 -143
  124. package/vendor/dsh-dog/lib/index.js +0 -1279
  125. package/vendor/dsh-dog/package.json +0 -110
  126. package/vendor/dsh-dog/schemas/schema-0.2/graph.schema.json +0 -139
  127. package/vendor/dsh-dog/schemas/schema-0.2/report.schema.json +0 -87
  128. package/vendor/dsh-dog/schemas/schema-0.2/run.schema.json +0 -259
  129. package/vendor/dsh-dog/schemas/schema-0.2/runtime-event.schema.json +0 -105
  130. package/vendor/dsh-dog/schemas/schema-0.2/verification.schema.json +0 -40
@@ -1,8 +1,11 @@
1
- /** Author-visible manuscript range used by selection polish and the editor. */
1
+ import { type EditorSerialization } from './selection-coordinates.ts';
2
+ /** Editor UTF-16 positions, plus the exact editor value which produced them. */
2
3
  export type ManuscriptSelection = {
3
4
  start: number;
4
5
  end: number;
5
6
  text: string;
7
+ source: string;
8
+ serialization?: EditorSerialization | undefined;
6
9
  };
7
10
  export type SelectionPolishKind = 'expand' | 'compress' | 'naturalize' | 'reviewFix';
8
11
  export declare const SELECTION_POLISH_ACTIONS: readonly {
@@ -17,7 +20,12 @@ export declare function shouldClearManuscriptSelection(options: {
17
20
  selectionInsideEditor: boolean;
18
21
  preserveNode: EventTarget | Node | null;
19
22
  }): boolean;
20
- export declare function manuscriptSelectionFromRange(value: string, start: number, end: number): ManuscriptSelection | null;
23
+ export declare function manuscriptSelectionFromRange(value: string, start: number, end: number, serialization?: EditorSerialization): ManuscriptSelection | null;
24
+ export declare function mapManuscriptSelection(source: string, selection: ManuscriptSelection): {
25
+ start: number;
26
+ end: number;
27
+ text: string;
28
+ } | null;
21
29
  /** Count non-overlapping occurrences of needle in haystack. */
22
30
  export declare function countOccurrences(haystack: string, needle: string): number;
23
31
  /** Zero-based nth occurrence start, or -1. */
@@ -26,7 +34,7 @@ export declare function nthIndexOf(haystack: string, needle: string, occurrence:
26
34
  * Map a live editor selection onto markdown offsets.
27
35
  * Duplicate quotes without an occurrence index do not bind the first hit.
28
36
  */
29
- export declare function locateSelectedMarkdown(value: string, selected: string, occurrence?: number): ManuscriptSelection | null;
37
+ export declare function locateSelectedMarkdown(value: string, selected: string, occurrence?: number, serialization?: EditorSerialization): ManuscriptSelection | null;
30
38
  export type QuoteAnchor = {
31
39
  state: 'attached' | 'relocated';
32
40
  start: number;
@@ -38,20 +46,25 @@ export type QuoteAnchor = {
38
46
  /** Bind a stored quote to current markdown using hints, then unique relocate. */
39
47
  export declare function locateQuote(content: string, quote: string, startHint: number, endHint: number): QuoteAnchor;
40
48
  /** Review issues whose evidence sits inside the current manuscript selection. */
41
- export declare function reviewIssuesForSelection(issues: readonly unknown[], selection: ManuscriptSelection): string[];
49
+ export declare function reviewIssuesForSelection(issues: readonly unknown[], selection: {
50
+ start: number;
51
+ end: number;
52
+ text: string;
53
+ }): string[];
42
54
  export type SelectionPolishRequest = {
43
55
  ok: true;
44
56
  path: '/revisions/selection' | '/revisions/from-review';
45
57
  body: Record<string, unknown>;
46
58
  } | {
47
59
  ok: false;
48
- reason: 'empty' | 'no-review' | 'no-issues';
60
+ reason: 'empty' | 'no-review' | 'no-issues' | 'source-changed';
49
61
  };
50
62
  /** Map one author action onto the existing Studio revision-create routes. */
51
63
  export declare function selectionPolishRequest(input: {
52
64
  kind: SelectionPolishKind;
53
65
  chapterId: string;
54
66
  selection: ManuscriptSelection;
67
+ documentContent: string;
55
68
  documentRevision: string;
56
69
  reviewRevision: string;
57
70
  reviewFresh: boolean;
@@ -0,0 +1,15 @@
1
+ /** Exact mapping between an editor's UTF-16 view and the saved source's code points. */
2
+ export type SourceRange = {
3
+ start: number;
4
+ end: number;
5
+ text: string;
6
+ };
7
+ /** A pair captured from the editor's initial serialization, before author input. */
8
+ export type EditorSerialization = {
9
+ source: string;
10
+ value: string;
11
+ };
12
+ /** Exact content and witnessed newline serialization; never guess among quotes. */
13
+ export declare function mapEditorRange(source: string, editor: string, start: number, end: number, serialization?: EditorSerialization): SourceRange | null;
14
+ /** Translate stored source code-point hints back into the editor's UTF-16 view. */
15
+ export declare function mapSourceRange(source: string, editor: string, start: number, end: number, serialization?: EditorSerialization): SourceRange | null;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Browser chrome the OpenWrite workbench owns inside the host conversation shell.
3
+ *
4
+ * The host parks its composer in a seat element (`[data-composer-seat]`) inside
5
+ * the conversation scroller (`[data-conversation-scroll]`). The seat is what
6
+ * pushed the workbench's bottom edge up: the host reserved the seat's band out
7
+ * of the content area, so our panel stopped short of the viewport bottom and
8
+ * left a bare white slab under the composer — a flat white strip, not a
9
+ * composer floating over the workbench.
10
+ *
11
+ * The OpenWrite workbench is a full-screen surface, not a chat transcript: it
12
+ * has its own navigation, its own panes and its own scrolling. A centered
13
+ * composer card floating over that layout covers real content (the outline
14
+ * card was cut in half by it). So the seat is hidden while one of our views is
15
+ * mounted and the workbench takes the whole content area.
16
+ *
17
+ * The host exposes no slot for "hide the composer". `conversation.composer` is
18
+ * a chain, but a chain selector can only read session state, not the active
19
+ * view, and it runs before the view mounts — so it cannot answer "is this our
20
+ * view?" at the moment it is asked. A `:has()` rule over the host's own stable
21
+ * data attributes is the one hook that reacts to the mounted view, and it is
22
+ * scoped to our pages: the host's Chat and Trajectory views keep their
23
+ * composer because they never carry the marker.
24
+ */
25
+ /** Marker attribute carried by the root element of every OpenWrite workbench view. */
26
+ export declare const WORKSPACE_MARKER_ATTRIBUTE = "data-openwrite-workspace";
27
+ /**
28
+ * The rule that hides the host composer seat while an OpenWrite view is mounted.
29
+ *
30
+ * `:has()` matches the scroller that *contains* a workbench marker, and the
31
+ * direct-child combinator pins the rule to that scroller's own seat, so a seat
32
+ * belonging to some other scroller is never touched.
33
+ */
34
+ export declare const WORKSPACE_CHROME_CSS = "[data-conversation-scroll]:has([data-openwrite-workspace]) > [data-composer-seat] { display: none; }";
35
+ /**
36
+ * Install the workbench chrome stylesheet once per document.
37
+ *
38
+ * Safe to call from every client activation: the second call finds the
39
+ * existing element and returns.
40
+ */
41
+ export declare function installWorkspaceChromeStyles(): void;
@@ -35,13 +35,15 @@
35
35
  "platform": "web"
36
36
  },
37
37
  "compatibility": {
38
- "dsh": "0.1.2-rc.1 || 0.1.5-alpha.1 || 0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3",
38
+ "dsh": "0.1.2-rc.1 || 0.1.5-alpha.1 || 0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5",
39
39
  "dshReleases": {
40
40
  "0.1.2-rc.1": "compatible",
41
41
  "0.1.5-alpha.1": "compatible",
42
42
  "0.2.0-rc.2": "compatible",
43
43
  "0.2.1-rc.2": "compatible",
44
- "0.2.1-rc.3": "compatible"
44
+ "0.2.1-rc.3": "compatible",
45
+ "0.2.1-rc.4": "compatible",
46
+ "0.2.1-rc.5": "compatible"
45
47
  }
46
48
  }
47
49
  },
@@ -71,7 +73,7 @@
71
73
  },
72
74
  "peerDependencies": {
73
75
  "@deepseek-ai/cordis": "4.0.2 || ~4.0.4",
74
- "@deepseek-ai/dsh-host-webserver": "0.1.2-rc.1 || 0.1.5-alpha.1 || 0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3",
76
+ "@deepseek-ai/dsh-host-webserver": "0.1.2-rc.1 || 0.1.5-alpha.1 || 0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5",
75
77
  "react": "^18.2.0",
76
78
  "react-dom": "^18.2.0"
77
79
  },
package/plugin.mjs CHANGED
@@ -3,23 +3,122 @@ import { createHash, randomUUID } from 'node:crypto'
3
3
  import { lock } from 'proper-lockfile'
4
4
  import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
5
5
  import { join } from 'node:path'
6
- import { fileURLToPath } from 'node:url'
7
- import { legacyPresetIds, presetDefinition } from './preset-definition.mjs'
6
+ import { fileURLToPath, pathToFileURL } from 'node:url'
7
+ import { createRequire } from 'node:module'
8
+ import { legacyPresetIds, legacyAliasName, presetDefinition, referencedPresetIds } from './preset-definition.mjs'
9
+ import { detectCompanions, OPENWRITE_OPS_OPTIONS, summarizeOpsPlan } from './companions.mjs'
8
10
 
9
11
  export const name = 'dsh-openwrite'
10
12
 
11
- async function digestTree(dir) {
12
- const hash = createHash('sha256')
13
- async function visit(path, prefix = '') {
14
- for (const entry of (await readdir(path, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
15
- if (entry.name === '.openwrite-managed.json') continue
16
- const key = prefix + entry.name
17
- hash.update(key)
18
- if (entry.isDirectory()) await visit(join(path, entry.name), key + '/')
19
- else hash.update(await readFile(join(path, entry.name)))
13
+ /**
14
+ * Which companion plugins are installed and loadable here, for the workbench's
15
+ * companion section. Best-effort by design: an unreadable home reports nothing
16
+ * rather than failing the route.
17
+ */
18
+ async function loadOpsResolver(home, entry) {
19
+ // The companion may live in a profile whose node_modules is not on this
20
+ // plugin's resolution path (dsh-openwrite can be a symlinked dev install), so
21
+ // resolve from the profile that actually owns dsh-ops first.
22
+ const candidates = []
23
+ if (typeof entry.profile === 'string' && entry.profile !== '') {
24
+ try {
25
+ const fromProfile = createRequire(join(home, 'profiles', entry.profile, 'package.json'))
26
+ candidates.push(fromProfile.resolve('dsh-ops/integration'))
27
+ } catch { /* fall through to the normal resolution path */ }
28
+ }
29
+ candidates.push('dsh-ops/integration')
30
+ for (const candidate of candidates) {
31
+ try {
32
+ const module = await import(candidate.startsWith('/') ? pathToFileURL(candidate).href : candidate)
33
+ if (typeof module.resolveIntegrationConfig === 'function') return module.resolveIntegrationConfig
34
+ } catch { /* try the next candidate */ }
35
+ }
36
+ return null
37
+ }
38
+
39
+ async function opsPlan(home, entry) {
40
+ if (!entry.usable) return null
41
+ try {
42
+ // Read-only normalization: the same options the preset row uses. A dsh-ops
43
+ // release without this export (or one that changed the option names) is
44
+ // reported as no plan rather than failing the companion inventory.
45
+ const resolveIntegrationConfig = await loadOpsResolver(home, entry)
46
+ if (resolveIntegrationConfig === null) return null
47
+ return summarizeOpsPlan(resolveIntegrationConfig({ ...OPENWRITE_OPS_OPTIONS }))
48
+ } catch { return null }
49
+ }
50
+
51
+ export async function companionSummary(home) {
52
+ try {
53
+ const companions = await detectCompanions(home)
54
+ const describe = entry => ({ installed: entry.installed, usable: entry.usable, version: entry.version, reason: entry.reason })
55
+ return {
56
+ host: companions.host,
57
+ dog: describe(companions.dog),
58
+ ops: { ...describe(companions.ops), plan: await opsPlan(home, companions.ops) },
59
+ freeModel: describe(companions.freeModel),
20
60
  }
61
+ } catch { return null }
62
+ }
63
+
64
+ /**
65
+ * The host's own default-model selection, read from the service the host
66
+ * exposes to plugins (`ctx.agentDefaultModel`).
67
+ *
68
+ * The API key is deliberately NOT part of this answer. The selection is a fact
69
+ * the author already sees in the host's own settings; the key is a secret that
70
+ * must never travel to a browser, and the host keeps it in its credentials
71
+ * service rather than in this selection at all.
72
+ *
73
+ * A host that does not compose the service, or one whose selection is not
74
+ * configured yet, answers `available: false` instead of throwing: the settings
75
+ * page shows "not configured", which is true, rather than failing to open.
76
+ */
77
+ function hostModelSelection(ctx) {
78
+ const empty = { available: false, provider: '', model: '', reasoningEffort: '' }
79
+ let service
80
+ try { service = ctx.get('agentDefaultModel') } catch { return empty }
81
+ if (service === undefined || service === null) return empty
82
+ let selection
83
+ try { selection = service.currentSelection() } catch { return empty }
84
+ const provider = typeof selection?.provider === 'string' ? selection.provider : ''
85
+ const model = typeof selection?.model === 'string' ? selection.model : ''
86
+ const reasoningEffort = typeof selection?.reasoningEffort === 'string' ? selection.reasoningEffort : ''
87
+ // A selection with no provider or no model names no model: report it as
88
+ // unconfigured rather than as a selection of two empty strings.
89
+ return { available: provider !== '' && model !== '', provider, model, reasoningEffort }
90
+ }
91
+
92
+ /** Every non-hidden file of a tree, keyed by its path relative to the root. */
93
+ async function listFiles(dir, prefix = '', found = new Map()) {
94
+ for (const entry of (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
95
+ // Editor and OS noise is not an author edit: a `.DS_Store` must not make the
96
+ // next activation refuse to start.
97
+ if (entry.name.startsWith('.')) continue
98
+ const key = prefix + entry.name
99
+ const path = join(dir, entry.name)
100
+ if (entry.isDirectory()) await listFiles(path, key + '/', found)
101
+ else found.set(key, path)
102
+ }
103
+ return found
104
+ }
105
+
106
+ /**
107
+ * Digest of a managed preset directory, over the files the package ships only.
108
+ * A foreign file (the author's note, a skill they added, a dangling symlink) is
109
+ * not a modification: counting it would make one stray file brick the plugin.
110
+ */
111
+ async function digestShipped(source, destination) {
112
+ const shipped = await listFiles(source)
113
+ const present = await listFiles(destination)
114
+ const hash = createHash('sha256')
115
+ for (const key of [...shipped.keys()].sort()) {
116
+ const path = present.get(key)
117
+ if (path === undefined) { hash.update(key + '\u0000missing'); continue }
118
+ hash.update(key)
119
+ try { hash.update(await readFile(path)) }
120
+ catch (error) { if (error.code !== 'ENOENT') hash.update(key + '\u0000unreadable') }
21
121
  }
22
- await visit(dir)
23
122
  return hash.digest('hex')
24
123
  }
25
124
 
@@ -39,12 +138,13 @@ export async function apply(ctx) {
39
138
  const release = await lock(leases, { realpath: false, stale: 30_000, retries: { retries: 30, minTimeout: 100, maxTimeout: 500 } })
40
139
  try { return await operation() } finally { await release() }
41
140
  }
42
- const expected = await digestTree(source)
43
141
  const marker = join(destination, '.openwrite-managed.json')
44
142
  await withLock(async () => {
45
143
  const existing = await readFile(marker, 'utf8').then(JSON.parse).catch(() => null)
46
- if (existing && (existing.source !== expected || existing.tree !== await digestTree(destination))) {
47
- throw new Error(`OpenWrite preset ${id} was modified; copy it to a custom preset before reinstalling this version`)
144
+ if (existing) {
145
+ if (existing.source !== await digestShipped(source, destination)) {
146
+ throw new Error(`OpenWrite preset ${id} was modified; copy it to a custom preset before reinstalling this version`)
147
+ }
48
148
  }
49
149
  if (!existing) {
50
150
  if (await lstat(destination).then(() => true).catch(error => { if (error.code === 'ENOENT') return false; throw error })) {
@@ -54,7 +154,8 @@ export async function apply(ctx) {
54
154
  await mkdir(staging, { recursive: true })
55
155
  try {
56
156
  await cp(source, staging, { recursive: true })
57
- await writeFile(join(staging, '.openwrite-managed.json'), JSON.stringify({ source: expected, tree: expected, version }))
157
+ const digest = await digestShipped(source, staging)
158
+ await writeFile(join(staging, '.openwrite-managed.json'), JSON.stringify({ source: digest, tree: digest, version }))
58
159
  await rename(staging, destination)
59
160
  } finally { await rm(staging, { recursive: true, force: true }) }
60
161
  }
@@ -79,9 +180,16 @@ export async function apply(ctx) {
79
180
  if (typeof presetCtx.agentPresets.register !== 'function') return
80
181
  const definition = await presetDefinition(version, true)
81
182
  yield await presetCtx.agentPresets.register(definition)
82
- const ids = new Set([...legacyPresetIds, id,
83
- ...(await readdir(join(home, '.agent-presets'))).filter(name => /^openwrite-\d+-\d+-\d+(?:-[a-z0-9-]+)?$/.test(name)),
84
- ])
183
+ const discovered = (await readdir(join(home, '.agent-presets')).catch(() => []))
184
+ .filter(name => /^openwrite-\d+-\d+-\d+(?:-[a-z0-9-]+)?$/.test(name))
185
+ // An alias keeps a session written by an older release resumable. Registering
186
+ // every released id instead floods the new-session selector with entries no
187
+ // session references, so the alias set follows the session store and only
188
+ // falls back to the durable list when that store cannot be read. This
189
+ // release's own identity is excluded: it is registered as the stable preset
190
+ // and its directory belongs to this plugin, so an alias for it is noise.
191
+ const referenced = await referencedPresetIds(home)
192
+ const ids = new Set([...(referenced ?? legacyPresetIds), ...discovered].filter(candidate => candidate !== id))
85
193
  for (const legacyId of ids) {
86
194
  // An author-supplied definition wins; readDocument checks presence without
87
195
  // waiting for host activation (list/resolve would deadlock startup).
@@ -90,8 +198,39 @@ export async function apply(ctx) {
90
198
  catch (error) { if (error?.code !== 'agent-preset/not-found') throw error }
91
199
  }
92
200
  yield await presetCtx.agentPresets.register({ ...definition, id: legacyId,
93
- order: 1000, name: `${definition.name}(历史会话 ${legacyId})`,
201
+ order: 1000, name: legacyAliasName(legacyId, definition.name),
94
202
  })
95
203
  }
96
204
  })
205
+ // Companion status for the workbench's companion section, and the host's own
206
+ // default-model selection, so OpenWrite's page in the host's settings dialog
207
+ // can show which model dsh itself would use. The companion route reports
208
+ // which implementation serves DoG and whether the optional integrations are
209
+ // live; it never changes what the preset mounts. The model route carries no
210
+ // key: the selection is a browser-visible fact, the key is not. Like every
211
+ // other /studio-panel/* route both sit behind the host's
212
+ // browser-authentication fence: without it any local process could read the
213
+ // host version, the companion inventory and the host's model selection.
214
+ ctx.inject(['webServer'], webCtx => webCtx.effect(() => {
215
+ webCtx.webServer.register({
216
+ kind: 'exact', path: '/studio-panel/companions', handler: async (req, res) => {
217
+ const respond = (code, data) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(data)) }
218
+ const connection = webCtx.get('connection')
219
+ const rejection = connection ? connection.requestRejection({ headers: req.headers }) : 503
220
+ if (rejection !== undefined) { respond(rejection, { error: 'dsh browser authentication required', message: 'dsh browser authentication required', code: 'AUTHENTICATION_REQUIRED' }); return }
221
+ if (req.method !== 'GET') { respond(405, { error: 'Method not allowed', message: 'Method not allowed', code: 'METHOD_NOT_ALLOWED' }); return }
222
+ respond(200, await companionSummary(home) ?? { host: null, dog: { installed: false, usable: false, version: null, reason: null }, ops: { installed: false, usable: false, version: null, reason: null }, freeModel: { installed: false, usable: false, version: null, reason: null } })
223
+ },
224
+ }, 'openwrite: companion status')
225
+ webCtx.webServer.register({
226
+ kind: 'exact', path: '/studio-panel/host-model', handler: async (req, res) => {
227
+ const respond = (code, data) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(data)) }
228
+ const connection = webCtx.get('connection')
229
+ const rejection = connection ? connection.requestRejection({ headers: req.headers }) : 503
230
+ if (rejection !== undefined) { respond(rejection, { error: 'dsh browser authentication required', message: 'dsh browser authentication required', code: 'AUTHENTICATION_REQUIRED' }); return }
231
+ if (req.method !== 'GET') { respond(405, { error: 'Method not allowed', message: 'Method not allowed', code: 'METHOD_NOT_ALLOWED' }); return }
232
+ respond(200, hostModelSelection(ctx))
233
+ },
234
+ }, 'openwrite: host model selection')
235
+ }))
97
236
  }
@@ -1,6 +1,11 @@
1
- import { readFile } from 'node:fs/promises'
1
+ import { open, readFile, readdir, stat, mkdir, writeFile } from 'node:fs/promises'
2
+ import { createHash } from 'node:crypto'
3
+ import { zstdDecompressSync } from 'node:zlib'
4
+ import { join } from 'node:path'
2
5
  import { fileURLToPath } from 'node:url'
3
6
  import { load, Type, JSON_SCHEMA } from 'js-yaml'
7
+ import { detectCompanions } from './companions.mjs'
8
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
4
9
 
5
10
  // Preserve Loader expressions; evaluating them here loses the preset's baseUrl
6
11
  // and entry-local realm. This is the host's entry-list !!js wire representation.
@@ -12,16 +17,150 @@ const schema = JSON_SCHEMA.extend(new Type('tag:yaml.org,2002:js', {
12
17
  export const stablePresetId = 'openwrite'
13
18
 
14
19
  // These released identities are durable session references, even after their
15
- // package-managed directories have been removed by older plugin versions.
16
- export const legacyPresetIds = Array.from({ length: 27 }, (_, i) => `openwrite-0-2-${i + 1}`)
20
+ // package-managed directories have been removed by older plugin versions. They
21
+ // are the safety net used when the session store cannot be read; the live alias
22
+ // set follows the sessions that actually reference them (see below).
23
+ /**
24
+ * Every released identity on the current `major.minor` line.
25
+ *
26
+ * Derived from the package version rather than a hardcoded count: `{ length: 29 }`
27
+ * could only ever express `0.2.x`, so a move to 0.3.0 or 1.0.0 structurally could
28
+ * not produce the new identity and the durable-resume safety net would silently
29
+ * omit it.
30
+ */
31
+ const releaseVersion = JSON.parse(await readFile(new URL('package.json', import.meta.url), 'utf8')).version
32
+ const releaseLine = /^(\d+)\.(\d+)\.(\d+)/.exec(String(releaseVersion || ''))
33
+ export const legacyPresetIds = releaseLine === null
34
+ ? []
35
+ : Array.from({ length: Number(releaseLine[3]) }, (_, i) => `openwrite-${releaseLine[1]}-${releaseLine[2]}-${i + 1}`)
17
36
 
18
- export async function presetDefinition(version, modern = false) {
37
+ /** One released versioned preset identity, including any pre-release suffix. */
38
+ const versionedPresetId = /^openwrite-\d+-\d+-\d+(?:-[a-z0-9-]+)?$/
39
+
40
+ /** Session log artifacts, in both encodings the host persistence backend writes. */
41
+ const sessionLogSuffixes = ['.jsonl.zstd', '.jsonl']
42
+
43
+ /** A session header is the first frame of its log; a bounded prefix reads it. */
44
+ const headerPrefixBytes = 256 * 1024
45
+
46
+ /**
47
+ * Name an alias after the release it restores. A bare id is an internal detail
48
+ * that leaves dozens of identically prefixed entries in the preset selector.
49
+ */
50
+ export function legacyAliasName(id, name) {
51
+ if (!versionedPresetId.test(id)) return `${name}(历史会话 ${id})`
52
+ return `${name}(旧版 ${id.replace(/^openwrite-/, '').replaceAll('-', '.')} · 仅用于恢复历史会话)`
53
+ }
54
+
55
+ async function collectSessionLogs(root) {
56
+ const logs = []
57
+ const walk = async directory => {
58
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
59
+ const path = join(directory, entry.name)
60
+ if (entry.isDirectory()) await walk(path)
61
+ else if (sessionLogSuffixes.some(suffix => entry.name.endsWith(suffix))) logs.push(path)
62
+ }
63
+ }
64
+ await walk(root)
65
+ return logs
66
+ }
67
+
68
+ /**
69
+ * A digest over every log's identity, not just {count, newest mtime}: deleting
70
+ * one session and adding another in the same second keeps that pair unchanged
71
+ * and would serve a stale alias set (a missing id, a lingering one). Stats only,
72
+ * so this stays cheap enough to re-derive on every start.
73
+ */
74
+ async function fingerprintLogs(logs) {
75
+ const hash = createHash('sha256')
76
+ const rows = []
77
+ for (const path of logs) {
78
+ let info
79
+ try { info = await stat(path) } catch { continue }
80
+ rows.push(`${path}\u0000${Math.round(info.mtimeMs)}\u0000${info.size}\n`)
81
+ }
82
+ rows.sort()
83
+ for (const row of rows) hash.update(row)
84
+ return { files: rows.length, digest: hash.digest('hex') }
85
+ }
86
+
87
+ /** The `agentPreset` a stored session header carries, or undefined when unreadable. */
88
+ async function headerPresetId(path) {
89
+ const handle = await open(path, 'r')
90
+ try {
91
+ const buffer = Buffer.alloc(headerPrefixBytes)
92
+ const { bytesRead } = await handle.read(buffer, 0, headerPrefixBytes, 0)
93
+ const head = buffer.subarray(0, bytesRead)
94
+ // node:zlib decodes the leading frame and ignores the appended event frames.
95
+ const text = path.endsWith('.zstd') ? zstdDecompressSync(head).toString('utf8') : head.toString('utf8')
96
+ const header = JSON.parse(text.split('\n', 1)[0])
97
+ return typeof header?.agentPreset === 'string' ? header.agentPreset : undefined
98
+ } finally {
99
+ await handle.close()
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The versioned preset ids stored sessions still reference. Registering every
105
+ * released id instead would fill the new-session selector with entries no
106
+ * session can resume, so the alias set is derived from the session store.
107
+ * Returns null when that store cannot be read at all, which keeps the durable
108
+ * alias list rather than silently dropping historical session resume.
109
+ */
110
+ export async function referencedPresetIds(home) {
111
+ const root = join(home, 'sessions')
112
+ let logs
113
+ try {
114
+ logs = await collectSessionLogs(root)
115
+ } catch (error) {
116
+ if (error?.code !== 'ENOENT') return null
117
+ logs = []
118
+ }
119
+ // fingerprintLogs skips logs it cannot stat (a dangling symlink, or one the
120
+ // host prunes mid-scan); a walk that cannot even be listed is the only case
121
+ // that falls back to the durable list.
122
+ const fingerprint = await fingerprintLogs(logs).catch(() => null)
123
+ if (!fingerprint) return null
124
+ const cache = join(home, 'openwrite', 'preset-aliases.json')
125
+ const cached = await readFile(cache, 'utf8').then(JSON.parse).catch(() => null)
126
+ if (cached?.fingerprint?.files === fingerprint.files && cached.fingerprint.digest === fingerprint.digest) {
127
+ return new Set((cached.ids ?? []).filter(id => versionedPresetId.test(id)))
128
+ }
129
+ const ids = new Set()
130
+ for (const path of logs) {
131
+ const id = await headerPresetId(path).catch(() => undefined)
132
+ if (id && versionedPresetId.test(id)) ids.add(id)
133
+ }
134
+ await mkdir(join(home, 'openwrite'), { recursive: true }).catch(() => {})
135
+ await writeFile(cache, JSON.stringify({ fingerprint, ids: [...ids] }) + '\n').catch(() => {})
136
+ return ids
137
+ }
138
+
139
+ /**
140
+ * Mount dsh-ops into this preset when the companion is installed, its declared
141
+ * host range covers the running host, and it exposes the integration entry.
142
+ */
143
+ async function adaptCompanions(plugins, options) {
144
+ if (plugins.some(row => row.id === 'openwrite-ops')) return
145
+ let companions = null
146
+ try {
147
+ companions = await detectCompanions(resolveDshHome(), options)
148
+ } catch { /* an unreadable home leaves the preset exactly as shipped */ }
149
+ if (!companions?.ops?.usable) return
150
+ plugins.push({ id: 'openwrite-ops', name: 'dsh-openwrite/ops-integration' })
151
+ }
152
+
153
+ export async function presetDefinition(version, modern = false, options = {}) {
19
154
  const baseUrl = new URL('./presets/openwrite/', import.meta.url)
20
155
  const plugins = load(await readFile(new URL('agent.cordis.yml', baseUrl), 'utf8'), { schema })
21
156
  const metadata = load(await readFile(new URL('preset.yml', baseUrl), 'utf8'))
22
157
  if (modern) {
23
158
  const adapt = rows => {
24
159
  for (const row of rows) {
160
+ // The workflow row is renamed for hosts that ship the PTC variant. This
161
+ // repo's node_modules carries an older host without that package, so the
162
+ // resolution gate treats it as expected-absent rather than failing (see
163
+ // scripts/preset-smoke.mjs).
25
164
  if (row.name === '@deepseek-ai/dsh-workflow-worker-thread') {
26
165
  row.name = '@deepseek-ai/dsh-workflow-ptc'
27
166
  }
@@ -33,5 +172,6 @@ export async function presetDefinition(version, modern = false) {
33
172
  }
34
173
  adapt(plugins)
35
174
  }
175
+ await adaptCompanions(plugins, options)
36
176
  return { ...metadata, id: stablePresetId, order: -1, plugins }
37
177
  }
@@ -20,7 +20,7 @@
20
20
  outlines and assets unless the author asks to translate them. Chinese skill instructions
21
21
  describe procedures; they do not require Chinese output. Translate their headings and help
22
22
  when presenting them. In English conversations, use English throughout the creative loop.
23
- Writing tasks return a task_id immediately; poll novel_task_get until terminal and present
23
+ Writing tasks return a task_id immediately; poll novel_task(action="get") until terminal and present
24
24
  cancellation as requested until the task reports cancelled. Resume saved drafts via run_id_v2.
25
25
 
26
26
  你是 OpenWrite 创作 Agent,运行在 DeepSeek Harness(dsh)的统一 OpenWrite 预设中。
@@ -66,12 +66,12 @@
66
66
  → rereview 闭环。低质量、硬门禁失败或上下文不完整时先报告原因,不强行交付。
67
67
  修改已有正文、人物、世界、正典、伏笔或其他项目文档时,先用 novel_search / novel_status
68
68
  定位正式实体并读取最新版本。正文、正典和其他普通文档的每个独立修改分别调用
69
- novel_document_change_plan(action="preview");大纲、资产、创作重点、伏笔和写作目标的每个独立
70
- 修改分别调用 novel_structured_change_plan(action="preview")。向用户展示返回的 before/after 与来源版本;
69
+ novel_change_plan(action="document", step="preview");大纲、资产、创作重点、伏笔和写作目标的每个独立
70
+ 修改分别调用 novel_change_plan(action="structured", step="preview")。向用户展示返回的 before/after 与来源版本;
71
71
  只有用户明确确认后,才把原 preview_token 原样传给 action="apply", confirm=true,不得重新生成路径、
72
72
  锚点或内容。用户拒绝时用 action="reject";源文件冲突时用 action="retry" 生成新预览;
73
73
  已应用项需要撤销时,只能把返回的 undo_preview_token 传给 action="undo", confirm=true。
74
- novel_doc_write 是已获明确授权的完整文档写入兼容入口,不得用它绕过逐项预览。
74
+ novel_doc(action="write") 是已获明确授权的完整文档写入兼容入口,不得用它绕过逐项预览。
75
75
  完成写入后可用 novel_trace_list 核对来源、预算、模型调用、工具调用和领域变更。Trace 只保留摘要、
76
76
  revision 与用量,作品正文、提示原文、模型原文、凭据和推理内容不得写入 trace。
77
77
  普通讨论、分析和未确认建议不得写入资产。
@@ -81,9 +81,9 @@
81
81
  或阶段仍在大纲滚动期但用户明确要求“根据现在/当前大纲写下一章”、
82
82
  “就按这个大纲写”或等价表达,先向用户确认大纲范围,再执行章节预检与写作。
83
83
  用户要求修改大纲时,先用 novel_outline_read 定位结构,再用
84
- novel_structured_change_plan(change_kind="outline") 对一个结构操作逐项预览和确认;不要用
85
- novel_doc_write 绕过大纲门控,也不要整篇覆盖 src/outline.md。novel_outline_edit、
86
- novel_asset_update、novel_focus、novel_foreshadowing 和 novel_writing_targets 仅在用户本轮已经
84
+ novel_change_plan(action="structured", step="preview", change_kind="outline") 对一个结构操作逐项预览和确认;不要用
85
+ novel_doc(action="write") 绕过大纲门控,也不要整篇覆盖 src/outline.md。novel_outline_edit、
86
+ novel_assets(action="update")、novel_focus、novel_foreshadowing 和 novel_writing_targets 仅在用户本轮已经
87
87
  明确授权对应的精确变更时作为直接写入兼容入口使用。
88
88
  用户要求删除已写正文或章节时,先向用户说明将删除的章节与影响并得到明确同意。
89
89
  单章使用 novel_chapter_delete;清理连续尾段时使用 novel_chapter_delete_batch,
@@ -335,5 +335,3 @@
335
335
  - id: openwrite-tools
336
336
  name: dsh-openwrite/tools
337
337
 
338
- - id: openwrite-dog-tools
339
- name: dsh-openwrite/dog-tools
@@ -0,0 +1,28 @@
1
+ // dsh-ops preset integration. The row that mounts this module is only added
2
+ // when dsh-ops is installed, its declared host range covers the running host,
3
+ // and it exposes `dsh-ops/integration`. The import is still defensive: a
4
+ // companion that changed its entry between detection and load must degrade to
5
+ // a no-op row rather than take the whole preset composition down.
6
+ export const name = '@dsh-novel/openwrite-ops'
7
+
8
+ export async function apply(ctx) {
9
+ let integration
10
+ try {
11
+ ({ integration } = await import('dsh-ops/integration'))
12
+ } catch {
13
+ ctx.effect(() => () => {}, 'openwrite-ops: dsh-ops integration unavailable')
14
+ return
15
+ }
16
+ // Bounded file tools plus progressive discovery for the OpenWrite preset.
17
+ // Official file tools stay available (`replaceOfficial: false`), no shell is
18
+ // published (`shell: false`), and the preset's own guidance is not replaced.
19
+ const plugin = integration({
20
+ preset: 'openwrite',
21
+ files: true,
22
+ discovery: true,
23
+ presentation: 'native',
24
+ replaceOfficial: false,
25
+ shell: false,
26
+ })
27
+ return plugin.apply(ctx)
28
+ }
@@ -1,3 +1,4 @@
1
1
  name: OpenWrite 创作
2
2
  description: 长篇小说全流程 Agent:在同一会话中完成灵感收敛、人物/设定/大纲、正文写作、六域累加评审与修订交付,保留 37 项展开查询,按项目阶段自动切换规划与写作模式。
3
- order: 10
3
+ # `order` is set by presetDefinition (the stable id leads the selector); a value
4
+ # here would be silently overridden.
@@ -9,9 +9,9 @@ Language: follow the author's current conversation language for responses and he
9
9
 
10
10
  接到已有 `novel_*`。人物、地点、世界观以 OpenWrite 资产为准。
11
11
 
12
- 1. `novel_assets_list` 列出人物/世界资产(含名称与别名)。
13
- 2. 指定实体用 `novel_asset_read`。
12
+ 1. `novel_assets(action="list"` 列出人物/世界资产(含名称与别名)。
13
+ 2. 指定实体用 `novel_assets(action="read", kind="character"|"world"|"progression", id=…)`。
14
14
  3. 按名称或别名找不到时 `novel_search`(scope 可 `assets`)。
15
15
  4. 关系与状态漂移用 `novel_continuity`。
16
16
 
17
- 向作者报告规范名、别名、摘要。改设定走 `novel_structured_change_plan(change_kind="asset")`;普通讨论不得写入。
17
+ 向作者报告规范名、别名、摘要。改设定走 `novel_change_plan(action="structured"(change_kind="asset")`;普通讨论不得写入。