@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/commands.js
ADDED
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side slash commands for the knowledge layer (knowledge-repo.md §5, §2).
|
|
3
|
+
* Both render as command flow nodes — verified to stay OUT of the model's
|
|
4
|
+
* context (record §7.2: in-session UX, zero context cost).
|
|
5
|
+
*
|
|
6
|
+
* /chapters-link show upstream + mirror status
|
|
7
|
+
* /chapters-link <local-path> bind a path upstream (no creds)
|
|
8
|
+
* /chapters-link <https-url> <token> bind a network git upstream
|
|
9
|
+
* /chapters-link treedx+<url>/<repo> <token> bind a TreeDX service upstream
|
|
10
|
+
* /chapters-status last sync result
|
|
11
|
+
* /chapters-models [<provider> [<model>]] discover the ids the three model setters accept (#173)
|
|
12
|
+
* /chapters-enrich run | model … | report P2 enrichment surface (record §6.2)
|
|
13
|
+
* /chapters-prompt model … | off | suggest prompt recommendations (#71, site §28)
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { DEFAULT_CLONE_DIR, planStoreToRepo, projectForCwd, readSyncStatus, readToken, runSync, writeToken, } from "./sync.js";
|
|
18
|
+
import { classifyOffer } from "./prompt-state.js";
|
|
19
|
+
import { isLocalUpstreamUrl, readOrigin, resolveLocalUpstreamPath } from "./gitops.js";
|
|
20
|
+
import { parseKnowledgeRemote, projectKeyForTarget, projectKeyFromRemote, resolveProject } from "./repo.js";
|
|
21
|
+
import { createTreeDxClient } from "./treedx/client.js";
|
|
22
|
+
import { resolveRepoId } from "./treedx/provider.js";
|
|
23
|
+
import { readState } from "./treedx/state.js";
|
|
24
|
+
import { selectProvider } from "./provider.js";
|
|
25
|
+
import { modelsCommand } from "./models-command.js";
|
|
26
|
+
import { resolveStoreRoot } from "./store.js";
|
|
27
|
+
import { diagnosticStatusLine } from "./diag.js";
|
|
28
|
+
import { registryHealth } from "./repair.js";
|
|
29
|
+
import { removedGuard } from "./removal.js";
|
|
30
|
+
import { applyRemoval, planRemoval, readMissingAccess, resolveRemovalId, restoreRemoval } from "./removal.js";
|
|
31
|
+
import { readDurableSession } from "./auto-save.js";
|
|
32
|
+
import { ancestorPath, buildIndex, isRemovedChapter } from "./registry.js";
|
|
33
|
+
import { verifyChapter } from "./archive.js";
|
|
34
|
+
import { searchKnowledge, searchLocalStore } from "./search.js";
|
|
35
|
+
import { makeArchiveFs } from "./store.js";
|
|
36
|
+
/** Door 5 state, stated with its config name so a user can flip it (issue #47). */
|
|
37
|
+
const AUTO_SAVE_LINE = (config) => `\nSubagent auto-save: ${config.subagentAutoSave === false ? 'OFF (subagentAutoSave: false)' : 'ON — chapters-preset children archive their completed turns at settlement'}`;
|
|
38
|
+
const cwdOf = (agent) => (agent?.session?.header?.cwd ?? process.cwd());
|
|
39
|
+
/** branch name (or short oid) the mirror's HEAD points at. */
|
|
40
|
+
function readMirrorHead(mirrorAbs) {
|
|
41
|
+
try {
|
|
42
|
+
const head = fs.readFileSync(path.join(mirrorAbs, '.git', 'HEAD'), 'utf8').trim();
|
|
43
|
+
const m = /^ref: refs\/heads\/(.+)$/.exec(head);
|
|
44
|
+
return m?.[1] ?? head.slice(0, 8);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** store files not yet present in the mirror (the honest pending-push view). */
|
|
51
|
+
function countPending(mirrorAbs, storeDir, projectKey) {
|
|
52
|
+
try {
|
|
53
|
+
return planStoreToRepo(storeDir, projectKey).filter((f) => !fs.existsSync(path.join(mirrorAbs, f.rel))).length;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return -1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function registerHostCommands(ctx, domain, config) {
|
|
60
|
+
const commands = ctx.get?.('commands');
|
|
61
|
+
const disposers = [];
|
|
62
|
+
if (commands?.register === undefined)
|
|
63
|
+
return () => { };
|
|
64
|
+
/**
|
|
65
|
+
* /chapters-link — the upstream configuration surface, three modes:
|
|
66
|
+
* (no args) show the current upstream + mirror state (git-status-like)
|
|
67
|
+
* <local-path> bind the pool to a directory upstream (bare repo created
|
|
68
|
+
* on demand; no credentials — §2.1's local upstreams)
|
|
69
|
+
* <url> <token> bind to an http(s) upstream; the token is REQUIRED unless
|
|
70
|
+
* the host is loopback, and is stored in the DSH home, 0600
|
|
71
|
+
*/
|
|
72
|
+
const linkCommand = {
|
|
73
|
+
name: 'chapters-link',
|
|
74
|
+
description: 'Show / set this project\u2019s knowledge upstream: /chapters-link [path | <https-url> <token> | treedx+<url>/<repo> <token>]',
|
|
75
|
+
input: { hint: '[path | <https-remote-url> <token> | treedx+<url>/<repo> <token>]' },
|
|
76
|
+
async handler(invocation) {
|
|
77
|
+
const parts = (invocation.rawInput ?? '').trim().split(/\s+/).filter((p) => p.length > 0);
|
|
78
|
+
const cwd = cwdOf(invocation.agent);
|
|
79
|
+
const table = domain.table('projects');
|
|
80
|
+
try {
|
|
81
|
+
// ---- mode 1: no args — the git-status-like view
|
|
82
|
+
if (parts.length === 0) {
|
|
83
|
+
const rec = projectForCwd(table.entries(), cwd);
|
|
84
|
+
if (rec === undefined) {
|
|
85
|
+
return { kind: 'success', text: 'Upstream: none — this workspace is knowledge-local (chapters, forks, signatures all work; nothing syncs).\nSet one: /chapters-link <path> or /chapters-link <https-url> <token>' };
|
|
86
|
+
}
|
|
87
|
+
const mirrorAbs = path.join(cwd, DEFAULT_CLONE_DIR);
|
|
88
|
+
const treedx = (rec.kind ?? 'git') === 'treedx';
|
|
89
|
+
const tstate = treedx ? readState(mirrorAbs) : null;
|
|
90
|
+
const head = treedx ? (tstate?.head !== null && tstate?.head !== undefined ? tstate.head.slice(0, 10) : null) : readMirrorHead(mirrorAbs);
|
|
91
|
+
const origin = treedx ? tstate?.origin ?? null : readOrigin(mirrorAbs);
|
|
92
|
+
const status = readSyncStatus(cwd, config.artifactStoreRoot);
|
|
93
|
+
const pending = countPending(mirrorAbs, resolveStoreRoot(cwd, config.artifactStoreRoot), rec.projectKey);
|
|
94
|
+
const hasToken = readToken(cwd, config.artifactStoreRoot, rec.projectKey) !== undefined;
|
|
95
|
+
const local = isLocalUpstreamUrl(rec.remote);
|
|
96
|
+
const lines = [
|
|
97
|
+
`Provider: ${rec.kind ?? 'git'}`,
|
|
98
|
+
`Upstream: ${rec.remote}`,
|
|
99
|
+
`Bound: ${rec.slug} · key ${rec.projectKey} · since ${rec.linkedAt}`,
|
|
100
|
+
// §7.8 IDENTITY MUST FAIL LOUDLY. Project rules carry across checkouts
|
|
101
|
+
// because the key derives from the canonicalized REMOTE — so the four
|
|
102
|
+
// ways it can diverge (no remote ⇒ path-derived, a fork listed first,
|
|
103
|
+
// a TreeDX link keyed on the pool, an unlinked clone) are reported here
|
|
104
|
+
// rather than leaving a checkout silently owning its own namespace.
|
|
105
|
+
(() => {
|
|
106
|
+
const derived = resolveProject(cwd, config.projectKeyOverride !== undefined && config.projectKeyOverride !== '' ? config.projectKeyOverride : undefined);
|
|
107
|
+
const where = derived.projectKeySource === 'remote' ? `remote ${derived.remote ?? ''}`
|
|
108
|
+
: derived.projectKeySource === 'override' ? 'config override'
|
|
109
|
+
: `PATH ${cwd} (not the remote — unstable across machines)`;
|
|
110
|
+
return derived.projectKey === rec.projectKey
|
|
111
|
+
? `Identity: ${derived.projectKeySource} — matches the bound key (${where})`
|
|
112
|
+
: `Identity: ${derived.projectKeySource} ⚠ THIS CHECKOUT RESOLVES A DIFFERENT PROJECT KEY (${derived.projectKey.slice(0, 8)}… vs bound ${rec.projectKey.slice(0, 8)}…) — rules and chapters approved elsewhere will NOT appear here. ${where}`;
|
|
113
|
+
})(),
|
|
114
|
+
diagnosticStatusLine(),
|
|
115
|
+
`Mirror: ${fs.existsSync(mirrorAbs) ? DEFAULT_CLONE_DIR : '(not created yet)'}` +
|
|
116
|
+
(treedx ? ` head ${head ?? (tstate === null ? 'not cloned' : 'empty repo')}${tstate?.offline === true ? ' · OFFLINE (push deferred)' : ''}` : ` branch ${head ?? 'main'}`) +
|
|
117
|
+
(origin !== null && origin !== (treedx ? rec.remote : local ? resolveLocalUpstreamPath(rec.remote, cwd) : rec.remote) ? ` [origin drift: ${origin} — will rebuild]` : ''),
|
|
118
|
+
`Sync: ${status === null ? 'never run' : `${status.mode} @ ${status.at}`}${status !== null && !status.lastOk ? ` — ${status.detail}` : ''}`,
|
|
119
|
+
`Pending: ${pending} file(s) not yet mirrored${hasToken || local ? '' : ' ⚠ no credentials — /chapters-link ' + (treedx ? 'treedx+<url>/<repo> <token>' : '<url> <token>')}`,
|
|
120
|
+
`Harness: ${rec.harnessId}`,
|
|
121
|
+
];
|
|
122
|
+
return { kind: 'success', text: lines.join('\n') };
|
|
123
|
+
}
|
|
124
|
+
// ---- mode 2: local-path upstream
|
|
125
|
+
const target = parts[0];
|
|
126
|
+
if (!/^https?:\/\//.test(target) && (target.startsWith('/') || target === '~' || target.startsWith('~/') || target.startsWith('./') || target.startsWith('../') || /^file:\/\/\//.test(target))) {
|
|
127
|
+
const resolved = resolveLocalUpstreamPath(target, cwd);
|
|
128
|
+
const record = {
|
|
129
|
+
projectKey: projectKeyFromRemote(`file://${resolved}`),
|
|
130
|
+
slug: path.basename(resolved).replace(/\.git$/, ''),
|
|
131
|
+
remote: resolved,
|
|
132
|
+
harnessId: config.harnessId,
|
|
133
|
+
linkedAt: new Date().toISOString(),
|
|
134
|
+
cwd,
|
|
135
|
+
};
|
|
136
|
+
table.put(record.projectKey, record);
|
|
137
|
+
const sync = await syncNow(cwd, record, undefined);
|
|
138
|
+
return { kind: 'success', text: `Linked to local upstream ${resolved} (a bare pool repo is created there if absent; no credentials needed).\nKey: ${record.projectKey}\nSync: ${sync.mode} \u2014 ${sync.steps.join(' \u2192 ') || sync.detail}` };
|
|
139
|
+
}
|
|
140
|
+
// ---- mode 4: TreeDX service upstream (§15 amendment)
|
|
141
|
+
if (target.startsWith('treedx+')) {
|
|
142
|
+
const parsed = parseKnowledgeRemote(target); // throws on a malformed form — caught below
|
|
143
|
+
const token = parts[1];
|
|
144
|
+
if (token === undefined || token === '') {
|
|
145
|
+
return { kind: 'error', text: 'TreeDX always needs a bearer token: /chapters-link treedx+<url>/<repo> <token> (a local dev container: scripts/treedx-local.sh token mints one)' };
|
|
146
|
+
}
|
|
147
|
+
if (parsed.treedx === undefined)
|
|
148
|
+
return { kind: 'error', text: 'internal: treedx+ target did not parse to a service target' };
|
|
149
|
+
const client = createTreeDxClient({ baseUrl: parsed.treedx.baseUrl, token, fetchTimeoutMs: 15000 });
|
|
150
|
+
// Resolve-or-create the managed repository — linking is an explicit
|
|
151
|
+
// human act (§2.1), so creating the named pool is in scope here and
|
|
152
|
+
// ONLY here. 409 (exists) is a success path. One shared live-shaped
|
|
153
|
+
// catalog parser with the transport (repoCatalog/resolveRepoId) —
|
|
154
|
+
// the first live run broke precisely on parser drift.
|
|
155
|
+
let repoId = await resolveRepoId(client, parsed.treedx.repoName);
|
|
156
|
+
let resolveNote = '';
|
|
157
|
+
if (repoId === '') {
|
|
158
|
+
const created = await client.post('/api/v1/repos', {
|
|
159
|
+
repositoryName: parsed.treedx.repoName, source: { type: 'empty' }, placement: { mode: 'local' },
|
|
160
|
+
});
|
|
161
|
+
if (!created.ok && created.code !== 'conflict' && created.status !== 409) {
|
|
162
|
+
return { kind: 'error', text: `TreeDX repository '${parsed.treedx.repoName}' not found and create failed (${created.detail}) — check the URL/token or create it server-side.` };
|
|
163
|
+
}
|
|
164
|
+
resolveNote = '\nCreated the managed repository (born with refs/heads/main).';
|
|
165
|
+
repoId = await resolveRepoId(client, parsed.treedx.repoName);
|
|
166
|
+
}
|
|
167
|
+
const record = {
|
|
168
|
+
projectKey: projectKeyForTarget(parsed),
|
|
169
|
+
slug: parsed.treedx.repoName,
|
|
170
|
+
remote: target,
|
|
171
|
+
kind: 'treedx',
|
|
172
|
+
...(repoId !== '' ? { repoId } : {}),
|
|
173
|
+
harnessId: config.harnessId,
|
|
174
|
+
linkedAt: new Date().toISOString(),
|
|
175
|
+
cwd,
|
|
176
|
+
};
|
|
177
|
+
writeToken(record.projectKey, token);
|
|
178
|
+
table.put(record.projectKey, record);
|
|
179
|
+
const sync = await syncNow(cwd, record, token);
|
|
180
|
+
return {
|
|
181
|
+
kind: 'success',
|
|
182
|
+
text: `Linked to TreeDX ${parsed.treedx.baseUrl} repo '${parsed.treedx.repoName}'${repoId !== '' ? ` (${repoId})` : ''}.\nKey: ${record.projectKey}\nCredentials stored (DSH home, 0600).${resolveNote}\nSync: ${sync.mode} — ${sync.steps.join(' → ') || sync.detail}` +
|
|
183
|
+
(sync.ok || sync.mode === 'local-only' ? '' : '\nThe service may be down or the token wrong — /chapters-status carries the detail; nothing else is affected.'),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// ---- mode 3: https upstream — token required unless loopback
|
|
187
|
+
if (!/^https?:\/\//.test(target)) {
|
|
188
|
+
return { kind: 'error', text: 'usage: /chapters-link [<local-path> | <https-url> <token>] \u2014 (no args shows the current upstream)' };
|
|
189
|
+
}
|
|
190
|
+
const token = parts[1];
|
|
191
|
+
const host = (() => { try {
|
|
192
|
+
return new URL(target).hostname;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return '';
|
|
196
|
+
} })();
|
|
197
|
+
const loopback = host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
198
|
+
if (token === undefined && !loopback) {
|
|
199
|
+
return { kind: 'error', text: 'A credential is required for a network upstream: /chapters-link <url> <token> (stored 0600 under the DSH home \u2014 never in the project). Local paths need none: /chapters-link ./vendor/kb.git' };
|
|
200
|
+
}
|
|
201
|
+
const resolved = resolveProject(cwd, undefined, target);
|
|
202
|
+
const record = {
|
|
203
|
+
projectKey: resolved.projectKey,
|
|
204
|
+
slug: (resolved.remote ?? target).split('/').pop().replace(/\.git$/, ''),
|
|
205
|
+
remote: target,
|
|
206
|
+
harnessId: config.harnessId,
|
|
207
|
+
linkedAt: new Date().toISOString(),
|
|
208
|
+
cwd,
|
|
209
|
+
};
|
|
210
|
+
if (token !== undefined)
|
|
211
|
+
writeToken(record.projectKey, token);
|
|
212
|
+
table.put(record.projectKey, record);
|
|
213
|
+
const sync = await syncNow(cwd, record, token ?? readToken(cwd, config.artifactStoreRoot, record.projectKey));
|
|
214
|
+
const hint = sync.ok || sync.mode === 'local-only'
|
|
215
|
+
? ''
|
|
216
|
+
: '\nThe upstream may not exist yet \u2014 create the private repo (GitHub creates one on push for a suitably-scoped token), or point at a local path: /chapters-link ./vendor/kb.git';
|
|
217
|
+
return { kind: 'success', text: `Linked to ${target}.\nKey: ${record.projectKey}${token === undefined ? '\n(no credentials: loopback upstream)' : '\nCredentials stored (DSH home, 0600).'}\nSync: ${sync.mode} \u2014 ${sync.steps.join(' \u2192 ') || sync.detail}${hint}` };
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
return { kind: 'error', text: `chapters-link failed: ${String(error?.message ?? error)}`.slice(0, 300) };
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
/** one immediate sync pass for a fresh record (explicit project wins). */
|
|
225
|
+
async function syncNow(cwd, record, token) {
|
|
226
|
+
if (config.scheduler !== undefined)
|
|
227
|
+
return config.scheduler.run(cwd, 'link', record);
|
|
228
|
+
return runSync({
|
|
229
|
+
cwd, storeRoot: config.artifactStoreRoot, cloneDir: DEFAULT_CLONE_DIR,
|
|
230
|
+
project: record, ...(token !== undefined && token !== null ? { token } : {}),
|
|
231
|
+
// #255: the guard reads removed stubs from the registry rows this command already holds —
|
|
232
|
+
// a deletion must never be rehydrated back into the store on the pass that publishes it.
|
|
233
|
+
removed: removedGuard(domain.table('sessions').entries()),
|
|
234
|
+
force: true,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const statusCommand = {
|
|
238
|
+
name: 'chapters-status',
|
|
239
|
+
description: 'Show the knowledge-repo mirror status for this project.',
|
|
240
|
+
input: { hint: '[prints project, remote, and last sync result]' },
|
|
241
|
+
async handler(invocation) {
|
|
242
|
+
const cwd = cwdOf(invocation.agent);
|
|
243
|
+
const project = projectForCwd(domain.table('projects').entries(), cwd);
|
|
244
|
+
const status = readSyncStatus(cwd, config.artifactStoreRoot);
|
|
245
|
+
// #61: this command used to RETURN here when nothing was linked — one sentence, and every health
|
|
246
|
+
// line below it (Enrichment, Diagnostics, Auto-save, Findings) was unreachable precisely for the
|
|
247
|
+
// users who most needed it. An unlinked project is a STATE, not an absence of health.
|
|
248
|
+
const lines = [];
|
|
249
|
+
if (project === undefined && status === null) {
|
|
250
|
+
lines.push('No knowledge repository is linked yet. Link one with /chapters-link <https-remote-url> [token] — '
|
|
251
|
+
+ 'until then this project\u2019s chapters, rules and index stay on THIS machine only.');
|
|
252
|
+
}
|
|
253
|
+
if (project !== undefined) {
|
|
254
|
+
const pending = config.scheduler?.hasPending(cwd) === true;
|
|
255
|
+
lines.push(`Project: ${project.slug} \u00B7 key ${project.projectKey}`);
|
|
256
|
+
lines.push(`Remote: ${project.remote}`);
|
|
257
|
+
try {
|
|
258
|
+
lines.push(`Provider: ${selectProvider(project).describe(project)}`);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
lines.push('Provider: (kind unrecognized — sync will refuse loudly, nothing silently reroutes)');
|
|
262
|
+
}
|
|
263
|
+
// #50 §7.8 IDENTITY MUST FAIL LOUDLY. Project rules carry across checkouts
|
|
264
|
+
// because the key is UUIDv5 over the canonicalized REMOTE — so the ways it
|
|
265
|
+
// can silently diverge (no readable remote ⇒ path-derived, a fork listed
|
|
266
|
+
// before origin, a TreeDX link keyed on the pool location) are reported
|
|
267
|
+
// here instead of leaving a checkout owning a private rules namespace while
|
|
268
|
+
// its developer believes the team's rules apply.
|
|
269
|
+
{
|
|
270
|
+
const derived = resolveProject(cwd, config.projectKeyOverride !== undefined && config.projectKeyOverride !== '' ? config.projectKeyOverride : undefined);
|
|
271
|
+
const where = derived.projectKeySource === 'remote' ? `remote ${derived.remote ?? ''}`
|
|
272
|
+
: derived.projectKeySource === 'override' ? 'config override'
|
|
273
|
+
: `PATH ${cwd} (not the remote — unstable across machines)`;
|
|
274
|
+
lines.push(derived.projectKey === project.projectKey
|
|
275
|
+
? `Identity: ${derived.projectKeySource} — matches the bound key (${where})`
|
|
276
|
+
: `Identity: ${derived.projectKeySource} ⚠ THIS CHECKOUT RESOLVES A DIFFERENT PROJECT KEY (${derived.projectKey.slice(0, 8)}… vs bound ${project.projectKey.slice(0, 8)}…) — rules and chapters approved elsewhere will NOT appear here. ${where}`);
|
|
277
|
+
}
|
|
278
|
+
lines.push(pending ? 'Sync: a push is pending (debounced)' : `Sync: ${status?.mode ?? 'never run'}${pending ? '' : ''}`);
|
|
279
|
+
}
|
|
280
|
+
if (status !== null) {
|
|
281
|
+
lines.push(`Last sync: ${status.at} \u2014 ${status.lastOk ? 'synced' : `${status.mode ?? 'local-only'}: ${status.detail}`}`);
|
|
282
|
+
lines.push(` ${(status.steps ?? []).join(' \u2192 ')}`);
|
|
283
|
+
}
|
|
284
|
+
// #61: an unwired surface is STATED, not omitted. A reader who sees no enrichment line cannot tell
|
|
285
|
+
// whether enrichment is off, broken, or simply not printed here — which is how "the feature never
|
|
286
|
+
// ran" hides for three days behind a logger nobody reads.
|
|
287
|
+
lines.push(config.enrich !== undefined ? config.enrich.statusLine() : 'Enrichment: not wired on this plane (no model route registered)');
|
|
288
|
+
lines.push(config.prompt !== undefined ? config.prompt.statusLine() : 'Prompt suggestions: not wired on this plane');
|
|
289
|
+
// #225: the prompt PLANE block — model, transport, door sightings, and THIS session's last
|
|
290
|
+
// persisted verdict. The maintainer asked why hundreds of pulls never produced an offer;
|
|
291
|
+
// from here that question is one command wide, answered by facts instead of archaeology.
|
|
292
|
+
if (config.prompt !== undefined) {
|
|
293
|
+
const plane = config.prompt;
|
|
294
|
+
if (typeof plane.planeStatus === 'function')
|
|
295
|
+
for (const l of plane.planeStatus())
|
|
296
|
+
lines.push(l);
|
|
297
|
+
if (typeof plane.sessionRow === 'function') {
|
|
298
|
+
const sessId = invocation.agent?.session?.id
|
|
299
|
+
?? invocation.agent?.agent?.session?.id;
|
|
300
|
+
if (sessId !== undefined) {
|
|
301
|
+
try {
|
|
302
|
+
lines.push(await plane.sessionRow(sessId));
|
|
303
|
+
}
|
|
304
|
+
catch { /* the block degrades to what it has */ }
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
lines.push(diagnosticStatusLine());
|
|
309
|
+
lines.push(AUTO_SAVE_LINE(config));
|
|
310
|
+
// Findings, read-only, from the #60 auditor's row-level view. What a status cannot check WITHOUT
|
|
311
|
+
// the durable log is named as unchecked rather than counted as clean: coverage holes, orphan
|
|
312
|
+
// files and dead citations need `/chapters-repair audit`.
|
|
313
|
+
{
|
|
314
|
+
const states = [...domain.table('sessions').entries()]
|
|
315
|
+
.map(([, st]) => st)
|
|
316
|
+
.filter((st) => st.rootSession !== undefined);
|
|
317
|
+
const health = registryHealth(states);
|
|
318
|
+
const dead = states.reduce((m, st) => m + (st.chapters?.length ?? 0), 0);
|
|
319
|
+
lines.push(`Findings (registry-level, this machine): ${health.text}`);
|
|
320
|
+
lines.push(` NOT checked here: coverage holes, orphan files, dead citations — run /chapters-repair audit${dead === 0 ? ' (no chapter records at all yet)' : ''}`);
|
|
321
|
+
}
|
|
322
|
+
// #255: removals are a STATE — printed in every condition, unlinked included (#61).
|
|
323
|
+
{
|
|
324
|
+
const storeDir0 = cwd === '' ? '' : resolveStoreRoot(cwd, config.artifactStoreRoot);
|
|
325
|
+
const stubs = storeDir0 === '' ? 0 : [...domain.table('sessions').entries()]
|
|
326
|
+
.filter(([, st]) => st.rootSession !== undefined && fs.existsSync(path.join(storeDir0, st.rootSession)))
|
|
327
|
+
.reduce((m, [, st]) => m + (st.chapters ?? []).filter(isRemovedChapter).length, 0);
|
|
328
|
+
const mal = storeDir0 === '' ? null : readMissingAccess(storeDir0, 0);
|
|
329
|
+
lines.push(`Removals (this project): ${stubs} removed stub(s); missing-access ledger: ${mal === null ? 'no workspace cwd on this session' : mal.total === 0 ? 'empty (it fills at the next TOC build that cites a vanished chapter — /chapters-remove report)' : `${mal.total} line(s) at ${mal.file}`}`);
|
|
330
|
+
}
|
|
331
|
+
// The retrievability line: a long session that has never tripped an archive moment is the field
|
|
332
|
+
// case #61 found (2,363 events, 414 tool calls, `chapters: []`) — knowledge that exists only in
|
|
333
|
+
// the log and is invisible to every search on this machine.
|
|
334
|
+
{
|
|
335
|
+
const session = invocation.agent;
|
|
336
|
+
const sess = session.session ?? session.agent?.session;
|
|
337
|
+
const events = sess?.snapshotEvents?.() ?? [];
|
|
338
|
+
const ends = events.filter((e) => e.type === 'turn/end').length;
|
|
339
|
+
const mine = sess?.id !== undefined
|
|
340
|
+
? (domain.table('sessions').get(sess.id)?.chapters.length ?? 0)
|
|
341
|
+
: 0;
|
|
342
|
+
if (mine === 0 && ends > 0) {
|
|
343
|
+
lines.push(`This session: ${events.length} events, ${ends} completed turn(s), 0 chapters — NOTHING it does is retrievable. /chapters-save archives it, or /chapters-continue opens a child on the archive.`);
|
|
344
|
+
}
|
|
345
|
+
else if (mine > 0) {
|
|
346
|
+
lines.push(`This session: ${events.length} events, ${ends} completed turn(s), ${mine} chapter record(s) archived.`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return { kind: 'success', text: lines.join('\n') };
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
const enrichCommand = {
|
|
353
|
+
name: 'chapters-enrich',
|
|
354
|
+
description: 'P2 chapter enrichment (record \u00A76): run a batch now, set the model, or report pending work.',
|
|
355
|
+
input: { hint: 'run | model <provider/model | clear> | report' },
|
|
356
|
+
async handler(invocation) {
|
|
357
|
+
if (config.enrich === undefined)
|
|
358
|
+
return { kind: 'error', text: 'enrichment is not wired on this plane' };
|
|
359
|
+
const parts = (invocation.rawInput ?? '').trim().split(/\s+/).filter((p) => p.length > 0);
|
|
360
|
+
const verb = parts[0] ?? 'help';
|
|
361
|
+
try {
|
|
362
|
+
if (verb === 'model') {
|
|
363
|
+
const arg = parts[1];
|
|
364
|
+
if (arg === undefined) {
|
|
365
|
+
const o = config.enrich.modelOverride();
|
|
366
|
+
return { kind: 'success', text: `enrichment model: ${o ?? 'conversation default'}${o === undefined || o === null ? '' : ' (override)'}` };
|
|
367
|
+
}
|
|
368
|
+
await config.enrich.setModelOverride(arg === 'clear' ? null : arg);
|
|
369
|
+
return { kind: 'success', text: arg === 'clear' ? 'enrichment model reset to the conversation default' : `enrichment model set to ${arg} (pending chapters re-qualify under the new key)` };
|
|
370
|
+
}
|
|
371
|
+
if (verb === 'run') {
|
|
372
|
+
const r = await config.enrich.runNow(cwdOf(invocation.agent));
|
|
373
|
+
return { kind: 'success', text: `enrichment: processed ${r.processed} chapter(s); ${r.remaining} pending` };
|
|
374
|
+
}
|
|
375
|
+
if (verb === 'report') {
|
|
376
|
+
return { kind: 'success', text: `${config.enrich.statusLine()}\npending: ${await config.enrich.pendingCount()} chapter(s)` };
|
|
377
|
+
}
|
|
378
|
+
return { kind: 'success', text: 'usage: /chapters-enrich run | model <provider/model | clear> | report' };
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
return { kind: 'error', text: `chapters-enrich failed: ${String(error?.message ?? error).slice(0, 200)}` };
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
// #173: the discovery surface for the three model settings. Pure core in models-command.ts;
|
|
386
|
+
// this shell only refuses when the catalog is not wired and re-reads the io per invocation.
|
|
387
|
+
// #221 ANCHOR RPC: the door buttons need the durable seq of the assistant message they were
|
|
388
|
+
// clicked under. The client only ever sees `messageId` (a uuid — measured in the host's own
|
|
389
|
+
// TurnTailNodeView); the seq lives in the session log, which only the server reads. This command
|
|
390
|
+
// is that lookup: `chapters-anchor <messageId>` answers with the bare seq, nothing else. It is
|
|
391
|
+
// plugin-private by naming (no slash menu pretension beyond its purpose) and idempotent.
|
|
392
|
+
const anchorCommand = {
|
|
393
|
+
name: 'chapters-anchor',
|
|
394
|
+
description: 'Resolve an assistant messageId to its durable seq (the continuation doors use it to anchor at the clicked message).',
|
|
395
|
+
input: { hint: '<messageId>' },
|
|
396
|
+
async handler(invocation) {
|
|
397
|
+
const messageId = (invocation.rawInput ?? '').trim();
|
|
398
|
+
if (messageId === '')
|
|
399
|
+
return { kind: 'error', text: 'chapters-anchor: usage /chapters-anchor <messageId>' };
|
|
400
|
+
const session = invocation.agent?.session
|
|
401
|
+
?? invocation.agent?.agent?.session;
|
|
402
|
+
if (session === undefined || typeof session.snapshotEvents !== 'function') {
|
|
403
|
+
return { kind: 'error', text: 'chapters-anchor: no session context — run it in a session' };
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
for (const ev of session.snapshotEvents()) {
|
|
407
|
+
const e = ev;
|
|
408
|
+
if (e.type !== 'assistant/message')
|
|
409
|
+
continue;
|
|
410
|
+
if (e.data?.message?.id === messageId && typeof e.seq === 'number')
|
|
411
|
+
return { kind: 'success', text: String(e.seq) };
|
|
412
|
+
}
|
|
413
|
+
return { kind: 'error', text: `chapters-anchor: messageId ${messageId.slice(0, 12)}… not found in this session` };
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
return { kind: 'error', text: `chapters-anchor failed: ${String(error?.message ?? error).slice(0, 120)}` };
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
const modelsCatalogCommand = {
|
|
421
|
+
name: 'chapters-models',
|
|
422
|
+
description: 'Discover the provider/model identifiers the enrichment, rules-mining, and prompt settings accept, with capabilities and current values: /chapters-models [<provider> [<model>]]',
|
|
423
|
+
input: { hint: '[<provider> [<model>]] — a paste-ready provider/model id works as one arg' },
|
|
424
|
+
async handler(invocation) {
|
|
425
|
+
if (config.models === undefined)
|
|
426
|
+
return { kind: 'error', text: 'the model catalog is not wired on this plane' };
|
|
427
|
+
return modelsCommand(config.models(), invocation.rawInput ?? '');
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
const promptCommand = {
|
|
431
|
+
name: 'chapters-prompt',
|
|
432
|
+
description: 'Prompt recommendations (#71, site \u00A728): set the defining model, turn the composer ghost off, or answer the client\u2019s pull.',
|
|
433
|
+
input: { hint: 'model [provider/model | clear] | off | suggest' },
|
|
434
|
+
async handler(invocation) {
|
|
435
|
+
if (config.prompt === undefined)
|
|
436
|
+
return { kind: 'error', text: 'prompt recommendations are not wired on this plane' };
|
|
437
|
+
const parts = (invocation.rawInput ?? '').trim().split(/\s+/).filter((p) => p.length > 0);
|
|
438
|
+
const verb = parts[0] ?? 'help';
|
|
439
|
+
try {
|
|
440
|
+
if (verb === 'model') {
|
|
441
|
+
const arg = parts[1];
|
|
442
|
+
if (arg === undefined) {
|
|
443
|
+
const { spec, source } = config.prompt.effective();
|
|
444
|
+
return { kind: 'success', text: spec === ''
|
|
445
|
+
? `prompt model: OFF${source === 'override' ? ' (stored override — /chapters-prompt model <provider/model> to enable)' : ' (unset — set one with /chapters-prompt model <provider/model>)'}`
|
|
446
|
+
: `prompt model: ${spec} (${source}) — ON` };
|
|
447
|
+
}
|
|
448
|
+
if (arg === 'clear') {
|
|
449
|
+
await config.prompt.setModel(null);
|
|
450
|
+
const { spec } = config.prompt.effective();
|
|
451
|
+
return { kind: 'success', text: spec === ''
|
|
452
|
+
? 'prompt model override cleared — the config default is unset, so suggestions are OFF'
|
|
453
|
+
: `prompt model override cleared — now following the config default (${spec}, ON)` };
|
|
454
|
+
}
|
|
455
|
+
// #188: the caps ride WITH the model — `/chapters-prompt model <id> [maxChars [contextChars]]`.
|
|
456
|
+
// Explicit numbers store beside prompt.model in the settings table; omitted numbers derive from the
|
|
457
|
+
// selected model's real context window (window × promptContextRatio) at suggestion time, and the
|
|
458
|
+
// config keys are the no-window fallback. Resolution and sources: `/chapters-prompt state`.
|
|
459
|
+
const maxArg = parts[2];
|
|
460
|
+
const ctxArg = parts[3];
|
|
461
|
+
if (parts.length > 4 || (maxArg !== undefined && !/^[1-9][0-9]*$/.test(maxArg)) || (ctxArg !== undefined && !/^[1-9][0-9]*$/.test(ctxArg))) {
|
|
462
|
+
return { kind: 'error', text: `model takes <provider/model> [maxChars [contextChars]] — positive integers (got: ${parts.slice(1).join(' ')})` };
|
|
463
|
+
}
|
|
464
|
+
await config.prompt.setModel(arg);
|
|
465
|
+
if (config.prompt.setCaps !== undefined && (maxArg !== undefined || ctxArg !== undefined)) {
|
|
466
|
+
await config.prompt.setCaps({
|
|
467
|
+
...(maxArg !== undefined ? { maxChars: maxArg } : {}),
|
|
468
|
+
...(ctxArg !== undefined ? { contextChars: ctxArg } : {}),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
const capsNote = maxArg !== undefined || ctxArg !== undefined
|
|
472
|
+
? ` Caps stored beside the model${maxArg !== undefined ? `: suggestion ${maxArg} chars` : ''}${ctxArg !== undefined ? `${maxArg !== undefined ? ',' : ':'} context budget ${ctxArg} chars` : ''}.`
|
|
473
|
+
: " Caps derive from this model's context window (window × promptContextRatio); pass numbers to pin them.";
|
|
474
|
+
// #225: enabling is a PLANE-WIDE write; the bar for THIS session only learns about it by
|
|
475
|
+
// pulling (and with the ambient clock gone by default, that means next page load). The
|
|
476
|
+
// maintainer enabled at 18:16 and saw nothing appear — correct per schedule, terrible per
|
|
477
|
+
// experience. So the enable path writes the row and mirrors the record right here, making
|
|
478
|
+
// the bar visible immediately where push exists; pull hosts get the same answer from the
|
|
479
|
+
// state verb on their next sanctioned moment.
|
|
480
|
+
const enHolder = invocation.agent;
|
|
481
|
+
const enSess = (enHolder.session ?? enHolder.agent?.session);
|
|
482
|
+
if (enSess?.id !== undefined && config.prompt.bar !== undefined) {
|
|
483
|
+
try {
|
|
484
|
+
await config.prompt.bar.setOn(enSess.id, enSess);
|
|
485
|
+
}
|
|
486
|
+
catch { /* the enable itself succeeded */ }
|
|
487
|
+
}
|
|
488
|
+
return { kind: 'success', text: `prompt model set to ${arg} — this ENABLES the composer ghost (one bounded call per turn boundary; /chapters-prompt off disables).${capsNote} If the bar is hidden, it reappears at the next page reload.` };
|
|
489
|
+
}
|
|
490
|
+
if (verb === 'off') {
|
|
491
|
+
await config.prompt.setModel('');
|
|
492
|
+
// #194: the global switch alone leaves a PUSHED bar showing its last offer forever — no row
|
|
493
|
+
// write means no record. The calling session's row goes off too (what `setOff` exists for),
|
|
494
|
+
// so the record carries the truth to this bar; other open sessions converge through the
|
|
495
|
+
// pull staleness guard on next activation (documented in the contract's push lock).
|
|
496
|
+
const offHolder = invocation.agent;
|
|
497
|
+
const offSessId = offHolder.session?.id ?? offHolder.agent?.session?.id;
|
|
498
|
+
if (offSessId !== undefined && config.prompt.bar !== undefined) {
|
|
499
|
+
await config.prompt.bar.setOff(offSessId, offHolder.session ?? offHolder.agent?.session);
|
|
500
|
+
}
|
|
501
|
+
return { kind: 'success', text: 'prompt suggestions OFF (stored override; /chapters-prompt model clear restores the config default)' };
|
|
502
|
+
}
|
|
503
|
+
// #113: the bar's three pulls. `state` answers in k: v lines so ONE output serves both the human
|
|
504
|
+
// typing it and the component rendering it — a separate JSON verb would be a second contract to keep
|
|
505
|
+
// in step, and the goal bar's own dock reads plainly-worded state for the same reason.
|
|
506
|
+
if (verb === 'state') {
|
|
507
|
+
const sessId = invocation.agent
|
|
508
|
+
.session?.id ?? invocation.agent.agent?.session?.id;
|
|
509
|
+
if (sessId === undefined || config.prompt.bar === undefined) {
|
|
510
|
+
return { kind: 'error', text: 'state needs a session scope and the chapters prompt store — run it inside a session' };
|
|
511
|
+
}
|
|
512
|
+
// #231: the state reply comes from the ROW — a durable read with no side effects. The full
|
|
513
|
+
// `bar.read` (row + live loop status) is consulted ONLY when the row is absent, which is also
|
|
514
|
+
// the one moment worth mirroring: an empty row means "this session has never been served",
|
|
515
|
+
// so the mirror writes the record that makes future mounts push-fed. A ready offer was NEVER
|
|
516
|
+
// re-read or re-mirrored here before — every pull cost a store read and (on push hosts) a
|
|
517
|
+
// duplicate record append. On the maintainer's host that path ran every minute for hours.
|
|
518
|
+
let st = await config.prompt.bar.peekRow(sessId);
|
|
519
|
+
if (st === null) {
|
|
520
|
+
st = await config.prompt.bar.read(sessId);
|
|
521
|
+
const sessObj = invocation.agent.session
|
|
522
|
+
?? invocation.agent.agent?.session;
|
|
523
|
+
if (sessObj !== undefined && typeof config.prompt.bar.read.mirror === 'function') {
|
|
524
|
+
void config.prompt.bar.read.mirror(sessId, sessObj);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const { spec, source } = config.prompt.effective();
|
|
528
|
+
// #113: `off` must mean OFF. A session with a defining model and no stored offer is `idle` — enabled,
|
|
529
|
+
// awaiting its first turn — and collapsing that into `off` is what made the bar look switched-off
|
|
530
|
+
// while the config row was present. The distinction is the human's only signal that the feature is
|
|
531
|
+
// live but has nothing to say yet, so it is made here, at the one place the state is rendered.
|
|
532
|
+
const resting = st.status === 'off' && spec !== '' ? { ...st, status: 'idle' } : st;
|
|
533
|
+
return { kind: 'success', text: [
|
|
534
|
+
`suggested next step: ${resting.status}`,
|
|
535
|
+
` model: ${spec !== '' ? spec : 'unset'} (${source}) — ${spec === '' ? 'the bar is absent' : 'one offer per completed turn'}`,
|
|
536
|
+
` autorun: ${st.autorun}${st.autorun === 'armed' ? ` (${String(st.fired)} round(s) started this process)` : ''}`,
|
|
537
|
+
` turn: ${st.turnSeq === null ? '-' : String(st.turnSeq)}`,
|
|
538
|
+
// #113: intent and the revision it was cut against, because they decide whether autorun may act.
|
|
539
|
+
// A `goal-edit` offer is held for a keystroke — the host reserves goal edits for a human turn —
|
|
540
|
+
// and the bar must be able to say that without re-parsing the prompt.
|
|
541
|
+
` intent: ${st.intent}${st.intent === 'goal-edit' ? ' — re-cuts the goal, so Run it yourself; autorun will not' : ''}`,
|
|
542
|
+
` goal: ${st.goalRevision === null ? 'none' : `revision ${String(st.goalRevision)}`}`,
|
|
543
|
+
// #229: the heartbeat the server has configured rides EVERY state reply — the client's
|
|
544
|
+
// schedule is the operator's setting, not a compile-time guess. Zero (the default) means
|
|
545
|
+
// no ambient clock exists at all; the sanctioned moments still do.
|
|
546
|
+
` heartbeat: ${String(config.prompt.bar.quietHeartbeatSec ?? 0)}s`,
|
|
547
|
+
...(st.error === '' ? [] : [` error: ${st.error}`]),
|
|
548
|
+
...(st.text === '' ? [] : [' prompt:', ...st.text.split('\n').map((l) => ` ${l}`)]),
|
|
549
|
+
].join('\n') };
|
|
550
|
+
}
|
|
551
|
+
if (verb === 'run') {
|
|
552
|
+
// The bar's Run button. Session-scoped like `state`, because "the offer on screen" is a per-session
|
|
553
|
+
// fact and a global command would run whatever the last session happened to be shown.
|
|
554
|
+
const runId = invocation.agent?.session?.id
|
|
555
|
+
?? invocation.agent?.agent?.session?.id;
|
|
556
|
+
if (runId === undefined || config.prompt.bar === undefined) {
|
|
557
|
+
return { kind: 'error', text: 'run needs a session scope and the chapters prompt store' };
|
|
558
|
+
}
|
|
559
|
+
console.error(`[chapters-prompt] /run invoked for session=${String(runId)}`);
|
|
560
|
+
const res = await config.prompt.bar.runNow(runId);
|
|
561
|
+
console.error(`[chapters-prompt] /run result ok=${String(res.ok)} reason=${String(res.reason ?? '')}`);
|
|
562
|
+
return res.ok
|
|
563
|
+
? { kind: 'success', text: `running the suggested next step as your own turn:\n${res.text}` }
|
|
564
|
+
: { kind: 'error', text: `cannot run: ${res.reason ?? 'the offer is no longer available'}` };
|
|
565
|
+
}
|
|
566
|
+
if (verb === 'autorun') {
|
|
567
|
+
const arg = parts[1];
|
|
568
|
+
const sessId = invocation.agent
|
|
569
|
+
.session?.id ?? invocation.agent.agent?.session?.id;
|
|
570
|
+
const on = arg === 'on' || arg === 'arm';
|
|
571
|
+
const off = arg === 'off' || arg === 'disarm';
|
|
572
|
+
if (sessId === undefined || config.prompt.bar === undefined) {
|
|
573
|
+
return { kind: 'error', text: 'autorun needs a session scope — run it inside the session you want to drive' };
|
|
574
|
+
}
|
|
575
|
+
if (!on && !off) {
|
|
576
|
+
return { kind: 'error', text: `autorun needs 'on' or 'off' (got ${arg ?? 'nothing'}) — /chapters-prompt autorun on starts the loop, off leaves the offers in place and stops the running` };
|
|
577
|
+
}
|
|
578
|
+
const { spec } = config.prompt.effective();
|
|
579
|
+
if (on && spec === '') {
|
|
580
|
+
return { kind: 'error', text: 'cannot arm autorun with no prompt model — the loop has nothing to generate offers. /chapters-prompt model <provider/model> first' };
|
|
581
|
+
}
|
|
582
|
+
// #194: the live session rides along so the autorun flip can mirror to the push transport.
|
|
583
|
+
const sessObj = invocation.agent.session
|
|
584
|
+
?? invocation.agent.agent?.session;
|
|
585
|
+
const live = config.prompt.bar.setAutorun(sessId, on, sessObj);
|
|
586
|
+
return { kind: 'success', text: on
|
|
587
|
+
? `autorun ARMED for ${sessId} — each completed turn's offer runs for you, until you stop it or send a prompt yourself.` + (live.displaced
|
|
588
|
+
? ' The goal remains the objective; its own auto-continuation is disarmed, so the suggestion loop now drives the worker. Re-arm it with /goal resume.'
|
|
589
|
+
: ' The goal still owns the turn; this loop yields to it until the goal is paused, completed, or disarmed.') + ' State: /chapters-prompt state'
|
|
590
|
+
: `autorun disarmed for ${sessId} — offers still appear each turn; nothing runs itself (${String(live.fired)} round(s) were started this process)` };
|
|
591
|
+
}
|
|
592
|
+
if (verb === 'on') {
|
|
593
|
+
if (config.prompt.bar === undefined)
|
|
594
|
+
return { kind: 'error', text: 'the chapters prompt store is unavailable in this context' };
|
|
595
|
+
const { spec } = config.prompt.effective();
|
|
596
|
+
if (spec === '') {
|
|
597
|
+
return { kind: 'error', text: 'nothing to turn on: the prompt model is unset. /chapters-prompt model <provider/model> sets it, and setting it IS the enable' };
|
|
598
|
+
}
|
|
599
|
+
const holder = invocation.agent;
|
|
600
|
+
const sessId = holder.session?.id ?? holder.agent?.session?.id;
|
|
601
|
+
if (sessId !== undefined)
|
|
602
|
+
await config.prompt.bar.setOn(sessId, holder.session ?? holder.agent?.session);
|
|
603
|
+
return { kind: 'success', text: `suggestions ON (${spec}) — the next completed turn offers a next step; /chapters-prompt state shows it, /chapters-prompt autorun on runs them. If the bar is hidden, it reappears at the next page reload.` };
|
|
604
|
+
}
|
|
605
|
+
if (verb === 'suggest') {
|
|
606
|
+
const holder = invocation.agent;
|
|
607
|
+
const sess = holder.session ?? holder.agent?.session;
|
|
608
|
+
if (sess?.id === undefined)
|
|
609
|
+
return { kind: 'error', text: 'suggest needs a session scope — run it inside a session (the composer ghost calls this verb; humans rarely do)' };
|
|
610
|
+
const events = (sess.snapshotEvents?.() ?? []);
|
|
611
|
+
const titleEvent = [...events].reverse().find((e) => e.type === 'session/title');
|
|
612
|
+
const title = titleEvent !== undefined && typeof titleEvent.data.title === 'string' ? titleEvent.data.title : undefined;
|
|
613
|
+
const r = await config.prompt.suggest({ id: sess.id, title, cwd: cwdOf(invocation.agent), events: [...events] });
|
|
614
|
+
// #113: this returned `text: r.text` — ONE bare line, which the composer ellipsized with no way to
|
|
615
|
+
// recover it. /chapters-status gets an expander because it answers with a labelled multi-line block,
|
|
616
|
+
// so this does the same: the prompt verbatim inside a fence, then the facts that bound it (which
|
|
617
|
+
// model, from which source, against which cap, for which session). Stating the cap matters because an
|
|
618
|
+
// over-cap answer is refused and retried, never truncated — the number belongs in the output.
|
|
619
|
+
const F = String.fromCharCode(96).repeat(3);
|
|
620
|
+
const { spec, source } = config.prompt.effective();
|
|
621
|
+
const lim = config.prompt.limits !== undefined ? await config.prompt.limits(null) : undefined;
|
|
622
|
+
const who = `${title !== undefined && title !== '' ? `\u201c${title}\u201d ` : ''}(${sess.id})`;
|
|
623
|
+
const capLine = lim !== undefined
|
|
624
|
+
? ` caps: suggestion ${String(lim.maxChars)} chars (${lim.sources.maxChars}) · context budget ${String(lim.contextChars)} chars (${lim.sources.contextChars}) — over-cap is refused and retried, never truncated`
|
|
625
|
+
: ' caps: (not reported by this surface)';
|
|
626
|
+
const meta = ` model: ${spec !== '' ? spec : 'unset'} (${source})\n${capLine}`;
|
|
627
|
+
if (r.ok) {
|
|
628
|
+
// #225: `suggest` used to print the offer inside a fence with instructions to COPY IT
|
|
629
|
+
// INTO THE COMPOSER by hand — an affordance dead since the ghost chip was removed. The
|
|
630
|
+
// human asked for a suggestion; the answer is the BAR SHOWING IT, so this writes the
|
|
631
|
+
// offer through the same row + mirror path the turn-end runner uses and reports the
|
|
632
|
+
// facts compactly. Run stays the one way an offer becomes a turn (host rule: no plugin
|
|
633
|
+
// submits without a keystroke).
|
|
634
|
+
const sugHolder = invocation.agent;
|
|
635
|
+
const sugSess = (sugHolder.session ?? sugHolder.agent?.session);
|
|
636
|
+
const offer = classifyOffer(r.text);
|
|
637
|
+
if (sugSess?.id !== undefined && config.prompt.bar !== undefined) {
|
|
638
|
+
try {
|
|
639
|
+
await config.prompt.bar.setSuggested?.(sugSess.id, r.text, sugSess);
|
|
640
|
+
}
|
|
641
|
+
catch { /* the answer still prints below */ }
|
|
642
|
+
}
|
|
643
|
+
return { kind: 'success', text: [
|
|
644
|
+
`/chapters-prompt suggest — the next prompt for ${who} is on the bar:`,
|
|
645
|
+
` ${offer.text.split('\n')[0].slice(0, 140)}`,
|
|
646
|
+
meta,
|
|
647
|
+
].join('\n') };
|
|
648
|
+
}
|
|
649
|
+
if (r.quiet === true) {
|
|
650
|
+
// Nothing to offer is an ANSWER to an explicit request, not a failure. With the ghost gone there is
|
|
651
|
+
// no automated caller that reads a success line as an offer — that collision is what made wiring
|
|
652
|
+
// `quiet` wrong last time, and removing the chip is what unblocked it.
|
|
653
|
+
return { kind: 'success', text: [`/chapters-prompt suggest — nothing to offer yet for ${who}`, '', ` ${r.text}`, '', meta].join('\n') };
|
|
654
|
+
}
|
|
655
|
+
return { kind: 'error', text: `chapters-prompt: ${r.text}\n model: ${spec !== '' ? spec : 'unset'} (${source})` };
|
|
656
|
+
}
|
|
657
|
+
return { kind: 'success', text: 'usage: /chapters-prompt model [provider/model | clear] | off | suggest' };
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
return { kind: 'error', text: `chapters-prompt failed: ${String(error?.message ?? error).slice(0, 200)}` };
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
/**
|
|
665
|
+
* #255: `/chapters-remove` — the operator door that deletes BAD chapters from THIS project's
|
|
666
|
+
* memory. The field case is the wrong-project prompt; supersede (#60) keeps bytes, this removes
|
|
667
|
+
* them. Verbs: `list [sessionId]`, `find <query>`, `[dry-run] <id…>`, `apply <id…>`, `report`,
|
|
668
|
+
* `restore <journal-id>`. Dry-run is the default; `apply` is the only word that deletes. Scope
|
|
669
|
+
* is same-project by construction: only rows whose store tree exists under THIS workspace resolve.
|
|
670
|
+
*/
|
|
671
|
+
const removeCommand = {
|
|
672
|
+
name: 'chapters-remove',
|
|
673
|
+
description: 'Delete bad chapters from this project\u2019s memory (#255): list | find | dry-run | apply | report | restore.',
|
|
674
|
+
input: { hint: '[list [sessionId] | find <query> | [dry-run] <id…> | apply <id…> | report | restore <journal-id>]' },
|
|
675
|
+
async handler(invocation) {
|
|
676
|
+
const cwd = cwdOf(invocation.agent);
|
|
677
|
+
const cap = config.reportMaxLines ?? 40;
|
|
678
|
+
const err = (t) => ({ kind: 'error', text: t });
|
|
679
|
+
if (cwd === '')
|
|
680
|
+
return err('chapters-remove: this session has no workspace cwd — nowhere to act');
|
|
681
|
+
// The store handle is required only by MUTATING arms (`apply`, `restore`). `list`, `find`,
|
|
682
|
+
// `report` and the dry-run resolve are reads of rows and files the plane already exposes —
|
|
683
|
+
// refusing them for a plumbing detail would hide the operator's own memory behind it.
|
|
684
|
+
// Each mutating arm guards `config.store === undefined` inline, which also narrows the type.
|
|
685
|
+
const noStore = () => ({ kind: 'error', text: 'chapters-remove: this plane carries no registry handle — nothing was written' });
|
|
686
|
+
const wrapped = invocation.agent;
|
|
687
|
+
const callerId = wrapped.session?.id ?? wrapped.agent?.session?.id;
|
|
688
|
+
const storeDir = resolveStoreRoot(cwd, config.artifactStoreRoot);
|
|
689
|
+
// Same-project scope (#255): a row resolves iff its store tree exists under this workspace.
|
|
690
|
+
// Session ids are globally unique, so the tree-exists test cannot cross projects.
|
|
691
|
+
const allRows = [...domain.table('sessions').entries()];
|
|
692
|
+
const rows = allRows.filter(([, st]) => st.rootSession !== undefined && st.rootSession !== '' && fs.existsSync(path.join(storeDir, st.rootSession)));
|
|
693
|
+
const parts = (invocation.rawInput ?? '').trim().split(/\s+/).filter((p) => p.length > 0);
|
|
694
|
+
const verb = parts[0] ?? '';
|
|
695
|
+
const argTail = parts.slice(1);
|
|
696
|
+
const fmtBytes = (n) => n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`;
|
|
697
|
+
if (verb === 'list') {
|
|
698
|
+
const focus = argTail[0] ?? callerId;
|
|
699
|
+
if (focus === undefined)
|
|
700
|
+
return err('chapters-remove list: no session context — pass list <sessionId> whose ancestor path names the chapters to review');
|
|
701
|
+
const byId = new Map(rows);
|
|
702
|
+
if (byId.get(focus) === undefined)
|
|
703
|
+
return err(`chapters-remove list: session ${focus} has no row under this project's store (${storeDir}) — removal is same-project only; run it in the project that owns the session`);
|
|
704
|
+
let pathIds;
|
|
705
|
+
try {
|
|
706
|
+
pathIds = ancestorPath((id) => byId.get(id), focus);
|
|
707
|
+
}
|
|
708
|
+
catch (error) {
|
|
709
|
+
return err(`chapters-remove list: ${String(error?.message ?? error)}`);
|
|
710
|
+
}
|
|
711
|
+
const entries = buildIndex(pathIds, (id) => byId.get(id), () => 'ok');
|
|
712
|
+
const afs = makeArchiveFs(cwd);
|
|
713
|
+
const lines = [];
|
|
714
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
715
|
+
const e = entries[i];
|
|
716
|
+
const rec = byId.get(e.authorSession)?.chapters.find((c) => c.number === e.number);
|
|
717
|
+
const verdict = rec !== undefined ? await verifyChapter(afs, rec) : 'missing';
|
|
718
|
+
lines.push(` ${String(i + 1)}. ${e.authorSession}:${e.number} · ${verdict} · ${e.title} — ${e.summary}\n ${e.path}`);
|
|
719
|
+
}
|
|
720
|
+
const stubs = rows.reduce((m, [, st]) => m + st.chapters.filter(isRemovedChapter).length, 0);
|
|
721
|
+
const head = entries.length === 0
|
|
722
|
+
? 'chapters-remove list: no ACTIVE chapters on this ancestor path — nothing removable here.'
|
|
723
|
+
: `chapters-remove list (${pathIds.join(' → ')}) — ${entries.length} active chapter(s), reading order = TOC order; the ids are what dry-run/apply accept:`;
|
|
724
|
+
return { kind: 'success', text: [head, ...lines, ` +${stubs} removed stub(s) on this project (uncited by design; ledger: /chapters-remove report)`].join('\n') };
|
|
725
|
+
}
|
|
726
|
+
if (verb === 'find') {
|
|
727
|
+
const query = argTail.join(' ');
|
|
728
|
+
if (query === '')
|
|
729
|
+
return err('chapters-remove find: needs a query — /chapters-remove find <words>');
|
|
730
|
+
const project = projectForCwd(domain.table('projects').entries(), cwd);
|
|
731
|
+
const lines = [];
|
|
732
|
+
const push = (rawPath, title, kindLabel) => {
|
|
733
|
+
if (kindLabel === 'rule') {
|
|
734
|
+
lines.push(` ${rawPath} · ${title} — rules are revoked via /chapters-rule, not removed here`);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
const res = resolveRemovalId(rawPath, rows, cwd, callerId);
|
|
738
|
+
lines.push('target' in res
|
|
739
|
+
? ` ${rawPath} → removable id ${res.target.sessionId}:${res.target.record.number} · ${title}`
|
|
740
|
+
: ` ${rawPath} · ${title} — not removable from this tree (${res.refusal.split('\n')[0]})`);
|
|
741
|
+
};
|
|
742
|
+
if (project !== undefined) {
|
|
743
|
+
const out = searchKnowledge(path.join(cwd, DEFAULT_CLONE_DIR), query, 600, { projectKey: project.projectKey });
|
|
744
|
+
for (const r of out.results)
|
|
745
|
+
push(r.path, r.title, r.kind);
|
|
746
|
+
if (out.total === 0)
|
|
747
|
+
return { kind: 'success', text: `chapters-remove find "${query}" — no matches in the pool mirror. Nothing was written.` };
|
|
748
|
+
const withheldF = Math.max(0, out.results.length - cap);
|
|
749
|
+
return { kind: 'success', text: [
|
|
750
|
+
`chapters-remove find "${query}" — ${out.total} hit(s) in the pool mirror, ${lines.length} shown:`,
|
|
751
|
+
...lines.slice(0, cap),
|
|
752
|
+
...(withheldF > 0 ? [` … ${withheldF} more withheld by the report cap (${cap})`] : []),
|
|
753
|
+
' next: /chapters-remove <removable id> (dry-run), then apply <id>',
|
|
754
|
+
].join('\n') };
|
|
755
|
+
}
|
|
756
|
+
const out = searchLocalStore(storeDir, query, 600);
|
|
757
|
+
for (const r of out.results)
|
|
758
|
+
push(r.path, r.title, r.kind);
|
|
759
|
+
if (out.total === 0)
|
|
760
|
+
return { kind: 'success', text: `chapters-remove find "${query}" — no matches in this project's local store (${out.documents} document(s) scanned; no pool linked). Nothing was written.` };
|
|
761
|
+
const withheldL = Math.max(0, out.results.length - cap);
|
|
762
|
+
return { kind: 'success', text: [
|
|
763
|
+
`chapters-remove find "${query}" — ${out.total} hit(s) in the LOCAL store (knowledge-local: no pool linked), ${lines.length} shown:`,
|
|
764
|
+
...lines.slice(0, cap),
|
|
765
|
+
...(withheldL > 0 ? [` … ${withheldL} more withheld by the report cap (${cap})`] : []),
|
|
766
|
+
' next: /chapters-remove <removable id> (dry-run), then apply <id>',
|
|
767
|
+
].join('\n') };
|
|
768
|
+
}
|
|
769
|
+
// dry-run is the DEFAULT: a first token that is not a verb but looks like an id (path,
|
|
770
|
+
// `session:number`, pool tail, or a bare ordinal) resolves as `[dry-run] <id…>`. A token
|
|
771
|
+
// that is neither never guesses — it hits the unknown-verb refusal below.
|
|
772
|
+
const looksLikeId = !['list', 'find', 'report', 'restore', 'apply', 'dry-run'].includes(verb) && /[:/.0-9]/.test(verb);
|
|
773
|
+
if (verb === '' || verb === 'dry-run' || verb === 'apply' || looksLikeId) {
|
|
774
|
+
const ids = verb === 'apply' ? argTail : parts.filter((p) => p !== 'dry-run');
|
|
775
|
+
if (ids.length === 0)
|
|
776
|
+
return err('usage: /chapters-remove list [sessionId] | find <query> | [dry-run] <id…> | apply <id…> | report | restore <journal-id>');
|
|
777
|
+
const targets = [];
|
|
778
|
+
const refusals = [];
|
|
779
|
+
for (const raw of ids) {
|
|
780
|
+
const res = resolveRemovalId(raw, rows, cwd, callerId);
|
|
781
|
+
if ('target' in res)
|
|
782
|
+
targets.push(res.target);
|
|
783
|
+
else
|
|
784
|
+
refusals.push(res.refusal);
|
|
785
|
+
}
|
|
786
|
+
if (refusals.length > 0)
|
|
787
|
+
return err(`chapters-remove: NOTHING WRITTEN — ${refusals.length} unresolved id(s):\n ${refusals.join('\n ')}${targets.length > 0 ? `\n (${targets.length} did resolve; drop the bad ids and re-run)` : ''}`);
|
|
788
|
+
const plan = planRemoval({ targets, rows, cwd });
|
|
789
|
+
const bytes = plan.chapterFiles.reduce((s, f) => s + f.bytes, 0) + plan.blobFiles.reduce((s, f) => s + f.bytes, 0);
|
|
790
|
+
const header = [`chapters-remove ${verb === 'apply' ? 'APPLY' : 'dry-run'} — ${targets.length} chapter(s) from ${new Set(targets.map((t) => t.sessionId)).size} tree(s):`];
|
|
791
|
+
const set = [
|
|
792
|
+
...plan.chapterFiles.map((f) => ` chapter ${f.stored} (${fmtBytes(f.bytes)})`),
|
|
793
|
+
...plan.blobFiles.map((f) => ` blob ${f.stored} (${fmtBytes(f.bytes)}) — orphaned: no active chapter names it`),
|
|
794
|
+
...plan.missing.map((m) => ` absent ${m} — the record cites it, the disk does not have it; the stub closes the claim anyway`),
|
|
795
|
+
` total leaving the store: ${fmtBytes(bytes)} across ${plan.chapterFiles.length + plan.blobFiles.length} file(s); blobs shared with a surviving chapter are NEVER deleted (orphan math)`,
|
|
796
|
+
];
|
|
797
|
+
const dang = plan.dangling.length > 0
|
|
798
|
+
? [` dangling after removal (${plan.dangling.length}) — surviving chapters that still cite a removed file ("does the referrer stay?" is the operator's call):`,
|
|
799
|
+
...plan.dangling.slice(0, cap).map((d) => ` ${d.referrer} cites ${d.needle}`),
|
|
800
|
+
...(plan.dangling.length > cap ? [` … ${plan.dangling.length - cap} more (all of them land in /chapters-remove report)`] : [])]
|
|
801
|
+
: [' no surviving chapter cites these files'];
|
|
802
|
+
if (verb !== 'apply')
|
|
803
|
+
return { kind: 'success', text: [...header, ...set, ...dang, ' NOTHING WRITTEN. apply: /chapters-remove apply <the same ids>'].join('\n') };
|
|
804
|
+
// the mutating seam: narrowing this way (a lone guard at the use site) is what keeps
|
|
805
|
+
// `config.store` typed below — the compound `verb && !store` guard never narrowed.
|
|
806
|
+
if (config.store === undefined)
|
|
807
|
+
return noStore();
|
|
808
|
+
const project = projectForCwd(domain.table('projects').entries(), cwd);
|
|
809
|
+
const mirrorAbs = path.join(cwd, DEFAULT_CLONE_DIR);
|
|
810
|
+
const out = await applyRemoval({
|
|
811
|
+
store: config.store,
|
|
812
|
+
cwd, rows, storeDir,
|
|
813
|
+
...(project !== undefined && fs.existsSync(mirrorAbs) ? { cloneDir: mirrorAbs, projectKey: project.projectKey } : {}),
|
|
814
|
+
now: () => new Date().toISOString(),
|
|
815
|
+
}, plan);
|
|
816
|
+
let syncLine = ' pool: unlinked project — store-only removal; this machine\u2019s memory is what got cleaned';
|
|
817
|
+
if (project !== undefined) {
|
|
818
|
+
try {
|
|
819
|
+
const res = config.scheduler !== undefined
|
|
820
|
+
? await config.scheduler.run(cwd, 'removal', project)
|
|
821
|
+
: await syncNow(cwd, project, readToken(cwd, config.artifactStoreRoot, project.projectKey));
|
|
822
|
+
syncLine = ` sync: ${res.steps.join('') !== '' ? res.steps.join(' → ') : res.detail}${res.ok ? '' : ' ⚠ the deletion is applied locally; the pool push FAILED — /chapters-status carries the detail'}`;
|
|
823
|
+
}
|
|
824
|
+
catch (error) {
|
|
825
|
+
syncLine = ` sync: pass threw (${String(error?.message ?? error)}) — local removal is complete; run /chapters-link or let the scheduler retry`;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return { kind: 'success', text: [
|
|
829
|
+
...header, ...set, ...dang,
|
|
830
|
+
` removed ${out.removed.length} file(s), stubbed ${out.stubbed.length === 0 ? 'NONE (see problems)' : out.stubbed.join(', ')}`,
|
|
831
|
+
` journal: ${out.journalId !== '' ? `${out.journalId} — reversal: /chapters-remove restore ${out.journalId}` : 'NOT WRITTEN — nothing was deleted'}`,
|
|
832
|
+
out.problems.length > 0 ? ` PROBLEMS (${out.problems.length}): ${out.problems.join(' | ')}` : ' no problems',
|
|
833
|
+
syncLine,
|
|
834
|
+
' every TOC built from here on omits the removed chapter(s); already-seeded children keep their recorded text (invariant 1) and their misses land in /chapters-remove report',
|
|
835
|
+
].join('\n') };
|
|
836
|
+
}
|
|
837
|
+
if (verb === 'report') {
|
|
838
|
+
const r = readMissingAccess(storeDir, cap);
|
|
839
|
+
if (r.total === 0)
|
|
840
|
+
return { kind: 'success', text: `chapters-remove report: the missing-access ledger is empty — no removal citations and no dead-citation misses recorded on this project yet. File when written: ${r.file}` };
|
|
841
|
+
return { kind: 'success', text: [
|
|
842
|
+
`chapters-remove report — ${r.total} line(s) at ${r.file}, last ${r.lines.length}:`,
|
|
843
|
+
...r.lines,
|
|
844
|
+
...(r.withheld > 0 ? [` … ${r.withheld} earlier line(s) withheld by the report cap (${cap}) — read the file for all`] : []),
|
|
845
|
+
' verdicts: dead-citation = a TOC cite whose bytes vanished (decide: repair or remove the referrer); removed= = the removal ledger',
|
|
846
|
+
].join('\n') };
|
|
847
|
+
}
|
|
848
|
+
if (verb === 'restore') {
|
|
849
|
+
if (argTail[0] === undefined)
|
|
850
|
+
return err('chapters-remove restore: needs a journal id (printed by apply; journals live under <store>/<tree>/removals/)');
|
|
851
|
+
if (config.store === undefined)
|
|
852
|
+
return noStore();
|
|
853
|
+
if (config.save === undefined)
|
|
854
|
+
return err('chapters-remove restore: this plane carries no render config — restore re-derives through the save core and cannot guess its thresholds');
|
|
855
|
+
const get = (name) => { try {
|
|
856
|
+
return ctx.get?.(name);
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
return undefined;
|
|
860
|
+
} };
|
|
861
|
+
const out = await restoreRemoval({
|
|
862
|
+
store: config.store, cwd, storeDir,
|
|
863
|
+
readLog: async (sid) => (await readDurableSession(get, sid))?.events ?? null,
|
|
864
|
+
config: config.save,
|
|
865
|
+
now: () => new Date().toISOString(),
|
|
866
|
+
}, argTail[0]);
|
|
867
|
+
const lines = [
|
|
868
|
+
`chapters-remove restore — journal ${out.journalId}:`,
|
|
869
|
+
...(out.restored.length > 0 ? [` re-derived under new numbers: ${out.restored.join(' · ')}`] : [' nothing was re-derived']),
|
|
870
|
+
...out.skipped.map((s) => ` skipped: ${s}`),
|
|
871
|
+
...out.problems.map((p) => ` PROBLEM: ${p}`),
|
|
872
|
+
' the log was read, never written; removed numbers stay spent; the re-rendered chapters are normal chapters from here on',
|
|
873
|
+
];
|
|
874
|
+
let syncLine = ' pool: unlinked — the restored chapters live in the local store';
|
|
875
|
+
if (out.restored.length > 0) {
|
|
876
|
+
const project = projectForCwd(domain.table('projects').entries(), cwd);
|
|
877
|
+
if (project !== undefined) {
|
|
878
|
+
try {
|
|
879
|
+
const res = config.scheduler !== undefined
|
|
880
|
+
? await config.scheduler.run(cwd, 'removal-restore', project)
|
|
881
|
+
: await syncNow(cwd, project, readToken(cwd, config.artifactStoreRoot, project.projectKey));
|
|
882
|
+
syncLine = ` sync: ${res.steps.join(' → ') !== '' ? res.steps.join(' → ') : res.detail}${res.ok ? '' : ' ⚠ restored locally; the pool push failed — /chapters-status carries the detail'}`;
|
|
883
|
+
}
|
|
884
|
+
catch (error) {
|
|
885
|
+
syncLine = ` sync: pass threw (${String(error?.message ?? error)}) — restored chapters are safe in the store`;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return { kind: out.problems.length > 0 && out.restored.length === 0 ? 'error' : 'success', text: [...lines, syncLine].join('\n') };
|
|
890
|
+
}
|
|
891
|
+
return err(`chapters-remove: unknown verb "${verb}" — usage: list [sessionId] | find <query> | [dry-run] <id…> | apply <id…> | report | restore <journal-id>`);
|
|
892
|
+
},
|
|
893
|
+
};
|
|
894
|
+
try {
|
|
895
|
+
const d1 = commands.register(linkCommand);
|
|
896
|
+
if (typeof d1 === 'function')
|
|
897
|
+
disposers.push(d1);
|
|
898
|
+
const d2 = commands.register(statusCommand);
|
|
899
|
+
if (typeof d2 === 'function')
|
|
900
|
+
disposers.push(d2);
|
|
901
|
+
const dRemove = commands.register(removeCommand);
|
|
902
|
+
if (typeof dRemove === 'function')
|
|
903
|
+
disposers.push(dRemove);
|
|
904
|
+
const d3 = commands.register(enrichCommand);
|
|
905
|
+
if (typeof d3 === 'function')
|
|
906
|
+
disposers.push(d3);
|
|
907
|
+
const d3b = commands.register(promptCommand);
|
|
908
|
+
if (typeof d3b === 'function')
|
|
909
|
+
disposers.push(d3b);
|
|
910
|
+
const d3d = commands.register(anchorCommand);
|
|
911
|
+
if (typeof d3d === 'function')
|
|
912
|
+
disposers.push(d3d);
|
|
913
|
+
const d3c = commands.register(modelsCatalogCommand);
|
|
914
|
+
if (typeof d3c === 'function')
|
|
915
|
+
disposers.push(d3c);
|
|
916
|
+
if (config.rules !== undefined) {
|
|
917
|
+
const rulesFn = config.rules;
|
|
918
|
+
const rulesCommandDef = {
|
|
919
|
+
name: 'chapters-rule',
|
|
920
|
+
description: 'Rules (record \u00A77): project tier propose/approve/revoke per machine; session tier review, pin, conflict report.',
|
|
921
|
+
input: { hint: 'add <category> <text> | list [--all|--proposed|--category <c>|--scope project|session] | view <id> | active | approve <id> | revoke <id> | pin <id> | unpin <id> | conflicts' },
|
|
922
|
+
async handler(invocation) {
|
|
923
|
+
// the session id reaches the resolver because the SESSION tier activates
|
|
924
|
+
// by ancestry (§7.5): `active` cannot answer without knowing which tree
|
|
925
|
+
// is asking. Command output never enters the model context (§7.2).
|
|
926
|
+
const sessionId = invocation.agent?.session?.id;
|
|
927
|
+
const r = await rulesFn(cwdOf(invocation.agent), invocation.rawInput ?? '', sessionId);
|
|
928
|
+
return { kind: r.kind, text: r.text };
|
|
929
|
+
},
|
|
930
|
+
};
|
|
931
|
+
const d4 = commands.register(rulesCommandDef);
|
|
932
|
+
if (typeof d4 === 'function')
|
|
933
|
+
disposers.push(d4);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
catch (error) {
|
|
937
|
+
// LOUD on the operator's console too — a swallowed register error here is
|
|
938
|
+
// how r35g's silent command-absence cost an extra boot to diagnose.
|
|
939
|
+
const message = `dsh-chapters: host command registration failed (${String(error)})`;
|
|
940
|
+
ctx.logger?.warn?.(message);
|
|
941
|
+
console.error(message);
|
|
942
|
+
}
|
|
943
|
+
return () => { for (const d of disposers)
|
|
944
|
+
d(); };
|
|
945
|
+
}
|
|
946
|
+
//# sourceMappingURL=commands.js.map
|