@visual-z/xeno 0.1.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/README.md +498 -0
- package/docs/install.md +130 -0
- package/package.json +43 -0
- package/src/apps/approve.ts +49 -0
- package/src/apps/cli.ts +37 -0
- package/src/apps/commands.ts +226 -0
- package/src/apps/completions.ts +28 -0
- package/src/apps/input.ts +297 -0
- package/src/apps/render.ts +207 -0
- package/src/apps/shell.ts +2353 -0
- package/src/apps/startup.ts +1302 -0
- package/src/core/benchmark.ts +195 -0
- package/src/core/boundary.ts +174 -0
- package/src/core/churn.ts +59 -0
- package/src/core/completion-review.ts +66 -0
- package/src/core/context-policy.ts +60 -0
- package/src/core/context-window.ts +54 -0
- package/src/core/continuation.ts +114 -0
- package/src/core/cycle.ts +17 -0
- package/src/core/data-query.ts +201 -0
- package/src/core/deadline.ts +39 -0
- package/src/core/derive.ts +489 -0
- package/src/core/edit-plan.ts +200 -0
- package/src/core/environment.ts +29 -0
- package/src/core/event-validation.ts +99 -0
- package/src/core/events.ts +486 -0
- package/src/core/evidence-links.ts +71 -0
- package/src/core/evidence.ts +89 -0
- package/src/core/execution.ts +12 -0
- package/src/core/exhaustive.ts +18 -0
- package/src/core/experiment.ts +40 -0
- package/src/core/export.ts +169 -0
- package/src/core/file-references.ts +35 -0
- package/src/core/frontmatter.ts +213 -0
- package/src/core/handoff.ts +397 -0
- package/src/core/harness-comparison.ts +90 -0
- package/src/core/history-query.ts +103 -0
- package/src/core/history.ts +100 -0
- package/src/core/json-event.ts +135 -0
- package/src/core/json-pointer.ts +46 -0
- package/src/core/keymap.ts +241 -0
- package/src/core/observation-view.ts +87 -0
- package/src/core/outcome.ts +45 -0
- package/src/core/overflow.ts +59 -0
- package/src/core/permissions.ts +182 -0
- package/src/core/policy-evaluation.ts +55 -0
- package/src/core/progress.ts +101 -0
- package/src/core/project.ts +372 -0
- package/src/core/prompts.ts +83 -0
- package/src/core/queue.ts +60 -0
- package/src/core/read-bounds.ts +15 -0
- package/src/core/recall-analysis.ts +90 -0
- package/src/core/recall-trial.ts +153 -0
- package/src/core/response-recovery.ts +46 -0
- package/src/core/scheduling.ts +8 -0
- package/src/core/settings.ts +174 -0
- package/src/core/skills.ts +121 -0
- package/src/core/stall.ts +48 -0
- package/src/core/text-normalize.ts +81 -0
- package/src/core/theme.ts +179 -0
- package/src/core/tokens.ts +149 -0
- package/src/core/tool-contract.ts +102 -0
- package/src/core/tool-input.ts +38 -0
- package/src/core/transcript.ts +60 -0
- package/src/core/window-trial.ts +65 -0
- package/src/core/work-state.ts +103 -0
- package/src/core/yaml.ts +282 -0
- package/src/index.ts +10 -0
- package/src/runtime/auth.ts +255 -0
- package/src/runtime/benchmark.ts +187 -0
- package/src/runtime/catalog.ts +365 -0
- package/src/runtime/evidence-notes.ts +33 -0
- package/src/runtime/exec-sessions.ts +214 -0
- package/src/runtime/execution-control.ts +39 -0
- package/src/runtime/harness-comparison.ts +356 -0
- package/src/runtime/history-tools.ts +108 -0
- package/src/runtime/loop.ts +1983 -0
- package/src/runtime/model.ts +614 -0
- package/src/runtime/models-fetch.ts +155 -0
- package/src/runtime/oauth-device.ts +86 -0
- package/src/runtime/oauth.ts +759 -0
- package/src/runtime/openai-responses.ts +286 -0
- package/src/runtime/openai.ts +675 -0
- package/src/runtime/provider.ts +498 -0
- package/src/runtime/recall-trial.ts +226 -0
- package/src/runtime/resume.ts +66 -0
- package/src/runtime/retry.ts +297 -0
- package/src/runtime/tools.ts +1193 -0
- package/src/runtime/usage.ts +225 -0
- package/src/runtime/window-trial.ts +140 -0
- package/src/runtime/working-notes.ts +30 -0
- package/src/store/atomic.ts +31 -0
- package/src/store/auth-store.ts +50 -0
- package/src/store/blobs.ts +142 -0
- package/src/store/command.ts +75 -0
- package/src/store/config.ts +69 -0
- package/src/store/context-files.ts +356 -0
- package/src/store/file-index.ts +81 -0
- package/src/store/git-branch.ts +35 -0
- package/src/store/history-search.ts +19 -0
- package/src/store/log.ts +642 -0
- package/src/store/models.ts +41 -0
- package/src/store/observations.ts +181 -0
- package/src/store/query-source.ts +44 -0
- package/src/store/recovery.ts +39 -0
- package/src/store/replay.ts +63 -0
- package/src/store/resources.ts +361 -0
- package/src/store/restore.ts +302 -0
- package/src/store/search.ts +644 -0
- package/src/store/serial.ts +13 -0
- package/src/store/sessions.ts +463 -0
- package/src/store/trust.ts +69 -0
- package/src/store/workspace.ts +256 -0
- package/src/store/worktree.ts +233 -0
- package/src/tui/ansi.ts +67 -0
- package/src/tui/autocomplete.ts +29 -0
- package/src/tui/complete.ts +87 -0
- package/src/tui/diff.ts +160 -0
- package/src/tui/editor.ts +893 -0
- package/src/tui/exhaustive.ts +14 -0
- package/src/tui/fuzzy.ts +75 -0
- package/src/tui/host.ts +223 -0
- package/src/tui/input.ts +69 -0
- package/src/tui/keys.ts +378 -0
- package/src/tui/markdown.ts +17 -0
- package/src/tui/render.ts +153 -0
- package/src/tui/select.ts +240 -0
- package/src/tui/settings.ts +40 -0
- package/src/tui/statusline.ts +162 -0
- package/src/tui/style.ts +205 -0
- package/src/tui/terminal.ts +30 -0
- package/src/tui/text.ts +52 -0
- package/src/tui/toolview.ts +386 -0
- package/src/tui/transcript.ts +71 -0
- package/src/tui/tree.ts +405 -0
- package/src/tui/width.ts +567 -0
- package/src/vendor/pi-tui/LICENSE.upstream +21 -0
- package/src/vendor/pi-tui/VENDOR.md +23 -0
- package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
- package/src/vendor/pi-tui/autocomplete.ts +835 -0
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
- package/src/vendor/pi-tui/components/box.ts +167 -0
- package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
- package/src/vendor/pi-tui/components/editor.ts +2470 -0
- package/src/vendor/pi-tui/components/h-stack.ts +44 -0
- package/src/vendor/pi-tui/components/image.ts +127 -0
- package/src/vendor/pi-tui/components/input.ts +494 -0
- package/src/vendor/pi-tui/components/loader.ts +101 -0
- package/src/vendor/pi-tui/components/markdown.ts +1015 -0
- package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
- package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
- package/src/vendor/pi-tui/components/select-list.ts +273 -0
- package/src/vendor/pi-tui/components/settings-list.ts +328 -0
- package/src/vendor/pi-tui/components/spacer.ts +28 -0
- package/src/vendor/pi-tui/components/stack.ts +154 -0
- package/src/vendor/pi-tui/components/text.ts +107 -0
- package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
- package/src/vendor/pi-tui/components/v-stack.ts +33 -0
- package/src/vendor/pi-tui/editor-component.ts +74 -0
- package/src/vendor/pi-tui/fuzzy.ts +138 -0
- package/src/vendor/pi-tui/index.ts +156 -0
- package/src/vendor/pi-tui/keybindings.ts +320 -0
- package/src/vendor/pi-tui/keys.ts +1401 -0
- package/src/vendor/pi-tui/kill-ring.ts +46 -0
- package/src/vendor/pi-tui/latex.ts +1506 -0
- package/src/vendor/pi-tui/layout-node.ts +51 -0
- package/src/vendor/pi-tui/layout.ts +449 -0
- package/src/vendor/pi-tui/native-modifiers.ts +13 -0
- package/src/vendor/pi-tui/native-module-path.ts +31 -0
- package/src/vendor/pi-tui/native-platform.ts +63 -0
- package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
- package/src/vendor/pi-tui/terminal-colors.ts +73 -0
- package/src/vendor/pi-tui/terminal-image.ts +696 -0
- package/src/vendor/pi-tui/terminal.ts +547 -0
- package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
- package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
- package/src/vendor/pi-tui/tui.ts +1456 -0
- package/src/vendor/pi-tui/undo-stack.ts +28 -0
- package/src/vendor/pi-tui/utils.ts +1345 -0
- package/src/vendor/pi-tui/word-navigation.ts +117 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Many sessions under one project.
|
|
3
|
+
*
|
|
4
|
+
* A workspace used to hold a single log at `.xeno/log`. That made `/new`,
|
|
5
|
+
* `/resume`, `/name` and the rest unimplementable: there was nothing to
|
|
6
|
+
* list, name, or pick. Each session is now its own log tree under
|
|
7
|
+
* `.xeno/sessions/<id>/`, and the blob store stays at `.xeno/blobs` --
|
|
8
|
+
* content-addressed bytes are the same bytes no matter which session
|
|
9
|
+
* snapshotted them, and splitting the store would throw the dedup away
|
|
10
|
+
* without buying any isolation.
|
|
11
|
+
*
|
|
12
|
+
* There is no index file. An index is a second source of truth and will
|
|
13
|
+
* disagree with the directories; this project's whole position is that the
|
|
14
|
+
* log is the only truth, and a session list that can lie is the same class
|
|
15
|
+
* of error as a handoff that can lie. Listing is a scan. The cost is one
|
|
16
|
+
* small read per session, capped, which is the point of the cap.
|
|
17
|
+
*
|
|
18
|
+
* Why the id has a random suffix, not just a timestamp: two sessions
|
|
19
|
+
* created in the same millisecond would otherwise share a directory, and
|
|
20
|
+
* the second write would land in the first session's tree. That is the
|
|
21
|
+
* same collision shape that once had two node allocators agree on id 1
|
|
22
|
+
* and silently truncate a log.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { cp, mkdir, mkdtemp, open, readdir, rename, rm, stat } from "node:fs/promises";
|
|
26
|
+
import type { Dirent } from "node:fs";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
29
|
+
import {
|
|
30
|
+
digestNode,
|
|
31
|
+
listNodes,
|
|
32
|
+
nodePath,
|
|
33
|
+
initNode,
|
|
34
|
+
readManifest,
|
|
35
|
+
readCursor,
|
|
36
|
+
readNode,
|
|
37
|
+
writeCursor,
|
|
38
|
+
type Manifest,
|
|
39
|
+
} from "./log.ts";
|
|
40
|
+
import { readNodeHistory } from "./restore.ts";
|
|
41
|
+
import { replayContext } from "./replay.ts";
|
|
42
|
+
import { recoverCut } from "./recovery.ts";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A listing that reads every session ever is unbounded. Two hundred is
|
|
46
|
+
* enough to find the one you were in last week; beyond that the scan would
|
|
47
|
+
* be hiding a missing search feature, not serving one. Assumed, not
|
|
48
|
+
* measured: this is a product cap, not a performance number. The rest stay
|
|
49
|
+
* on disk -- the listing says it truncated, so they are not silent.
|
|
50
|
+
*/
|
|
51
|
+
export const MAX_SESSION_LIST = 200;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Exclusive-create retries when the id is already on disk.
|
|
55
|
+
*
|
|
56
|
+
* The suffix space is 36^4 (about 1.7 million). A collision is a bug in
|
|
57
|
+
* the clock -- same millisecond AND same random -- not a thing we wait
|
|
58
|
+
* out. Four tries then refuse, rather than loop.
|
|
59
|
+
*/
|
|
60
|
+
const MAX_ID_ATTEMPTS = 4;
|
|
61
|
+
|
|
62
|
+
const SUFFIX_SPAN = 36 ** 4;
|
|
63
|
+
|
|
64
|
+
export interface SessionIdClock {
|
|
65
|
+
nowMs: () => number;
|
|
66
|
+
/** In [0, 1), same range as Math.random. */
|
|
67
|
+
random: () => number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type ListedSession =
|
|
71
|
+
| {
|
|
72
|
+
ok: true;
|
|
73
|
+
id: string;
|
|
74
|
+
name: string | null;
|
|
75
|
+
model: string | null;
|
|
76
|
+
node: number | null;
|
|
77
|
+
events: number;
|
|
78
|
+
updated_at: string | null;
|
|
79
|
+
path: string;
|
|
80
|
+
}
|
|
81
|
+
| {
|
|
82
|
+
ok: false;
|
|
83
|
+
id: string;
|
|
84
|
+
path: string;
|
|
85
|
+
error: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export interface SessionList {
|
|
89
|
+
sessions: ListedSession[];
|
|
90
|
+
/** Session directories not read because the listing cap was hit. */
|
|
91
|
+
truncated: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const systemIdClock: SessionIdClock = {
|
|
95
|
+
nowMs: () => Date.now(),
|
|
96
|
+
random: () => Math.random(),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export function sessionsDir(workspace: string, override?: string): string {
|
|
100
|
+
if (override !== undefined && override.length > 0) {
|
|
101
|
+
return isAbsolute(override) ? override : join(workspace, override);
|
|
102
|
+
}
|
|
103
|
+
return join(workspace, ".xeno", "sessions");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function legacyLogDir(workspace: string): string {
|
|
107
|
+
return join(workspace, ".xeno", "log");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `<base36 ms>-<4 digit base36 random>`.
|
|
112
|
+
*
|
|
113
|
+
* Time first so ids sort by creation. The suffix is what makes two calls
|
|
114
|
+
* in the same millisecond distinct; without it the second session is the
|
|
115
|
+
* first session's directory.
|
|
116
|
+
*/
|
|
117
|
+
export function formatSessionId(ms: number, random: number): string {
|
|
118
|
+
const n = Math.min(SUFFIX_SPAN - 1, Math.max(0, Math.floor(random * SUFFIX_SPAN)));
|
|
119
|
+
return `${ms.toString(36)}-${n.toString(36).padStart(4, "0")}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function truncationNote(truncated: number): string | null {
|
|
123
|
+
if (truncated <= 0) return null;
|
|
124
|
+
const noun = truncated === 1 ? "session" : "sessions";
|
|
125
|
+
return `listing capped at ${MAX_SESSION_LIST}; ${truncated} older ${noun} not shown`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function createSession(
|
|
129
|
+
workspace: string,
|
|
130
|
+
clock: SessionIdClock = systemIdClock,
|
|
131
|
+
sessionDir?: string,
|
|
132
|
+
): Promise<{ id: string; path: string }> {
|
|
133
|
+
const dir = sessionsDir(workspace, sessionDir);
|
|
134
|
+
await mkdir(dir, { recursive: true });
|
|
135
|
+
for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt++) {
|
|
136
|
+
const id = formatSessionId(clock.nowMs(), clock.random());
|
|
137
|
+
const path = join(dir, id);
|
|
138
|
+
try {
|
|
139
|
+
// Exclusive. recursive would succeed on an existing directory and the
|
|
140
|
+
// second session would write into the first -- the collision this
|
|
141
|
+
// suffix exists to make unrepresentable.
|
|
142
|
+
await mkdir(path);
|
|
143
|
+
return { id, path };
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if ((err as { code?: string }).code !== "EEXIST") throw err;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
throw new Error(
|
|
149
|
+
`could not allocate a unique session id under ${dir} after ${MAX_ID_ATTEMPTS} attempts`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function listSessions(workspace: string, sessionDir?: string): Promise<SessionList> {
|
|
154
|
+
const dir = sessionsDir(workspace, sessionDir);
|
|
155
|
+
let entries: Dirent[];
|
|
156
|
+
try {
|
|
157
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if ((err as { code?: string }).code === "ENOENT") return { sessions: [], truncated: 0 };
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const dirs = entries.filter((e) => e.isDirectory());
|
|
164
|
+
const withMtime = await Promise.all(
|
|
165
|
+
dirs.map(async (d) => {
|
|
166
|
+
const path = join(dir, d.name);
|
|
167
|
+
const st = await stat(path);
|
|
168
|
+
return { id: d.name, path, mtime: st.mtimeMs };
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
// The cap is "which 200 do we even open", and mtime is the cheap proxy
|
|
172
|
+
// for "likely recent". After that, the list is ordered by what each
|
|
173
|
+
// session recorded: a backup restore can make an old session the
|
|
174
|
+
// newest directory on disk, and that must not hide the one that ran last.
|
|
175
|
+
withMtime.sort((a, b) => b.mtime - a.mtime);
|
|
176
|
+
const truncated = Math.max(0, withMtime.length - MAX_SESSION_LIST);
|
|
177
|
+
const chosen = withMtime.slice(0, MAX_SESSION_LIST);
|
|
178
|
+
|
|
179
|
+
const sessions: ListedSession[] = [];
|
|
180
|
+
for (const d of chosen) {
|
|
181
|
+
sessions.push(await readListed(d.id, d.path));
|
|
182
|
+
}
|
|
183
|
+
sessions.sort(byRecordedTime);
|
|
184
|
+
return { sessions, truncated };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A leftover `.xeno/log` is not migrated. Moving someone else's data cannot
|
|
189
|
+
* be a startup side effect; the person keeps using it by pointing `--log`
|
|
190
|
+
* at it. Once `.xeno/sessions/` exists they are already in the new layout
|
|
191
|
+
* and the leftover is just leftover, so the notice stays quiet.
|
|
192
|
+
*/
|
|
193
|
+
export async function legacyLogNotice(workspace: string): Promise<string | null> {
|
|
194
|
+
if (!(await isDirectory(legacyLogDir(workspace)))) return null;
|
|
195
|
+
if (await isDirectory(sessionsDir(workspace))) return null;
|
|
196
|
+
return (
|
|
197
|
+
"This workspace still has a log at .xeno/log from before named sessions existed. " +
|
|
198
|
+
"It has not been moved. Continue it with --log .xeno/log"
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export type SessionPrefixMatch =
|
|
203
|
+
| { ok: true; session: Extract<ListedSession, { ok: true }> }
|
|
204
|
+
| { ok: false; reason: string; candidates: string[] };
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Unique prefix or exact path only. Two matches is a list, never catalog
|
|
208
|
+
* order -- the same rule as findModelByPattern. Guessing here would open
|
|
209
|
+
* someone else's session because ids share a timestamp prefix.
|
|
210
|
+
*/
|
|
211
|
+
export async function findSessionByPrefix(
|
|
212
|
+
workspace: string,
|
|
213
|
+
prefix: string,
|
|
214
|
+
sessionDir?: string,
|
|
215
|
+
): Promise<SessionPrefixMatch> {
|
|
216
|
+
const needle = prefix.trim();
|
|
217
|
+
if (needle.length === 0) {
|
|
218
|
+
return { ok: false, reason: "empty session id", candidates: [] };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const { sessions } = await listSessions(workspace, sessionDir);
|
|
222
|
+
const ok = sessions.filter((s): s is Extract<ListedSession, { ok: true }> => s.ok);
|
|
223
|
+
|
|
224
|
+
const exactId = ok.filter((s) => s.id === needle);
|
|
225
|
+
if (exactId.length === 1) return { ok: true, session: exactId[0]! };
|
|
226
|
+
|
|
227
|
+
const exactPath = ok.filter((s) => s.path === needle);
|
|
228
|
+
if (exactPath.length === 1) return { ok: true, session: exactPath[0]! };
|
|
229
|
+
|
|
230
|
+
const existing = await existingDir(workspace, needle);
|
|
231
|
+
if (existing !== null) {
|
|
232
|
+
const listed = ok.find((s) => s.path === existing);
|
|
233
|
+
if (listed !== undefined) return { ok: true, session: listed };
|
|
234
|
+
const read = await readListed(basename(existing), existing);
|
|
235
|
+
if (read.ok) return { ok: true, session: read };
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
session: {
|
|
239
|
+
ok: true,
|
|
240
|
+
id: basename(existing),
|
|
241
|
+
path: existing,
|
|
242
|
+
name: null,
|
|
243
|
+
model: null,
|
|
244
|
+
node: null,
|
|
245
|
+
events: 0,
|
|
246
|
+
updated_at: null,
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const hits = ok.filter((s) => s.id.startsWith(needle));
|
|
252
|
+
if (hits.length === 1) return { ok: true, session: hits[0]! };
|
|
253
|
+
if (hits.length === 0) {
|
|
254
|
+
return { ok: false, reason: `unknown session: ${needle}`, candidates: [] };
|
|
255
|
+
}
|
|
256
|
+
const ids = hits.map((s) => s.id);
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
reason:
|
|
260
|
+
`ambiguous session id prefix ${JSON.stringify(needle)}: matches ${ids.join(", ")}. ` +
|
|
261
|
+
"Be more specific rather than guessing",
|
|
262
|
+
candidates: ids,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Copy the log graph and its content-addressed observations, then open a new
|
|
268
|
+
* inherit node at the chosen point. Like copying Git objects and moving a ref,
|
|
269
|
+
* this keeps immutable evidence intact (D3/D8 / M.2). Flattening just the current
|
|
270
|
+
* context loses earlier handoffs; flattening every event loses cut semantics.
|
|
271
|
+
* Publication is atomic into an empty destination, so a partial copy cannot be
|
|
272
|
+
* mistaken for a resumable session. Historical log_ref paths retain provenance;
|
|
273
|
+
* readers resolve node IDs and artifact hashes inside the new session.
|
|
274
|
+
*/
|
|
275
|
+
export async function copyAncestryTo(
|
|
276
|
+
sourceDir: string,
|
|
277
|
+
destDir: string,
|
|
278
|
+
createdAt: string,
|
|
279
|
+
untilEvent?: string,
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
const rel = relative(resolve(sourceDir), resolve(destDir));
|
|
282
|
+
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
|
|
283
|
+
throw new Error("cannot copy a session into itself or its descendants");
|
|
284
|
+
}
|
|
285
|
+
await mkdir(destDir, { recursive: true });
|
|
286
|
+
if ((await readdir(destDir)).length > 0) throw new Error("cannot copy session into a nonempty destination");
|
|
287
|
+
const stage = await mkdtemp(join(dirname(destDir), ".xeno-copy-"));
|
|
288
|
+
try {
|
|
289
|
+
await cp(sourceDir, stage, { recursive: true, force: false, errorOnExist: true });
|
|
290
|
+
const recorded = await readCursor(stage);
|
|
291
|
+
const cursor = recorded === null ? null : await recoverCut(stage, recorded);
|
|
292
|
+
const tip = cursor?.node ?? 0;
|
|
293
|
+
const history = await readNodeHistory(stage, tip);
|
|
294
|
+
if (!history.ok) throw new Error(`cannot copy session: ${history.error}`);
|
|
295
|
+
let target = tip;
|
|
296
|
+
if (untilEvent !== undefined) {
|
|
297
|
+
const matches = history.nodes.filter((n) => n.events.some((e) => e.id === untilEvent));
|
|
298
|
+
if (matches.length !== 1) throw new Error(`cannot copy session: event ${untilEvent} is missing or ambiguous on the current path`);
|
|
299
|
+
target = matches[0]!.node;
|
|
300
|
+
}
|
|
301
|
+
const nodes = await listNodes(stage);
|
|
302
|
+
const next = Math.max(cursor?.next_node ?? 1, ...nodes.map((n) => n + 1));
|
|
303
|
+
await initNode(stage, next, {
|
|
304
|
+
node: target, log_ref: nodePath(destDir, target), sha256: await digestNode(stage, target),
|
|
305
|
+
...(untilEvent === undefined ? {} : { at_event: untilEvent }),
|
|
306
|
+
}, createdAt, "inherit");
|
|
307
|
+
await writeCursor(stage, {
|
|
308
|
+
...cursor, node: next, next_node: next + 1, model: cursor?.model ?? "unknown", updated_at: createdAt,
|
|
309
|
+
});
|
|
310
|
+
await replayContext(stage, next);
|
|
311
|
+
await syncCopiedTree(stage);
|
|
312
|
+
await rename(stage, destDir);
|
|
313
|
+
const parent = await open(dirname(destDir), "r");
|
|
314
|
+
try { await parent.sync(); } finally { await parent.close(); }
|
|
315
|
+
} finally { await rm(stage, { recursive: true, force: true }); }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function syncCopiedTree(dir: string): Promise<void> {
|
|
319
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
320
|
+
const path = join(dir, entry.name);
|
|
321
|
+
if (entry.isDirectory()) await syncCopiedTree(path);
|
|
322
|
+
else if (entry.isFile()) {
|
|
323
|
+
const file = await open(path, "r");
|
|
324
|
+
try { await file.sync(); } finally { await file.close(); }
|
|
325
|
+
} else throw new Error(`cannot copy unsupported session entry: ${path}`);
|
|
326
|
+
}
|
|
327
|
+
const directory = await open(dir, "r");
|
|
328
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export interface ResolveLogOpts {
|
|
332
|
+
explicit?: string;
|
|
333
|
+
fresh?: boolean;
|
|
334
|
+
/**
|
|
335
|
+
* `-c` / `--continue`. Pick the newest session and refuse if there is
|
|
336
|
+
* none -- creating one would make `-c` indistinguishable from a first run.
|
|
337
|
+
*/
|
|
338
|
+
continueLatest?: boolean;
|
|
339
|
+
/**
|
|
340
|
+
* `--no-session`. Log goes to a temp directory, so the workspace never
|
|
341
|
+
* grows a `.xeno/sessions/` folder. A flag that still called createSession
|
|
342
|
+
* would pass every assertion on the flag and still leave a directory.
|
|
343
|
+
*/
|
|
344
|
+
noSession?: boolean;
|
|
345
|
+
session?: string;
|
|
346
|
+
sessionDir?: string;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Picks the log directory for a process that did not pass a path, or
|
|
351
|
+
* honours one that did.
|
|
352
|
+
*
|
|
353
|
+
* Newest-by-recorded-time is the default because that is the session the
|
|
354
|
+
* person was just in. `--fresh` always allocates a new one; it never
|
|
355
|
+
* deletes. A leftover `.xeno/log` is not picked -- that would make the
|
|
356
|
+
* `--log .xeno/log` the card requires for continuing it unnecessary, and
|
|
357
|
+
* it would mix the old layout into the new one without anyone asking.
|
|
358
|
+
*/
|
|
359
|
+
export async function resolveLogDir(
|
|
360
|
+
workspace: string,
|
|
361
|
+
opts: ResolveLogOpts = {},
|
|
362
|
+
): Promise<{ logDir: string; created: boolean; legacyNotice: string | null }> {
|
|
363
|
+
// Computed first: createSession will mkdir `.xeno/sessions/`, after which
|
|
364
|
+
// the leftover-log condition is no longer true.
|
|
365
|
+
const legacyNotice = await legacyLogNotice(workspace);
|
|
366
|
+
if (opts.noSession === true) {
|
|
367
|
+
const logDir = await mkdtemp(join(tmpdir(), "xeno-nosession-"));
|
|
368
|
+
return { logDir, created: true, legacyNotice };
|
|
369
|
+
}
|
|
370
|
+
if (opts.explicit !== undefined) {
|
|
371
|
+
return { logDir: opts.explicit, created: false, legacyNotice };
|
|
372
|
+
}
|
|
373
|
+
if (opts.session !== undefined && opts.session.length > 0) {
|
|
374
|
+
const found = await findSessionByPrefix(workspace, opts.session, opts.sessionDir);
|
|
375
|
+
if (!found.ok) throw new Error(found.reason);
|
|
376
|
+
return { logDir: found.session.path, created: false, legacyNotice };
|
|
377
|
+
}
|
|
378
|
+
if (opts.continueLatest === true) {
|
|
379
|
+
const { sessions } = await listSessions(workspace, opts.sessionDir);
|
|
380
|
+
const newest = sessions.find((s) => s.ok);
|
|
381
|
+
if (newest === undefined) {
|
|
382
|
+
throw new Error("no session to continue");
|
|
383
|
+
}
|
|
384
|
+
return { logDir: newest.path, created: false, legacyNotice };
|
|
385
|
+
}
|
|
386
|
+
if (opts.fresh !== true) {
|
|
387
|
+
const { sessions } = await listSessions(workspace, opts.sessionDir);
|
|
388
|
+
const newest = sessions.find((s) => s.ok);
|
|
389
|
+
if (newest !== undefined) {
|
|
390
|
+
return { logDir: newest.path, created: false, legacyNotice };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const created = await createSession(workspace, systemIdClock, opts.sessionDir);
|
|
394
|
+
return { logDir: created.path, created: true, legacyNotice };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function existingDir(workspace: string, prefix: string): Promise<string | null> {
|
|
398
|
+
if (await isDirectory(prefix)) return prefix;
|
|
399
|
+
const rel = join(workspace, prefix);
|
|
400
|
+
if (rel !== prefix && (await isDirectory(rel))) return rel;
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function readListed(id: string, path: string): Promise<ListedSession> {
|
|
405
|
+
let parsed: unknown;
|
|
406
|
+
try {
|
|
407
|
+
parsed = await readManifest(path);
|
|
408
|
+
} catch (err) {
|
|
409
|
+
if ((err as { code?: string }).code === "ENOENT") {
|
|
410
|
+
return {
|
|
411
|
+
ok: true, id, path,
|
|
412
|
+
name: null, model: null, node: null, events: 0, updated_at: null,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
return { ok: false, id, path, error: `${path} could not be read: ${(err as Error).message}` };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
419
|
+
return { ok: false, id, path, error: `${path} manifest is not an object` };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const manifest = parsed as Manifest;
|
|
423
|
+
const cursor = manifest.cursor;
|
|
424
|
+
if (cursor === undefined) {
|
|
425
|
+
return {
|
|
426
|
+
ok: true, id, path,
|
|
427
|
+
name: null, model: null, node: null, events: 0, updated_at: null,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// The open node's size, not an ancestry walk. Walking can fail the whole
|
|
432
|
+
// listing (a cycle, a missing parent), and a list that throws for one
|
|
433
|
+
// bad tree is the same shape as a list that throws for one bad file.
|
|
434
|
+
const events = (await readNode(path, cursor.node)).events.length;
|
|
435
|
+
return {
|
|
436
|
+
ok: true,
|
|
437
|
+
id,
|
|
438
|
+
path,
|
|
439
|
+
name: cursor.name ?? null,
|
|
440
|
+
model: cursor.model,
|
|
441
|
+
node: cursor.node,
|
|
442
|
+
events,
|
|
443
|
+
updated_at: cursor.updated_at,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function byRecordedTime(a: ListedSession, b: ListedSession): number {
|
|
448
|
+
const ta = a.ok ? a.updated_at : null;
|
|
449
|
+
const tb = b.ok ? b.updated_at : null;
|
|
450
|
+
if (ta !== null && tb !== null) return tb < ta ? -1 : tb > ta ? 1 : 0;
|
|
451
|
+
if (ta !== null) return -1;
|
|
452
|
+
if (tb !== null) return 1;
|
|
453
|
+
return 0;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
457
|
+
try {
|
|
458
|
+
return (await stat(path)).isDirectory();
|
|
459
|
+
} catch (err) {
|
|
460
|
+
if ((err as { code?: string }).code === "ENOENT") return false;
|
|
461
|
+
throw err;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project trust: whether to load `.xeno/settings.json` for a directory.
|
|
3
|
+
*
|
|
4
|
+
* Trust does not affect tool execution. Path tools stay confined to the
|
|
5
|
+
* workspace root regardless, and bash still goes through the permission
|
|
6
|
+
* gate. This file only records which directories may contribute project
|
|
7
|
+
* settings. AGENTS.md is loaded whether the project is trusted or not.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
import { configDir, readJsonFile, writeJsonFile } from "./config.ts";
|
|
12
|
+
|
|
13
|
+
export type TrustFile = { readonly [dir: string]: boolean };
|
|
14
|
+
|
|
15
|
+
export function trustPath(): string {
|
|
16
|
+
return join(configDir(), "trust.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function projectSettingsPath(workspace: string): string {
|
|
20
|
+
return join(workspace, ".xeno", "settings.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Closest saved decision on `dir` or a parent. Undefined if none.
|
|
25
|
+
*
|
|
26
|
+
* A parent applies to its children so trusting a repo root covers the
|
|
27
|
+
* packages underneath; a closer entry wins so a single untrusted nested
|
|
28
|
+
* project can opt out.
|
|
29
|
+
*/
|
|
30
|
+
export function lookupTrust(decisions: TrustFile, dir: string): boolean | undefined {
|
|
31
|
+
let current = resolve(dir);
|
|
32
|
+
for (;;) {
|
|
33
|
+
const value = decisions[current];
|
|
34
|
+
if (value !== undefined) return value;
|
|
35
|
+
const parent = dirname(current);
|
|
36
|
+
if (parent === current) return undefined;
|
|
37
|
+
current = parent;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function readTrust(): Promise<{ value: TrustFile; error: string | null }> {
|
|
42
|
+
const { value, error } = await readJsonFile<unknown>(trustPath());
|
|
43
|
+
if (error) return { value: {}, error };
|
|
44
|
+
if (value === null) return { value: {}, error: null };
|
|
45
|
+
if (!isTrustFile(value)) {
|
|
46
|
+
return { value: {}, error: `${trustPath()} is not a directory-to-boolean map` };
|
|
47
|
+
}
|
|
48
|
+
return { value, error: null };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function writeTrust(decisions: TrustFile): Promise<void> {
|
|
52
|
+
await writeJsonFile(trustPath(), decisions, false);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function saveTrust(dir: string, trusted: boolean): Promise<void> {
|
|
56
|
+
const { value } = await readTrust();
|
|
57
|
+
const next: { [dir: string]: boolean } = { ...value };
|
|
58
|
+
next[resolve(dir)] = trusted;
|
|
59
|
+
await writeTrust(next);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isTrustFile(value: unknown): value is TrustFile {
|
|
63
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
64
|
+
const rec = value as { readonly [k: string]: unknown };
|
|
65
|
+
for (const key of Object.keys(rec)) {
|
|
66
|
+
if (typeof rec[key] !== "boolean") return false;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|