@visual-z/xeno 0.8.4 → 0.9.0
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/CHANGELOG.md +8 -0
- package/README.md +31 -746
- package/bin/xeno.exe +26 -0
- package/install.cjs +88 -0
- package/package.json +18 -48
- package/docs/app-server.md +0 -115
- package/docs/foundations.md +0 -88
- package/docs/install.md +0 -244
- package/docs/providers.md +0 -172
- package/docs/research/foundation-clients-2026-10-09.md +0 -27
- package/docs/research/foundation-editing-2026-10-09.md +0 -56
- package/docs/research/foundation-network-2026-10-09.md +0 -37
- package/docs/research/foundation-workflow-2026-10-09.md +0 -37
- package/src/apps/acp-server.ts +0 -41
- package/src/apps/agent-profiles.ts +0 -19
- package/src/apps/app-server.ts +0 -30
- package/src/apps/approve.ts +0 -49
- package/src/apps/cli.ts +0 -99
- package/src/apps/commands.ts +0 -319
- package/src/apps/completions.ts +0 -87
- package/src/apps/control-transport.ts +0 -90
- package/src/apps/export-cli.ts +0 -24
- package/src/apps/goal-flow.ts +0 -70
- package/src/apps/image-draft.ts +0 -46
- package/src/apps/input.ts +0 -312
- package/src/apps/model-catalog.ts +0 -69
- package/src/apps/preferences.ts +0 -20
- package/src/apps/project-trust.ts +0 -9
- package/src/apps/provider-connect.ts +0 -346
- package/src/apps/question-ui.ts +0 -27
- package/src/apps/render.ts +0 -181
- package/src/apps/resources.ts +0 -66
- package/src/apps/review.ts +0 -109
- package/src/apps/session-hub.ts +0 -96
- package/src/apps/session-lifecycle.ts +0 -64
- package/src/apps/shell.ts +0 -2071
- package/src/apps/startup-input.ts +0 -135
- package/src/apps/startup.ts +0 -1677
- package/src/apps/ui/app.ts +0 -1818
- package/src/apps/ui/control-model.ts +0 -43
- package/src/apps/ui/extension-host.ts +0 -194
- package/src/apps/ui/image-cache.ts +0 -121
- package/src/apps/ui/palette.ts +0 -88
- package/src/apps/ui/project.ts +0 -127
- package/src/apps/ui/standalone.ts +0 -148
- package/src/apps/ui/status-model.ts +0 -68
- package/src/apps/ui/surface.ts +0 -91
- package/src/apps/ui/theme.ts +0 -86
- package/src/apps/update-check.ts +0 -112
- package/src/apps/workflow.ts +0 -58
- package/src/core/acceptance.ts +0 -139
- package/src/core/action-defaults.ts +0 -58
- package/src/core/activity-status.ts +0 -52
- package/src/core/agent-profile.ts +0 -130
- package/src/core/attachment-input.ts +0 -17
- package/src/core/blind-checks.ts +0 -72
- package/src/core/boundary.ts +0 -174
- package/src/core/branch-receipt.ts +0 -26
- package/src/core/catalog-merge.ts +0 -19
- package/src/core/checkpoints.ts +0 -85
- package/src/core/churn.ts +0 -61
- package/src/core/command-artifacts.ts +0 -94
- package/src/core/command-contract.ts +0 -151
- package/src/core/completion-action.ts +0 -20
- package/src/core/completion-review.ts +0 -98
- package/src/core/context-detail.ts +0 -37
- package/src/core/context-policy.ts +0 -133
- package/src/core/context-window.ts +0 -54
- package/src/core/continuation.ts +0 -130
- package/src/core/control-protocol.ts +0 -69
- package/src/core/control-view.ts +0 -39
- package/src/core/cycle.ts +0 -17
- package/src/core/data-query.ts +0 -201
- package/src/core/deadline.ts +0 -40
- package/src/core/derive.ts +0 -516
- package/src/core/draft-images.ts +0 -56
- package/src/core/edit-plan.ts +0 -200
- package/src/core/environment.ts +0 -181
- package/src/core/event-validation.ts +0 -220
- package/src/core/events.ts +0 -670
- package/src/core/evidence-ladder.ts +0 -62
- package/src/core/evidence-links.ts +0 -68
- package/src/core/evidence.ts +0 -240
- package/src/core/execution.ts +0 -18
- package/src/core/exhaustive.ts +0 -18
- package/src/core/export.ts +0 -179
- package/src/core/extension-flags.ts +0 -14
- package/src/core/external-effects.ts +0 -488
- package/src/core/failure-recovery.ts +0 -173
- package/src/core/file-precondition.ts +0 -78
- package/src/core/file-references.ts +0 -35
- package/src/core/frontmatter.ts +0 -213
- package/src/core/git-status.ts +0 -29
- package/src/core/goal-view.ts +0 -64
- package/src/core/goal.ts +0 -200
- package/src/core/handoff.ts +0 -479
- package/src/core/history-query.ts +0 -139
- package/src/core/history.ts +0 -237
- package/src/core/image-limits.ts +0 -72
- package/src/core/images.ts +0 -46
- package/src/core/integration-health.ts +0 -34
- package/src/core/json-event.ts +0 -131
- package/src/core/json-pointer.ts +0 -46
- package/src/core/keymap.ts +0 -153
- package/src/core/lsp-framing.ts +0 -34
- package/src/core/mcp-config.ts +0 -68
- package/src/core/model-picker.ts +0 -64
- package/src/core/model-preferences.ts +0 -28
- package/src/core/model-replay.ts +0 -22
- package/src/core/model-scope.ts +0 -53
- package/src/core/note-guidance.ts +0 -34
- package/src/core/oauth-fields.ts +0 -44
- package/src/core/observation-view.ts +0 -152
- package/src/core/outcome.ts +0 -44
- package/src/core/overflow.ts +0 -59
- package/src/core/packages.ts +0 -47
- package/src/core/patch.ts +0 -130
- package/src/core/path-suggestions.ts +0 -59
- package/src/core/permissions.ts +0 -410
- package/src/core/process-receipt.ts +0 -105
- package/src/core/process-state.ts +0 -37
- package/src/core/program-status.ts +0 -40
- package/src/core/progress.ts +0 -150
- package/src/core/project.ts +0 -431
- package/src/core/prompt-policy.ts +0 -46
- package/src/core/prompts.ts +0 -111
- package/src/core/question.ts +0 -94
- package/src/core/queue.ts +0 -78
- package/src/core/read-bounds.ts +0 -24
- package/src/core/read-observation.ts +0 -106
- package/src/core/recovery-sources.ts +0 -74
- package/src/core/regex-dialect.ts +0 -25
- package/src/core/request-budget.ts +0 -23
- package/src/core/request-progress.ts +0 -87
- package/src/core/request-state.ts +0 -273
- package/src/core/resource-selection.ts +0 -36
- package/src/core/response-recovery.ts +0 -59
- package/src/core/resume.ts +0 -59
- package/src/core/review.ts +0 -68
- package/src/core/ripgrep-release.ts +0 -64
- package/src/core/runtime-target.ts +0 -94
- package/src/core/scheduling.ts +0 -8
- package/src/core/search-observation.ts +0 -97
- package/src/core/secrets.ts +0 -188
- package/src/core/session-archive.ts +0 -4
- package/src/core/session-browser.ts +0 -82
- package/src/core/session-entry.ts +0 -61
- package/src/core/session-exit.ts +0 -42
- package/src/core/session-format.ts +0 -169
- package/src/core/session-position.ts +0 -26
- package/src/core/session-tree.ts +0 -147
- package/src/core/settings.ts +0 -192
- package/src/core/skills.ts +0 -165
- package/src/core/sql-query.ts +0 -211
- package/src/core/stall.ts +0 -128
- package/src/core/stop-report.ts +0 -68
- package/src/core/text-normalize.ts +0 -81
- package/src/core/theme.ts +0 -166
- package/src/core/thinking.ts +0 -26
- package/src/core/tokens.ts +0 -151
- package/src/core/tool-contract.ts +0 -153
- package/src/core/tool-discovery.ts +0 -87
- package/src/core/tool-failure.ts +0 -81
- package/src/core/tool-input.ts +0 -44
- package/src/core/transcript.ts +0 -108
- package/src/core/tui-mode.ts +0 -29
- package/src/core/update-check.ts +0 -157
- package/src/core/version.ts +0 -55
- package/src/core/web-content.ts +0 -80
- package/src/core/work-state.ts +0 -154
- package/src/core/workflow-mode.ts +0 -34
- package/src/core/working-set.ts +0 -119
- package/src/core/yaml.ts +0 -282
- package/src/runtime/acp-agent.ts +0 -289
- package/src/runtime/agent-profiles.ts +0 -21
- package/src/runtime/auth.ts +0 -367
- package/src/runtime/blind-check.ts +0 -36
- package/src/runtime/branch-jobs.ts +0 -36
- package/src/runtime/catalog.ts +0 -285
- package/src/runtime/clipboard.ts +0 -125
- package/src/runtime/command-artifacts.ts +0 -64
- package/src/runtime/connection-reuse.ts +0 -40
- package/src/runtime/control-client.ts +0 -149
- package/src/runtime/control-connection.ts +0 -139
- package/src/runtime/counter-check.ts +0 -74
- package/src/runtime/environment-tools.ts +0 -60
- package/src/runtime/evidence-notes.ts +0 -35
- package/src/runtime/exec-sessions.ts +0 -456
- package/src/runtime/execution-control.ts +0 -40
- package/src/runtime/extension-loader.ts +0 -37
- package/src/runtime/extension-ui.ts +0 -47
- package/src/runtime/extensions.ts +0 -370
- package/src/runtime/formatter-tool.ts +0 -38
- package/src/runtime/formatter.ts +0 -50
- package/src/runtime/foundation.ts +0 -116
- package/src/runtime/history-cache.ts +0 -57
- package/src/runtime/history-tools.ts +0 -168
- package/src/runtime/images.ts +0 -80
- package/src/runtime/integrations.ts +0 -216
- package/src/runtime/loop.ts +0 -3108
- package/src/runtime/lsp-client.ts +0 -201
- package/src/runtime/mcp-client.ts +0 -264
- package/src/runtime/mcp-oauth.ts +0 -168
- package/src/runtime/mcp-schema.ts +0 -51
- package/src/runtime/model-config.ts +0 -232
- package/src/runtime/model-data.LICENSE +0 -27
- package/src/runtime/model-data.ts +0 -21
- package/src/runtime/model-discovery.ts +0 -208
- package/src/runtime/model-replay.ts +0 -10
- package/src/runtime/model-scope.ts +0 -20
- package/src/runtime/model-sources.ts +0 -151
- package/src/runtime/model.ts +0 -686
- package/src/runtime/models-fetch.ts +0 -57
- package/src/runtime/oauth-device.ts +0 -86
- package/src/runtime/oauth.ts +0 -853
- package/src/runtime/open-image.ts +0 -42
- package/src/runtime/openai-responses.ts +0 -304
- package/src/runtime/openai.ts +0 -697
- package/src/runtime/output-schema.ts +0 -26
- package/src/runtime/package-manager.ts +0 -125
- package/src/runtime/patch-tool.ts +0 -38
- package/src/runtime/permission-resources.ts +0 -18
- package/src/runtime/pi-provider.ts +0 -423
- package/src/runtime/program-status.ts +0 -38
- package/src/runtime/progress.ts +0 -26
- package/src/runtime/provider-access.ts +0 -21
- package/src/runtime/provider-connect.ts +0 -109
- package/src/runtime/provider-presets.ts +0 -98
- package/src/runtime/provider-protocols.ts +0 -29
- package/src/runtime/provider-request.ts +0 -87
- package/src/runtime/provider-tool-policy.ts +0 -33
- package/src/runtime/provider.ts +0 -580
- package/src/runtime/question-tool.ts +0 -62
- package/src/runtime/retry.ts +0 -447
- package/src/runtime/review-tool.ts +0 -48
- package/src/runtime/rich-text.ts +0 -36
- package/src/runtime/runtime-target.ts +0 -149
- package/src/runtime/secrets.ts +0 -79
- package/src/runtime/self-update.ts +0 -74
- package/src/runtime/session-control.ts +0 -265
- package/src/runtime/session-hub.ts +0 -85
- package/src/runtime/stream-idle.ts +0 -80
- package/src/runtime/tool-broker.ts +0 -137
- package/src/runtime/tool-discovery.ts +0 -58
- package/src/runtime/tool-queue.ts +0 -77
- package/src/runtime/tools.ts +0 -1820
- package/src/runtime/turn-effects.ts +0 -31
- package/src/runtime/update-check.ts +0 -48
- package/src/runtime/usage.ts +0 -253
- package/src/runtime/version.ts +0 -12
- package/src/runtime/web-http.ts +0 -56
- package/src/runtime/web-tools.ts +0 -88
- package/src/runtime/working-notes.ts +0 -60
- package/src/runtime/workspace-lifecycle.ts +0 -141
- package/src/store/acceptance.ts +0 -15
- package/src/store/agent-profile-files.ts +0 -13
- package/src/store/atomic.ts +0 -31
- package/src/store/auth-store.ts +0 -50
- package/src/store/blind-checks.ts +0 -32
- package/src/store/blobs.ts +0 -143
- package/src/store/checkpoint-history.ts +0 -32
- package/src/store/command-artifacts.ts +0 -96
- package/src/store/command.ts +0 -111
- package/src/store/config.ts +0 -69
- package/src/store/context-files.ts +0 -436
- package/src/store/file-checkpoints.ts +0 -108
- package/src/store/file-index.ts +0 -174
- package/src/store/foundation-config.ts +0 -24
- package/src/store/git-checkpoints.ts +0 -67
- package/src/store/git-status.ts +0 -15
- package/src/store/history-search.ts +0 -19
- package/src/store/log.ts +0 -725
- package/src/store/lsp-document.ts +0 -30
- package/src/store/mcp-credentials.ts +0 -85
- package/src/store/models.ts +0 -60
- package/src/store/observations.ts +0 -224
- package/src/store/packages.ts +0 -98
- package/src/store/patch.ts +0 -92
- package/src/store/permission-paths.ts +0 -34
- package/src/store/process-receipts.ts +0 -34
- package/src/store/query-source.ts +0 -49
- package/src/store/recovery.ts +0 -44
- package/src/store/replay.ts +0 -76
- package/src/store/resource-index.ts +0 -170
- package/src/store/resources.ts +0 -285
- package/src/store/restore.ts +0 -110
- package/src/store/review.ts +0 -109
- package/src/store/runtime-target.ts +0 -64
- package/src/store/search.ts +0 -728
- package/src/store/secrets.ts +0 -44
- package/src/store/serial.ts +0 -13
- package/src/store/session-archive.ts +0 -165
- package/src/store/session-browser.ts +0 -68
- package/src/store/session-position.ts +0 -27
- package/src/store/sessions.ts +0 -525
- package/src/store/source-cancellation.ts +0 -6
- package/src/store/tool-binaries.ts +0 -90
- package/src/store/trust.ts +0 -82
- package/src/store/update-cache.ts +0 -36
- package/src/store/user-settings.ts +0 -30
- package/src/store/workspace-clone.ts +0 -42
- package/src/store/workspace-observation.ts +0 -113
- package/src/store/workspace.ts +0 -115
- package/src/store/worktree.ts +0 -278
- package/src/tui/ansi-spans.ts +0 -126
- package/src/tui/diff.ts +0 -160
- package/src/tui/exhaustive.ts +0 -14
- package/src/tui/fuzzy.ts +0 -75
- package/src/tui/style.ts +0 -208
- package/src/tui/ui/atomic-draft.ts +0 -121
- package/src/tui/ui/branch-panel.ts +0 -47
- package/src/tui/ui/canvas.ts +0 -96
- package/src/tui/ui/colors.ts +0 -70
- package/src/tui/ui/composer.ts +0 -199
- package/src/tui/ui/control-panel.ts +0 -25
- package/src/tui/ui/control-sidebar.ts +0 -49
- package/src/tui/ui/dialog.ts +0 -398
- package/src/tui/ui/draft.ts +0 -23
- package/src/tui/ui/duration.ts +0 -16
- package/src/tui/ui/edge.ts +0 -186
- package/src/tui/ui/image-size.ts +0 -44
- package/src/tui/ui/ink.ts +0 -210
- package/src/tui/ui/items.ts +0 -42
- package/src/tui/ui/keys.ts +0 -40
- package/src/tui/ui/layout.ts +0 -74
- package/src/tui/ui/list-model.ts +0 -86
- package/src/tui/ui/list-view.ts +0 -172
- package/src/tui/ui/mascot.ts +0 -67
- package/src/tui/ui/messages.ts +0 -518
- package/src/tui/ui/motion.ts +0 -28
- package/src/tui/ui/paths.ts +0 -10
- package/src/tui/ui/pictures.ts +0 -108
- package/src/tui/ui/prompt-history.ts +0 -60
- package/src/tui/ui/screen.ts +0 -579
- package/src/tui/ui/status.ts +0 -403
- package/src/tui/ui/styled.ts +0 -85
- package/src/tui/ui/tool-card.ts +0 -150
- package/src/tui/width.ts +0 -567
- package/src/vendor/pi-rich/LICENSE.upstream +0 -21
- package/src/vendor/pi-rich/UPSTREAM.json +0 -17
- package/src/vendor/pi-rich/clipboard-command.ts +0 -44
- package/src/vendor/pi-rich/exif-orientation.ts +0 -182
- package/src/vendor/pi-rich/html.ts +0 -51
- package/src/vendor/pi-rich/image-convert.ts +0 -49
- package/src/vendor/pi-rich/image-resize-core.ts +0 -164
- package/src/vendor/pi-rich/image-resize-worker.ts +0 -42
- package/src/vendor/pi-rich/image-resize.ts +0 -123
- package/src/vendor/pi-rich/mime.ts +0 -116
- package/src/vendor/pi-rich/photon.ts +0 -139
- package/src/vendor/pi-rich/syntax-highlight.ts +0 -214
package/src/store/log.ts
DELETED
|
@@ -1,725 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The session tree on disk (card: refactor-log-tree).
|
|
3
|
-
*
|
|
4
|
-
* The only module in the project that does I/O, so ordering, durability and
|
|
5
|
-
* corruption are handled here and nowhere else.
|
|
6
|
-
*
|
|
7
|
-
* Layout:
|
|
8
|
-
* log/node-0000.jsonl header line, then one JSON event per line
|
|
9
|
-
* log/handoff-0000.yaml the handoff derived when that node closed
|
|
10
|
-
* log/manifest.json the index: one fixed-size record per closed node
|
|
11
|
-
*
|
|
12
|
-
* NNNN is the node's ALLOCATION order, not its position in the tree. Structure
|
|
13
|
-
* lives only in parent pointers -- the same split git makes between a commit's
|
|
14
|
-
* identity and its shape. A linear session is the degenerate tree where every
|
|
15
|
-
* node has one child, which is why this refactor leaves behaviour unchanged.
|
|
16
|
-
*
|
|
17
|
-
* One file per node, one writer per file. That is what makes parallel branches
|
|
18
|
-
* free of contention: no locks, no interleaved half-written lines, and D14's
|
|
19
|
-
* "a subagent writes its own branch file" becomes automatic rather than an
|
|
20
|
-
* extra mechanism.
|
|
21
|
-
*
|
|
22
|
-
* JSONL rather than SQLite so the agent can recover anything a handoff omits
|
|
23
|
-
* with the tools it already has -- grep, sed, jq -- and no recall tool has to
|
|
24
|
-
* exist. What that gives up is a real index, so cross-node queries by resource
|
|
25
|
-
* are linear scans; per-node files keep each one small enough for that to be
|
|
26
|
-
* fine and, unlike a single rollout file, nothing grows without bound.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { access, mkdir, readdir, readFile, open } from "node:fs/promises";
|
|
30
|
-
import { createHash } from "node:crypto";
|
|
31
|
-
import { join } from "node:path";
|
|
32
|
-
import type { LogEvent, NodeRef, IsoTime, EventId } from "../core/events.ts";
|
|
33
|
-
import { assertNever } from "../core/exhaustive.ts";
|
|
34
|
-
import type { Handoff } from "../core/handoff.ts";
|
|
35
|
-
import { isLogEvent, isRecord, validNodeRef } from "../core/event-validation.ts";
|
|
36
|
-
import { emitHandoff } from "../core/handoff.ts";
|
|
37
|
-
import { atomicWrite, durableAppend } from "./atomic.ts";
|
|
38
|
-
import { serialized } from "./serial.ts";
|
|
39
|
-
import { stateAtPosition } from "../core/session-position.ts";
|
|
40
|
-
import { SESSION_FORMAT, SessionFormatError } from "../core/session-format.ts";
|
|
41
|
-
|
|
42
|
-
/** Allocation number. Not an ordering over the tree. */
|
|
43
|
-
export type NodeId = number;
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* The first line of every node file.
|
|
47
|
-
*
|
|
48
|
-
* It shares the file with events and is shaped like one, but it is not an
|
|
49
|
-
* event: it describes the node rather than something that happened inside it,
|
|
50
|
-
* so readNode returns it separately and it never reaches the projection.
|
|
51
|
-
*/
|
|
52
|
-
/**
|
|
53
|
-
* What a node takes from its parent.
|
|
54
|
-
*
|
|
55
|
-
* inherit the parent's events, so the prefix is shared and the cache with
|
|
56
|
-
* it -- a fork exploring an alternative
|
|
57
|
-
* handoff the parent's derived handoff instead of its events -- a cut,
|
|
58
|
-
* taken when the context filled
|
|
59
|
-
* none nothing; the parent pointer records provenance only -- a
|
|
60
|
-
* subagent working in isolation
|
|
61
|
-
*
|
|
62
|
-
* Declared by the child rather than inferred from the parent, because the
|
|
63
|
-
* same parent can have children of every kind at once. Inferring it from
|
|
64
|
-
* whether the parent had a handoff is right for a linear session and wrong
|
|
65
|
-
* for a fork, whose parent is almost always closed.
|
|
66
|
-
*/
|
|
67
|
-
export type Basis = "inherit" | "handoff" | "none";
|
|
68
|
-
|
|
69
|
-
export interface NodeHeader {
|
|
70
|
-
t: "header";
|
|
71
|
-
node: NodeId;
|
|
72
|
-
parent: NodeRef | null;
|
|
73
|
-
basis: Basis;
|
|
74
|
-
created_at: IsoTime;
|
|
75
|
-
adopted_from?: NodeRef;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export interface NodeRecord {
|
|
79
|
-
node: NodeId;
|
|
80
|
-
parent: NodeRef | null;
|
|
81
|
-
log_ref: string;
|
|
82
|
-
sha256: string;
|
|
83
|
-
handoff_ref: string | null;
|
|
84
|
-
events: number;
|
|
85
|
-
closed_at: IsoTime;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Where the session was when it last stopped.
|
|
90
|
-
*
|
|
91
|
-
* Written down rather than inferred. The manifest indexes only CLOSED nodes,
|
|
92
|
-
* so the open one is not in it -- and the tree shape cannot be read backwards
|
|
93
|
-
* to find the cursor, because three legal shapes are indistinguishable: a
|
|
94
|
-
* node abandoned by a rewind stays open, a resolved branch holds the highest
|
|
95
|
-
* id while the cursor sits in its parent, and both can be true at once. This
|
|
96
|
-
* project already settled the same question once, for `basis`, and settled it
|
|
97
|
-
* the same way: declare it instead of deducing it.
|
|
98
|
-
*/
|
|
99
|
-
export interface Cursor {
|
|
100
|
-
/** The open node the session was working in. */
|
|
101
|
-
node: NodeId;
|
|
102
|
-
/**
|
|
103
|
-
* The allocator's state, not `max(id) + 1`. Dead limbs and resolved
|
|
104
|
-
* branches both hold ids, and recomputing the next one is exactly the
|
|
105
|
-
* shape of the bug that once had a cut and a branch agree on id 1 and
|
|
106
|
-
* silently truncate a node's whole log.
|
|
107
|
-
*/
|
|
108
|
-
next_node: NodeId;
|
|
109
|
-
/**
|
|
110
|
-
* The model this session ran under. Its thinking blocks are that model's:
|
|
111
|
-
* replaying them to another provider is a bad request, so resume checks
|
|
112
|
-
* this rather than discovering it on the wire.
|
|
113
|
-
*/
|
|
114
|
-
model: string;
|
|
115
|
-
/** New cursors disambiguate identical model IDs served by different providers. */
|
|
116
|
-
provider?: string;
|
|
117
|
-
/** Configuration takes effect after this committed event; absent means node start. */
|
|
118
|
-
at_event?: string;
|
|
119
|
-
thinking?: string;
|
|
120
|
-
blob_dir?: string;
|
|
121
|
-
/** Legacy historical positions with unknown model provenance must cut before dispatch. */
|
|
122
|
-
needs_model_checkpoint?: boolean;
|
|
123
|
-
updated_at: IsoTime;
|
|
124
|
-
/** Active checkout, which changes when a writing branch becomes mainline. */
|
|
125
|
-
workspace?: string;
|
|
126
|
-
/** Content-addressed frozen context; credentials and approvals are excluded. */
|
|
127
|
-
context_ref?: string;
|
|
128
|
-
/**
|
|
129
|
-
* The xeno release that wrote this cursor. Every start writes one, so the
|
|
130
|
-
* append-only metadata journal is a timeline of the versions a session ran under.
|
|
131
|
-
*/
|
|
132
|
-
xeno_version?: string;
|
|
133
|
-
/**
|
|
134
|
-
* A human name. Optional because most sessions never get one, and a log
|
|
135
|
-
* written before names existed has none. Absent rather than empty: an
|
|
136
|
-
* empty string would still show up in a listing as a name that isn't.
|
|
137
|
-
*/
|
|
138
|
-
name?: string;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export interface Manifest {
|
|
142
|
-
version: 1;
|
|
143
|
-
nodes: NodeRecord[];
|
|
144
|
-
/** An import keeps historical bytes and relocates their storage through journal metadata. */
|
|
145
|
-
relocated?: true;
|
|
146
|
-
/** Absent in a log written before resume existed, and in an empty one. */
|
|
147
|
-
cursor?: Cursor;
|
|
148
|
-
parent_session?: string;
|
|
149
|
-
labels?: Record<string, { text: string; at: string }>;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* A chain longer than this is a bug rather than a deep session: it would mean
|
|
154
|
-
* thousands of boundaries with no progress, which the stop-and-ask rule (D9)
|
|
155
|
-
* catches long before. The cap exists so a malformed tree fails fast instead
|
|
156
|
-
* of consuming the process -- unbounded traversal is not allowed here any more
|
|
157
|
-
* than an unbounded buffer would be.
|
|
158
|
-
*/
|
|
159
|
-
export const MAX_ANCESTRY_DEPTH = 1000;
|
|
160
|
-
|
|
161
|
-
const pad = (n: number) => String(n).padStart(4, "0");
|
|
162
|
-
|
|
163
|
-
export function nodePath(dir: string, node: NodeId): string {
|
|
164
|
-
return join(dir, `node-${pad(node)}.jsonl`);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export function handoffPath(dir: string, node: NodeId): string {
|
|
168
|
-
return join(dir, `handoff-${pad(node)}.yaml`);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export function manifestPath(dir: string): string {
|
|
172
|
-
return join(dir, "manifest.json");
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export async function initLog(dir: string): Promise<void> {
|
|
176
|
-
await mkdir(dir, { recursive: true });
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Every node in the log, in allocation order.
|
|
181
|
-
*
|
|
182
|
-
* From the directory rather than the manifest: the manifest holds only CLOSED
|
|
183
|
-
* nodes, and an open node -- including the one being written right now -- is
|
|
184
|
-
* exactly the one a reader is most likely to be asking about.
|
|
185
|
-
*/
|
|
186
|
-
export async function listNodes(dir: string): Promise<NodeId[]> {
|
|
187
|
-
let names: string[];
|
|
188
|
-
try {
|
|
189
|
-
names = await readdir(dir);
|
|
190
|
-
} catch (err) {
|
|
191
|
-
if ((err as { code?: string }).code === "ENOENT") return [];
|
|
192
|
-
throw err;
|
|
193
|
-
}
|
|
194
|
-
return names
|
|
195
|
-
.map((n) => /^node-(\d+)\.jsonl$/.exec(n))
|
|
196
|
-
.filter((m): m is RegExpExecArray => m !== null)
|
|
197
|
-
.map((m) => Number(m[1]))
|
|
198
|
-
.sort((a, b) => a - b);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** Creates a node file by writing its header. Must precede any appendEvent. */
|
|
202
|
-
export async function initNode(
|
|
203
|
-
dir: string,
|
|
204
|
-
node: NodeId,
|
|
205
|
-
parent: NodeRef | null,
|
|
206
|
-
createdAt: IsoTime,
|
|
207
|
-
// Defaults to inherit, which is the only safe choice for an unstated one:
|
|
208
|
-
// inheriting too much shows the model work it has already seen, while
|
|
209
|
-
// truncating too early silently hides work it needed. A caller that means
|
|
210
|
-
// to cut says so.
|
|
211
|
-
basis: Basis = parent === null ? "none" : "inherit",
|
|
212
|
-
metadata: Pick<NodeHeader, "adopted_from"> = {},
|
|
213
|
-
): Promise<NodeHeader> {
|
|
214
|
-
const header: NodeHeader = { t: "header", node, parent, basis, created_at: createdAt, ...metadata };
|
|
215
|
-
try {
|
|
216
|
-
// Exclusive create. A node log is append-only, so a second init is always
|
|
217
|
-
// a bug -- and writeFile would truncate, taking every event in the node
|
|
218
|
-
// with it while every ref pointing at it still resolves. Two allocators
|
|
219
|
-
// over one id namespace once did exactly that here, silently.
|
|
220
|
-
const file = await open(nodePath(dir, node), "wx", 0o600);
|
|
221
|
-
try { await file.writeFile(JSON.stringify(header) + "\n"); await file.sync(); }
|
|
222
|
-
finally { await file.close(); }
|
|
223
|
-
const directory = await open(dir, "r");
|
|
224
|
-
try { await directory.sync(); } finally { await directory.close(); }
|
|
225
|
-
} catch (err) {
|
|
226
|
-
if ((err as { code?: string }).code === "EEXIST") {
|
|
227
|
-
throw new Error(`node ${node} already exists at ${nodePath(dir, node)} -- refusing to overwrite it`);
|
|
228
|
-
}
|
|
229
|
-
throw err;
|
|
230
|
-
}
|
|
231
|
-
return header;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Appends one event as a single line.
|
|
236
|
-
*
|
|
237
|
-
* JSON.stringify never emits a raw newline inside a string, so one event is
|
|
238
|
-
* always exactly one line and a partially written tail can only ever damage
|
|
239
|
-
* the last line -- which readNode reports rather than silently dropping.
|
|
240
|
-
*/
|
|
241
|
-
export async function appendEvent(
|
|
242
|
-
dir: string,
|
|
243
|
-
node: NodeId,
|
|
244
|
-
event: LogEvent,
|
|
245
|
-
): Promise<void> {
|
|
246
|
-
if ((await readManifest(dir)).nodes.some((entry) => entry.node === node)) {
|
|
247
|
-
throw new Error(`INTEGRITY: cannot append to sealed node ${node}`);
|
|
248
|
-
}
|
|
249
|
-
await durableAppend(nodePath(dir, node), JSON.stringify(event) + "\n");
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
export interface ReadResult {
|
|
253
|
-
/** Null when the file has no header line, which is itself reported as corrupt. */
|
|
254
|
-
header: NodeHeader | null;
|
|
255
|
-
events: LogEvent[];
|
|
256
|
-
/** Lines that would not parse. Surfaced, never skipped quietly. */
|
|
257
|
-
corrupt: { line: number; text: string }[];
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
export async function readNode(dir: string, node: NodeId): Promise<ReadResult> {
|
|
261
|
-
let text: string;
|
|
262
|
-
try {
|
|
263
|
-
text = await readFile(nodePath(dir, node), "utf8");
|
|
264
|
-
} catch (err) {
|
|
265
|
-
if ((err as { code?: string }).code === "ENOENT") {
|
|
266
|
-
return { header: null, events: [], corrupt: [] };
|
|
267
|
-
}
|
|
268
|
-
throw err;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
let header: NodeHeader | null = null;
|
|
272
|
-
const events: LogEvent[] = [];
|
|
273
|
-
const corrupt: ReadResult["corrupt"] = [];
|
|
274
|
-
const lines = text.split("\n");
|
|
275
|
-
const ids = new Set<string>();
|
|
276
|
-
|
|
277
|
-
for (let i = 0; i < lines.length; i++) {
|
|
278
|
-
const line = lines[i]!;
|
|
279
|
-
if (line.length === 0) continue;
|
|
280
|
-
let parsed: unknown;
|
|
281
|
-
try {
|
|
282
|
-
parsed = JSON.parse(line);
|
|
283
|
-
} catch {
|
|
284
|
-
corrupt.push({ line: i + 1, text: line.slice(0, 200) });
|
|
285
|
-
continue;
|
|
286
|
-
}
|
|
287
|
-
// The header describes the node; it is not something that happened in it.
|
|
288
|
-
if (isRecord(parsed) && parsed["t"] === "header") {
|
|
289
|
-
if (i !== 0 || header !== null || parsed["node"] !== node || typeof parsed["created_at"] !== "string" ||
|
|
290
|
-
!(parsed["parent"] === null || validNodeRef(parsed["parent"])) ||
|
|
291
|
-
!(parsed["basis"] === undefined || ["inherit", "none", "handoff"].includes(String(parsed["basis"])))) {
|
|
292
|
-
corrupt.push({ line: i + 1, text: line.slice(0, 200) }); continue;
|
|
293
|
-
}
|
|
294
|
-
header = {
|
|
295
|
-
t: "header", node, parent: parsed["parent"], created_at: parsed["created_at"],
|
|
296
|
-
basis: parsed["basis"] === undefined ? (parsed["parent"] === null ? "none" : "inherit") : parsed["basis"] as Basis,
|
|
297
|
-
...(validNodeRef(parsed["adopted_from"]) ? { adopted_from: parsed["adopted_from"] } : {}),
|
|
298
|
-
};
|
|
299
|
-
continue;
|
|
300
|
-
}
|
|
301
|
-
if (!isLogEvent(parsed) || ids.has(parsed.id) || i === lines.length - 1) {
|
|
302
|
-
corrupt.push({ line: i + 1, text: line.slice(0, 200) }); continue;
|
|
303
|
-
}
|
|
304
|
-
ids.add(parsed.id);
|
|
305
|
-
events.push(parsed);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
return { header, events, corrupt };
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// ---------------------------------------------------------------------------
|
|
312
|
-
// Ancestry
|
|
313
|
-
// ---------------------------------------------------------------------------
|
|
314
|
-
|
|
315
|
-
export interface AncestryError {
|
|
316
|
-
code:
|
|
317
|
-
| "CYCLE"
|
|
318
|
-
| "MISSING_PARENT"
|
|
319
|
-
| "TOO_DEEP"
|
|
320
|
-
| "MISSING_HEADER"
|
|
321
|
-
| "CORRUPT"
|
|
322
|
-
| "INTEGRITY"
|
|
323
|
-
| "MISSING_BASIS_HANDOFF"
|
|
324
|
-
| "MISSING_FORK_POINT";
|
|
325
|
-
detail: string;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
export type AncestryResult =
|
|
329
|
-
| {
|
|
330
|
-
ok: true;
|
|
331
|
-
/**
|
|
332
|
-
* The ancestor whose handoff is the context basis, set when a node on
|
|
333
|
-
* the path declared `basis: "handoff"`. Its events do not replay. Null
|
|
334
|
-
* when the walk reached a root or a `none` boundary instead.
|
|
335
|
-
*/
|
|
336
|
-
basis: { node: NodeId; handoff_ref: string } | null;
|
|
337
|
-
/** Root-to-leaf order, ready to hand to projectContext. */
|
|
338
|
-
events: LogEvent[];
|
|
339
|
-
/** Nodes contributing events, root-to-leaf. Excludes the basis node. */
|
|
340
|
-
visited: NodeId[];
|
|
341
|
-
}
|
|
342
|
-
| { ok: false; error: AncestryError };
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Walks the parent chain from `node` upward, collecting events, and stops at
|
|
346
|
-
* the first ancestor that closed with a handoff.
|
|
347
|
-
*
|
|
348
|
-
* Returns an error rather than partial results: a chain that cannot be walked
|
|
349
|
-
* means the caller's picture of the tree is wrong, and half an ancestry would
|
|
350
|
-
* project a context that silently omits work.
|
|
351
|
-
*/
|
|
352
|
-
export async function readAncestry(
|
|
353
|
-
dir: string,
|
|
354
|
-
node: NodeId,
|
|
355
|
-
manifest?: Manifest,
|
|
356
|
-
): Promise<AncestryResult> {
|
|
357
|
-
const index = new Map(((manifest ?? await readManifest(dir)).nodes).map((r) => [r.node, r]));
|
|
358
|
-
const chain: { node: NodeId; events: LogEvent[] }[] = [];
|
|
359
|
-
// Revisiting a node means the parent pointers form a loop. The depth cap
|
|
360
|
-
// alone would stop the walk, but it would report exhaustion -- a reader
|
|
361
|
-
// needs to know the tree is malformed, not that the chain was long.
|
|
362
|
-
const seen = new Set<NodeId>();
|
|
363
|
-
|
|
364
|
-
let current: NodeId | null = node;
|
|
365
|
-
// Set when a child declared a fork point: the parent contributes only the
|
|
366
|
-
// events up to and including that one.
|
|
367
|
-
let cutoff: EventId | undefined;
|
|
368
|
-
let depth = 0;
|
|
369
|
-
let basis: { node: NodeId; handoff_ref: string } | null = null;
|
|
370
|
-
|
|
371
|
-
while (current !== null) {
|
|
372
|
-
if (seen.has(current)) {
|
|
373
|
-
return {
|
|
374
|
-
ok: false,
|
|
375
|
-
error: {
|
|
376
|
-
code: "CYCLE",
|
|
377
|
-
detail:
|
|
378
|
-
`parent chain from node ${node} revisits node ${current} ` +
|
|
379
|
-
`(walked ${[...seen].join(" -> ")})`,
|
|
380
|
-
},
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
seen.add(current);
|
|
384
|
-
|
|
385
|
-
if (depth++ >= MAX_ANCESTRY_DEPTH) {
|
|
386
|
-
return {
|
|
387
|
-
ok: false,
|
|
388
|
-
error: {
|
|
389
|
-
code: "TOO_DEEP",
|
|
390
|
-
detail: `ancestry of node ${node} exceeded ${MAX_ANCESTRY_DEPTH} nodes`,
|
|
391
|
-
},
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
const { header, events, corrupt } = await readNode(dir, current);
|
|
396
|
-
const sealed = index.get(current);
|
|
397
|
-
if (sealed && sealed.sha256 !== await digestNode(dir, current)) {
|
|
398
|
-
return { ok: false, error: { code: "INTEGRITY", detail: `node ${current} content digest does not match its sealed record` } };
|
|
399
|
-
}
|
|
400
|
-
if (header === null) {
|
|
401
|
-
return {
|
|
402
|
-
ok: false,
|
|
403
|
-
error: {
|
|
404
|
-
code: "MISSING_HEADER",
|
|
405
|
-
detail: `node ${current} has no header line (reached from ${node})`,
|
|
406
|
-
},
|
|
407
|
-
};
|
|
408
|
-
}
|
|
409
|
-
if (corrupt.length > 0) {
|
|
410
|
-
return {
|
|
411
|
-
ok: false,
|
|
412
|
-
error: {
|
|
413
|
-
code: "CORRUPT",
|
|
414
|
-
detail: `node ${current} has ${corrupt.length} unparsable line(s), first at ${corrupt[0]!.line}`,
|
|
415
|
-
},
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// A fork point truncates this node's contribution. Naming an event the
|
|
420
|
-
// node does not have is an error rather than a silent fall back to all of
|
|
421
|
-
// them: the caller's picture of the tree is wrong either way, and half an
|
|
422
|
-
// ancestry projects a context that quietly omits work.
|
|
423
|
-
let contributed = events;
|
|
424
|
-
if (cutoff !== undefined) {
|
|
425
|
-
const at = events.findIndex((e) => e.id === cutoff);
|
|
426
|
-
if (at < 0) {
|
|
427
|
-
return {
|
|
428
|
-
ok: false,
|
|
429
|
-
error: {
|
|
430
|
-
code: "MISSING_FORK_POINT",
|
|
431
|
-
detail: `node ${current} has no event ${cutoff} (fork point reached from ${node})`,
|
|
432
|
-
},
|
|
433
|
-
};
|
|
434
|
-
}
|
|
435
|
-
contributed = events.slice(0, at + 1);
|
|
436
|
-
}
|
|
437
|
-
chain.push({ node: current, events: contributed });
|
|
438
|
-
|
|
439
|
-
const parent: NodeRef | null = header.parent;
|
|
440
|
-
if (parent === null) break;
|
|
441
|
-
|
|
442
|
-
// What this node takes from its parent is declared here, not inferred
|
|
443
|
-
// from the parent. The same parent can have a fork, a cut and a subagent
|
|
444
|
-
// as children at once, so no property of the parent could decide it.
|
|
445
|
-
switch (header.basis) {
|
|
446
|
-
case "none":
|
|
447
|
-
// The parent pointer is provenance; it contributes no context.
|
|
448
|
-
current = null;
|
|
449
|
-
break;
|
|
450
|
-
case "handoff": {
|
|
451
|
-
const record = index.get(parent.node);
|
|
452
|
-
if (!record?.handoff_ref) {
|
|
453
|
-
return {
|
|
454
|
-
ok: false,
|
|
455
|
-
error: {
|
|
456
|
-
code: "MISSING_BASIS_HANDOFF",
|
|
457
|
-
detail:
|
|
458
|
-
`node ${current} declares a handoff basis but node ${parent.node} has none ` +
|
|
459
|
-
`(it may not be closed yet, or the manifest was not passed)`,
|
|
460
|
-
},
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
basis = { node: parent.node, handoff_ref: record.handoff_ref };
|
|
464
|
-
current = null;
|
|
465
|
-
break;
|
|
466
|
-
}
|
|
467
|
-
case "inherit":
|
|
468
|
-
cutoff = parent.at_event;
|
|
469
|
-
current = parent.node;
|
|
470
|
-
break;
|
|
471
|
-
default:
|
|
472
|
-
return assertNever(header.basis, "readAncestry");
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
chain.reverse();
|
|
477
|
-
return {
|
|
478
|
-
ok: true,
|
|
479
|
-
basis,
|
|
480
|
-
events: chain.flatMap((c) => c.events),
|
|
481
|
-
visited: chain.map((c) => c.node),
|
|
482
|
-
};
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// ---------------------------------------------------------------------------
|
|
486
|
-
// Digest, handoff, manifest
|
|
487
|
-
// ---------------------------------------------------------------------------
|
|
488
|
-
|
|
489
|
-
export async function digestNode(dir: string, node: NodeId): Promise<string> {
|
|
490
|
-
try {
|
|
491
|
-
const buf = await readFile(nodePath(dir, node));
|
|
492
|
-
return createHash("sha256").update(buf).digest("hex");
|
|
493
|
-
} catch (err) {
|
|
494
|
-
if ((err as { code?: string }).code === "ENOENT") {
|
|
495
|
-
return createHash("sha256").update("").digest("hex");
|
|
496
|
-
}
|
|
497
|
-
throw err;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
export async function writeHandoff(
|
|
502
|
-
dir: string,
|
|
503
|
-
node: NodeId,
|
|
504
|
-
handoff: Handoff,
|
|
505
|
-
): Promise<string> {
|
|
506
|
-
const text = emitHandoff(handoff);
|
|
507
|
-
await atomicWrite(handoffPath(dir, node), text);
|
|
508
|
-
return text;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
type MetadataRecord =
|
|
512
|
-
| { kind: "format"; version: number }
|
|
513
|
-
| { kind: "relocate"; workspace: string }
|
|
514
|
-
| { kind: "cursor"; cursor: Cursor }
|
|
515
|
-
| { kind: "parent_session"; path: string }
|
|
516
|
-
| { kind: "label"; target: string; text: string; at: string }
|
|
517
|
-
| { kind: "node"; record: NodeRecord };
|
|
518
|
-
|
|
519
|
-
function metadataPath(dir: string): string { return join(dir, "metadata.jsonl"); }
|
|
520
|
-
|
|
521
|
-
/**
|
|
522
|
-
* S.3: the journal's first record declares the session format. A directory in
|
|
523
|
-
* any other format -- a journal opening with something else, or a manifest
|
|
524
|
-
* with no journal at all -- is refused before anything reads or writes it.
|
|
525
|
-
* Null means a directory no session has written yet.
|
|
526
|
-
*/
|
|
527
|
-
async function readMetadata(dir: string): Promise<MetadataRecord[] | null> {
|
|
528
|
-
let text: string;
|
|
529
|
-
try { text = await readFile(metadataPath(dir), "utf8"); }
|
|
530
|
-
catch (error) {
|
|
531
|
-
if ((error as { code?: string }).code !== "ENOENT") throw error;
|
|
532
|
-
if (await access(manifestPath(dir)).then(() => true, () => false)) throw new SessionFormatError(dir, "undeclared");
|
|
533
|
-
return null;
|
|
534
|
-
}
|
|
535
|
-
if (text.length > 0 && !text.endsWith("\n")) throw new Error("INTEGRITY: incomplete metadata journal tail; no cursor is guessed");
|
|
536
|
-
const records = text.split("\n").filter(Boolean).map((line) => {
|
|
537
|
-
const envelope = JSON.parse(line) as { record: MetadataRecord; sha256: string };
|
|
538
|
-
const digest = createHash("sha256").update(JSON.stringify(envelope.record)).digest("hex");
|
|
539
|
-
if (envelope.sha256 !== digest) throw new Error("INTEGRITY: metadata journal digest mismatch");
|
|
540
|
-
return envelope.record;
|
|
541
|
-
});
|
|
542
|
-
const first = records[0];
|
|
543
|
-
if (first?.kind !== "format") throw new SessionFormatError(dir, "undeclared");
|
|
544
|
-
if (first.version !== SESSION_FORMAT) throw new SessionFormatError(dir, first.version);
|
|
545
|
-
return records;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/** The metadata journal already owns configuration history; no second state log is needed. */
|
|
549
|
-
export async function readCursorAt(dir: string, node: number, atEvent?: string): Promise<Cursor | null> {
|
|
550
|
-
const records = await readMetadata(dir);
|
|
551
|
-
const states: Cursor[] = [];
|
|
552
|
-
let relocated = false;
|
|
553
|
-
for (const record of records ?? []) {
|
|
554
|
-
if (record.kind === "cursor") states.push({ ...record.cursor });
|
|
555
|
-
else if (record.kind === "relocate") { relocated = true; for (const state of states) state.workspace = record.workspace; }
|
|
556
|
-
}
|
|
557
|
-
if (relocated) for (const state of states) state.blob_dir = join(dir, "blobs");
|
|
558
|
-
const { events } = await readNode(dir, node);
|
|
559
|
-
return stateAtPosition(states, node, events.map(event => event.id), atEvent);
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/**
|
|
563
|
-
* When this session was forked, or null for an original (fix-fork-start-time).
|
|
564
|
-
* copyAncestryTo keeps the parent's nodes and their timestamps, then writes
|
|
565
|
-
* the fork cursor and, right after it, the parent_session record; that
|
|
566
|
-
* cursor's updated_at is the fork moment. The last parent_session record is
|
|
567
|
-
* this session's own: a copied parent that was itself a fork brings its
|
|
568
|
-
* record along in the copied journal.
|
|
569
|
-
*/
|
|
570
|
-
export async function forkedAt(dir: string): Promise<string | null> {
|
|
571
|
-
const records = await readMetadata(dir) ?? [];
|
|
572
|
-
const own = records.findLastIndex(record => record.kind === "parent_session");
|
|
573
|
-
const cursor = records.slice(0, Math.max(0, own)).findLast(record => record.kind === "cursor");
|
|
574
|
-
return own >= 0 && cursor?.kind === "cursor" ? cursor.cursor.updated_at : null;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
/** D3: the replaceable manifest is a projection of committed metadata events. */
|
|
578
|
-
export async function readManifest(dir: string): Promise<Manifest> {
|
|
579
|
-
return serialized(dir, () => readManifestUnlocked(dir));
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
async function readManifestUnlocked(dir: string): Promise<Manifest> {
|
|
583
|
-
const records = await readMetadata(dir);
|
|
584
|
-
const manifest: Manifest = { version: 1, nodes: [] };
|
|
585
|
-
for (const event of records ?? []) {
|
|
586
|
-
switch (event.kind) {
|
|
587
|
-
case "format": break;
|
|
588
|
-
case "cursor": manifest.cursor = event.cursor; break;
|
|
589
|
-
case "relocate":
|
|
590
|
-
manifest.relocated = true;
|
|
591
|
-
if (manifest.cursor) manifest.cursor = { ...manifest.cursor, workspace: event.workspace };
|
|
592
|
-
break;
|
|
593
|
-
case "parent_session": manifest.parent_session = event.path; break;
|
|
594
|
-
case "label":
|
|
595
|
-
manifest.labels ??= {};
|
|
596
|
-
if (event.text) Object.defineProperty(manifest.labels, event.target, { value: { text: event.text, at: event.at }, enumerable: true, configurable: true, writable: true });
|
|
597
|
-
else delete manifest.labels[event.target];
|
|
598
|
-
break;
|
|
599
|
-
case "node": {
|
|
600
|
-
const i = manifest.nodes.findIndex((n) => n.node === event.record.node);
|
|
601
|
-
if (i < 0) manifest.nodes.push(event.record);
|
|
602
|
-
else manifest.nodes[i] = event.record;
|
|
603
|
-
break;
|
|
604
|
-
}
|
|
605
|
-
default: return assertNever(event, "metadata journal");
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
manifest.nodes.sort((a, b) => a.node - b.node);
|
|
609
|
-
if (manifest.relocated && manifest.cursor) manifest.cursor = { ...manifest.cursor, blob_dir: join(dir, "blobs") };
|
|
610
|
-
return manifest;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
/** Relocation changes recovery locations, not historical events or evidence (D3/D8). */
|
|
614
|
-
export async function relocateSession(dir: string, workspace: string): Promise<void> {
|
|
615
|
-
return serialized(dir, async () => {
|
|
616
|
-
await appendMetadata(dir, { kind: "relocate", workspace });
|
|
617
|
-
await atomicWrite(manifestPath(dir), JSON.stringify(await readManifestUnlocked(dir), null, 2) + "\n");
|
|
618
|
-
});
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
async function appendMetadata(dir: string, event: MetadataRecord): Promise<void> {
|
|
622
|
-
await mkdir(dir, { recursive: true });
|
|
623
|
-
const encode = (record: MetadataRecord) => JSON.stringify({
|
|
624
|
-
record, sha256: createHash("sha256").update(JSON.stringify(record)).digest("hex"),
|
|
625
|
-
}) + "\n";
|
|
626
|
-
// The first write declares the format; readMetadata has already refused a
|
|
627
|
-
// directory in any other format, so nothing here appends to an old log.
|
|
628
|
-
if (await readMetadata(dir) === null) await durableAppend(metadataPath(dir), encode({ kind: "format", version: SESSION_FORMAT }));
|
|
629
|
-
await durableAppend(metadataPath(dir), encode(event));
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
/**
|
|
633
|
-
* Moves the cursor. Called at every point the session changes node, so that
|
|
634
|
-
* "where was I" is a recorded fact rather than a reconstruction.
|
|
635
|
-
*
|
|
636
|
-
* Rewrites the whole manifest, like `recordNode`, so the index and the cursor
|
|
637
|
-
* cannot disagree -- two files could say node 7 is both closed and current.
|
|
638
|
-
*
|
|
639
|
-
* A name already on the cursor is kept unless the caller set one. The loop
|
|
640
|
-
* writes the cursor at every move and does not know about names; replacing
|
|
641
|
-
* the whole record would drop a name the moment a session cuts or starts,
|
|
642
|
-
* making `/name` a lie.
|
|
643
|
-
*/
|
|
644
|
-
export async function writeCursor(dir: string, cursor: Cursor): Promise<void> {
|
|
645
|
-
return serialized(dir, async () => {
|
|
646
|
-
const manifest = await readManifestUnlocked(dir);
|
|
647
|
-
const previousName = manifest.cursor?.name;
|
|
648
|
-
manifest.cursor =
|
|
649
|
-
previousName !== undefined && !("name" in cursor)
|
|
650
|
-
? { ...cursor, name: previousName }
|
|
651
|
-
: cursor;
|
|
652
|
-
await appendMetadata(dir, { kind: "cursor", cursor: manifest.cursor });
|
|
653
|
-
await atomicWrite(manifestPath(dir), JSON.stringify(manifest, null, 2) + "\n");
|
|
654
|
-
});
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
export async function readCursor(dir: string): Promise<Cursor | null> {
|
|
658
|
-
return (await readManifest(dir)).cursor ?? null;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
/** Labels are display metadata, never model evidence or rewritten conversation events. */
|
|
662
|
-
export async function setSessionLabel(dir: string, target: string, text: string, at: string): Promise<void> {
|
|
663
|
-
return serialized(dir, async () => {
|
|
664
|
-
await appendMetadata(dir, { kind: "label", target, text: text.trim(), at });
|
|
665
|
-
await atomicWrite(manifestPath(dir), JSON.stringify(await readManifestUnlocked(dir), null, 2) + "\n");
|
|
666
|
-
});
|
|
667
|
-
}
|
|
668
|
-
export async function setParentSession(dir: string, path: string): Promise<void> {
|
|
669
|
-
return serialized(dir, async () => {
|
|
670
|
-
await appendMetadata(dir, { kind: "parent_session", path });
|
|
671
|
-
await atomicWrite(manifestPath(dir), JSON.stringify(await readManifestUnlocked(dir), null, 2) + "\n");
|
|
672
|
-
});
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
/** Direct children of `node`, in allocation order. */
|
|
676
|
-
export function childrenOf(manifest: Manifest, node: NodeId): NodeRecord[] {
|
|
677
|
-
return manifest.nodes.filter((r) => r.parent?.node === node);
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
/** Records with no parent. A well-formed tree has exactly one. */
|
|
681
|
-
export function rootsOf(manifest: Manifest): NodeRecord[] {
|
|
682
|
-
return manifest.nodes.filter((r) => r.parent === null);
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
/**
|
|
686
|
-
* Records a closed node. Rewrites the manifest whole, which is fine because it
|
|
687
|
-
* holds one small record per node -- and makes the file atomically consistent
|
|
688
|
-
* rather than an append log that can end mid-record.
|
|
689
|
-
*/
|
|
690
|
-
export async function recordNode(dir: string, record: NodeRecord): Promise<Manifest> {
|
|
691
|
-
return serialized(dir, async () => {
|
|
692
|
-
const manifest = await readManifestUnlocked(dir);
|
|
693
|
-
const existing = manifest.nodes.findIndex((r) => r.node === record.node);
|
|
694
|
-
if (existing >= 0) manifest.nodes[existing] = record;
|
|
695
|
-
else manifest.nodes.push(record);
|
|
696
|
-
manifest.nodes.sort((a, b) => a.node - b.node);
|
|
697
|
-
await appendMetadata(dir, { kind: "node", record });
|
|
698
|
-
await atomicWrite(manifestPath(dir), JSON.stringify(manifest, null, 2) + "\n");
|
|
699
|
-
return manifest;
|
|
700
|
-
});
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
/** Closes a node: fingerprint it, write its handoff, index it. */
|
|
704
|
-
export async function closeNode(
|
|
705
|
-
dir: string,
|
|
706
|
-
node: NodeId,
|
|
707
|
-
handoff: Handoff,
|
|
708
|
-
eventCount: number,
|
|
709
|
-
closedAt: IsoTime,
|
|
710
|
-
): Promise<NodeRecord> {
|
|
711
|
-
const { header } = await readNode(dir, node);
|
|
712
|
-
const sha256 = await digestNode(dir, node);
|
|
713
|
-
await writeHandoff(dir, node, handoff);
|
|
714
|
-
const record: NodeRecord = {
|
|
715
|
-
node,
|
|
716
|
-
parent: header?.parent ?? null,
|
|
717
|
-
log_ref: nodePath(dir, node),
|
|
718
|
-
sha256,
|
|
719
|
-
handoff_ref: handoffPath(dir, node),
|
|
720
|
-
events: eventCount,
|
|
721
|
-
closed_at: closedAt,
|
|
722
|
-
};
|
|
723
|
-
await recordNode(dir, record);
|
|
724
|
-
return record;
|
|
725
|
-
}
|