@gmickel/gno 2.4.0 → 2.6.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 +69 -6
- package/assets/skill/README.md +5 -1
- package/assets/skill/SKILL.md +98 -1
- package/assets/skill/cli-reference.md +140 -0
- package/assets/skill/examples.md +45 -0
- package/assets/skill/mcp-reference.md +62 -0
- package/assets/skill/recipes/capture-and-file.md +6 -0
- package/assets/skill/recipes/memory-file-decision.md +6 -0
- package/assets/skill/recipes/memory-supersede-fact.md +5 -0
- package/assets/skill/recipes/session-evidence-lookup.md +98 -0
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.4.0.zip → gno-browser-clipper-v2.6.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/cli.md +358 -24
- package/spec/compiled-context.md +68 -0
- package/spec/mcp.md +223 -2
- package/spec/output-schemas/capture-receipt.schema.json +3 -0
- package/spec/output-schemas/compiled-context-check.schema.json +44 -0
- package/spec/output-schemas/compiled-context-file.schema.json +165 -0
- package/spec/output-schemas/compiled-context-preview.schema.json +142 -0
- package/spec/output-schemas/mcp-capture-result.schema.json +3 -0
- package/spec/output-schemas/memory-remember.schema.json +8 -2
- package/spec/output-schemas/request-status.schema.json +113 -0
- package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
- package/spec/output-schemas/sessions-discovery.schema.json +38 -0
- package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
- package/spec/output-schemas/sessions-status.schema.json +432 -0
- package/src/app/compiled-context-files.ts +361 -0
- package/src/app/compiled-context.ts +240 -0
- package/src/app/context-surface.ts +7 -2
- package/src/cli/commands/ask.ts +14 -2
- package/src/cli/commands/audit.ts +4 -0
- package/src/cli/commands/capture.ts +55 -96
- package/src/cli/commands/context-compiled.ts +136 -0
- package/src/cli/commands/daemon.ts +41 -0
- package/src/cli/commands/ls.ts +3 -0
- package/src/cli/commands/memory.ts +12 -3
- package/src/cli/commands/request-status.ts +59 -0
- package/src/cli/commands/reset.ts +39 -5
- package/src/cli/commands/sessions.ts +713 -0
- package/src/cli/commands/shared.ts +14 -1
- package/src/cli/errors.ts +10 -3
- package/src/cli/program.ts +458 -1
- package/src/cli/session-binding.ts +49 -0
- package/src/config/types.ts +8 -0
- package/src/core/capture-publish.ts +239 -0
- package/src/core/capture-sync.ts +3 -0
- package/src/core/compiled-context.ts +254 -0
- package/src/core/context-budget.ts +2 -10
- package/src/core/file-lock.ts +22 -5
- package/src/core/folder-setup-planning.ts +2 -1
- package/src/core/memory-remember.ts +233 -122
- package/src/core/memory-types.ts +11 -0
- package/src/core/network-boundary-inventory.ts +16 -0
- package/src/core/request-receipts.ts +671 -0
- package/src/core/setup-receipt.ts +27 -20
- package/src/core/typed-metadata.ts +4 -0
- package/src/core/validation.ts +9 -2
- package/src/core/windows-private-path.ts +96 -0
- package/src/index.ts +11 -2
- package/src/ingestion/compiled-context.ts +15 -0
- package/src/ingestion/sync.ts +40 -8
- package/src/ingestion/walker.ts +5 -4
- package/src/llm/nodeLlamaCpp/simulator-install.ts +6 -2
- package/src/mcp/context.ts +8 -0
- package/src/mcp/http-egress.ts +15 -3
- package/src/mcp/http-transport.ts +2 -0
- package/src/mcp/retrieval-warnings.ts +24 -0
- package/src/mcp/tools/ask.ts +14 -1
- package/src/mcp/tools/capture.ts +87 -83
- package/src/mcp/tools/context.ts +46 -2
- package/src/mcp/tools/index.ts +119 -7
- package/src/mcp/tools/memory-remember.ts +7 -0
- package/src/mcp/tools/memory-shared.ts +7 -1
- package/src/mcp/tools/query.ts +3 -1
- package/src/mcp/tools/request-status.ts +73 -0
- package/src/mcp/tools/search.ts +3 -1
- package/src/mcp/tools/sessions.ts +208 -0
- package/src/mcp/tools/vsearch.ts +3 -1
- package/src/sdk/client.ts +252 -89
- package/src/sdk/index.ts +13 -0
- package/src/sdk/types.ts +82 -3
- package/src/serve/capture-service.ts +98 -32
- package/src/serve/compiled-context.ts +84 -0
- package/src/serve/config-sync.ts +3 -2
- package/src/serve/public/app.tsx +15 -1
- package/src/serve/public/components/CaptureModal.tsx +26 -8
- package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
- package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
- package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
- package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
- package/src/serve/public/components/sessions/api.ts +40 -0
- package/src/serve/public/components/sessions/snippet.tsx +53 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/hooks/use-api.ts +10 -2
- package/src/serve/public/lib/request-intent.ts +69 -0
- package/src/serve/public/lib/workspace-actions.ts +12 -1
- package/src/serve/public/lib/workspace-tabs.ts +4 -0
- package/src/serve/public/pages/CompiledContext.tsx +364 -0
- package/src/serve/public/pages/Dashboard.tsx +17 -0
- package/src/serve/public/pages/DocView.tsx +15 -1
- package/src/serve/public/pages/DocumentEditor.tsx +139 -96
- package/src/serve/public/pages/Sessions.tsx +350 -0
- package/src/serve/resident-runtime.ts +43 -3
- package/src/serve/routes/api.ts +476 -147
- package/src/serve/routes/sessions.ts +766 -0
- package/src/serve/security.ts +9 -0
- package/src/serve/server.ts +248 -3
- package/src/serve/session-automation.ts +146 -0
- package/src/serve/spa-production-build.ts +6 -5
- package/src/sessions/archive.ts +348 -0
- package/src/sessions/automation-state.ts +444 -0
- package/src/sessions/automation-status.ts +239 -0
- package/src/sessions/automation.ts +1169 -0
- package/src/sessions/binding.ts +105 -0
- package/src/sessions/claude-hook.ts +240 -0
- package/src/sessions/config.ts +176 -0
- package/src/sessions/format.ts +191 -0
- package/src/sessions/import-child-env.ts +8 -0
- package/src/sessions/import-child.ts +152 -0
- package/src/sessions/parsers/claude-code.ts +259 -0
- package/src/sessions/parsers/codex.ts +303 -0
- package/src/sessions/parsers/hermes.ts +248 -0
- package/src/sessions/parsers/openclaw.ts +496 -0
- package/src/sessions/parsers/shared.ts +184 -0
- package/src/sessions/sanitize.ts +222 -0
- package/src/sessions/service.ts +1533 -0
- package/src/sessions/setup.ts +477 -0
- package/src/sessions/sources.ts +518 -0
- package/src/sessions/state.ts +118 -0
- package/src/sessions/types.ts +457 -0
- package/src/store/sqlite/adapter.ts +68 -16
- package/src/store/sqlite/scoped-index.ts +9 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.4.0.zip.sha256 +0 -1
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session source roots: discovery candidates, unit enumeration, structural
|
|
3
|
+
* format detection and path safety.
|
|
4
|
+
*
|
|
5
|
+
* A source root is walked without following symlinks that leave it; the
|
|
6
|
+
* archive root and GNO's own config/data/cache directories are never read
|
|
7
|
+
* (no recursive ingestion). Units are exposed with a safe locator (a
|
|
8
|
+
* basename-level path inside the root) so receipts never carry host paths.
|
|
9
|
+
*
|
|
10
|
+
* @module src/sessions/sources
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Database } from "bun:sqlite";
|
|
14
|
+
|
|
15
|
+
// Configures the platform SQLite before any Database opens (macOS).
|
|
16
|
+
import "../store/sqlite/setup";
|
|
17
|
+
// node:fs: permission constants for access(); no Bun equivalent.
|
|
18
|
+
import { constants } from "node:fs";
|
|
19
|
+
// node:fs/promises: access/readdir/lstat/realpath/stat have no Bun equivalents.
|
|
20
|
+
import {
|
|
21
|
+
access,
|
|
22
|
+
lstat,
|
|
23
|
+
readdir,
|
|
24
|
+
readlink,
|
|
25
|
+
realpath,
|
|
26
|
+
stat,
|
|
27
|
+
} from "node:fs/promises";
|
|
28
|
+
// node:os homedir: no Bun equivalent.
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
// node:path: no Bun path utilities.
|
|
31
|
+
import nodePath, {
|
|
32
|
+
basename,
|
|
33
|
+
dirname,
|
|
34
|
+
isAbsolute,
|
|
35
|
+
join,
|
|
36
|
+
relative,
|
|
37
|
+
resolve,
|
|
38
|
+
sep,
|
|
39
|
+
} from "node:path";
|
|
40
|
+
|
|
41
|
+
import type { ParseUnitResult, SessionHarness } from "./types";
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
CLAUDE_CODE_PARSER,
|
|
45
|
+
parseClaudeCodeSession,
|
|
46
|
+
} from "./parsers/claude-code";
|
|
47
|
+
import { CODEX_PARSER, parseCodexRollout } from "./parsers/codex";
|
|
48
|
+
import {
|
|
49
|
+
HERMES_PARSER,
|
|
50
|
+
isHermesDatabase,
|
|
51
|
+
parseHermesDatabase,
|
|
52
|
+
} from "./parsers/hermes";
|
|
53
|
+
import {
|
|
54
|
+
isOpenClawDatabase,
|
|
55
|
+
OPENCLAW_PARSER,
|
|
56
|
+
parseOpenClawDatabase,
|
|
57
|
+
parseOpenClawJsonl,
|
|
58
|
+
} from "./parsers/openclaw";
|
|
59
|
+
import { isRecord, readJsonlRecords } from "./parsers/shared";
|
|
60
|
+
import { emptyDiagnostics, SESSION_LIMITS, SessionsError } from "./types";
|
|
61
|
+
|
|
62
|
+
export interface SessionUnit {
|
|
63
|
+
harness: SessionHarness;
|
|
64
|
+
/** Canonical absolute path of the file or database. */
|
|
65
|
+
path: string;
|
|
66
|
+
/** Safe locator relative to the source root. */
|
|
67
|
+
locator: string;
|
|
68
|
+
storage: "jsonl" | "sqlite";
|
|
69
|
+
size: number;
|
|
70
|
+
mtimeMs: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Part of a source that could not be read. A directory is reported without
|
|
75
|
+
* a locator: its name can be a host path (Claude Code encodes working
|
|
76
|
+
* directories), so only its reason is exposed.
|
|
77
|
+
*/
|
|
78
|
+
export interface UnreadableEntry {
|
|
79
|
+
locator: string | null;
|
|
80
|
+
reason: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Stable, path-free reason for a filesystem read failure. */
|
|
84
|
+
export function readFailureReason(error: unknown): string {
|
|
85
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
86
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
87
|
+
if (code === "ENOENT" || /ENOENT|no such file/i.test(message)) {
|
|
88
|
+
return "source_missing";
|
|
89
|
+
}
|
|
90
|
+
if (
|
|
91
|
+
code === "EACCES" ||
|
|
92
|
+
code === "EPERM" ||
|
|
93
|
+
/EACCES|EPERM|permission/i.test(message)
|
|
94
|
+
) {
|
|
95
|
+
return "permission_denied";
|
|
96
|
+
}
|
|
97
|
+
return "read_failed";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Whether an existing source root can be read now: a directory must be
|
|
102
|
+
* listable and a file readable. A missing root is not readable either.
|
|
103
|
+
*/
|
|
104
|
+
export async function isReadableRoot(root: string): Promise<boolean> {
|
|
105
|
+
try {
|
|
106
|
+
const info = await stat(root);
|
|
107
|
+
await access(
|
|
108
|
+
root,
|
|
109
|
+
info.isDirectory() ? constants.R_OK | constants.X_OK : constants.R_OK
|
|
110
|
+
);
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** True when `child` equals `parent` or lies inside it (canonical paths). */
|
|
118
|
+
export function isWithin(parent: string, child: string): boolean {
|
|
119
|
+
if (parent === child) return true;
|
|
120
|
+
const rel = relative(parent, child);
|
|
121
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Canonicalize an existing path; returns null when it cannot be resolved. */
|
|
125
|
+
export async function canonicalPath(path: string): Promise<string | null> {
|
|
126
|
+
try {
|
|
127
|
+
return await realpath(path);
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Default host roots checked by discovery, per harness. */
|
|
134
|
+
export function defaultDiscoveryRoots(
|
|
135
|
+
env: NodeJS.ProcessEnv = process.env
|
|
136
|
+
): Array<{ harness: SessionHarness; path: string }> {
|
|
137
|
+
const home = env.HOME || homedir();
|
|
138
|
+
const roots: Array<{ harness: SessionHarness; path: string }> = [];
|
|
139
|
+
roots.push({
|
|
140
|
+
harness: "codex",
|
|
141
|
+
path: join(env.CODEX_HOME || join(home, ".codex"), "sessions"),
|
|
142
|
+
});
|
|
143
|
+
const claudeRoots = new Set([join(home, ".claude")]);
|
|
144
|
+
if (env.CLAUDE_CONFIG_DIR) claudeRoots.add(env.CLAUDE_CONFIG_DIR);
|
|
145
|
+
for (const root of claudeRoots) {
|
|
146
|
+
roots.push({ harness: "claude-code", path: join(root, "projects") });
|
|
147
|
+
}
|
|
148
|
+
roots.push({
|
|
149
|
+
harness: "openclaw",
|
|
150
|
+
path:
|
|
151
|
+
env.OPENCLAW_STATE_DIR || join(env.OPENCLAW_HOME || home, ".openclaw"),
|
|
152
|
+
});
|
|
153
|
+
roots.push({
|
|
154
|
+
harness: "hermes",
|
|
155
|
+
path: env.HERMES_HOME || join(home, ".hermes"),
|
|
156
|
+
});
|
|
157
|
+
return roots;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Directory listing, injectable so tests can simulate read failures. */
|
|
161
|
+
export type ReadDirectory = typeof readdir;
|
|
162
|
+
|
|
163
|
+
interface WalkOptions {
|
|
164
|
+
root: string;
|
|
165
|
+
readDirectory: ReadDirectory;
|
|
166
|
+
excluded: readonly string[];
|
|
167
|
+
maxDepth: number;
|
|
168
|
+
accept: (relPath: string, name: string) => boolean;
|
|
169
|
+
limit: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* List candidate files under `root`. A root that cannot be listed throws; a
|
|
174
|
+
* subdirectory that cannot be listed is reported in `unreadable` so callers
|
|
175
|
+
* never mistake unread data for "no changes".
|
|
176
|
+
*/
|
|
177
|
+
async function walk(options: WalkOptions): Promise<{
|
|
178
|
+
files: Array<{ path: string; relPath: string }>;
|
|
179
|
+
truncated: boolean;
|
|
180
|
+
unreadable: UnreadableEntry[];
|
|
181
|
+
}> {
|
|
182
|
+
const files: Array<{ path: string; relPath: string }> = [];
|
|
183
|
+
const unreadable: UnreadableEntry[] = [];
|
|
184
|
+
const queue: Array<{ dir: string; depth: number }> = [
|
|
185
|
+
{ dir: options.root, depth: 0 },
|
|
186
|
+
];
|
|
187
|
+
let truncated = false;
|
|
188
|
+
while (queue.length > 0) {
|
|
189
|
+
const next = queue.shift();
|
|
190
|
+
if (!next) break;
|
|
191
|
+
let entries;
|
|
192
|
+
try {
|
|
193
|
+
entries = await options.readDirectory(next.dir, { withFileTypes: true });
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (next.dir === options.root) throw error;
|
|
196
|
+
unreadable.push({ locator: null, reason: readFailureReason(error) });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
const full = join(next.dir, entry.name);
|
|
202
|
+
if (options.excluded.some((excluded) => isWithin(excluded, full))) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
// Symlinks are never followed: a link could leave the root.
|
|
206
|
+
if (entry.isSymbolicLink()) continue;
|
|
207
|
+
if (entry.isDirectory()) {
|
|
208
|
+
if (next.depth < options.maxDepth) {
|
|
209
|
+
queue.push({ dir: full, depth: next.depth + 1 });
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!entry.isFile()) continue;
|
|
214
|
+
const relPath = relative(options.root, full).split(sep).join("/");
|
|
215
|
+
if (!options.accept(relPath, entry.name)) continue;
|
|
216
|
+
if (files.length >= options.limit) {
|
|
217
|
+
truncated = true;
|
|
218
|
+
return { files, truncated, unreadable };
|
|
219
|
+
}
|
|
220
|
+
files.push({ path: full, relPath });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { files, truncated, unreadable };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const CLAUDE_MAIN = /^[^/]+\/[^/]+\.jsonl$/;
|
|
227
|
+
const CLAUDE_SUBAGENT = /^[^/]+\/[^/]+\/subagents\/agent-[^/]+\.jsonl$/;
|
|
228
|
+
const OPENCLAW_DB = /^agents\/[^/]+\/agent\/openclaw-agent\.sqlite$/;
|
|
229
|
+
const OPENCLAW_JSONL = /^agents\/[^/]+\/sessions\/[^/]+\.jsonl$/;
|
|
230
|
+
const HERMES_DB = /^(?:state\.db|profiles\/[^/]+\/state\.db)$/;
|
|
231
|
+
|
|
232
|
+
function unitLocator(harness: SessionHarness, relPath: string): string {
|
|
233
|
+
switch (harness) {
|
|
234
|
+
case "codex":
|
|
235
|
+
return basename(relPath);
|
|
236
|
+
case "claude-code": {
|
|
237
|
+
const parts = relPath.split("/");
|
|
238
|
+
// Drop the encoded working-directory folder: it is a host path.
|
|
239
|
+
return parts.slice(1).join("/").replace("/subagents/", "/");
|
|
240
|
+
}
|
|
241
|
+
case "openclaw":
|
|
242
|
+
return relPath.replace(/^agents\//, "").replace("/agent/", "/");
|
|
243
|
+
case "hermes":
|
|
244
|
+
return relPath;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Enumerate the units of one source root. A root that is itself a file is a
|
|
250
|
+
* single unit. Paths outside the root (via symlinks) and excluded
|
|
251
|
+
* directories are skipped. A root that cannot be read throws; unreadable
|
|
252
|
+
* subdirectories and files are returned in `unreadable`.
|
|
253
|
+
*/
|
|
254
|
+
export async function enumerateUnits(options: {
|
|
255
|
+
harness: SessionHarness;
|
|
256
|
+
root: string;
|
|
257
|
+
excluded: readonly string[];
|
|
258
|
+
limit?: number;
|
|
259
|
+
readDirectory?: ReadDirectory;
|
|
260
|
+
}): Promise<{
|
|
261
|
+
units: SessionUnit[];
|
|
262
|
+
truncated: boolean;
|
|
263
|
+
unreadable: UnreadableEntry[];
|
|
264
|
+
}> {
|
|
265
|
+
const limit = options.limit ?? SESSION_LIMITS.maxUnitsPerSource;
|
|
266
|
+
const info = await stat(options.root);
|
|
267
|
+
if (info.isFile()) {
|
|
268
|
+
await access(options.root, constants.R_OK);
|
|
269
|
+
return {
|
|
270
|
+
units: [
|
|
271
|
+
{
|
|
272
|
+
harness: options.harness,
|
|
273
|
+
path: options.root,
|
|
274
|
+
locator: basename(options.root),
|
|
275
|
+
storage: /\.(?:sqlite|db)$/i.test(options.root) ? "sqlite" : "jsonl",
|
|
276
|
+
size: info.size,
|
|
277
|
+
mtimeMs: info.mtimeMs,
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
truncated: false,
|
|
281
|
+
unreadable: [],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const accept = (relPath: string, name: string): boolean => {
|
|
285
|
+
switch (options.harness) {
|
|
286
|
+
case "codex":
|
|
287
|
+
return name.startsWith("rollout-") && name.endsWith(".jsonl");
|
|
288
|
+
case "claude-code":
|
|
289
|
+
return CLAUDE_MAIN.test(relPath) || CLAUDE_SUBAGENT.test(relPath);
|
|
290
|
+
case "openclaw":
|
|
291
|
+
return OPENCLAW_DB.test(relPath) || OPENCLAW_JSONL.test(relPath);
|
|
292
|
+
case "hermes":
|
|
293
|
+
return HERMES_DB.test(relPath);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
const { files, truncated, unreadable } = await walk({
|
|
297
|
+
root: options.root,
|
|
298
|
+
readDirectory: options.readDirectory ?? readdir,
|
|
299
|
+
excluded: options.excluded,
|
|
300
|
+
maxDepth: options.harness === "codex" ? 4 : 3,
|
|
301
|
+
accept,
|
|
302
|
+
limit,
|
|
303
|
+
});
|
|
304
|
+
const units: SessionUnit[] = [];
|
|
305
|
+
for (const file of files) {
|
|
306
|
+
const locator = unitLocator(options.harness, file.relPath);
|
|
307
|
+
let info2;
|
|
308
|
+
try {
|
|
309
|
+
info2 = await lstat(file.path);
|
|
310
|
+
await access(file.path, constants.R_OK);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
unreadable.push({ locator, reason: readFailureReason(error) });
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
units.push({
|
|
316
|
+
harness: options.harness,
|
|
317
|
+
path: file.path,
|
|
318
|
+
locator,
|
|
319
|
+
storage: /\.(?:sqlite|db)$/.test(file.relPath) ? "sqlite" : "jsonl",
|
|
320
|
+
size: info2.size,
|
|
321
|
+
mtimeMs: info2.mtimeMs,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return { units, truncated, unreadable };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
328
|
+
// Structural detection and dispatch
|
|
329
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
const DETECT_RECORDS = 50;
|
|
332
|
+
|
|
333
|
+
/** Detect a unit's harness from its structure, never from substrings. */
|
|
334
|
+
export async function detectHarness(
|
|
335
|
+
path: string
|
|
336
|
+
): Promise<SessionHarness | null> {
|
|
337
|
+
if (/\.(?:sqlite|db)$/i.test(path)) {
|
|
338
|
+
try {
|
|
339
|
+
const db = new Database(path, { readonly: true });
|
|
340
|
+
try {
|
|
341
|
+
if (isOpenClawDatabase(db)) return "openclaw";
|
|
342
|
+
if (isHermesDatabase(db)) return "hermes";
|
|
343
|
+
} finally {
|
|
344
|
+
db.close();
|
|
345
|
+
}
|
|
346
|
+
} catch {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
let seen = 0;
|
|
352
|
+
for await (const { record } of readJsonlRecords(path, emptyDiagnostics())) {
|
|
353
|
+
seen += 1;
|
|
354
|
+
if (record.type === "session_meta" && isRecord(record.payload)) {
|
|
355
|
+
return "codex";
|
|
356
|
+
}
|
|
357
|
+
if (
|
|
358
|
+
record.type === "session" &&
|
|
359
|
+
typeof record.id === "string" &&
|
|
360
|
+
"version" in record
|
|
361
|
+
) {
|
|
362
|
+
return "openclaw";
|
|
363
|
+
}
|
|
364
|
+
if (
|
|
365
|
+
typeof record.sessionId === "string" &&
|
|
366
|
+
typeof record.uuid === "string" &&
|
|
367
|
+
(record.type === "user" || record.type === "assistant")
|
|
368
|
+
) {
|
|
369
|
+
return "claude-code";
|
|
370
|
+
}
|
|
371
|
+
if (seen >= DETECT_RECORDS) break;
|
|
372
|
+
}
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Harness of an explicit path: a file is detected by structure; a directory
|
|
378
|
+
* by the first unit each harness layout finds there, confirmed structurally.
|
|
379
|
+
*/
|
|
380
|
+
export async function detectRootHarness(
|
|
381
|
+
root: string,
|
|
382
|
+
excluded: readonly string[],
|
|
383
|
+
readDirectory?: ReadDirectory
|
|
384
|
+
): Promise<SessionHarness | null> {
|
|
385
|
+
const info = await stat(root);
|
|
386
|
+
if (info.isFile()) return detectHarness(root);
|
|
387
|
+
for (const harness of SESSION_HARNESS_ORDER) {
|
|
388
|
+
const { units } = await enumerateUnits({
|
|
389
|
+
harness,
|
|
390
|
+
root,
|
|
391
|
+
excluded,
|
|
392
|
+
limit: 1,
|
|
393
|
+
readDirectory,
|
|
394
|
+
});
|
|
395
|
+
const first = units[0];
|
|
396
|
+
if (first && (await detectHarness(first.path)) === harness) return harness;
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const SESSION_HARNESS_ORDER: readonly SessionHarness[] = [
|
|
402
|
+
"codex",
|
|
403
|
+
"claude-code",
|
|
404
|
+
"openclaw",
|
|
405
|
+
"hermes",
|
|
406
|
+
];
|
|
407
|
+
|
|
408
|
+
/** Current parser identity per harness; a change forces a reparse. */
|
|
409
|
+
export const SESSION_PARSERS: Record<SessionHarness, string> = {
|
|
410
|
+
codex: CODEX_PARSER,
|
|
411
|
+
"claude-code": CLAUDE_CODE_PARSER,
|
|
412
|
+
openclaw: OPENCLAW_PARSER,
|
|
413
|
+
hermes: HERMES_PARSER,
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
export async function parseUnit(unit: SessionUnit): Promise<ParseUnitResult> {
|
|
417
|
+
switch (unit.harness) {
|
|
418
|
+
case "codex":
|
|
419
|
+
return parseCodexRollout(unit.path);
|
|
420
|
+
case "claude-code":
|
|
421
|
+
return parseClaudeCodeSession(unit.path);
|
|
422
|
+
case "openclaw":
|
|
423
|
+
return unit.storage === "sqlite"
|
|
424
|
+
? parseOpenClawDatabase(unit.path)
|
|
425
|
+
: parseOpenClawJsonl(unit.path);
|
|
426
|
+
case "hermes":
|
|
427
|
+
if (unit.storage !== "sqlite") {
|
|
428
|
+
throw new SessionsError(
|
|
429
|
+
"SESSIONS_UNSUPPORTED_FORMAT",
|
|
430
|
+
"Hermes sessions are read from state.db"
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
return parseHermesDatabase(unit.path);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* A filesystem or drive root (`/`, `C:\`, a UNC share root): never a session
|
|
439
|
+
* archive or source, since either would span the whole volume.
|
|
440
|
+
*/
|
|
441
|
+
/**
|
|
442
|
+
* True for a filesystem or drive root. A Windows drive root is recognised in
|
|
443
|
+
* every form a resolver may return: with or without its trailing separator,
|
|
444
|
+
* and with the `\\?\` device prefix.
|
|
445
|
+
* `api` defaults to the platform path module (tests pass `path.win32`).
|
|
446
|
+
*/
|
|
447
|
+
export function isFilesystemRoot(
|
|
448
|
+
path: string,
|
|
449
|
+
api: Pick<typeof nodePath, "dirname" | "resolve" | "sep"> = nodePath
|
|
450
|
+
): boolean {
|
|
451
|
+
let candidate = path;
|
|
452
|
+
if (api.sep === "\\") {
|
|
453
|
+
candidate = candidate.replace(/^\\\\\?\\(?!UNC\\)/i, "");
|
|
454
|
+
if (/^[a-z]:$/i.test(candidate)) candidate += "\\";
|
|
455
|
+
}
|
|
456
|
+
const absolute = api.resolve(candidate);
|
|
457
|
+
return api.dirname(absolute) === absolute;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Follow a chain of symlinks (or junctions) at `path` to its final target. */
|
|
461
|
+
async function linkTarget(path: string): Promise<string> {
|
|
462
|
+
const MAX_HOPS = 40;
|
|
463
|
+
let current = resolve(path);
|
|
464
|
+
for (let hop = 0; hop < MAX_HOPS; hop += 1) {
|
|
465
|
+
let info;
|
|
466
|
+
try {
|
|
467
|
+
info = await lstat(current);
|
|
468
|
+
} catch {
|
|
469
|
+
return current;
|
|
470
|
+
}
|
|
471
|
+
if (!info.isSymbolicLink()) return current;
|
|
472
|
+
current = resolve(dirname(current), await readlink(current));
|
|
473
|
+
}
|
|
474
|
+
return current;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Refuse a filesystem root named directly, through a link chain, or by its
|
|
479
|
+
* canonical path, before anything is created or read under it.
|
|
480
|
+
*/
|
|
481
|
+
export async function assertNotFilesystemRootAnyForm(
|
|
482
|
+
path: string,
|
|
483
|
+
what: string
|
|
484
|
+
): Promise<void> {
|
|
485
|
+
assertNotFilesystemRoot(path, what);
|
|
486
|
+
assertNotFilesystemRoot(await linkTarget(path), what);
|
|
487
|
+
const canonical = await canonicalPath(path);
|
|
488
|
+
if (canonical) assertNotFilesystemRoot(canonical, what);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Refuse a filesystem root; `what` names the path in the message. */
|
|
492
|
+
export function assertNotFilesystemRoot(path: string, what: string): void {
|
|
493
|
+
if (isFilesystemRoot(path)) {
|
|
494
|
+
throw new SessionsError(
|
|
495
|
+
"SESSIONS_UNSAFE_PATH",
|
|
496
|
+
`${what} cannot be a filesystem or drive root; choose a dedicated directory.`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Directories GNO itself owns, which a source may never include, and
|
|
503
|
+
* filesystem roots, which would walk the whole volume.
|
|
504
|
+
*/
|
|
505
|
+
export function assertSafeSourceRoot(
|
|
506
|
+
root: string,
|
|
507
|
+
protectedRoots: readonly string[]
|
|
508
|
+
): void {
|
|
509
|
+
assertNotFilesystemRoot(root, "A session source");
|
|
510
|
+
for (const protectedRoot of protectedRoots) {
|
|
511
|
+
if (isWithin(protectedRoot, root)) {
|
|
512
|
+
throw new SessionsError(
|
|
513
|
+
"SESSIONS_UNSAFE_PATH",
|
|
514
|
+
"Source path lies inside the session archive or a GNO config/data/cache directory; choose a harness session directory instead."
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import checkpoint state stored beside the archive.
|
|
3
|
+
*
|
|
4
|
+
* State lives in `<archiveRoot>/.gno-sessions/state.json`, outside every
|
|
5
|
+
* archive collection root, and never records host paths: units are keyed by
|
|
6
|
+
* a hash of source ID and safe locator. A unit
|
|
7
|
+
* advances to `complete` only after a clean, fully read run; partial reads
|
|
8
|
+
* stay `incomplete` and are retried. The archive files themselves are the
|
|
9
|
+
* source of truth for idempotency: a changed unit is re-rendered and
|
|
10
|
+
* compared with the archived bytes.
|
|
11
|
+
*
|
|
12
|
+
* @module src/sessions/state
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// node:fs/promises mkdir/stat: filesystem structure ops without Bun equivalents.
|
|
16
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
17
|
+
// node:path: no Bun path utilities.
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
import type { SessionUnit } from "./sources";
|
|
21
|
+
import type { SessionHarness } from "./types";
|
|
22
|
+
|
|
23
|
+
import { hashRecordValue } from "../converters/adapters/shared/record-utils";
|
|
24
|
+
import { atomicWrite } from "../core/file-ops";
|
|
25
|
+
import { SESSION_STATE_DIRNAME } from "./archive";
|
|
26
|
+
|
|
27
|
+
export type UnitStatus = "complete" | "incomplete" | "failed" | "unsupported";
|
|
28
|
+
|
|
29
|
+
export interface UnitState {
|
|
30
|
+
locator: string;
|
|
31
|
+
/** Harness detected for the unit (path imports may differ per unit). */
|
|
32
|
+
harness?: SessionHarness;
|
|
33
|
+
fingerprint: string;
|
|
34
|
+
status: UnitStatus;
|
|
35
|
+
parser: string | null;
|
|
36
|
+
/** Redaction policy stamp the archive was produced with. */
|
|
37
|
+
redaction: string;
|
|
38
|
+
/** Destination settings (collection + project mappings) it was routed with. */
|
|
39
|
+
destinations: string;
|
|
40
|
+
format: number;
|
|
41
|
+
threads: Array<{ collection: string; relPath: string }>;
|
|
42
|
+
updatedAt: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SourceState {
|
|
46
|
+
lastImportAt: string | null;
|
|
47
|
+
units: Record<string, UnitState>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SessionsState {
|
|
51
|
+
version: 1;
|
|
52
|
+
sources: Record<string, SourceState>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const stateDir = (archiveRoot: string): string =>
|
|
56
|
+
join(archiveRoot, SESSION_STATE_DIRNAME);
|
|
57
|
+
|
|
58
|
+
/** Archive files that could not be rescanned; outside every collection. */
|
|
59
|
+
export const withheldPath = (
|
|
60
|
+
archiveRoot: string,
|
|
61
|
+
collection: string,
|
|
62
|
+
relPath: string
|
|
63
|
+
): string => join(stateDir(archiveRoot), "withheld", collection, relPath);
|
|
64
|
+
|
|
65
|
+
const statePath = (archiveRoot: string): string =>
|
|
66
|
+
join(stateDir(archiveRoot), "state.json");
|
|
67
|
+
|
|
68
|
+
export const importLockPath = (archiveRoot: string): string =>
|
|
69
|
+
join(stateDir(archiveRoot), "import.lock");
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Unit identity inside a source: the safe locator, not the host path, so a
|
|
73
|
+
* source re-registered at a new path or a file moved within its root keeps
|
|
74
|
+
* its archive files.
|
|
75
|
+
*/
|
|
76
|
+
export const unitKey = (sourceId: string, locator: string): string =>
|
|
77
|
+
hashRecordValue("gno-session-unit-v2", `${sourceId}\0${locator}`).slice(
|
|
78
|
+
0,
|
|
79
|
+
32
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
export async function loadState(archiveRoot: string): Promise<SessionsState> {
|
|
83
|
+
const file = Bun.file(statePath(archiveRoot));
|
|
84
|
+
if (!(await file.exists())) return { version: 1, sources: {} };
|
|
85
|
+
try {
|
|
86
|
+
const parsed = (await file.json()) as SessionsState;
|
|
87
|
+
if (parsed?.version === 1 && parsed.sources) return parsed;
|
|
88
|
+
} catch {
|
|
89
|
+
// A corrupt state file only costs a full re-render; archives stay intact.
|
|
90
|
+
}
|
|
91
|
+
return { version: 1, sources: {} };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function saveState(
|
|
95
|
+
archiveRoot: string,
|
|
96
|
+
state: SessionsState
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
await mkdir(stateDir(archiveRoot), { recursive: true });
|
|
99
|
+
await atomicWrite(statePath(archiveRoot), `${JSON.stringify(state)}\n`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Cheap change detector. SQLite sources include their WAL, where a live
|
|
104
|
+
* writer appends without touching the main file.
|
|
105
|
+
*/
|
|
106
|
+
export async function unitFingerprint(unit: SessionUnit): Promise<string> {
|
|
107
|
+
const main = await stat(unit.path);
|
|
108
|
+
let fingerprint = `${main.size}:${Math.trunc(main.mtimeMs)}`;
|
|
109
|
+
if (unit.storage === "sqlite") {
|
|
110
|
+
try {
|
|
111
|
+
const wal = await stat(`${unit.path}-wal`);
|
|
112
|
+
fingerprint += `:${wal.size}:${Math.trunc(wal.mtimeMs)}`;
|
|
113
|
+
} catch {
|
|
114
|
+
fingerprint += ":-";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return fingerprint;
|
|
118
|
+
}
|