@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,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text renderings of session receipts and status, shared by every text
|
|
3
|
+
* surface (CLI terminal output, MCP text content).
|
|
4
|
+
*
|
|
5
|
+
* @module src/sessions/format
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
SessionAutomationRunResult,
|
|
10
|
+
SessionAutomationStatus,
|
|
11
|
+
SessionImportReceipt,
|
|
12
|
+
SessionsStatus,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
/** Plain-text import receipt shared by the CLI and MCP. */
|
|
16
|
+
export function formatImportReceiptText(receipt: SessionImportReceipt): string {
|
|
17
|
+
const c = receipt.counts;
|
|
18
|
+
const lines = [
|
|
19
|
+
`Session import ${receipt.dryRun ? "(dry run, nothing written) " : ""}${receipt.status}`,
|
|
20
|
+
`threads: ${c.imported} imported, ${c.updated} updated, ${c.unchanged} unchanged, ${c.skippedPolicy} skipped by policy`,
|
|
21
|
+
`units: ${c.incomplete} incomplete, ${c.failed} failed, ${c.unsupported} unsupported${receipt.deferredUnits > 0 ? `, ${receipt.deferredUnits} deferred by limit` : ""}`,
|
|
22
|
+
`turns: ${receipt.turns.human} human, ${receipt.turns.assistant} assistant, ${receipt.turns.redactions} redactions, ${receipt.turns.injectedSkipped} injected skipped`,
|
|
23
|
+
`lexical: ${receipt.lexical.status}${receipt.lexical.error ? ` (${receipt.lexical.error})` : ""}; embedding backlog: ${receipt.embedding.backlog ?? "n/a"}`,
|
|
24
|
+
];
|
|
25
|
+
const notable = receipt.units.filter(
|
|
26
|
+
(unit) =>
|
|
27
|
+
unit.outcome !== "unchanged" &&
|
|
28
|
+
unit.outcome !== "imported" &&
|
|
29
|
+
unit.outcome !== "updated"
|
|
30
|
+
);
|
|
31
|
+
for (const unit of notable.slice(0, 20)) {
|
|
32
|
+
lines.push(
|
|
33
|
+
`- ${unit.sourceId} ${unit.locator}: ${unit.outcome}${unit.reason ? ` (${unit.reason})` : ""}`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
for (const warning of receipt.warnings) lines.push(`warning: ${warning}`);
|
|
37
|
+
return lines.join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Plain-text archive status shared by the CLI and MCP. */
|
|
41
|
+
export function formatStatusText(status: SessionsStatus): string {
|
|
42
|
+
const lines = [`Session archive (index ${status.index})`];
|
|
43
|
+
for (const collection of status.collections) {
|
|
44
|
+
lines.push(
|
|
45
|
+
`- collection ${collection.name}: ${collection.threads} threads`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
for (const source of status.sources) {
|
|
49
|
+
lines.push(
|
|
50
|
+
`- source ${source.id} (${source.harness} -> ${source.collection}): ${source.available ? "available" : "UNAVAILABLE"}; units ${source.units.complete} complete, ${source.units.incomplete} incomplete, ${source.units.failed} failed, ${source.units.pending} pending; last import ${source.lastImportAt ?? "never"}`
|
|
51
|
+
);
|
|
52
|
+
if (source.sourceUnavailable > 0) {
|
|
53
|
+
lines.push(
|
|
54
|
+
` ${source.sourceUnavailable} archived units no longer have a source (archive retained${source.staleParser > 0 ? `, ${source.staleParser} from an older parser` : ""})`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
lines.push(...formatAutomationLines(status.automation));
|
|
59
|
+
for (const warning of status.warnings) lines.push(`warning: ${warning}`);
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* When a failed run is retried. Only a running daemon retries, so without one
|
|
65
|
+
* no time is promised, and a due time already passed is not shown as upcoming.
|
|
66
|
+
*/
|
|
67
|
+
export function retryWording(
|
|
68
|
+
retryAt: string | null,
|
|
69
|
+
daemonRunning: boolean,
|
|
70
|
+
now: Date,
|
|
71
|
+
at: (iso: string | null) => string
|
|
72
|
+
): string | null {
|
|
73
|
+
if (!retryAt) return null;
|
|
74
|
+
if (!daemonRunning) {
|
|
75
|
+
return "retried when the daemon runs, or run the profile again yourself";
|
|
76
|
+
}
|
|
77
|
+
return Date.parse(retryAt) <= now.getTime()
|
|
78
|
+
? "retry due on the daemon's next tick"
|
|
79
|
+
: `retried automatically at ${at(retryAt)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** UTC instant shown in the reader's timezone, e.g. `2026-09-24 15:04:05`. */
|
|
83
|
+
export function formatLocalTime(iso: string | null, timezone: string): string {
|
|
84
|
+
if (!iso) return "never";
|
|
85
|
+
const date = new Date(iso);
|
|
86
|
+
if (Number.isNaN(date.getTime())) return iso;
|
|
87
|
+
return date.toLocaleString("sv-SE", { timeZone: timezone });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function daemonLine(
|
|
91
|
+
automation: SessionAutomationStatus,
|
|
92
|
+
at: (iso: string | null) => string
|
|
93
|
+
): string {
|
|
94
|
+
if (automation.daemon.state === "running") {
|
|
95
|
+
return `daemon running (heartbeat ${at(automation.daemon.heartbeatAt)})`;
|
|
96
|
+
}
|
|
97
|
+
if (automation.daemon.state === "stale") {
|
|
98
|
+
return `daemon stale (last heartbeat ${at(automation.daemon.heartbeatAt)})`;
|
|
99
|
+
}
|
|
100
|
+
return "not running: no daemon";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatAutomationLines(automation: SessionAutomationStatus): string[] {
|
|
104
|
+
if (automation.profiles.length === 0) {
|
|
105
|
+
return ["Automation: off (manual imports only)"];
|
|
106
|
+
}
|
|
107
|
+
const at = (iso: string | null) => formatLocalTime(iso, automation.timezone);
|
|
108
|
+
const lines = [
|
|
109
|
+
`Automation (${daemonLine(automation, at)}; times in ${automation.timezone})`,
|
|
110
|
+
];
|
|
111
|
+
for (const profile of automation.profiles) {
|
|
112
|
+
const hookMissing =
|
|
113
|
+
profile.hook?.enabled && profile.hook.installed === false
|
|
114
|
+
? " (entry missing)"
|
|
115
|
+
: "";
|
|
116
|
+
const hook = profile.hook
|
|
117
|
+
? `hook ${profile.hook.harness} ${profile.hook.enabled ? "on" : "off"}${hookMissing}`
|
|
118
|
+
: "hook off";
|
|
119
|
+
let schedule = "schedule off";
|
|
120
|
+
if (profile.schedule?.enabled) {
|
|
121
|
+
let next = "not running: no daemon";
|
|
122
|
+
if (profile.schedule.nextDueAt) {
|
|
123
|
+
next = `next ${at(profile.schedule.nextDueAt)}`;
|
|
124
|
+
} else if (automation.daemon.state === "running") {
|
|
125
|
+
next = "due time set on the daemon's next tick";
|
|
126
|
+
}
|
|
127
|
+
schedule = `schedule every ${profile.schedule.cadence}, ${next}`;
|
|
128
|
+
}
|
|
129
|
+
lines.push(
|
|
130
|
+
`- profile ${profile.id}: ${profile.state}; sources ${profile.sources.join(", ")} -> ${profile.collections.join(", ") || "(none)"}; ${hook}; ${schedule}`
|
|
131
|
+
);
|
|
132
|
+
// A failure awaiting correction is shown by state and action, not as
|
|
133
|
+
// queued work.
|
|
134
|
+
if (profile.pending && profile.state !== "failed") {
|
|
135
|
+
const wording = retryWording(
|
|
136
|
+
profile.retryAt,
|
|
137
|
+
automation.daemon.state === "running",
|
|
138
|
+
new Date(),
|
|
139
|
+
at
|
|
140
|
+
);
|
|
141
|
+
const retry = wording ? `; ${wording}` : "";
|
|
142
|
+
lines.push(
|
|
143
|
+
` pending since ${at(profile.pending.since)} (${profile.pending.triggers.join(", ") || "continuation"})${retry}`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (profile.running) {
|
|
147
|
+
lines.push(` running since ${at(profile.running.startedAt)}`);
|
|
148
|
+
}
|
|
149
|
+
if (profile.lastRun) {
|
|
150
|
+
const run = profile.lastRun;
|
|
151
|
+
lines.push(
|
|
152
|
+
` last run ${at(run.finishedAt)}: ${run.outcome}${run.reason ? ` (${run.reason})` : ""}; threads ${run.threads.imported} imported, ${run.threads.updated} updated; units ${run.units.incomplete} incomplete, ${run.units.failed} failed, ${run.units.deferred} deferred`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
lines.push(` last success ${at(profile.lastSuccessAt)}`);
|
|
156
|
+
if (profile.recovery) lines.push(` action: ${profile.recovery}`);
|
|
157
|
+
}
|
|
158
|
+
return lines;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** What follows a run: remaining work, or the next step after a failure. */
|
|
162
|
+
const RETRYABLE_REASONS = new Set(["busy", "runtime_error", "interrupted"]);
|
|
163
|
+
|
|
164
|
+
export function runTail(result: SessionAutomationRunResult): string {
|
|
165
|
+
if (result.outcome === "failed") {
|
|
166
|
+
return RETRYABLE_REASONS.has(result.reason ?? "")
|
|
167
|
+
? "; a running daemon retries it, or run it again once the archive is free (see gno sessions status)"
|
|
168
|
+
: "; see gno sessions status for the recovery action";
|
|
169
|
+
}
|
|
170
|
+
return result.pending ? "; more work is pending" : "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Plain-text automation run result shared by the CLI and MCP. */
|
|
174
|
+
export function formatAutomationRunText(
|
|
175
|
+
result: SessionAutomationRunResult
|
|
176
|
+
): string {
|
|
177
|
+
if (!result.ran) {
|
|
178
|
+
return `Automation profile ${result.profileId}: not started (${result.reason ?? "unknown"})${result.pending ? "; work stays pending" : ""}`;
|
|
179
|
+
}
|
|
180
|
+
const lines = [
|
|
181
|
+
`Automation profile ${result.profileId}: ${result.outcome}${result.reason ? ` (${result.reason})` : ""}${runTail(result)}`,
|
|
182
|
+
];
|
|
183
|
+
for (const receipt of result.receipts) {
|
|
184
|
+
const deferred =
|
|
185
|
+
receipt.deferredUnits > 0 ? `, ${receipt.deferredUnits} deferred` : "";
|
|
186
|
+
lines.push(
|
|
187
|
+
`- ${receipt.sourceIds.join(", ")}: ${receipt.status}; ${receipt.counts.imported} imported, ${receipt.counts.updated} updated, ${receipt.counts.unchanged} unchanged; ${receipt.counts.incomplete} incomplete, ${receipt.counts.failed} failed${deferred}`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return lines.join("\n");
|
|
191
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment flag a compiled executable sets when it re-runs itself as the
|
|
3
|
+
* session import child. Dependency-free so the CLI entry can check it
|
|
4
|
+
* without loading the sessions service.
|
|
5
|
+
*
|
|
6
|
+
* @module src/sessions/import-child-env
|
|
7
|
+
*/
|
|
8
|
+
export const IMPORT_CHILD_ENV = "GNO_INTERNAL_SESSION_IMPORT_CHILD";
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a registered-source import in a child process.
|
|
3
|
+
*
|
|
4
|
+
* Parsing, sanitizing and above all the index sync of thousands of archive
|
|
5
|
+
* records are CPU-bound and synchronous (each archive file syncs in one
|
|
6
|
+
* SQLite transaction), so a long-lived server that imported in-process would
|
|
7
|
+
* stop answering every request until the import finished. `gno serve` runs
|
|
8
|
+
* the same `SessionsService.import` here instead, in a child with its own
|
|
9
|
+
* store connection: the archive import lock, receipts and error codes are
|
|
10
|
+
* unchanged.
|
|
11
|
+
*
|
|
12
|
+
* The child reads one request from stdin and writes one JSON result line to
|
|
13
|
+
* stdout. The request carries the caller's loaded config: the child opens
|
|
14
|
+
* the index without syncing any config into it and imports with exactly the
|
|
15
|
+
* config the server holds. From source the child runs this module; a
|
|
16
|
+
* compiled executable re-runs itself with `IMPORT_CHILD_ENV` set, which the
|
|
17
|
+
* CLI entry routes here before any command runs.
|
|
18
|
+
*
|
|
19
|
+
* @module src/sessions/import-child
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Config } from "../config/types";
|
|
23
|
+
import type { SessionImportReceipt } from "./types";
|
|
24
|
+
|
|
25
|
+
import { getIndexDbPath } from "../app/constants";
|
|
26
|
+
import { isStandaloneExecutable } from "../serve/spa-production-build";
|
|
27
|
+
import { SqliteAdapter } from "../store/sqlite/adapter";
|
|
28
|
+
import { assertSessionBinding } from "./binding";
|
|
29
|
+
import { IMPORT_CHILD_ENV } from "./import-child-env";
|
|
30
|
+
import { SessionsService } from "./service";
|
|
31
|
+
import { type SessionsErrorCode, SessionsError } from "./types";
|
|
32
|
+
|
|
33
|
+
export interface ImportChildRequest {
|
|
34
|
+
config: Config;
|
|
35
|
+
configPath: string;
|
|
36
|
+
indexName: string;
|
|
37
|
+
sourceId: string;
|
|
38
|
+
dryRun: boolean;
|
|
39
|
+
limit?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type ImportChildResult =
|
|
43
|
+
| { ok: true; receipt: SessionImportReceipt }
|
|
44
|
+
| { ok: false; code: SessionsErrorCode | null; message: string };
|
|
45
|
+
|
|
46
|
+
async function importInThisProcess(
|
|
47
|
+
request: ImportChildRequest
|
|
48
|
+
): Promise<SessionImportReceipt> {
|
|
49
|
+
const input = {
|
|
50
|
+
sourceId: request.sourceId,
|
|
51
|
+
dryRun: request.dryRun,
|
|
52
|
+
limit: request.limit,
|
|
53
|
+
};
|
|
54
|
+
if (request.dryRun) {
|
|
55
|
+
return new SessionsService({
|
|
56
|
+
config: request.config,
|
|
57
|
+
configPath: request.configPath,
|
|
58
|
+
indexName: request.indexName,
|
|
59
|
+
}).import(input, { allowPaths: false });
|
|
60
|
+
}
|
|
61
|
+
const dbPath = getIndexDbPath(request.indexName);
|
|
62
|
+
await assertSessionBinding({
|
|
63
|
+
config: request.config,
|
|
64
|
+
configPath: request.configPath,
|
|
65
|
+
indexName: request.indexName,
|
|
66
|
+
dbPath,
|
|
67
|
+
});
|
|
68
|
+
// The server already projected this config into the index; the child only
|
|
69
|
+
// opens it (no collection/context sync).
|
|
70
|
+
const store = new SqliteAdapter();
|
|
71
|
+
store.setConfigPath(request.configPath);
|
|
72
|
+
const opened = await store.open(
|
|
73
|
+
dbPath,
|
|
74
|
+
request.config.ftsTokenizer,
|
|
75
|
+
request.config.busyTimeoutMs
|
|
76
|
+
);
|
|
77
|
+
if (!opened.ok) throw new Error(opened.error.message);
|
|
78
|
+
try {
|
|
79
|
+
return await new SessionsService({
|
|
80
|
+
config: request.config,
|
|
81
|
+
configPath: request.configPath,
|
|
82
|
+
indexName: request.indexName,
|
|
83
|
+
store,
|
|
84
|
+
}).import(input, { allowPaths: false });
|
|
85
|
+
} finally {
|
|
86
|
+
await store.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Import a registered source without blocking the caller's event loop.
|
|
92
|
+
* Throws a `SessionsError` for typed failures and a plain `Error` otherwise.
|
|
93
|
+
*/
|
|
94
|
+
export async function importInChildProcess(
|
|
95
|
+
request: ImportChildRequest
|
|
96
|
+
): Promise<SessionImportReceipt> {
|
|
97
|
+
// A compiled executable cannot run an external TS entry: it re-runs itself.
|
|
98
|
+
// Shared check: covers the POSIX `/$bunfs/` and Windows `B:/~BUN/` roots.
|
|
99
|
+
const compiled = isStandaloneExecutable();
|
|
100
|
+
const child = Bun.spawn({
|
|
101
|
+
cmd: compiled ? [process.execPath] : [process.execPath, import.meta.path],
|
|
102
|
+
env: compiled ? { ...process.env, [IMPORT_CHILD_ENV]: "1" } : process.env,
|
|
103
|
+
stdin: new Blob([JSON.stringify(request)]),
|
|
104
|
+
stdout: "pipe",
|
|
105
|
+
stderr: "pipe",
|
|
106
|
+
});
|
|
107
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
108
|
+
new Response(child.stdout).text(),
|
|
109
|
+
new Response(child.stderr).text(),
|
|
110
|
+
child.exited,
|
|
111
|
+
]);
|
|
112
|
+
const line = stdout.trim().split("\n").at(-1) ?? "";
|
|
113
|
+
let result: ImportChildResult | null = null;
|
|
114
|
+
try {
|
|
115
|
+
result = JSON.parse(line) as ImportChildResult;
|
|
116
|
+
} catch {
|
|
117
|
+
result = null;
|
|
118
|
+
}
|
|
119
|
+
if (!result) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`session import child exited ${exitCode}: ${stderr.trim().slice(-2000)}`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (result.ok) return result.receipt;
|
|
125
|
+
if (result.code) throw new SessionsError(result.code, result.message);
|
|
126
|
+
throw new Error(result.message);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Child entry: one request on stdin, one JSON result line on stdout. */
|
|
130
|
+
export async function runImportChild(): Promise<void> {
|
|
131
|
+
// Nothing this child starts may re-enter child mode.
|
|
132
|
+
delete process.env[IMPORT_CHILD_ENV];
|
|
133
|
+
let result: ImportChildResult;
|
|
134
|
+
try {
|
|
135
|
+
const request = JSON.parse(
|
|
136
|
+
await new Response(Bun.stdin.stream()).text()
|
|
137
|
+
) as ImportChildRequest;
|
|
138
|
+
result = { ok: true, receipt: await importInThisProcess(request) };
|
|
139
|
+
} catch (error) {
|
|
140
|
+
result =
|
|
141
|
+
error instanceof SessionsError
|
|
142
|
+
? { ok: false, code: error.code, message: error.message }
|
|
143
|
+
: {
|
|
144
|
+
ok: false,
|
|
145
|
+
code: null,
|
|
146
|
+
message: error instanceof Error ? error.message : String(error),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (import.meta.main) await runImportChild();
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code project JSONL parser.
|
|
3
|
+
*
|
|
4
|
+
* Verified layouts (2026-09, CLI 2.1.2xx): `projects/<encoded-cwd>/<sessionId>.jsonl`
|
|
5
|
+
* for the main thread and `.../<sessionId>/subagents/agent-<agentId>.jsonl`
|
|
6
|
+
* for spawned subagents. Records carry `uuid`, `sessionId`, `timestamp`,
|
|
7
|
+
* `cwd`, `version`, `type`.
|
|
8
|
+
*
|
|
9
|
+
* Human speech is structural: `type=user` records whose `origin.kind` is
|
|
10
|
+
* `human`. Files written before the `origin` field existed fall back to a
|
|
11
|
+
* conservative shape test (plain text, not meta, not a compaction summary,
|
|
12
|
+
* no tool result, no command/notification wrapper). Injected attachments,
|
|
13
|
+
* system records, compaction summaries, slash-command wrappers, task
|
|
14
|
+
* notifications and the parent task prompt inside a subagent file are never
|
|
15
|
+
* human speech. A slash command a person typed (`origin.kind=human`) is
|
|
16
|
+
* archived as `/name args`. Prompts of SDK / `claude -p` sessions
|
|
17
|
+
* (`entrypoint=sdk-*`) come from a program and are not attributed to a
|
|
18
|
+
* person. Assistant turns are the text blocks of `type=assistant` records;
|
|
19
|
+
* thinking and tool calls are excluded.
|
|
20
|
+
*
|
|
21
|
+
* @module src/sessions/parsers/claude-code
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
emptyDiagnostics,
|
|
26
|
+
normalizeTimestamp,
|
|
27
|
+
noteUnknownKind,
|
|
28
|
+
type ParsedThread,
|
|
29
|
+
type ParsedTurn,
|
|
30
|
+
type ParseUnitResult,
|
|
31
|
+
} from "../types";
|
|
32
|
+
import {
|
|
33
|
+
missingHumanTurns,
|
|
34
|
+
isRecord,
|
|
35
|
+
joinTextBlocks,
|
|
36
|
+
pushTurn,
|
|
37
|
+
readJsonlRecords,
|
|
38
|
+
stringField,
|
|
39
|
+
} from "./shared";
|
|
40
|
+
|
|
41
|
+
export const CLAUDE_CODE_PARSER = "claude-code/1";
|
|
42
|
+
|
|
43
|
+
const SKIPPED_TYPES = new Set([
|
|
44
|
+
"attachment",
|
|
45
|
+
"system",
|
|
46
|
+
"summary",
|
|
47
|
+
"file-history-snapshot",
|
|
48
|
+
"file-history-delta",
|
|
49
|
+
"queue-operation",
|
|
50
|
+
"last-prompt",
|
|
51
|
+
"ai-title",
|
|
52
|
+
"custom-title",
|
|
53
|
+
"permission-mode",
|
|
54
|
+
"mode",
|
|
55
|
+
"bridge-session",
|
|
56
|
+
"atis-latch",
|
|
57
|
+
"frame-link",
|
|
58
|
+
"pr-link",
|
|
59
|
+
"cost-state",
|
|
60
|
+
"tag",
|
|
61
|
+
"agent-name",
|
|
62
|
+
"continued-in",
|
|
63
|
+
"artifact-autoreact-ledger",
|
|
64
|
+
"artifact-comment-monitor",
|
|
65
|
+
"progress",
|
|
66
|
+
"fork-context-ref",
|
|
67
|
+
"history-suppression",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/** Wrappers the CLI writes around non-speech input. */
|
|
71
|
+
const WRAPPER_PREFIXES = [
|
|
72
|
+
"<command-name>",
|
|
73
|
+
"<command-message>",
|
|
74
|
+
"<command-args>",
|
|
75
|
+
"<local-command-",
|
|
76
|
+
"<task-notification>",
|
|
77
|
+
"<system-reminder>",
|
|
78
|
+
"<bash-input>",
|
|
79
|
+
"<bash-stdout>",
|
|
80
|
+
"<bash-stderr>",
|
|
81
|
+
"<user-memory-input>",
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const COMMAND_NAME = /<command-name>([^<]{1,256})<\/command-name>/;
|
|
85
|
+
const COMMAND_ARGS = /<command-args>([\s\S]{0,65536}?)<\/command-args>/;
|
|
86
|
+
|
|
87
|
+
/** A human-typed slash command becomes `/name args`; other wrappers stay out. */
|
|
88
|
+
function slashCommandText(text: string): string | undefined {
|
|
89
|
+
const name = COMMAND_NAME.exec(text)?.[1]?.trim();
|
|
90
|
+
if (!name) return undefined;
|
|
91
|
+
const args = COMMAND_ARGS.exec(text)?.[1]?.trim() ?? "";
|
|
92
|
+
const command = name.startsWith("/") ? name : `/${name}`;
|
|
93
|
+
return args ? `${command} ${args}` : command;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface UserCandidate {
|
|
97
|
+
turn: ParsedTurn;
|
|
98
|
+
originKind?: string;
|
|
99
|
+
hasOrigin: boolean;
|
|
100
|
+
plainShape: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const SUBAGENT_FILE = /(?:^|\/)agent-([A-Za-z0-9_-]{1,128})\.jsonl$/;
|
|
104
|
+
|
|
105
|
+
function userText(message: Record<string, unknown>): {
|
|
106
|
+
text?: string;
|
|
107
|
+
hasToolResult: boolean;
|
|
108
|
+
} {
|
|
109
|
+
const content = message.content;
|
|
110
|
+
if (typeof content === "string")
|
|
111
|
+
return { text: content, hasToolResult: false };
|
|
112
|
+
if (!Array.isArray(content)) return { hasToolResult: false };
|
|
113
|
+
const hasToolResult = content.some(
|
|
114
|
+
(block) => isRecord(block) && block.type === "tool_result"
|
|
115
|
+
);
|
|
116
|
+
return { text: joinTextBlocks(content, ["text"]), hasToolResult };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function parseClaudeCodeSession(
|
|
120
|
+
path: string
|
|
121
|
+
): Promise<ParseUnitResult> {
|
|
122
|
+
const diagnostics = emptyDiagnostics();
|
|
123
|
+
const subagentMatch = SUBAGENT_FILE.exec(path.replaceAll("\\", "/"));
|
|
124
|
+
const isSubagentFile = Boolean(subagentMatch);
|
|
125
|
+
const ordered: Array<ParsedTurn | UserCandidate> = [];
|
|
126
|
+
let sessionId: string | undefined;
|
|
127
|
+
let agentId: string | undefined = subagentMatch?.[1];
|
|
128
|
+
let firstCwd: string | undefined;
|
|
129
|
+
let fileHasOrigin = false;
|
|
130
|
+
let programmaticEntry = false;
|
|
131
|
+
|
|
132
|
+
for await (const { lineNumber, record } of readJsonlRecords(
|
|
133
|
+
path,
|
|
134
|
+
diagnostics
|
|
135
|
+
)) {
|
|
136
|
+
const type = record.type;
|
|
137
|
+
sessionId ??= stringField(record.sessionId);
|
|
138
|
+
agentId ??= isSubagentFile ? stringField(record.agentId) : undefined;
|
|
139
|
+
const cwd = stringField(record.cwd);
|
|
140
|
+
firstCwd ??= cwd;
|
|
141
|
+
if (diagnostics.formatVersion === undefined) {
|
|
142
|
+
diagnostics.formatVersion = stringField(record.version);
|
|
143
|
+
}
|
|
144
|
+
if (type === "user" && "origin" in record) fileHasOrigin = true;
|
|
145
|
+
// SDK / `claude -p` sessions receive prompts from a program, which may be
|
|
146
|
+
// another agent; they are not attributed to a person.
|
|
147
|
+
const entrypoint = stringField(record.entrypoint);
|
|
148
|
+
if (entrypoint?.startsWith("sdk")) programmaticEntry = true;
|
|
149
|
+
|
|
150
|
+
if (type !== "user" && type !== "assistant") {
|
|
151
|
+
if (typeof type !== "string" || !SKIPPED_TYPES.has(type)) {
|
|
152
|
+
noteUnknownKind(diagnostics, type);
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const message = isRecord(record.message) ? record.message : {};
|
|
157
|
+
const base = {
|
|
158
|
+
turnId: stringField(record.uuid) ?? `line:${lineNumber}`,
|
|
159
|
+
timestamp: normalizeTimestamp(record.timestamp),
|
|
160
|
+
locator: `line:${lineNumber}`,
|
|
161
|
+
cwd,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Sidechain records inside a main file are agent-to-agent traffic.
|
|
165
|
+
if (!isSubagentFile && record.isSidechain === true) {
|
|
166
|
+
diagnostics.injectedSkipped += 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (type === "assistant") {
|
|
171
|
+
const text = joinTextBlocks(message.content, ["text"]);
|
|
172
|
+
if (text !== undefined) {
|
|
173
|
+
ordered.push({ ...base, role: "assistant", text });
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const { text, hasToolResult } = userText(message);
|
|
179
|
+
if (hasToolResult || text === undefined) continue;
|
|
180
|
+
const trimmed = text.trimStart();
|
|
181
|
+
const wrapped = WRAPPER_PREFIXES.some((prefix) =>
|
|
182
|
+
trimmed.startsWith(prefix)
|
|
183
|
+
);
|
|
184
|
+
const plainShape =
|
|
185
|
+
record.isMeta !== true &&
|
|
186
|
+
record.isCompactSummary !== true &&
|
|
187
|
+
record.isVisibleInTranscriptOnly !== true &&
|
|
188
|
+
!wrapped;
|
|
189
|
+
const origin = isRecord(record.origin) ? record.origin : undefined;
|
|
190
|
+
const originKind = stringField(origin?.kind);
|
|
191
|
+
const command =
|
|
192
|
+
originKind === "human" && wrapped && record.isMeta !== true
|
|
193
|
+
? slashCommandText(trimmed)
|
|
194
|
+
: undefined;
|
|
195
|
+
ordered.push({
|
|
196
|
+
turn: { ...base, role: "human", text: command ?? text },
|
|
197
|
+
originKind,
|
|
198
|
+
hasOrigin: "origin" in record,
|
|
199
|
+
plainShape: plainShape || command !== undefined,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const turns: ParsedTurn[] = [];
|
|
204
|
+
for (const entry of ordered) {
|
|
205
|
+
let turn: ParsedTurn;
|
|
206
|
+
if ("turn" in entry) {
|
|
207
|
+
// In a subagent file the "user" message is the parent agent's task.
|
|
208
|
+
const human =
|
|
209
|
+
!isSubagentFile &&
|
|
210
|
+
!programmaticEntry &&
|
|
211
|
+
entry.plainShape &&
|
|
212
|
+
(fileHasOrigin ? entry.originKind === "human" : true);
|
|
213
|
+
if (!human) {
|
|
214
|
+
diagnostics.injectedSkipped += 1;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
turn = entry.turn;
|
|
218
|
+
} else {
|
|
219
|
+
turn = entry;
|
|
220
|
+
}
|
|
221
|
+
if (!pushTurn(turns, turn, diagnostics)) break;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!sessionId) {
|
|
225
|
+
return {
|
|
226
|
+
threads: [],
|
|
227
|
+
diagnostics,
|
|
228
|
+
complete: !diagnostics.truncatedTail,
|
|
229
|
+
parser: CLAUDE_CODE_PARSER,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (!(isSubagentFile || programmaticEntry)) {
|
|
233
|
+
diagnostics.humanTurnsMissing = missingHumanTurns(turns);
|
|
234
|
+
}
|
|
235
|
+
const thread: ParsedThread = isSubagentFile
|
|
236
|
+
? {
|
|
237
|
+
harness: "claude-code",
|
|
238
|
+
threadId: `${sessionId}/agent-${agentId ?? "unknown"}`,
|
|
239
|
+
sessionId,
|
|
240
|
+
parentThreadId: sessionId,
|
|
241
|
+
kind: "subagent",
|
|
242
|
+
cwd: firstCwd,
|
|
243
|
+
turns,
|
|
244
|
+
}
|
|
245
|
+
: {
|
|
246
|
+
harness: "claude-code",
|
|
247
|
+
threadId: sessionId,
|
|
248
|
+
sessionId,
|
|
249
|
+
kind: "main",
|
|
250
|
+
cwd: firstCwd,
|
|
251
|
+
turns,
|
|
252
|
+
};
|
|
253
|
+
return {
|
|
254
|
+
threads: [thread],
|
|
255
|
+
diagnostics,
|
|
256
|
+
complete: !diagnostics.truncatedTail && !diagnostics.humanTurnsMissing,
|
|
257
|
+
parser: CLAUDE_CODE_PARSER,
|
|
258
|
+
};
|
|
259
|
+
}
|