@fractalsynapse/dsh-chapters 0.0.0-stage → 0.2.20
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 +14 -0
- package/README.md +76 -2
- package/cordis.patch.yml +363 -0
- package/docs/architecture.md +692 -0
- package/docs/autorun-guide.md +248 -0
- package/docs/backlog.md +2022 -0
- package/docs/contract.md +938 -0
- package/docs/development.md +990 -0
- package/docs/host-compaction-seam.md +225 -0
- package/docs/host-protected-head-ask.md +80 -0
- package/docs/knowledge-repo.md +1332 -0
- package/docs/plans-carriage.md +571 -0
- package/docs/project-state.md +642 -0
- package/docs/provider.md +98 -0
- package/docs/superpowers/plans/2026-10-17-chapters-save.md +198 -0
- package/docs/superpowers/plans/2026-11-02-chapter-removal.md +151 -0
- package/docs/superpowers/specs/2026-11-02-chapter-removal-design.md +115 -0
- package/docs/verify.md +561 -0
- package/lib/archive.d.ts +161 -0
- package/lib/archive.js +229 -0
- package/lib/archive.js.map +1 -0
- package/lib/arrival.d.ts +23 -0
- package/lib/arrival.js +125 -0
- package/lib/arrival.js.map +1 -0
- package/lib/artifact-query.d.ts +62 -0
- package/lib/artifact-query.js +187 -0
- package/lib/artifact-query.js.map +1 -0
- package/lib/attach.d.ts +46 -0
- package/lib/attach.js +120 -0
- package/lib/attach.js.map +1 -0
- package/lib/auto-save.d.ts +145 -0
- package/lib/auto-save.js +334 -0
- package/lib/auto-save.js.map +1 -0
- package/lib/aux-message.d.ts +17 -0
- package/lib/aux-message.js +20 -0
- package/lib/aux-message.js.map +1 -0
- package/lib/client.js +1419 -0
- package/lib/client.js.map +1 -0
- package/lib/codegraph.d.ts +76 -0
- package/lib/codegraph.js +190 -0
- package/lib/codegraph.js.map +1 -0
- package/lib/commands.d.ts +52 -0
- package/lib/commands.js +946 -0
- package/lib/commands.js.map +1 -0
- package/lib/compose.d.ts +30 -0
- package/lib/compose.js +185 -0
- package/lib/compose.js.map +1 -0
- package/lib/continue-core.d.ts +245 -0
- package/lib/continue-core.js +405 -0
- package/lib/continue-core.js.map +1 -0
- package/lib/diag.d.ts +55 -0
- package/lib/diag.js +186 -0
- package/lib/diag.js.map +1 -0
- package/lib/engine-core.d.ts +301 -0
- package/lib/engine-core.js +531 -0
- package/lib/engine-core.js.map +1 -0
- package/lib/engine-note.d.ts +35 -0
- package/lib/engine-note.js +32 -0
- package/lib/engine-note.js.map +1 -0
- package/lib/engine.d.ts +329 -0
- package/lib/engine.js +1179 -0
- package/lib/engine.js.map +1 -0
- package/lib/enrich-queue.d.ts +66 -0
- package/lib/enrich-queue.js +126 -0
- package/lib/enrich-queue.js.map +1 -0
- package/lib/enrich-store.d.ts +31 -0
- package/lib/enrich-store.js +103 -0
- package/lib/enrich-store.js.map +1 -0
- package/lib/enrich-wire.d.ts +163 -0
- package/lib/enrich-wire.js +353 -0
- package/lib/enrich-wire.js.map +1 -0
- package/lib/enrich.d.ts +102 -0
- package/lib/enrich.js +267 -0
- package/lib/enrich.js.map +1 -0
- package/lib/gitops.d.ts +107 -0
- package/lib/gitops.js +539 -0
- package/lib/gitops.js.map +1 -0
- package/lib/index.d.ts +241 -0
- package/lib/index.js +1162 -0
- package/lib/index.js.map +1 -0
- package/lib/indexing.d.ts +98 -0
- package/lib/indexing.js +223 -0
- package/lib/indexing.js.map +1 -0
- package/lib/injections.d.ts +90 -0
- package/lib/injections.js +165 -0
- package/lib/injections.js.map +1 -0
- package/lib/last-resort.d.ts +24 -0
- package/lib/last-resort.js +27 -0
- package/lib/last-resort.js.map +1 -0
- package/lib/log-shape.d.ts +38 -0
- package/lib/log-shape.js +93 -0
- package/lib/log-shape.js.map +1 -0
- package/lib/models-command.d.ts +74 -0
- package/lib/models-command.js +100 -0
- package/lib/models-command.js.map +1 -0
- package/lib/notice.d.ts +74 -0
- package/lib/notice.js +92 -0
- package/lib/notice.js.map +1 -0
- package/lib/plans.d.ts +154 -0
- package/lib/plans.js +315 -0
- package/lib/plans.js.map +1 -0
- package/lib/preset.d.ts +186 -0
- package/lib/preset.js +212 -0
- package/lib/preset.js.map +1 -0
- package/lib/prompt-keys.d.ts +7 -0
- package/lib/prompt-keys.js +8 -0
- package/lib/prompt-keys.js.map +1 -0
- package/lib/prompt-loop.d.ts +73 -0
- package/lib/prompt-loop.js +147 -0
- package/lib/prompt-loop.js.map +1 -0
- package/lib/prompt-push.d.ts +47 -0
- package/lib/prompt-push.js +115 -0
- package/lib/prompt-push.js.map +1 -0
- package/lib/prompt-runner.d.ts +48 -0
- package/lib/prompt-runner.js +90 -0
- package/lib/prompt-runner.js.map +1 -0
- package/lib/prompt-state.d.ts +142 -0
- package/lib/prompt-state.js +101 -0
- package/lib/prompt-state.js.map +1 -0
- package/lib/prompt.d.ts +270 -0
- package/lib/prompt.js +408 -0
- package/lib/prompt.js.map +1 -0
- package/lib/provider.d.ts +44 -0
- package/lib/provider.js +66 -0
- package/lib/provider.js.map +1 -0
- package/lib/redact.d.ts +24 -0
- package/lib/redact.js +85 -0
- package/lib/redact.js.map +1 -0
- package/lib/registry.d.ts +187 -0
- package/lib/registry.js +235 -0
- package/lib/registry.js.map +1 -0
- package/lib/removal.d.ts +159 -0
- package/lib/removal.js +556 -0
- package/lib/removal.js.map +1 -0
- package/lib/render.d.ts +97 -0
- package/lib/render.js +530 -0
- package/lib/render.js.map +1 -0
- package/lib/repair-run.d.ts +76 -0
- package/lib/repair-run.js +506 -0
- package/lib/repair-run.js.map +1 -0
- package/lib/repair.d.ts +208 -0
- package/lib/repair.js +357 -0
- package/lib/repair.js.map +1 -0
- package/lib/repo.d.ts +78 -0
- package/lib/repo.js +184 -0
- package/lib/repo.js.map +1 -0
- package/lib/rules-commands.d.ts +46 -0
- package/lib/rules-commands.js +379 -0
- package/lib/rules-commands.js.map +1 -0
- package/lib/rules.d.ts +267 -0
- package/lib/rules.js +478 -0
- package/lib/rules.js.map +1 -0
- package/lib/save-core.d.ts +72 -0
- package/lib/save-core.js +220 -0
- package/lib/save-core.js.map +1 -0
- package/lib/search.d.ts +112 -0
- package/lib/search.js +282 -0
- package/lib/search.js.map +1 -0
- package/lib/session-rules.d.ts +222 -0
- package/lib/session-rules.js +454 -0
- package/lib/session-rules.js.map +1 -0
- package/lib/signature.d.ts +45 -0
- package/lib/signature.js +199 -0
- package/lib/signature.js.map +1 -0
- package/lib/store.d.ts +156 -0
- package/lib/store.js +368 -0
- package/lib/store.js.map +1 -0
- package/lib/sync.d.ts +220 -0
- package/lib/sync.js +810 -0
- package/lib/sync.js.map +1 -0
- package/lib/tools.d.ts +113 -0
- package/lib/tools.js +1090 -0
- package/lib/tools.js.map +1 -0
- package/lib/treedx/client.d.ts +57 -0
- package/lib/treedx/client.js +82 -0
- package/lib/treedx/client.js.map +1 -0
- package/lib/treedx/provider.d.ts +39 -0
- package/lib/treedx/provider.js +479 -0
- package/lib/treedx/provider.js.map +1 -0
- package/lib/treedx/state.d.ts +27 -0
- package/lib/treedx/state.js +65 -0
- package/lib/treedx/state.js.map +1 -0
- package/lib/types.d.ts +210 -0
- package/lib/types.js +31 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +9 -0
- package/lib/version.js +10 -0
- package/lib/version.js.map +1 -0
- package/lib/vocabulary.d.ts +41 -0
- package/lib/vocabulary.js +118 -0
- package/lib/vocabulary.js.map +1 -0
- package/lib/warnings.d.ts +10 -0
- package/lib/warnings.js +18 -0
- package/lib/warnings.js.map +1 -0
- package/package.json +151 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,1162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-chapters — cordis entry point (host plane). The ONLY host-facing function
|
|
3
|
+
* plugin; engine logic lives in engine.ts (mounted per preset realm), every
|
|
4
|
+
* decision in the pure modules (docs/development.md § L0).
|
|
5
|
+
*
|
|
6
|
+
* Responsibilities:
|
|
7
|
+
* 1. Make the `chapters` preset EXIST in the host's preset roster, and say loudly
|
|
8
|
+
* when it does not. Hosts before 0.2.0 are NOT supported: a preset is an
|
|
9
|
+
* `@deepseek-ai/dsh-agent-preset` declaration row, and this plugin registers
|
|
10
|
+
* that row at activation through the host's own `agentPresets.register()`
|
|
11
|
+
* (`src/preset.ts`). The old `$DSH_HOME/.agent-presets/` directory copy is
|
|
12
|
+
* gone — no 0.2.x host reads it, and shipping the row as a patch layer instead
|
|
13
|
+
* breaks the boot of every pre-0.1.7 host (measured; docs/contract.md).
|
|
14
|
+
* 2. Open the dsh_chapters storage domain and register chapters_segment /
|
|
15
|
+
* chapters_continue (src/tools.ts adapts them to the pure core).
|
|
16
|
+
* 3. Witness/dev diagnostics.
|
|
17
|
+
*
|
|
18
|
+
* Protocol notes (each measured, docs/contract.md):
|
|
19
|
+
* - function plugin: named exports `name` / `inject` / `Config` / `apply`;
|
|
20
|
+
* NO default export (mixing forms makes the Loader discard the namespace).
|
|
21
|
+
* - Config uses schemastery (default import) — plain specs are for tool
|
|
22
|
+
* parameters, zod for domain records; never mix.
|
|
23
|
+
*/
|
|
24
|
+
import { writeFileSync, readFileSync } from 'node:fs';
|
|
25
|
+
import { dirname, join, isAbsolute } from 'node:path';
|
|
26
|
+
import { hostname } from 'node:os';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
29
|
+
import { appendChapters, reserve } from "./registry.js";
|
|
30
|
+
import { acquireChapterStore, makeDomainStore, resolveStoreRoot } from "./store.js";
|
|
31
|
+
import { registerChaptersTools } from "./tools.js";
|
|
32
|
+
import { CHAPTERS_PRESET_ID, inventoryEntry, unusableRows } from "./preset.js";
|
|
33
|
+
import { registerHostCommands } from "./commands.js";
|
|
34
|
+
import { wireSubagentAutoSave } from "./auto-save.js";
|
|
35
|
+
import { createEnrichWiring } from "./enrich-wire.js";
|
|
36
|
+
import { ENRICH_MAX_TOKENS_DEFAULT } from "./enrich.js";
|
|
37
|
+
import { carryActivePlan } from "./plans.js";
|
|
38
|
+
import { buildRulesSection } from "./rules.js";
|
|
39
|
+
import { createPromptSurface, promptChaptersForLineage, PROMPT_SETTING_KEY } from "./prompt.js";
|
|
40
|
+
import { makePromptLoop, suggestPromptMessage } from "./prompt-loop.js";
|
|
41
|
+
import { makePromptRoundRunner } from "./prompt-runner.js";
|
|
42
|
+
import { resolvePushWriter, promptProjectionDefinition, promptRecordPayload } from "./prompt-push.js";
|
|
43
|
+
import { classifyOffer, goalDirectiveOf, noSuggestion, readActiveGoal } from "./prompt-state.js";
|
|
44
|
+
import { rulesCommand } from "./rules-commands.js";
|
|
45
|
+
import { ancestorSessionIds, captureMinedRules, makeHeaderParentReader, ruleAncestorSet } from "./session-rules.js";
|
|
46
|
+
import { configureDiagnostics, configureDiagnosticsCap, diagnosticStatusLine } from "./diag.js";
|
|
47
|
+
import { BlockAssembler } from '@deepseek-ai/dsh-llm';
|
|
48
|
+
import { auxPromptMessage } from "./aux-message.js";
|
|
49
|
+
import { createSyncScheduler, makeCollectionsReader, projectForCwd, readToken, DEFAULT_CLONE_DIR } from "./sync.js";
|
|
50
|
+
import { removedGuard } from "./removal.js";
|
|
51
|
+
import { parseKnowledgeRemote, projectKeyForTarget, resolveProject } from "./repo.js";
|
|
52
|
+
import { providerKindFor, registerProvider } from "./provider.js";
|
|
53
|
+
import { createTreedxProvider } from "./treedx/provider.js";
|
|
54
|
+
export const Config = Schema.object({
|
|
55
|
+
// The preset is how the engine reaches real sessions (preset realm mount);
|
|
56
|
+
// turning this off means opting into manual preset authoring instead.
|
|
57
|
+
// Accepted-then-ignored, deliberately: removing a key would make an old profile patch
|
|
58
|
+
// fail to PARSE, and a bundle that will not mount is worse than a key that does nothing.
|
|
59
|
+
installChaptersPreset: Schema.boolean().default(true),
|
|
60
|
+
// The engine row of the registered preset (docs/host-compaction-seam.md). This is the
|
|
61
|
+
// one row config that used to live in a preset YAML a human edited in the home; with
|
|
62
|
+
// the row registered from code it belongs to THIS config, so a profile patch retunes it.
|
|
63
|
+
compactionThresholdRatio: Schema.number().default(0.9),
|
|
64
|
+
artifactStoreRoot: Schema.string().default('.dsh-chapters'),
|
|
65
|
+
// #85: pool-mirror root for rules/knowledge reads that produce model-visible text.
|
|
66
|
+
knowledgeMirrorDir: Schema.string().default('.dsh-knowledge'),
|
|
67
|
+
// Lines of the chapter index a continuation notice lists. 0 = list every chapter (the historical behavior).
|
|
68
|
+
// A small cap keeps the notice's SIZE settled, which is what makes a leg parked on the compaction boundary
|
|
69
|
+
// reproducible as a tape; the omission is stated in the notice itself, never silent (#56).
|
|
70
|
+
noticeIndexCap: Schema.number().step(1).min(0).default(6),
|
|
71
|
+
// #87: the newest human turn carried verbatim into a relief summary, capped.
|
|
72
|
+
openRequestCarryMaxChars: Schema.number().step(1).min(200).default(4000),
|
|
73
|
+
// #78 A: survivability switch, default on (docs/host-compaction-seam.md).
|
|
74
|
+
lastResortArtifact: Schema.boolean().default(true),
|
|
75
|
+
chapterTokenTarget: Schema.number().default(8000),
|
|
76
|
+
toolResultDeferFloorTokens: Schema.number().default(200),
|
|
77
|
+
// Share of the window REMAINING after the header bound — never of the
|
|
78
|
+
// whole window (docs/architecture.md § Budgets).
|
|
79
|
+
continuationBudgetRatio: Schema.number().default(0.25),
|
|
80
|
+
fallbackPreset: Schema.string().default('chapters'),
|
|
81
|
+
// Topic-sequential composition (record §4.2) for the model-free fork path.
|
|
82
|
+
mergeThreshold: Schema.number().default(0.3),
|
|
83
|
+
subagentAutoSave: Schema.boolean().default(true),
|
|
84
|
+
chapterLimit: Schema.number().default(8000),
|
|
85
|
+
chapterOversizeFactor: Schema.number().default(2),
|
|
86
|
+
warningsDisplayCap: Schema.number().default(6),
|
|
87
|
+
segmentCandidateCapTokens: Schema.number().default(4000),
|
|
88
|
+
diagnosticsLog: Schema.string().default(''),
|
|
89
|
+
diagnosticsMaxBytes: Schema.number().step(1).min(0).default(8388608),
|
|
90
|
+
// Plans (docs/plans-carriage.md § 7). A plan's full text is ALWAYS
|
|
91
|
+
// artifact-backed, so planInlineChars is display economy and never a data
|
|
92
|
+
// decision. callArgArtifactTokens defaults to 0 = OFF: turning it on changes
|
|
93
|
+
// chapter bytes for sessions with large tool-call arguments (a `write` call
|
|
94
|
+
// carries a whole file), which means model tapes must be re-recorded — so a
|
|
95
|
+
// deployment opts in deliberately rather than by accident.
|
|
96
|
+
planInlineChars: Schema.number().default(900),
|
|
97
|
+
callArgArtifactTokens: Schema.number().default(0),
|
|
98
|
+
// Renders zero bytes when the path holds no plans, so the default-on cost for
|
|
99
|
+
// sessions that never enter plan mode is exactly nothing.
|
|
100
|
+
plansInNotice: Schema.boolean().default(true),
|
|
101
|
+
retrievalHints: Schema.boolean().default(true),
|
|
102
|
+
// Seeding is measured-safe (kernel accepts the two-event seed and the host's own
|
|
103
|
+
// plan projection folds it to active) and it is what keeps the agent anchored to
|
|
104
|
+
// the task across a continuation — so it ships ON, for continuations only.
|
|
105
|
+
planSeedMode: Schema.boolean().default(true),
|
|
106
|
+
plansInCheckpoint: Schema.boolean().default(true),
|
|
107
|
+
planCheckpointChars: Schema.number().default(1600),
|
|
108
|
+
// Per-machine identity for the knowledge repo (commit author + per-harness
|
|
109
|
+
// token scoping, record §2.1). The hostname is the honest default; override
|
|
110
|
+
// per machine when two sessions on one box must be told apart.
|
|
111
|
+
harnessId: Schema.string().default(hostname()),
|
|
112
|
+
// Knowledge repo (record §12): '' leaves linking to /chapters-link; a URL
|
|
113
|
+
// auto-links every workspace on its first archive event.
|
|
114
|
+
knowledgeRemote: Schema.string().default(''),
|
|
115
|
+
projectKeyOverride: Schema.string().default(''),
|
|
116
|
+
syncDebounceMs: Schema.number().default(30000),
|
|
117
|
+
searchDefaultMaxTokens: Schema.number().default(400),
|
|
118
|
+
enrichmentEnabled: Schema.boolean().default(true),
|
|
119
|
+
enrichmentModel: Schema.string().default(''),
|
|
120
|
+
enrichmentIdleMs: Schema.number().default(60000),
|
|
121
|
+
enrichmentMaxTokens: Schema.number().default(ENRICH_MAX_TOKENS_DEFAULT),
|
|
122
|
+
enrichmentBatchCap: Schema.number().default(5),
|
|
123
|
+
vocabApply: Schema.boolean().default(false),
|
|
124
|
+
vocabCoMin: Schema.number().default(3),
|
|
125
|
+
vocabOverlapMin: Schema.number().default(0.5),
|
|
126
|
+
coreRulesBudgetTokens: Schema.number().default(1200),
|
|
127
|
+
rulesCoreBonus: Schema.number().default(0.15),
|
|
128
|
+
// #50 §7.3–§7.5: the session tier + per-tier display caps. The three the
|
|
129
|
+
// checkpoint also needs are mirrored into the engine row (docs/configuration).
|
|
130
|
+
sessionRulesEnabled: Schema.boolean().default(true),
|
|
131
|
+
rulesMarkerPrefix: Schema.string().default('ADD RULE:'),
|
|
132
|
+
projectRulesDisplayCap: Schema.number().default(12),
|
|
133
|
+
sessionRulesDisplayCap: Schema.number().default(12),
|
|
134
|
+
rulesRelevance: Schema.boolean().default(true),
|
|
135
|
+
rulesEnrichment: Schema.boolean().default(true),
|
|
136
|
+
rulesMinedPerChapter: Schema.number().default(3),
|
|
137
|
+
rulesAutoActivate: Schema.boolean().default(true),
|
|
138
|
+
rulesModel: Schema.string().default(''),
|
|
139
|
+
// #71 prompt recommendations: the model IS the switch (empty ⇒ off); the two
|
|
140
|
+
// caps bound the auxiliary call's input slice and its answer. The completion
|
|
141
|
+
// rides the existing enrichmentMaxTokens budget — one auxiliary budget, now
|
|
142
|
+
// three consumers (annotation, mined rules, recommendations).
|
|
143
|
+
promptModel: Schema.string().default(''),
|
|
144
|
+
// #188: the caps belong to the model. contextChars is now the NO-WINDOW FALLBACK — when the prompt
|
|
145
|
+
// model is set through /chapters-prompt model, the budget derives from its real context window
|
|
146
|
+
// (window × promptContextRatio at 4 chars/token); 32000 keeps a 32K-class model whole when no
|
|
147
|
+
// window resolves. maxChars rises 240 → 4096: a cap that cannot carry a paragraph cannot carry an
|
|
148
|
+
// instruction (the maintainer's number, 2026-10-30).
|
|
149
|
+
promptContextChars: Schema.number().default(32000),
|
|
150
|
+
promptMaxChars: Schema.number().default(4096),
|
|
151
|
+
promptContextRatio: Schema.number().default(0.5),
|
|
152
|
+
// #229: the ambient heartbeat is OFF by default — "never repeated chapters-prompt executions" is
|
|
153
|
+
// the user's absolute rule, and every pull is two visible history nodes on every host so far.
|
|
154
|
+
promptQuietHeartbeatSec: Schema.number().default(0),
|
|
155
|
+
promptMaxRounds: Schema.number().default(24),
|
|
156
|
+
/** #117: how many lines a `/chapters-repair` report may print. The finding COUNT always leads; */
|
|
157
|
+
repairReportMaxLines: Schema.number().default(60),
|
|
158
|
+
// enum-style string (same register as `fallbackPreset`): 'auto' | 'git' |
|
|
159
|
+
// 'treedx', validated at use — schemastery has no literal schema.
|
|
160
|
+
knowledgeProvider: Schema.string().default('auto'),
|
|
161
|
+
treedxFetchTimeoutMs: Schema.number().default(15000),
|
|
162
|
+
treedxWorkspaceTtlSeconds: Schema.number().default(900),
|
|
163
|
+
treedxLeaseRetries: Schema.number().default(3),
|
|
164
|
+
treedxLeaseRetryDelayMs: Schema.number().default(1000),
|
|
165
|
+
// CodeGraph integration: auto-detect by default. The CLI is probed lazily
|
|
166
|
+
// at first use; if absent, every integration point is a silent no-op.
|
|
167
|
+
codegraphEnabled: Schema.boolean().default(true),
|
|
168
|
+
codegraphEnrich: Schema.boolean().default(true),
|
|
169
|
+
codegraphSearchBonus: Schema.number().default(0.2),
|
|
170
|
+
});
|
|
171
|
+
export const name = '@fractalsynapse/dsh-chapters';
|
|
172
|
+
export const inject = ['storageDomain', 'tools', 'agents', 'llm', 'sessionProjections'];
|
|
173
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
174
|
+
const PKG_ROOT = join(HERE, '..');
|
|
175
|
+
/** Registrations this module owns for the life of the process. The registry hands back an
|
|
176
|
+
* unregister disposer, and holding it — never calling it — is the point: a disposer wired
|
|
177
|
+
* through `ctx.effect` runs when the apply fiber's activation completes, which un-registered
|
|
178
|
+
* the preset the instant it had been registered (measured: `register()` reported success
|
|
179
|
+
* while the roster came back without `chapters`). The same apply-fiber trap bit the storage
|
|
180
|
+
* domain in r35. */
|
|
181
|
+
const heldDisposers = [];
|
|
182
|
+
export async function apply(ctx, config) {
|
|
183
|
+
// #61: the diagnostics sink defaults ON. An operator who set `diagnosticsLog` in the profile gets
|
|
184
|
+
// that path; `DSH_CHAPTERS_ENGINE_ERRORS` beats it; the literal `off` is the only silence. With
|
|
185
|
+
// neither set, the FIRST door that touches a project resolves `<store-root>/diagnostics.log`
|
|
186
|
+
// (ensureDiagnosticsDefault in tools/engine), which is why nothing is configured here yet — the
|
|
187
|
+
// three SAIP compaction finalization failures left no trace precisely because the sink had to be
|
|
188
|
+
// turned on before it could help.
|
|
189
|
+
if (config.diagnosticsLog !== '')
|
|
190
|
+
configureDiagnostics(config.diagnosticsLog);
|
|
191
|
+
configureDiagnosticsCap(config.diagnosticsMaxBytes);
|
|
192
|
+
// § Preset delivery lives in `src/preset.ts` — the `preset-chapters` declaration row in
|
|
193
|
+
// this bundle's patch, NOT here. A row is visible (`dsh --dump-default-config`, the config
|
|
194
|
+
// editor, `list_plugins`), overridable and disable-able from a profile patch, and its
|
|
195
|
+
// registration's lifetime belongs to Cordis rather than to our `apply()` fiber. What stays
|
|
196
|
+
// here is only the VERIFICATION: whether the roster ends up holding the preset at all
|
|
197
|
+
// (below), because declaring it and having it usable are different facts.
|
|
198
|
+
let domain;
|
|
199
|
+
try {
|
|
200
|
+
const storageDomain = ctx.get?.('storageDomain');
|
|
201
|
+
if (storageDomain === undefined)
|
|
202
|
+
throw new Error('storageDomain absent');
|
|
203
|
+
const handle = await acquireChapterStore(storageDomain);
|
|
204
|
+
domain = handle.domain;
|
|
205
|
+
const store = handle.store;
|
|
206
|
+
// No close effect here: the facility's own disposer closes domains still
|
|
207
|
+
// open at facility unmount (its documented teardown), and a close effect
|
|
208
|
+
// on the apply fiber fires when THAT fiber is disposed — mid-run, which
|
|
209
|
+
// closed the domain under later commands (r35 caught it). One opener, and
|
|
210
|
+
// the closer is the facility's unmount, not our fiber.
|
|
211
|
+
void handle.owner;
|
|
212
|
+
// §15 amendment: the TreeDX transport, configured and registered in THIS
|
|
213
|
+
// module instance (host plane). The realm engine registers its own copy
|
|
214
|
+
// (separate module instances under isolation) — same code, per-plane.
|
|
215
|
+
registerProvider(createTreedxProvider({
|
|
216
|
+
fetchTimeoutMs: config.treedxFetchTimeoutMs,
|
|
217
|
+
workspaceTtlSeconds: config.treedxWorkspaceTtlSeconds,
|
|
218
|
+
leaseRetries: config.treedxLeaseRetries,
|
|
219
|
+
leaseRetryDelayMs: config.treedxLeaseRetryDelayMs,
|
|
220
|
+
}));
|
|
221
|
+
// P2: host-plane enrichment wiring — command surface + status only
|
|
222
|
+
// (trigger forced manual; the realm engine owns auto drains).
|
|
223
|
+
// The routed model of the last real tool-carrying session on this plane:
|
|
224
|
+
// the host has no live agent, so without this a fork-only flow (never any
|
|
225
|
+
// in-place compaction => realm never captures lastRoute) leaves
|
|
226
|
+
// /chapters-enrich run with no route to call. Persisted immediately so
|
|
227
|
+
// the realm's resolver sees it via the shared settings table too.
|
|
228
|
+
let hostRoute = null;
|
|
229
|
+
const enrich = createEnrichWiring({
|
|
230
|
+
store,
|
|
231
|
+
cwd: () => undefined,
|
|
232
|
+
config: {
|
|
233
|
+
enabled: config.enrichmentEnabled,
|
|
234
|
+
model: config.enrichmentModel,
|
|
235
|
+
trigger: 'manual',
|
|
236
|
+
idleMs: config.enrichmentIdleMs,
|
|
237
|
+
maxTokens: config.enrichmentMaxTokens,
|
|
238
|
+
batchCap: config.enrichmentBatchCap,
|
|
239
|
+
mineRules: config.rulesEnrichment,
|
|
240
|
+
mineCap: config.rulesMinedPerChapter,
|
|
241
|
+
rulesAutoActivate: config.rulesAutoActivate,
|
|
242
|
+
rulesModel: config.rulesModel,
|
|
243
|
+
},
|
|
244
|
+
// §7.5 host-plane sink: `/chapters-enrich run` drains here, so a manual run
|
|
245
|
+
// mines exactly like the realm's auto pass. Local files only — nothing is
|
|
246
|
+
// armed on the scheduler, because session rules never travel (§7.5).
|
|
247
|
+
onMinedRules: async ({ cwd, sessionId, chapterNumber, humanText, rules, max, autoActivate }) => {
|
|
248
|
+
const res = captureMinedRules({
|
|
249
|
+
storeDir: resolveStoreRoot(cwd, config.artifactStoreRoot),
|
|
250
|
+
harnessId: config.harnessId,
|
|
251
|
+
sessionId,
|
|
252
|
+
records: store.rules(),
|
|
253
|
+
humanText,
|
|
254
|
+
rules,
|
|
255
|
+
maxPerChapter: max,
|
|
256
|
+
chapterNumber,
|
|
257
|
+
autoActivate,
|
|
258
|
+
});
|
|
259
|
+
return { written: res.ids.length, stated: res.stated, inferred: res.inferred, pending: res.pending };
|
|
260
|
+
},
|
|
261
|
+
fetch: async (prompt, route) => {
|
|
262
|
+
if (route === null)
|
|
263
|
+
throw new Error('no enrichment route resolvable (set /chapters-enrich model … or run a turn first)');
|
|
264
|
+
const llm = ctx.llm;
|
|
265
|
+
if (llm?.stream === undefined)
|
|
266
|
+
throw new Error('llm.stream absent');
|
|
267
|
+
const assembler = new BlockAssembler();
|
|
268
|
+
for await (const chunk of llm.stream({
|
|
269
|
+
provider: route.provider, model: route.model,
|
|
270
|
+
messages: [auxPromptMessage(prompt)],
|
|
271
|
+
maxTokens: config.enrichmentMaxTokens,
|
|
272
|
+
purpose: 'compaction',
|
|
273
|
+
}))
|
|
274
|
+
assembler.push(chunk);
|
|
275
|
+
return assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text ?? '').join('');
|
|
276
|
+
},
|
|
277
|
+
conversationRoute: () => hostRoute,
|
|
278
|
+
log: (m) => ctx.logger?.info?.(`dsh-chapters: ${m}`),
|
|
279
|
+
warn: (m) => ctx.logger?.warn?.(`dsh-chapters: ${m}`),
|
|
280
|
+
});
|
|
281
|
+
// #71: prompt recommendations, host plane only (the web composer pulls;
|
|
282
|
+
// headless sessions have no ghost and never pay the call). The route for a
|
|
283
|
+
// bare model name rides the same last-seen route enrichment uses, with the
|
|
284
|
+
// persisted route as fallback — a fork-only flow that never compacted in
|
|
285
|
+
// place still has a provider to route through after one tool call.
|
|
286
|
+
/** Pull the host's own failure text out of a `finish` chunk whose reason kind is `error`. */
|
|
287
|
+
function hostStreamFailure(chunks) {
|
|
288
|
+
for (const raw of chunks) {
|
|
289
|
+
try {
|
|
290
|
+
const j = JSON.parse(raw);
|
|
291
|
+
if (j.type === 'finish' && j.reason?.kind === 'error') {
|
|
292
|
+
const msg = j.reason.failure?.message ?? 'the host reported an error without a message';
|
|
293
|
+
return `host llm.stream failed: ${msg.replace(/\s+/g, ' ').slice(0, 300)}`;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch { /* truncated probe slice, not an error signal */ }
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
const prompt = createPromptSurface({
|
|
301
|
+
getSetting: (k) => store.getSetting(k),
|
|
302
|
+
putSetting: (k, v) => store.putSetting(k, v),
|
|
303
|
+
deleteSetting: (k) => store.deleteSetting(k),
|
|
304
|
+
// #188: the ANCESTOR PATH's chapters with plots, not the live row alone — a continuation
|
|
305
|
+
// mid-goal inherits its lineage's trajectory. Bodies are read from the archive itself
|
|
306
|
+
// (deterministic, zero model tokens); an unreadable body degrades to no plots, never to an
|
|
307
|
+
// assembly failure.
|
|
308
|
+
chaptersOf: async (session) => {
|
|
309
|
+
const rows = [];
|
|
310
|
+
const seen = new Set();
|
|
311
|
+
let cur = session.id;
|
|
312
|
+
while (cur !== null && !seen.has(cur)) {
|
|
313
|
+
seen.add(cur);
|
|
314
|
+
const row = await store.get(cur);
|
|
315
|
+
if (row === undefined)
|
|
316
|
+
break;
|
|
317
|
+
rows.unshift(row);
|
|
318
|
+
cur = row.parentSession;
|
|
319
|
+
}
|
|
320
|
+
const cwd = session.cwd ?? '';
|
|
321
|
+
return promptChaptersForLineage({
|
|
322
|
+
rows,
|
|
323
|
+
readBody: (rel) => {
|
|
324
|
+
try {
|
|
325
|
+
return readFileSync(isAbsolute(rel) ? rel : join(cwd, rel), 'utf8');
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
},
|
|
333
|
+
// #188: the caps derive from the selected model's REAL window; null falls back to config loudly.
|
|
334
|
+
resolveWindow: async (route) => {
|
|
335
|
+
const info = await ctx.llm?.resolveModelInfo(route.provider, route.model).catch(() => undefined);
|
|
336
|
+
return info?.context?.contextWindow ?? null;
|
|
337
|
+
},
|
|
338
|
+
// #188: the standing orders a rebuilt head would render — the SAME builder and budget, so the
|
|
339
|
+
// director and the worker can never operate under different rule sets. A degraded rules read
|
|
340
|
+
// degrades the suggestion (empty section); it never fails the assembly.
|
|
341
|
+
rulesOf: async (session) => {
|
|
342
|
+
try {
|
|
343
|
+
const cwd = session.cwd ?? '';
|
|
344
|
+
if (cwd === '')
|
|
345
|
+
return '';
|
|
346
|
+
const project = projectForCwd(store.projects(), cwd);
|
|
347
|
+
if (project === undefined)
|
|
348
|
+
return '';
|
|
349
|
+
const walk = await ruleAncestorSet({
|
|
350
|
+
seeds: [session.id],
|
|
351
|
+
registryAncestors: async (sid) => await ancestorSessionIds(async (x) => await store.get(x), sid),
|
|
352
|
+
});
|
|
353
|
+
const built = buildRulesSection(join(cwd, DEFAULT_CLONE_DIR), {
|
|
354
|
+
projectKey: project.projectKey,
|
|
355
|
+
harnessId: config.harnessId,
|
|
356
|
+
budgetTokens: config.coreRulesBudgetTokens,
|
|
357
|
+
storeDir: resolveStoreRoot(cwd, config.artifactStoreRoot),
|
|
358
|
+
ancestorSessions: walk.ancestors,
|
|
359
|
+
});
|
|
360
|
+
return 'text' in built ? built.text : '';
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return '';
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
// #188: the operative plan via the SAME carry mechanism continuations seed with, budgeted at the
|
|
367
|
+
// context cap — its own announced clip (which names the archive chapter citing the full text)
|
|
368
|
+
// fires only if the plan alone would eat the entire budget.
|
|
369
|
+
planOf: async (session) => {
|
|
370
|
+
try {
|
|
371
|
+
const row = await store.get(session.id);
|
|
372
|
+
const docs = (row?.planDocs ?? []);
|
|
373
|
+
return carryActivePlan(docs, { maxChars: config.promptContextChars }) ?? '';
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return '';
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
config: {
|
|
380
|
+
model: config.promptModel,
|
|
381
|
+
contextChars: config.promptContextChars,
|
|
382
|
+
maxChars: config.promptMaxChars,
|
|
383
|
+
contextRatio: config.promptContextRatio,
|
|
384
|
+
},
|
|
385
|
+
conversationRoute: () => {
|
|
386
|
+
if (hostRoute !== null)
|
|
387
|
+
return hostRoute;
|
|
388
|
+
const persisted = store.getSetting('enrichment.route') ?? '';
|
|
389
|
+
const i = persisted.indexOf('/');
|
|
390
|
+
return i > 0 ? { provider: persisted.slice(0, i), model: persisted.slice(i + 1) } : null;
|
|
391
|
+
},
|
|
392
|
+
fetch: async (input, route) => {
|
|
393
|
+
const llm = ctx.llm;
|
|
394
|
+
if (llm?.stream === undefined)
|
|
395
|
+
throw new Error('llm.stream absent');
|
|
396
|
+
const assembler = new BlockAssembler();
|
|
397
|
+
const seen = [];
|
|
398
|
+
const lastChunk = [];
|
|
399
|
+
for await (const chunk of llm.stream({
|
|
400
|
+
provider: route.provider, model: route.model,
|
|
401
|
+
messages: [auxPromptMessage(input)],
|
|
402
|
+
maxTokens: config.enrichmentMaxTokens,
|
|
403
|
+
purpose: 'compaction',
|
|
404
|
+
})) {
|
|
405
|
+
// Measured, not assumed: the fold below keeps only `type === 'text'` blocks, and the bar said
|
|
406
|
+
// "the model returned no text" on a route that returns text when called directly — so the shapes the
|
|
407
|
+
// host stream hands us are exactly what had to be seen.
|
|
408
|
+
lastChunk.push(JSON.stringify(chunk).slice(0, 120));
|
|
409
|
+
const c = chunk;
|
|
410
|
+
seen.push(`${String(c.type ?? '?')}:${String(c.content?.type ?? c.text ?? c.delta?.text ?? '').slice(0, 10)}`);
|
|
411
|
+
assembler.push(chunk);
|
|
412
|
+
}
|
|
413
|
+
// #61: the host already told us what went wrong, inside the stream. Before this, a provider with no
|
|
414
|
+
// adapter, a refused request, and an empty completion all arrived here as "the model returned no text"
|
|
415
|
+
// — the detected failure was thrown away and a stranger diagnosis took its place. Now the FIRST real
|
|
416
|
+
// failure is what the surface reports.
|
|
417
|
+
const failure = hostStreamFailure(lastChunk);
|
|
418
|
+
if (failure !== null)
|
|
419
|
+
throw new Error(failure);
|
|
420
|
+
return assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text ?? '').join('');
|
|
421
|
+
},
|
|
422
|
+
warn: (m) => ctx.logger?.warn?.(`dsh-chapters: ${m}`),
|
|
423
|
+
});
|
|
424
|
+
// #113: the Suggested Next Step bar's server-side state. ONE instance, built here, because it is the
|
|
425
|
+
// only place that has the store, the model switch, and the live agents at once — and a loop that starts
|
|
426
|
+
// real turns must not have two copies of "is it armed" to disagree about. `autorun` is process-local on
|
|
427
|
+
// purpose: like the host goal driver's activation, it starts disarmed and cannot resurrect itself after a
|
|
428
|
+
// restart, so an unattended loop is impossible by construction rather than by promise.
|
|
429
|
+
// ONE path from "run this suggestion" to a human-authored turn. Autorun calls it from the idle edge; the
|
|
430
|
+
// bar's Run calls it from the command plane. Two implementations of "run it" would be two behaviors, and
|
|
431
|
+
// only one of them is the one the human clicks.
|
|
432
|
+
const submitOffer = (sessionId, text) => {
|
|
433
|
+
const agents = ctx.agents;
|
|
434
|
+
const agent = agents?.get?.(sessionId);
|
|
435
|
+
if (agent?.followup === undefined)
|
|
436
|
+
throw new Error(`no live agent for ${String(sessionId)}`);
|
|
437
|
+
agent.followup(suggestPromptMessage(text, sessionId));
|
|
438
|
+
};
|
|
439
|
+
const promptLoop = makePromptLoop({
|
|
440
|
+
followup: submitOffer,
|
|
441
|
+
maxRounds: () => config.promptMaxRounds,
|
|
442
|
+
// The host's cooperative brake: `GoalService.disarm` stops the driver re-injecting rounds and changes
|
|
443
|
+
// NOTHING durable ("without changing durable goal phase or revision"), so the objective stays
|
|
444
|
+
// authoritative while exactly one loop — the one the human armed last — starts the next turn.
|
|
445
|
+
displaceGoal: (sessionId) => {
|
|
446
|
+
try {
|
|
447
|
+
const goals = (typeof ctx.get === 'function' ? ctx.get('goals') : undefined);
|
|
448
|
+
const agents = ctx.agents;
|
|
449
|
+
const agent = agents?.get?.(sessionId);
|
|
450
|
+
if (goals?.disarm === undefined || agent === undefined)
|
|
451
|
+
return false;
|
|
452
|
+
goals.disarm(agent);
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
// The goal signal comes from the SERVICE, never folded from the log: `activation` is process-local by
|
|
460
|
+
// design (dsh-goal keeps it in a WeakMap), so the only honest read is the live GoalView. This is what
|
|
461
|
+
// lets the loop know when the driver owns the next turn.
|
|
462
|
+
goalSignal: (sessionId) => {
|
|
463
|
+
try {
|
|
464
|
+
const goals = (typeof ctx.get === 'function' ? ctx.get('goals') : undefined);
|
|
465
|
+
const agents = ctx.agents;
|
|
466
|
+
const agent = agents?.get?.(sessionId);
|
|
467
|
+
if (goals === undefined || agent === undefined)
|
|
468
|
+
return null;
|
|
469
|
+
const view = goals.get?.(agent);
|
|
470
|
+
if (view === undefined || typeof view.revision !== 'number')
|
|
471
|
+
return null;
|
|
472
|
+
const phase = view.phase === 'paused' ? 'paused' : view.phase === 'blocked' ? 'blocked' : view.phase === 'complete' ? 'complete' : 'active';
|
|
473
|
+
return { phase, activation: view.activation === 'armed' ? 'armed' : 'disarmed', revision: view.revision };
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
log: (m) => ctx.logger?.info?.(`dsh-chapters: ${m}`),
|
|
480
|
+
warn: (m) => ctx.logger?.warn?.(`dsh-chapters: ${m}`),
|
|
481
|
+
});
|
|
482
|
+
// A narrow, explicit view of the registry row for the bar. The surrounding `store` handle is loosely
|
|
483
|
+
// typed here, and widening a host-facing type to make a call compile is the exact habit this repo has
|
|
484
|
+
// been bitten by (#58) — so the two verbs the bar needs are named, not assumed.
|
|
485
|
+
const promptRow = store;
|
|
486
|
+
// #194: the push transport. The writer resolves against the HOST's own module instance (the
|
|
487
|
+
// probe-proven recipe — a plugin-side copy throws `Receiver must be an instance of class Session`
|
|
488
|
+
// even at the identical version), and the projection is registered ONLY when the writer exists:
|
|
489
|
+
// registration is the feature signal the client reads through its standard `useProjection` seat.
|
|
490
|
+
// No writer ⇒ no projection ⇒ every client keeps the #203/#208 pull schedule, unchanged.
|
|
491
|
+
let pushWriter = null;
|
|
492
|
+
try {
|
|
493
|
+
pushWriter = await resolvePushWriter({ log: (m) => { console.error(`[chapters-push] ${m}`); ctx.logger?.warn?.(`dsh-chapters: ${m}`); } });
|
|
494
|
+
if (pushWriter !== null) {
|
|
495
|
+
const projections = ctx.sessionProjections;
|
|
496
|
+
if (typeof projections?.register === 'function') {
|
|
497
|
+
projections.register(promptProjectionDefinition());
|
|
498
|
+
// stderr witness, same discipline as the turn/end door's armed line: ctx.logger.info never
|
|
499
|
+
// reaches a boot log a probe can read, and "is push live?" is exactly the question a
|
|
500
|
+
// failed proof must answer first (#194).
|
|
501
|
+
console.error('[chapters-push] transport LIVE — the bar rides plugin records; zero command runs in steady state');
|
|
502
|
+
ctx.logger?.info?.('dsh-chapters: prompt push transport LIVE — the bar rides plugin records; zero command runs in steady state');
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
pushWriter = null; // no seat to fold records into; the pull floor stands
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
pushWriter = null;
|
|
511
|
+
ctx.logger?.warn?.(`dsh-chapters: prompt push wiring failed (${String(error?.message ?? error).slice(0, 140)}) — pull transport stands`);
|
|
512
|
+
}
|
|
513
|
+
// One serialized push per session: the fold is last-wins, so records must land in mutation
|
|
514
|
+
// order — a ready that overtakes its own analyzing would show a stale offer as fresh.
|
|
515
|
+
const pushChains = new Map();
|
|
516
|
+
// #218: sessions that already carry a birth record this process; a Set, not persistence —
|
|
517
|
+
// the record itself is durable, and a restarted process re-birthing would append a duplicate
|
|
518
|
+
// (harmless: the fold is last-wins, but a duplicate is a log write for nothing).
|
|
519
|
+
const seenBirthSessions = new Set();
|
|
520
|
+
// #225: sessions whose first-boundary verdict has been printed (per process), and the total
|
|
521
|
+
// boundaries sighted — the status command prints both. "No suggestion ever" must not be a
|
|
522
|
+
// silence to interpret; it is a printed verdict per session at its first boundary.
|
|
523
|
+
const promptVerdictSighted = new Set();
|
|
524
|
+
let promptBoundariesSighted = 0;
|
|
525
|
+
const pushBar = (session, sessionId) => {
|
|
526
|
+
if (pushWriter === null || session === undefined || session === null)
|
|
527
|
+
return;
|
|
528
|
+
const writer = pushWriter;
|
|
529
|
+
const prev = pushChains.get(sessionId) ?? Promise.resolve();
|
|
530
|
+
const next = prev.then(async () => {
|
|
531
|
+
const row = (await promptRow.get(sessionId)).promptSuggestion ?? noSuggestion;
|
|
532
|
+
const live = promptLoop.statusOf(sessionId);
|
|
533
|
+
writer.append(session, promptRecordPayload({
|
|
534
|
+
status: row.status, text: row.text, turnSeq: row.turnSeq, error: row.error,
|
|
535
|
+
intent: row.intent, goalRevision: row.goalRevision, autorun: live.autorun, at: row.at,
|
|
536
|
+
}));
|
|
537
|
+
}, () => undefined);
|
|
538
|
+
pushChains.set(sessionId, next.then(() => undefined, () => undefined));
|
|
539
|
+
};
|
|
540
|
+
const promptBar = {
|
|
541
|
+
// #229: the ambient heartbeat is config-gated and defaults to ZERO — the maintainer's rule is
|
|
542
|
+
// absolute ("never want to see repeated chapters-prompt executions in a session again"), and
|
|
543
|
+
// the measurement that proved it: exact 60.0s pull gaps on their live log even with 0.2.11
|
|
544
|
+
// installed correctly. The value rides EVERY state reply (quietHeartbeatSec) so the client's
|
|
545
|
+
// schedule is the server's setting, not a compile-time guess; the same number lands in the
|
|
546
|
+
// /chapters-prompt state text for humans.
|
|
547
|
+
quietHeartbeatSec: config.promptQuietHeartbeatSec,
|
|
548
|
+
read: async (sessionId) => {
|
|
549
|
+
const status = (await promptRow.get(sessionId)).promptSuggestion ?? noSuggestion;
|
|
550
|
+
const live = promptLoop.statusOf(sessionId);
|
|
551
|
+
return { ...status, ...live };
|
|
552
|
+
},
|
|
553
|
+
// #231: the row without the loop — cheap, pure, and the answer to almost every pull. The
|
|
554
|
+
// maintainer's host pulled once a minute for hours; each of those hit the full read + mirror.
|
|
555
|
+
peekRow: async (sessionId) => {
|
|
556
|
+
const ps = (await promptRow.get(sessionId)).promptSuggestion;
|
|
557
|
+
if (ps === undefined || ps.status === 'off')
|
|
558
|
+
return null;
|
|
559
|
+
const live = promptLoop.statusOf(sessionId);
|
|
560
|
+
return { ...ps, ...live };
|
|
561
|
+
},
|
|
562
|
+
// #218: the state verb calls this after serving a pull — the answer becomes the record the
|
|
563
|
+
// next mount reads from the projection. Pull-only surfaces never see it called (the caller
|
|
564
|
+
// feature-checks), so it lives on the same object that owns the push queue.
|
|
565
|
+
mirror: (sessionId, session) => { pushBar(session, sessionId); },
|
|
566
|
+
// #113: Run is a command, not a keystroke. The offer is read from the row the bar is displaying and
|
|
567
|
+
// injected as a human-authored follow-up — the same path autorun uses, so what the human clicks and what
|
|
568
|
+
// the loop arms cannot be two different behaviors. `no live agent` is the honest failure and reaches the
|
|
569
|
+
// bar as its reason.
|
|
570
|
+
runNow: async (sessionId) => {
|
|
571
|
+
const st = await promptBar.read(sessionId);
|
|
572
|
+
if (st.text === '' || st.status !== 'ready')
|
|
573
|
+
return { ok: false, reason: 'there is no offer on screen to run' };
|
|
574
|
+
submitOffer(sessionId, st.text);
|
|
575
|
+
return { ok: true, text: st.text };
|
|
576
|
+
},
|
|
577
|
+
setAutorun: (sessionId, on, session) => {
|
|
578
|
+
const r = promptLoop.setArmed(sessionId, on);
|
|
579
|
+
pushBar(session, sessionId);
|
|
580
|
+
return r;
|
|
581
|
+
},
|
|
582
|
+
disarmHuman: (sessionId, session) => { promptLoop.disarmForHuman(sessionId); pushBar(session, sessionId); },
|
|
583
|
+
// `off` stops generation for the session without touching the model, so back-on is one action; `on`
|
|
584
|
+
// clears it and the NEXT completed turn offers again. Both write through mutate, never a snapshot.
|
|
585
|
+
setOff: async (sessionId, session) => {
|
|
586
|
+
promptLoop.setArmed(sessionId, false);
|
|
587
|
+
await promptRow.mutate(sessionId, ((row) => ({ ...row, promptSuggestion: noSuggestion })));
|
|
588
|
+
pushBar(session, sessionId);
|
|
589
|
+
},
|
|
590
|
+
// #225: the suggest verb's answer becomes the durable offer — same row shape the runner writes
|
|
591
|
+
// (status ready, this turn's seq unknown for manual suggests ⇒ turnSeq null-safe via Date.now
|
|
592
|
+
// identity), then the mirror makes it visible without any pull.
|
|
593
|
+
setSuggested: async (sessionId, text, session) => {
|
|
594
|
+
promptLoop.setArmed(sessionId, false);
|
|
595
|
+
await promptRow.mutate(sessionId, ((row) => ({ ...row, promptSuggestion: {
|
|
596
|
+
// A manual suggest has no turn boundary of its own; `turnSeq: null` marks it as such — the
|
|
597
|
+
// bar renders ready offers regardless, and autorun never fires on a null-turnSeq row (its
|
|
598
|
+
// own gate refuses), so a human-requested suggestion can never start a turn by itself.
|
|
599
|
+
status: 'ready', text: classifyOffer(text).text, turnSeq: null,
|
|
600
|
+
error: '', at: new Date().toISOString(), intent: classifyOffer(text).intent, goalRevision: null,
|
|
601
|
+
} })));
|
|
602
|
+
if (session !== undefined)
|
|
603
|
+
pushBar(session, sessionId);
|
|
604
|
+
},
|
|
605
|
+
setOn: async (sessionId, session) => {
|
|
606
|
+
await promptRow.mutate(sessionId, ((row) => ({ ...row, promptSuggestion: noSuggestion })));
|
|
607
|
+
pushBar(session, sessionId);
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
const scheduler = createScheduler(store, domain, config, undefined, (cwd, ok) => {
|
|
611
|
+
enrich.rememberCwd(cwd);
|
|
612
|
+
if (ok)
|
|
613
|
+
enrich.queue.onSyncDone('afterPush');
|
|
614
|
+
}, (m) => ctx.logger?.warn?.(m));
|
|
615
|
+
// ONE assembly feeds both planes (#90 drift trap): the doors' ToolsConfig doubles as the
|
|
616
|
+
// commands' SaveSessionConfig for /chapters-remove restore — a second literal would be a
|
|
617
|
+
// second truth about thresholds.
|
|
618
|
+
const toolsConfig = {
|
|
619
|
+
artifactStoreRoot: config.artifactStoreRoot,
|
|
620
|
+
chapterTokenTarget: config.chapterTokenTarget,
|
|
621
|
+
repairReportMaxLines: config.repairReportMaxLines,
|
|
622
|
+
toolResultDeferFloorTokens: config.toolResultDeferFloorTokens,
|
|
623
|
+
continuationBudgetRatio: config.continuationBudgetRatio,
|
|
624
|
+
fallbackPreset: config.fallbackPreset,
|
|
625
|
+
mergeThreshold: config.mergeThreshold,
|
|
626
|
+
chapterLimit: config.chapterLimit,
|
|
627
|
+
chapterOversizeFactor: config.chapterOversizeFactor,
|
|
628
|
+
warningsDisplayCap: config.warningsDisplayCap,
|
|
629
|
+
segmentCandidateCapTokens: config.segmentCandidateCapTokens,
|
|
630
|
+
planInlineChars: config.planInlineChars,
|
|
631
|
+
callArgArtifactTokens: config.callArgArtifactTokens,
|
|
632
|
+
plansInNotice: config.plansInNotice,
|
|
633
|
+
retrievalHints: config.retrievalHints,
|
|
634
|
+
planSeedMode: config.planSeedMode,
|
|
635
|
+
searchMaxTokens: config.searchDefaultMaxTokens,
|
|
636
|
+
harnessId: config.harnessId,
|
|
637
|
+
coreRulesBudgetTokens: config.coreRulesBudgetTokens,
|
|
638
|
+
rulesCoreBonus: config.rulesCoreBonus,
|
|
639
|
+
// #50 §7.4 — the doors read the HOST plane; the compaction checkpoint reads
|
|
640
|
+
// the realm row (same names, same defaults). There is no cross-plane channel
|
|
641
|
+
// to inject host config into a preset row, so a profile that overrides a cap
|
|
642
|
+
// sets it in both places; leaving one unset keeps the documented default.
|
|
643
|
+
projectRulesDisplayCap: config.projectRulesDisplayCap,
|
|
644
|
+
sessionRulesDisplayCap: config.sessionRulesDisplayCap,
|
|
645
|
+
rulesRelevance: config.rulesRelevance,
|
|
646
|
+
scheduler,
|
|
647
|
+
noteRoute: (r) => {
|
|
648
|
+
hostRoute = r;
|
|
649
|
+
void store.putSetting('enrichment.route', `${r.provider}/${r.model}`).catch(() => undefined);
|
|
650
|
+
},
|
|
651
|
+
enrich,
|
|
652
|
+
};
|
|
653
|
+
registerChaptersTools(ctx, store, toolsConfig);
|
|
654
|
+
registerHostCommands(ctx, domain, {
|
|
655
|
+
artifactStoreRoot: config.artifactStoreRoot,
|
|
656
|
+
subagentAutoSave: config.subagentAutoSave,
|
|
657
|
+
harnessId: config.harnessId,
|
|
658
|
+
projectKeyOverride: config.projectKeyOverride,
|
|
659
|
+
// #255: /chapters-remove mutates through the plane's OWN store handle (one #59 section),
|
|
660
|
+
// and its print arms share the repair report cap — one knob, not two.
|
|
661
|
+
store,
|
|
662
|
+
reportMaxLines: config.repairReportMaxLines,
|
|
663
|
+
save: toolsConfig,
|
|
664
|
+
scheduler,
|
|
665
|
+
enrich,
|
|
666
|
+
// #173: discovery surface for the three model settings. The factory re-reads per
|
|
667
|
+
// invocation — settings mutate underneath (that is what the setters are for) and the
|
|
668
|
+
// catalog is a host verb, not a snapshot. `llm` is passed only when it really carries
|
|
669
|
+
// all three catalog verbs: a partial host surface must render as the clean refusal the
|
|
670
|
+
// pure core gives, never a TypeError mid-render.
|
|
671
|
+
models: () => {
|
|
672
|
+
const llm = ctx.llm;
|
|
673
|
+
const catalog = llm !== undefined
|
|
674
|
+
&& typeof llm.listProviders === 'function'
|
|
675
|
+
&& typeof llm.listModels === 'function'
|
|
676
|
+
&& typeof llm.resolveModelInfo === 'function'
|
|
677
|
+
? llm
|
|
678
|
+
: undefined;
|
|
679
|
+
const setting = (key) => { try {
|
|
680
|
+
return store.getSetting(key) ?? '';
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
return '';
|
|
684
|
+
} };
|
|
685
|
+
const enrichVal = setting('enrichment.model');
|
|
686
|
+
const rulesVal = setting('rules.model');
|
|
687
|
+
const promptVal = setting(PROMPT_SETTING_KEY);
|
|
688
|
+
return {
|
|
689
|
+
llm: catalog,
|
|
690
|
+
current: {
|
|
691
|
+
enrichment: enrichVal !== '' ? { value: enrichVal, source: 'settings' }
|
|
692
|
+
: config.enrichmentModel !== '' ? { value: config.enrichmentModel, source: 'config' }
|
|
693
|
+
: { value: setting('enrichment.route'), source: 'conversation-route' },
|
|
694
|
+
rules: rulesVal !== '' ? { value: rulesVal, source: 'settings' }
|
|
695
|
+
: config.rulesModel !== '' ? { value: config.rulesModel, source: 'config' }
|
|
696
|
+
: { value: '', source: 'rides-enrichment' },
|
|
697
|
+
prompt: promptVal !== '' ? { value: promptVal, source: 'settings' }
|
|
698
|
+
: config.promptModel !== '' ? { value: config.promptModel, source: 'config' }
|
|
699
|
+
: { value: '', source: 'off' },
|
|
700
|
+
},
|
|
701
|
+
};
|
|
702
|
+
},
|
|
703
|
+
prompt: Object.assign({ ...prompt, bar: promptBar }, {
|
|
704
|
+
// #225: `/chapters-status` answers "why is there no suggestion?" without reading files.
|
|
705
|
+
planeStatus: () => {
|
|
706
|
+
const lines = [];
|
|
707
|
+
const { spec, source } = prompt.effective();
|
|
708
|
+
lines.push(`Prompt plane: model ${spec !== '' ? `${spec} (${source})` : 'UNSET — the bar is absent (§28)'}`);
|
|
709
|
+
lines.push(` transport: ${pushWriter === null ? 'pull (this host has no appendPluginRecord — state reads are commands)' : 'push LIVE (plugin records; the bar rides the projection)'}`);
|
|
710
|
+
lines.push(` turn/end door: ${String(promptBoundariesSighted)} boundary event(s) sighted this process across ${String(promptVerdictSighted.size)} session(s)`);
|
|
711
|
+
return lines;
|
|
712
|
+
},
|
|
713
|
+
sessionRow: async (sessionId) => {
|
|
714
|
+
try {
|
|
715
|
+
const row = await store.get(sessionId);
|
|
716
|
+
const ps = row.promptSuggestion;
|
|
717
|
+
if (ps === undefined)
|
|
718
|
+
return ' this session: NO ROW YET — the runner never persisted a verdict (if the door sighted boundaries above, check server stderr for [chapters-prompt] lines)';
|
|
719
|
+
return ` this session: status=${ps.status}, turn=${String(ps.turnSeq ?? '-')}${ps.error !== '' ? `, error=${ps.error.slice(0, 90)}` : ''}, at=${ps.at}`;
|
|
720
|
+
}
|
|
721
|
+
catch (error) {
|
|
722
|
+
return ` this session: row unreadable (${String(error?.message ?? error).slice(0, 80)})`;
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
}),
|
|
726
|
+
rules: (cwd, args, sessionId) => rulesCommand({
|
|
727
|
+
cwd: () => cwd,
|
|
728
|
+
storeRoot: config.artifactStoreRoot,
|
|
729
|
+
harnessId: config.harnessId,
|
|
730
|
+
store,
|
|
731
|
+
projectFor: (c) => projectForCwd(store.projects(), c),
|
|
732
|
+
mirrorDir: (c) => join(c, DEFAULT_CLONE_DIR),
|
|
733
|
+
syncNow: async (c) => { try {
|
|
734
|
+
await scheduler.run(c, 'rules');
|
|
735
|
+
}
|
|
736
|
+
catch { /* status file records it */ } },
|
|
737
|
+
now: () => new Date(),
|
|
738
|
+
// #50 §7.5: the session tier activates by ancestry, so the surface needs
|
|
739
|
+
// the tree the caller is sitting on. No sessionId ⇒ `active` reports the
|
|
740
|
+
// session tier UNRESOLVED rather than guessing an empty path.
|
|
741
|
+
// #52: the SAME walk every door renders with — registry links plus the host
|
|
742
|
+
// header's delegation edge — so `active` can never describe a view the head
|
|
743
|
+
// will not show. One walk, memoized: two answers about one ancestry is how
|
|
744
|
+
// a surface starts disagreeing with itself.
|
|
745
|
+
...(sessionId !== undefined ? (() => {
|
|
746
|
+
const parentOf = makeHeaderParentReader((name) => { try {
|
|
747
|
+
return ctx.get?.(name);
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
return undefined;
|
|
751
|
+
} });
|
|
752
|
+
let once = null;
|
|
753
|
+
const walk = async () => {
|
|
754
|
+
if (once === null) {
|
|
755
|
+
once = ruleAncestorSet({
|
|
756
|
+
seeds: [sessionId],
|
|
757
|
+
registryAncestors: async (id) => await ancestorSessionIds(async (s) => await store.get(s), id),
|
|
758
|
+
...(parentOf !== undefined ? { parentOf } : {}),
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
return await once;
|
|
762
|
+
};
|
|
763
|
+
return {
|
|
764
|
+
ancestors: async () => (await walk()).ancestors,
|
|
765
|
+
ancestryTruncated: async () => (await walk()).truncated,
|
|
766
|
+
};
|
|
767
|
+
})() : {}),
|
|
768
|
+
// §7.3/§7.4: the SAME numbers the doors render with, so `active` can never
|
|
769
|
+
// describe a view the head will not show.
|
|
770
|
+
budgetTokens: config.coreRulesBudgetTokens,
|
|
771
|
+
projectCap: config.projectRulesDisplayCap,
|
|
772
|
+
sessionCap: config.sessionRulesDisplayCap,
|
|
773
|
+
// §7.5: the review surface reads the same activation posture the ladder uses
|
|
774
|
+
// (so `active` can say whether mined rules govern on arrival), and `model`
|
|
775
|
+
// sets a dedicated mining model through the shared settings table.
|
|
776
|
+
autoActivate: config.rulesAutoActivate,
|
|
777
|
+
settings: {
|
|
778
|
+
get: (k) => store.getSetting(k),
|
|
779
|
+
put: async (k, v) => { await store.putSetting(k, v); },
|
|
780
|
+
},
|
|
781
|
+
// §7.7: ONE bounded call, report-only, reusing the conversation route (and
|
|
782
|
+
// therefore the conversation's own cached prefix). An explicit exception to
|
|
783
|
+
// zero-inference in the elicitedPlot family — never a mutation.
|
|
784
|
+
ask: async (prompt) => {
|
|
785
|
+
const route = hostRoute;
|
|
786
|
+
if (route === null)
|
|
787
|
+
throw new Error('no conversation route yet — run a turn first');
|
|
788
|
+
const llm = ctx.llm;
|
|
789
|
+
if (llm?.stream === undefined)
|
|
790
|
+
throw new Error('llm.stream absent');
|
|
791
|
+
const assembler = new BlockAssembler();
|
|
792
|
+
for await (const chunk of llm.stream({
|
|
793
|
+
provider: route.provider, model: route.model,
|
|
794
|
+
messages: [auxPromptMessage(prompt)],
|
|
795
|
+
maxTokens: config.enrichmentMaxTokens,
|
|
796
|
+
purpose: 'compaction',
|
|
797
|
+
}))
|
|
798
|
+
assembler.push(chunk);
|
|
799
|
+
return assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text ?? '').join('');
|
|
800
|
+
},
|
|
801
|
+
}, args),
|
|
802
|
+
});
|
|
803
|
+
// Door 5 (issue #47): the fifth archive moment — subagent settlement. The
|
|
804
|
+
// host publishes `subagent/end` for every settled child run (one-shot AND
|
|
805
|
+
// each residency epoch of a continuable child); the wiring reads the live
|
|
806
|
+
// child synchronously (end strictly precedes disposal — measured, spike #47)
|
|
807
|
+
// with a durable sessionQuery fallback, gates on the header the HOST stamps
|
|
808
|
+
// (`origin: subagent`, `agentPreset`), and archives through the SAME
|
|
809
|
+
// saveSession core as /chapters-save. Enrichment is never run per child.
|
|
810
|
+
// Save-only: creates no session, so the "no continuation-creating actions
|
|
811
|
+
// mid-step" rule is not engaged; a settled child's turns are all completed
|
|
812
|
+
// by construction, and the anchor is its last `turn/end`.
|
|
813
|
+
if (config.subagentAutoSave) {
|
|
814
|
+
// #113: one suggestion per completed turn, and a human prompt that suspends the loop. Registered
|
|
815
|
+
// beside the other turn/end doors rather than as a new timing of our own (AGENTS: no second automatic
|
|
816
|
+
// engine), and inert whenever the model is unset — the model IS the switch.
|
|
817
|
+
const promptRunner = makePromptRoundRunner({
|
|
818
|
+
enabled: () => prompt.effective().spec !== '',
|
|
819
|
+
eligible: (session) => session.header?.origin !== 'subagent',
|
|
820
|
+
store: store,
|
|
821
|
+
// #194: every durable row write mirrors to the push transport (order serialized per session).
|
|
822
|
+
pushRow: (session, sessionId) => { pushBar(session, sessionId); },
|
|
823
|
+
generate: async (session, turnSeq) => {
|
|
824
|
+
// The live session object carries the events; this is the same read the archive door uses, and it
|
|
825
|
+
// is payload-bearing. The GOAL is folded from those events so a suggestion cannot ignore the
|
|
826
|
+
// purpose it is supposed to advance — the maintainer's rule that the two compose.
|
|
827
|
+
void turnSeq;
|
|
828
|
+
const events = [...(session.snapshotEvents?.() ?? [])];
|
|
829
|
+
const goal = readActiveGoal(events);
|
|
830
|
+
const r = await prompt.suggest({
|
|
831
|
+
id: session.id,
|
|
832
|
+
// #188: the workspace the chapter paths resolve against — without it the lineage walk
|
|
833
|
+
// cannot read chapter bodies, and the plots (the trajectory) would silently vanish.
|
|
834
|
+
cwd: session.header?.cwd ?? '',
|
|
835
|
+
events,
|
|
836
|
+
goalDirective: goalDirectiveOf(goal),
|
|
837
|
+
});
|
|
838
|
+
// Built as an object with ONLY the keys that hold a value: `exactOptionalPropertyTypes` is on, so
|
|
839
|
+
// `quiet: undefined` is not the same as omitting `quiet`, and a spread that always writes the key
|
|
840
|
+
// would not compile — which is the right outcome, because the runner branches on presence.
|
|
841
|
+
if (r.ok)
|
|
842
|
+
return { ok: true, text: r.text };
|
|
843
|
+
return { ok: false, reason: r.reason ?? r.text, ...(r.quiet === undefined ? {} : { quiet: r.quiet }) };
|
|
844
|
+
},
|
|
845
|
+
onReady: (sessionId, state) => {
|
|
846
|
+
// Fire only through the loop's own gate, against the LIVE goal signal: armed/idle/budget/one-per-offer
|
|
847
|
+
// plus intent, driver precedence and revision staleness, all re-checked at the moment of firing.
|
|
848
|
+
promptLoop.maybeFire(sessionId, state, promptLoop.goalSignal(sessionId));
|
|
849
|
+
},
|
|
850
|
+
now: () => new Date().toISOString(),
|
|
851
|
+
// The rig's legs run in a throwaway home whose `ctx.logger` output is not retained, so the prompt
|
|
852
|
+
// plane's own narration is copied to stderr — where the boot's server log keeps it. Every "why did the
|
|
853
|
+
// bar not move" answer tonight depended on lines that were being thrown away (#61).
|
|
854
|
+
log: (m) => { console.error(`[chapters-prompt] ${m}`); ctx.logger?.info?.(`dsh-chapters: ${m}`); },
|
|
855
|
+
warn: (m) => { console.error(`[chapters-prompt] WARN ${m}`); ctx.logger?.warn?.(`dsh-chapters: ${m}`); },
|
|
856
|
+
});
|
|
857
|
+
try {
|
|
858
|
+
ctx.on?.('session/event', ((session, event) => {
|
|
859
|
+
if (session?.id === undefined)
|
|
860
|
+
return;
|
|
861
|
+
if (event?.type === 'user/message' && event.data?.source?.kind === 'user')
|
|
862
|
+
promptBar.disarmHuman(session.id, session);
|
|
863
|
+
// Sighted, not assumed. Whether this listener is invoked at all is the question that has been
|
|
864
|
+
// unanswered for a day, and `ctx.logger` answers it only inside a home the rig deletes. stderr is
|
|
865
|
+
// captured by the boot's server log, so the answer survives the leg (#61).
|
|
866
|
+
if (event?.type === 'turn/end')
|
|
867
|
+
console.error(`[chapters-prompt] turn/end sighted session=${String(session.id)} seq=${String(event.seq ?? '?')}`);
|
|
868
|
+
if (event?.type === 'turn/end')
|
|
869
|
+
promptBoundariesSighted += 1;
|
|
870
|
+
// #225: the re-enable path needs a witness too. The maintainer ran `/chapters-prompt model
|
|
871
|
+
// …` at 18:16 and NO first-boundary line ever followed — meaning either no turn ended after
|
|
872
|
+
// it in this process, or the setting did not reach the plane. One line per enable tells
|
|
873
|
+
// which: `enable seen` with the effective spec immediately after the write.
|
|
874
|
+
if (event?.type === 'user/message' && event.data?.source?.kind === 'command') {
|
|
875
|
+
const argsV = String(event.data.args ?? '');
|
|
876
|
+
if (/^\s*model\s+\S/.test(argsV) || /^\s*suggest\b/.test(argsV)) {
|
|
877
|
+
const specN = prompt.effective().spec;
|
|
878
|
+
console.error(`[chapters-prompt] enable/suggest command seen for ${String(session.id)}: model now ${specN !== '' ? specN : 'STILL UNSET(→ writes landed elsewhere)'}`);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
if (event?.type === 'turn/end' && !promptVerdictSighted.has(String(session.id))) {
|
|
882
|
+
promptVerdictSighted.add(String(session.id));
|
|
883
|
+
const specV = prompt.effective().spec;
|
|
884
|
+
const originV = session.header?.origin;
|
|
885
|
+
console.error(`[chapters-prompt] first boundary for ${String(session.id)}: model=${specV !== '' ? specV : 'UNSET(→ skipped)'}, origin=${String(originV ?? '-')} (${originV === 'subagent' ? 'NOT eligible' : 'eligible'}), transport=${pushWriter === null ? 'pull' : 'push-live'}`);
|
|
886
|
+
}
|
|
887
|
+
// #218 BIRTH RECORD: the first event of a session gets an `idle` record, so the bar's
|
|
888
|
+
// first mount reads a PUSHED value and the discovery pull cannot happen — the maintainer
|
|
889
|
+
// watched a chapters-prompt node land BEFORE their first message, and that node was the
|
|
890
|
+
// discovery. Rows only change at turn/end, so a session born before its first turn has
|
|
891
|
+
// nothing to say — `idle` is the whole truth — and saying it through the projection costs
|
|
892
|
+
// zero commands forever.
|
|
893
|
+
if (pushWriter !== null && !seenBirthSessions.has(session.id)) {
|
|
894
|
+
seenBirthSessions.add(session.id);
|
|
895
|
+
if (prompt.effective().spec !== '')
|
|
896
|
+
pushBar(session, session.id);
|
|
897
|
+
}
|
|
898
|
+
promptRunner(session, event);
|
|
899
|
+
}));
|
|
900
|
+
console.error('[chapters-prompt] turn/end door armed');
|
|
901
|
+
ctx.logger?.info?.('dsh-chapters: suggested-next-step door armed — one offer per completed turn when a prompt model is set (state: /chapters-prompt state)');
|
|
902
|
+
}
|
|
903
|
+
catch (error) {
|
|
904
|
+
console.error(`[chapters-prompt] turn/end door REFUSED: ${String(error?.message ?? error)}`);
|
|
905
|
+
ctx.logger?.warn?.(`dsh-chapters: suggested-next-step door registration REFUSED — no turn will offer a suggestion (${String(error?.message ?? error).slice(0, 160)})`);
|
|
906
|
+
}
|
|
907
|
+
const autoSaveListener = wireSubagentAutoSave(ctx, store, {
|
|
908
|
+
presetId: CHAPTERS_PRESET_ID,
|
|
909
|
+
artifactStoreRoot: config.artifactStoreRoot,
|
|
910
|
+
chapterTokenTarget: config.chapterTokenTarget,
|
|
911
|
+
toolResultDeferFloorTokens: config.toolResultDeferFloorTokens,
|
|
912
|
+
mergeThreshold: config.mergeThreshold,
|
|
913
|
+
chapterLimit: config.chapterLimit,
|
|
914
|
+
chapterOversizeFactor: config.chapterOversizeFactor,
|
|
915
|
+
planInlineChars: config.planInlineChars,
|
|
916
|
+
callArgArtifactTokens: config.callArgArtifactTokens,
|
|
917
|
+
scheduler,
|
|
918
|
+
});
|
|
919
|
+
try {
|
|
920
|
+
ctx.on?.('subagent/end', autoSaveListener);
|
|
921
|
+
ctx.logger?.info?.('dsh-chapters: subagent auto-save armed — chapters-preset children archive at settlement (reported by /chapters-status)');
|
|
922
|
+
}
|
|
923
|
+
catch (error) {
|
|
924
|
+
const message = `dsh-chapters: subagent auto-save registration REFUSED — settling subagents will NOT archive (${String(error?.message ?? error)})`;
|
|
925
|
+
ctx.logger?.warn?.(message);
|
|
926
|
+
console.error(message);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
catch (error) {
|
|
931
|
+
// Tools are the whole user-facing surface short of the engine: a failure
|
|
932
|
+
// here is loud, never a silently missing tool. Loud means the operator
|
|
933
|
+
// actually sees it — the realm logger routes to boot stdout (r19/r35 twice
|
|
934
|
+
// burned by a warn nobody could read; the DSH_CHAPTERS_ERRORS sink env is
|
|
935
|
+
// the engine's precedent).
|
|
936
|
+
const message = `dsh-chapters: tool/command registration FAILED (${String(error)})`;
|
|
937
|
+
ctx.logger?.warn?.(message);
|
|
938
|
+
console.error(message);
|
|
939
|
+
}
|
|
940
|
+
// Honest status, because the settings surface reads "enabled and running" either way, and
|
|
941
|
+
// "mounted" never meant "the preset exists". The row logs its own delivery line; this one
|
|
942
|
+
// says what THIS plugin did, and the deferred roster check below says what the host has.
|
|
943
|
+
const mountMsg = 'dsh-chapters: mounted (tools + commands registered; preset is the `preset-chapters` row)';
|
|
944
|
+
ctx.logger?.info?.(mountMsg);
|
|
945
|
+
// Optional CodeGraph integration: lazy probe at activation. The subprocess
|
|
946
|
+
// service may not be mounted yet; resolveCodegraph caches the result so
|
|
947
|
+
// subsequent calls are free. Failure → silent no-op at every integration point.
|
|
948
|
+
if (config.codegraphEnabled !== false) {
|
|
949
|
+
const subprocess = ctx.get?.('subprocess');
|
|
950
|
+
void import("./codegraph.js").then(async (cg) => {
|
|
951
|
+
const exe = await cg.resolveCodegraph(subprocess);
|
|
952
|
+
ctx.logger?.info?.(`dsh-chapters: codegraph ${exe !== null ? 'found on PATH' : 'not found (integration inactive until installed)'}`);
|
|
953
|
+
}).catch(() => { });
|
|
954
|
+
}
|
|
955
|
+
// File presence is NOT preset existence (§ Preset delivery): the roster is the only
|
|
956
|
+
// evidence. It must be asked AFTER activation — the registry re-audits a mount by
|
|
957
|
+
// awaiting the settled loader tree, and a caller "must not run inside a Host row's
|
|
958
|
+
// own activation, which the settlement would wait on" — hence this deferred check,
|
|
959
|
+
// which also catches a registration that mounted but could not activate. One check
|
|
960
|
+
// per boot, reused by the dev witness.
|
|
961
|
+
const marker = process.env.DSH_CHAPTERS_WITNESS;
|
|
962
|
+
let roster;
|
|
963
|
+
const rosterCheck = () => roster ??= reportPresetRoster(ctx, config.fallbackPreset);
|
|
964
|
+
setTimeout(() => {
|
|
965
|
+
void rosterCheck().then((verdict) => {
|
|
966
|
+
if (marker !== undefined)
|
|
967
|
+
void witness(ctx, marker, domain, verdict);
|
|
968
|
+
});
|
|
969
|
+
}, 500).unref?.();
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* The §5 sync loop for the host plane. Auto-link (when `knowledgeRemote` is
|
|
973
|
+
* configured) happens lazily inside resolveProject: a workspace's first
|
|
974
|
+
* archive event finds a project record, so nothing network-shaped runs
|
|
975
|
+
* before anything archives.
|
|
976
|
+
*/
|
|
977
|
+
export function createScheduler(store, domain, config, debounceOverride, onSyncDone, warn) {
|
|
978
|
+
return createSyncScheduler({
|
|
979
|
+
storeRoot: config.artifactStoreRoot,
|
|
980
|
+
cloneDir: DEFAULT_CLONE_DIR,
|
|
981
|
+
debounceMs: debounceOverride ?? config.syncDebounceMs,
|
|
982
|
+
vocab: { apply: config.vocabApply, coMin: config.vocabCoMin, overlapMin: config.vocabOverlapMin },
|
|
983
|
+
...(onSyncDone !== undefined ? { onSyncDone } : {}),
|
|
984
|
+
resolveProject: (cwd) => {
|
|
985
|
+
const found = projectForCwd(store.projects(), cwd);
|
|
986
|
+
if (found !== undefined)
|
|
987
|
+
return found;
|
|
988
|
+
if (config.knowledgeRemote === '')
|
|
989
|
+
return undefined;
|
|
990
|
+
try {
|
|
991
|
+
const target = parseKnowledgeRemote(config.knowledgeRemote);
|
|
992
|
+
const kind = providerKindFor(target.kind, config.knowledgeProvider);
|
|
993
|
+
const resolved = resolveProject(cwd, config.projectKeyOverride !== '' ? config.projectKeyOverride : undefined, config.knowledgeRemote);
|
|
994
|
+
const rec = {
|
|
995
|
+
// Identity via projectKeyForTarget: §2.3 canonicalization, plus the
|
|
996
|
+
// treedx/ namespace for TreeDX targets (keyed per-machine-stable by
|
|
997
|
+
// construction — that is the whole point of §2.3). The override wins
|
|
998
|
+
// exactly as before.
|
|
999
|
+
projectKey: config.projectKeyOverride !== '' ? resolved.projectKey : projectKeyForTarget(target),
|
|
1000
|
+
slug: target.treedx?.repoName ?? (resolved.remote ?? config.knowledgeRemote).split('/').pop().replace(/\.git$/, ''),
|
|
1001
|
+
remote: config.knowledgeRemote,
|
|
1002
|
+
kind,
|
|
1003
|
+
harnessId: config.harnessId,
|
|
1004
|
+
linkedAt: new Date().toISOString(),
|
|
1005
|
+
cwd,
|
|
1006
|
+
};
|
|
1007
|
+
domain.table('projects').put(rec.projectKey, rec);
|
|
1008
|
+
return rec;
|
|
1009
|
+
}
|
|
1010
|
+
catch (error) {
|
|
1011
|
+
// A policy contradiction (knowledgeProvider vs the remote scheme) is
|
|
1012
|
+
// LOUD in the log but never throws into the archive path (§5.3 shape).
|
|
1013
|
+
warn?.(`dsh-chapters: knowledgeRemote auto-link refused: ${String(error?.message ?? error)}`);
|
|
1014
|
+
return undefined;
|
|
1015
|
+
}
|
|
1016
|
+
},
|
|
1017
|
+
tokenFor: (cwd, projectKey) => readToken(cwd, config.artifactStoreRoot, projectKey), // joins via resolveStoreRoot internally (#33)
|
|
1018
|
+
collectionsFor: makeCollectionsReader(store.sessions.bind(store), config.artifactStoreRoot),
|
|
1019
|
+
// #255: rehydration must not resurrect removed chapters; the guard reads the stub rows.
|
|
1020
|
+
removedFor: () => removedGuard(store.sessions()),
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
/** Ask the host which presets exist. Tolerates both shapes the service has
|
|
1024
|
+
* offered: the bare row array (`list()`) and the remote envelope (`{presets}`). */
|
|
1025
|
+
export async function readRosterVerdict(presets, presetId) {
|
|
1026
|
+
if (presets?.list === undefined)
|
|
1027
|
+
return { state: 'unknown', error: 'agentPresets service absent' };
|
|
1028
|
+
try {
|
|
1029
|
+
const raw = await presets.list();
|
|
1030
|
+
const rows = ((Array.isArray(raw) ? raw : raw?.presets) ?? []);
|
|
1031
|
+
const mine = rows.find((r) => r?.id === presetId);
|
|
1032
|
+
if (mine === undefined)
|
|
1033
|
+
return { state: 'absent', rows: rows.map((r) => String(r?.id ?? '?')).sort() };
|
|
1034
|
+
const broken = mine.broken;
|
|
1035
|
+
return typeof broken === 'string' && broken !== '' ? { state: 'present', broken } : { state: 'present' };
|
|
1036
|
+
}
|
|
1037
|
+
catch (error) {
|
|
1038
|
+
return { state: 'unknown', error: String(error?.message ?? error) };
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* The remedy for an absent preset. It is a configuration fact about someone else's tree —
|
|
1043
|
+
* a patch layer can be overridden or disabled by a profile we do not own — so the job is to
|
|
1044
|
+
* name where to look, never to retry behind the operator's back.
|
|
1045
|
+
*/
|
|
1046
|
+
export function rosterRemediation(presetId) {
|
|
1047
|
+
return `the plugin ships "${presetId}" as the "preset-chapters" declaration row in its bundle patch `
|
|
1048
|
+
+ '(cordis.patch.yml), which is a visible Loader entry: check that row in the plugin list, whether a '
|
|
1049
|
+
+ 'profile patch disables or overrides it, and whether @fractalsynapse/dsh-chapters is in this profile\u2019s '
|
|
1050
|
+
+ 'dsh.profile.bundles at all. If the row is present but this line says absent, the host is below the '
|
|
1051
|
+
+ '0.2.0 support floor and cannot register preset rows.';
|
|
1052
|
+
}
|
|
1053
|
+
/** Deferred boot check: one truthful line about the preset, plus the verdict for the witness.
|
|
1054
|
+
* Exported so the four arms of that line are tested without booting a host — the line IS the
|
|
1055
|
+
* health signal a user reads, and an untested health signal is how the silent case survived. */
|
|
1056
|
+
export async function reportPresetRoster(ctx, presetId) {
|
|
1057
|
+
const presets = ctx.get?.('agentPresets');
|
|
1058
|
+
const verdict = await readRosterVerdict(presets, presetId);
|
|
1059
|
+
// The roster says a preset is DECLARED; the inventory says whether its rows are ACTIVE.
|
|
1060
|
+
// A row can resolve and still never start, and that is invisible on the roster — so the
|
|
1061
|
+
// one line the operator reads carries both facts.
|
|
1062
|
+
const mounted = verdict.state === 'present' ? ' — ' + await describeMountedRows(presets, presetId) : '';
|
|
1063
|
+
const line = (message, warn) => {
|
|
1064
|
+
if (warn) {
|
|
1065
|
+
ctx.logger?.warn?.(message);
|
|
1066
|
+
console.error(message);
|
|
1067
|
+
}
|
|
1068
|
+
else
|
|
1069
|
+
ctx.logger?.info?.(message);
|
|
1070
|
+
};
|
|
1071
|
+
if (verdict.state === 'present' && verdict.broken === undefined) {
|
|
1072
|
+
line(`dsh-chapters: preset "${presetId}" is in the host roster${mounted} — chapter-form compaction and continuations will compose`, false);
|
|
1073
|
+
}
|
|
1074
|
+
else if (verdict.state === 'present') {
|
|
1075
|
+
line(`dsh-chapters: preset "${presetId}" is DECLARED BUT BROKEN — ${verdict.broken} — sessions choosing it will not compose. `
|
|
1076
|
+
+ 'A preset row whose child plugin no longer resolves fails here; the plugin drops rows this host cannot import, '
|
|
1077
|
+
+ `so this line means a package that resolves but will not activate — re-anchor the preset-chapters row against this host. Details: ${verdict.broken}`, true);
|
|
1078
|
+
}
|
|
1079
|
+
else if (verdict.state === 'absent') {
|
|
1080
|
+
line(`dsh-chapters: UNHEALTHY — preset "${presetId}" is NOT in the host roster (roster: ${verdict.rows.join(', ') || 'empty'}). `
|
|
1081
|
+
+ `Every session whose log records it will refuse to open with agent-preset/not-found — the logs are intact; the composition is missing. `
|
|
1082
|
+
+ `Remedy: ${rosterRemediation(presetId)}`, true);
|
|
1083
|
+
}
|
|
1084
|
+
else {
|
|
1085
|
+
line(`dsh-chapters: could not verify the preset roster (${verdict.error}) — treat chapter-form compaction as unconfirmed`, true);
|
|
1086
|
+
}
|
|
1087
|
+
return verdict;
|
|
1088
|
+
}
|
|
1089
|
+
/** One phrase describing what the host's inventory says about the preset's own rows.
|
|
1090
|
+
* Never throws: an unavailable inventory must degrade the line, not the boot check. */
|
|
1091
|
+
export async function describeMountedRows(presets, presetId) {
|
|
1092
|
+
try {
|
|
1093
|
+
const raw = await presets?.compositionInventory?.();
|
|
1094
|
+
const entry = inventoryEntry(raw, presetId);
|
|
1095
|
+
if (entry === undefined)
|
|
1096
|
+
return 'rows not reported by this host';
|
|
1097
|
+
const bad = unusableRows(entry);
|
|
1098
|
+
const total = (entry.rows ?? []).length;
|
|
1099
|
+
return bad.length === 0
|
|
1100
|
+
? `${total} mounted rows all active`
|
|
1101
|
+
: `${bad.length} of ${total} rows NOT active (${bad.join('; ')})`;
|
|
1102
|
+
}
|
|
1103
|
+
catch (error) {
|
|
1104
|
+
return `rows unreadable (${String(error?.message ?? error)})`;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
// ---------------------------------------------------------------- dev witness
|
|
1108
|
+
async function witness(ctx, marker, preopened, roster) {
|
|
1109
|
+
const WITNESS_SESSION = 'dsh-chapters-witness';
|
|
1110
|
+
const out = { at: new Date().toISOString() };
|
|
1111
|
+
try {
|
|
1112
|
+
out.storageDomainPresent = ctx.get?.('storageDomain') !== undefined;
|
|
1113
|
+
out.diagnostics = diagnosticStatusLine();
|
|
1114
|
+
// What this host's preset mechanism IS, and what the roster says about the
|
|
1115
|
+
// chapters preset. These two lines are the difference between "the preset files
|
|
1116
|
+
// are installed" (true, and meaningless on a declaration-row host) and "the
|
|
1117
|
+
// preset exists" — the only thing a session cares about.
|
|
1118
|
+
out.registryRegisters = typeof ctx.get?.('agentPresets')?.register === 'function';
|
|
1119
|
+
out.presetRoster = roster?.state ?? 'not-checked';
|
|
1120
|
+
out.presetRosterLine = await describeMountedRows(ctx.get?.('agentPresets'), CHAPTERS_PRESET_ID);
|
|
1121
|
+
// The roster says a preset is declared; the inventory says whether its rows are
|
|
1122
|
+
// actually active. A row that resolves but will not activate leaves the preset on
|
|
1123
|
+
// the menu and unusable, and only this distinction names it.
|
|
1124
|
+
const invRaw = await ctx.get?.('agentPresets')?.compositionInventory?.()?.catch?.(() => undefined);
|
|
1125
|
+
const inv = inventoryEntry(invRaw, CHAPTERS_PRESET_ID);
|
|
1126
|
+
if (inv !== undefined) {
|
|
1127
|
+
out.presetInventoryRows = (inv.rows ?? []).length;
|
|
1128
|
+
out.presetUnusableRows = unusableRows(inv);
|
|
1129
|
+
if (inv.broken !== undefined)
|
|
1130
|
+
out.presetInventoryBroken = inv.broken;
|
|
1131
|
+
}
|
|
1132
|
+
if (roster?.state === 'absent')
|
|
1133
|
+
out.presetRosterRows = roster.rows;
|
|
1134
|
+
if (roster?.state === 'present' && roster.broken !== undefined)
|
|
1135
|
+
out.presetRosterBroken = roster.broken;
|
|
1136
|
+
if (roster?.state === 'unknown')
|
|
1137
|
+
out.presetRosterError = roster.error;
|
|
1138
|
+
const storageDomain = ctx.get?.('storageDomain');
|
|
1139
|
+
const store = preopened !== undefined ? makeDomainStore(preopened) : (await acquireChapterStore(storageDomain)).store;
|
|
1140
|
+
const prior = await store.get(WITNESS_SESSION);
|
|
1141
|
+
out.priorChapters = prior.chapters.length;
|
|
1142
|
+
if (prior.chapters.length === 0) {
|
|
1143
|
+
const reserved = reserve(prior, 'witness@1', 1);
|
|
1144
|
+
const record = {
|
|
1145
|
+
number: reserved.numbers[0] ?? 1,
|
|
1146
|
+
path: `.dsh-chapters/${WITNESS_SESSION}/chapters/001-witness.md`,
|
|
1147
|
+
title: 'witness', summary: `first boot ${new Date().toISOString()}`,
|
|
1148
|
+
startSeq: 0, endSeq: 0, topics: [], messages: 0, sha256: '0'.repeat(64), estimatedTokens: 0, artifacts: [],
|
|
1149
|
+
};
|
|
1150
|
+
await store.put(WITNESS_SESSION, appendChapters(reserved.state, [record]));
|
|
1151
|
+
}
|
|
1152
|
+
else {
|
|
1153
|
+
await store.put(WITNESS_SESSION, prior);
|
|
1154
|
+
}
|
|
1155
|
+
out.storedChapters = (await store.get(WITNESS_SESSION)).chapters.length;
|
|
1156
|
+
}
|
|
1157
|
+
catch (error) {
|
|
1158
|
+
out.error = String(error);
|
|
1159
|
+
}
|
|
1160
|
+
writeFileSync(marker, JSON.stringify(out, null, 2));
|
|
1161
|
+
}
|
|
1162
|
+
//# sourceMappingURL=index.js.map
|