praxis-agent 0.32.0 → 0.33.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/dist/application/session-service.d.ts +26 -0
- package/dist/application/session-service.js +342 -41
- package/dist/cli-runtime.d.ts +1 -0
- package/dist/cli-runtime.js +111 -31
- package/dist/compatibility/claude/history.d.ts +8 -0
- package/dist/compatibility/claude/history.js +54 -0
- package/dist/compatibility/claude/interruption.d.ts +16 -0
- package/dist/compatibility/claude/interruption.js +147 -0
- package/dist/compatibility/claude/schema.js +22 -0
- package/dist/compatibility/claude/session-metadata.d.ts +44 -0
- package/dist/compatibility/claude/session-metadata.js +180 -0
- package/dist/persistence/claude-session-index.d.ts +39 -0
- package/dist/persistence/claude-session-index.js +175 -0
- package/dist/persistence/claude-transcript-store.d.ts +6 -2
- package/dist/persistence/claude-transcript-store.js +78 -3
- package/dist/persistence/in-memory-transcript-store.js +1 -0
- package/package.json +2 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
export const CLAUDE_DURABLE_METADATA_TYPES = [
|
|
2
|
+
'custom-title',
|
|
3
|
+
'ai-title',
|
|
4
|
+
'tag',
|
|
5
|
+
'agent-name',
|
|
6
|
+
'agent-color',
|
|
7
|
+
'agent-setting',
|
|
8
|
+
'permission-mode',
|
|
9
|
+
'mode',
|
|
10
|
+
'worktree-state',
|
|
11
|
+
'pr-link',
|
|
12
|
+
'last-prompt',
|
|
13
|
+
];
|
|
14
|
+
const DURABLE_METADATA_TYPE_SET = new Set(CLAUDE_DURABLE_METADATA_TYPES);
|
|
15
|
+
export function isClaudeDurableMetadataType(type) {
|
|
16
|
+
return DURABLE_METADATA_TYPE_SET.has(type);
|
|
17
|
+
}
|
|
18
|
+
function nonEmptyString(value) {
|
|
19
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
20
|
+
}
|
|
21
|
+
function record(value) {
|
|
22
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
23
|
+
? value
|
|
24
|
+
: undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Reduces Claude's append-only metadata records. Each metadata type is
|
|
28
|
+
* independently last-wins, while a user-authored custom title always takes
|
|
29
|
+
* precedence over an AI title regardless of append order.
|
|
30
|
+
*
|
|
31
|
+
* Unknown records remain untouched in the transcript but never become
|
|
32
|
+
* authoritative session state here.
|
|
33
|
+
*/
|
|
34
|
+
export function reduceClaudeSessionMetadata(entries, sessionId) {
|
|
35
|
+
const latest = new Map();
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (!isClaudeDurableMetadataType(entry.type))
|
|
38
|
+
continue;
|
|
39
|
+
if (sessionId !== undefined &&
|
|
40
|
+
typeof entry.sessionId === 'string' &&
|
|
41
|
+
entry.sessionId !== sessionId) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
latest.set(entry.type, entry);
|
|
45
|
+
}
|
|
46
|
+
const customTitle = nonEmptyString(latest.get('custom-title')?.customTitle);
|
|
47
|
+
const aiTitle = nonEmptyString(latest.get('ai-title')?.aiTitle);
|
|
48
|
+
const tag = nonEmptyString(latest.get('tag')?.tag);
|
|
49
|
+
const agentName = nonEmptyString(latest.get('agent-name')?.agentName);
|
|
50
|
+
const agentColor = nonEmptyString(latest.get('agent-color')?.agentColor);
|
|
51
|
+
const agentSetting = nonEmptyString(latest.get('agent-setting')?.agentSetting);
|
|
52
|
+
const permissionMode = nonEmptyString(latest.get('permission-mode')?.permissionMode);
|
|
53
|
+
const mode = nonEmptyString(latest.get('mode')?.mode);
|
|
54
|
+
const worktreeSession = record(latest.get('worktree-state')?.worktreeSession);
|
|
55
|
+
const pr = latest.get('pr-link');
|
|
56
|
+
const prNumber = pr?.prNumber;
|
|
57
|
+
const prUrl = nonEmptyString(pr?.prUrl);
|
|
58
|
+
const prRepository = nonEmptyString(pr?.prRepository);
|
|
59
|
+
const prLink = Number.isSafeInteger(prNumber) && Number(prNumber) > 0
|
|
60
|
+
? {
|
|
61
|
+
prNumber: Number(prNumber),
|
|
62
|
+
...(prUrl === undefined ? {} : { prUrl }),
|
|
63
|
+
...(prRepository === undefined ? {} : { prRepository }),
|
|
64
|
+
}
|
|
65
|
+
: undefined;
|
|
66
|
+
const lastPromptEntry = latest.get('last-prompt');
|
|
67
|
+
const lastPrompt = nonEmptyString(lastPromptEntry?.lastPrompt);
|
|
68
|
+
const lastPromptLeafUuid = nonEmptyString(lastPromptEntry?.leafUuid);
|
|
69
|
+
return {
|
|
70
|
+
...(customTitle === undefined ? {} : { customTitle }),
|
|
71
|
+
...(aiTitle === undefined ? {} : { aiTitle }),
|
|
72
|
+
...(customTitle !== undefined
|
|
73
|
+
? { title: customTitle, titleSource: 'custom-title' }
|
|
74
|
+
: aiTitle !== undefined
|
|
75
|
+
? { title: aiTitle, titleSource: 'ai-title' }
|
|
76
|
+
: {}),
|
|
77
|
+
...(tag === undefined ? {} : { tag }),
|
|
78
|
+
...(agentName === undefined ? {} : { agentName }),
|
|
79
|
+
...(agentColor === undefined ? {} : { agentColor }),
|
|
80
|
+
...(agentSetting === undefined ? {} : { agentSetting }),
|
|
81
|
+
...(permissionMode === undefined ? {} : { permissionMode }),
|
|
82
|
+
...(mode === undefined ? {} : { mode }),
|
|
83
|
+
...(worktreeSession === undefined ? {} : { worktreeSession }),
|
|
84
|
+
...(prLink === undefined ? {} : { prLink }),
|
|
85
|
+
...(lastPrompt === undefined ? {} : { lastPrompt }),
|
|
86
|
+
...(lastPromptLeafUuid === undefined ? {} : { lastPromptLeafUuid }),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Returns one current record per durable metadata type for a tail snapshot. */
|
|
90
|
+
export function createClaudeDurableMetadataSnapshot(entries, sessionId, options = {}) {
|
|
91
|
+
const latest = new Map();
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
if (!isClaudeDurableMetadataType(entry.type))
|
|
94
|
+
continue;
|
|
95
|
+
if (typeof entry.sessionId === 'string' && entry.sessionId !== sessionId) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
latest.set(entry.type, entry);
|
|
99
|
+
}
|
|
100
|
+
// A later AI title must never obscure a user rename in a bounded tail.
|
|
101
|
+
if (latest.has('custom-title'))
|
|
102
|
+
latest.delete('ai-title');
|
|
103
|
+
const lastPrompt = latest.get('last-prompt');
|
|
104
|
+
if (lastPrompt !== undefined) {
|
|
105
|
+
const committedLeafUuid = nonEmptyString(lastPrompt.leafUuid);
|
|
106
|
+
const committedLeaf = entries.find((entry) => entry.isSidechain !== true &&
|
|
107
|
+
typeof entry.uuid === 'string' &&
|
|
108
|
+
entry.uuid === committedLeafUuid &&
|
|
109
|
+
(entry.type === 'assistant' ||
|
|
110
|
+
(entry.type === 'system' &&
|
|
111
|
+
entry.subtype === 'local_command' &&
|
|
112
|
+
typeof entry.content === 'string' &&
|
|
113
|
+
entry.content.startsWith('<local-command-stdout>'))));
|
|
114
|
+
const trustedCommittedLeafUuid = committedLeaf?.uuid ??
|
|
115
|
+
(options.trustLastPromptReference ? committedLeafUuid : undefined);
|
|
116
|
+
if (trustedCommittedLeafUuid === undefined) {
|
|
117
|
+
latest.delete('last-prompt');
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
// Metadata-only re-appends never promote a newer assistant: it may be
|
|
121
|
+
// an abandoned or failed branch. A completed local command is itself a
|
|
122
|
+
// committed observable result, so it may safely advance the hint when
|
|
123
|
+
// it descends from the last committed turn.
|
|
124
|
+
const byUuid = new Map(entries.flatMap((entry) => entry.isSidechain !== true && typeof entry.uuid === 'string'
|
|
125
|
+
? [[entry.uuid, entry]]
|
|
126
|
+
: []));
|
|
127
|
+
const localCommandLeaf = [...entries].reverse().find((entry) => {
|
|
128
|
+
if (entry.isSidechain === true ||
|
|
129
|
+
entry.type !== 'system' ||
|
|
130
|
+
entry.subtype !== 'local_command' ||
|
|
131
|
+
typeof entry.uuid !== 'string' ||
|
|
132
|
+
typeof entry.content !== 'string' ||
|
|
133
|
+
!entry.content.startsWith('<local-command-stdout>')) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
let parentUuid = typeof entry.parentUuid === 'string' ? entry.parentUuid : null;
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
while (parentUuid !== null && !seen.has(parentUuid)) {
|
|
139
|
+
if (parentUuid === trustedCommittedLeafUuid)
|
|
140
|
+
return true;
|
|
141
|
+
seen.add(parentUuid);
|
|
142
|
+
const parent = byUuid.get(parentUuid);
|
|
143
|
+
if (parent?.type !== 'system' || parent.subtype !== 'local_command') {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
parentUuid =
|
|
147
|
+
typeof parent?.parentUuid === 'string' ? parent.parentUuid : null;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
});
|
|
151
|
+
if (localCommandLeaf === undefined) {
|
|
152
|
+
latest.set('last-prompt', lastPrompt);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
const snapshot = {
|
|
156
|
+
...lastPrompt,
|
|
157
|
+
leafUuid: localCommandLeaf.uuid,
|
|
158
|
+
};
|
|
159
|
+
delete snapshot.lastPrompt;
|
|
160
|
+
latest.set('last-prompt', snapshot);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return CLAUDE_DURABLE_METADATA_TYPES.flatMap((type) => {
|
|
165
|
+
const entry = latest.get(type);
|
|
166
|
+
return entry === undefined ? [] : [{ ...entry, sessionId }];
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/** Keeps the last known full baseline while overlaying metadata visible in a
|
|
170
|
+
* newer bounded head/tail observation. Structural entries are included only
|
|
171
|
+
* to validate or safely advance the committed last-prompt hint. */
|
|
172
|
+
export function mergeClaudeDurableMetadataSnapshot(baseline, observed, sessionId) {
|
|
173
|
+
return createClaudeDurableMetadataSnapshot([...baseline, ...observed], sessionId, { trustLastPromptReference: true });
|
|
174
|
+
}
|
|
175
|
+
export function createClaudeTagEntry(sessionId, tag) {
|
|
176
|
+
if (tag.length === 0)
|
|
177
|
+
throw new Error('Session tag must not be empty');
|
|
178
|
+
return { type: 'tag', tag, sessionId };
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=session-metadata.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ClaudeSchemaAdapter, ClaudeTranscriptEntry } from '../compatibility/claude/schema.js';
|
|
2
|
+
import type { TranscriptParseIssue } from './claude-transcript-store.js';
|
|
3
|
+
import type { TranscriptTail } from './claude-transcript-store.js';
|
|
4
|
+
export declare const CLAUDE_SESSION_INDEX_HEAD_BYTES: number;
|
|
5
|
+
export declare const CLAUDE_SESSION_INDEX_TAIL_BYTES: number;
|
|
6
|
+
export declare const CLAUDE_SESSION_INDEX_CONCURRENCY = 32;
|
|
7
|
+
export interface ClaudeSessionIndex {
|
|
8
|
+
entries: ClaudeTranscriptEntry[];
|
|
9
|
+
tailEntries: ClaudeTranscriptEntry[];
|
|
10
|
+
issue: TranscriptParseIssue | null;
|
|
11
|
+
byteLength: number;
|
|
12
|
+
newlineTerminated: boolean;
|
|
13
|
+
updatedAt: string;
|
|
14
|
+
tail: TranscriptTail;
|
|
15
|
+
}
|
|
16
|
+
export interface ClaudeSessionIndexRequest {
|
|
17
|
+
sessionId: string;
|
|
18
|
+
path: string;
|
|
19
|
+
}
|
|
20
|
+
export type ClaudeSessionIndexResult = ClaudeSessionIndexRequest & ({
|
|
21
|
+
index: ClaudeSessionIndex;
|
|
22
|
+
error?: never;
|
|
23
|
+
} | {
|
|
24
|
+
index?: never;
|
|
25
|
+
error: unknown;
|
|
26
|
+
});
|
|
27
|
+
export declare class ClaudeSessionIndexCandidateError extends Error {
|
|
28
|
+
readonly name = "ClaudeSessionIndexCandidateError";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Reads only bounded head/tail windows for session discovery. A partial final
|
|
32
|
+
* line is treated as an in-progress append and ignored. Full JSONL and graph
|
|
33
|
+
* validation remains the responsibility of inspect/resume.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readClaudeSessionIndex(path: string, schema: ClaudeSchemaAdapter): Promise<ClaudeSessionIndex>;
|
|
36
|
+
/** Reads a discovery set with a fixed worker pool so large projects cannot
|
|
37
|
+
* exhaust the process file-descriptor limit. Results preserve input order. */
|
|
38
|
+
export declare function readClaudeSessionIndexes(requests: readonly ClaudeSessionIndexRequest[], schema: ClaudeSchemaAdapter, concurrency?: number): Promise<ClaudeSessionIndexResult[]>;
|
|
39
|
+
//# sourceMappingURL=claude-session-index.d.ts.map
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, open } from 'node:fs/promises';
|
|
3
|
+
import { TextDecoder } from 'node:util';
|
|
4
|
+
import { isClaudeDurableLastPromptSnapshot } from '../compatibility/claude/history.js';
|
|
5
|
+
export const CLAUDE_SESSION_INDEX_HEAD_BYTES = 64 * 1024;
|
|
6
|
+
export const CLAUDE_SESSION_INDEX_TAIL_BYTES = 128 * 1024;
|
|
7
|
+
export const CLAUDE_SESSION_INDEX_CONCURRENCY = 32;
|
|
8
|
+
const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
|
|
9
|
+
function logicalTailUuid(entries) {
|
|
10
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
11
|
+
const entry = entries[index];
|
|
12
|
+
if (!entry)
|
|
13
|
+
continue;
|
|
14
|
+
if (entry.type === 'last-prompt' &&
|
|
15
|
+
isClaudeDurableLastPromptSnapshot(entries, index)) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (typeof entry.uuid === 'string')
|
|
19
|
+
return entry.uuid;
|
|
20
|
+
if (typeof entry.leafUuid === 'string')
|
|
21
|
+
return entry.leafUuid;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
export class ClaudeSessionIndexCandidateError extends Error {
|
|
26
|
+
name = 'ClaudeSessionIndexCandidateError';
|
|
27
|
+
}
|
|
28
|
+
function completeLines(source, absoluteStart, discardLeadingPartial, discardTrailingPartial, lineNumberStart, tail) {
|
|
29
|
+
let start = 0;
|
|
30
|
+
if (discardLeadingPartial) {
|
|
31
|
+
const newline = source.indexOf(0x0a);
|
|
32
|
+
if (newline < 0)
|
|
33
|
+
return [];
|
|
34
|
+
start = newline + 1;
|
|
35
|
+
}
|
|
36
|
+
let end = source.length;
|
|
37
|
+
if (discardTrailingPartial && source.at(-1) !== 0x0a) {
|
|
38
|
+
const newline = source.lastIndexOf(0x0a);
|
|
39
|
+
if (newline < start)
|
|
40
|
+
return [];
|
|
41
|
+
end = newline + 1;
|
|
42
|
+
}
|
|
43
|
+
const lines = [];
|
|
44
|
+
let lineStart = start;
|
|
45
|
+
for (let index = start; index < end; index += 1) {
|
|
46
|
+
if (source[index] !== 0x0a)
|
|
47
|
+
continue;
|
|
48
|
+
if (index > lineStart) {
|
|
49
|
+
lines.push({
|
|
50
|
+
source: source.subarray(lineStart, index),
|
|
51
|
+
byteOffset: absoluteStart + lineStart,
|
|
52
|
+
lineNumber: lineNumberStart === null ? null : lineNumberStart + lines.length,
|
|
53
|
+
tail,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
lineStart = index + 1;
|
|
57
|
+
}
|
|
58
|
+
if (!discardTrailingPartial && lineStart < end) {
|
|
59
|
+
lines.push({
|
|
60
|
+
source: source.subarray(lineStart, end),
|
|
61
|
+
byteOffset: absoluteStart + lineStart,
|
|
62
|
+
lineNumber: lineNumberStart === null ? null : lineNumberStart + lines.length,
|
|
63
|
+
tail,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return lines;
|
|
67
|
+
}
|
|
68
|
+
async function readWindow(handle, length, position) {
|
|
69
|
+
const buffer = Buffer.alloc(length);
|
|
70
|
+
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
|
71
|
+
return buffer.subarray(0, bytesRead);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Reads only bounded head/tail windows for session discovery. A partial final
|
|
75
|
+
* line is treated as an in-progress append and ignored. Full JSONL and graph
|
|
76
|
+
* validation remains the responsibility of inspect/resume.
|
|
77
|
+
*/
|
|
78
|
+
export async function readClaudeSessionIndex(path, schema) {
|
|
79
|
+
const pathMetadata = await lstat(path);
|
|
80
|
+
if (!pathMetadata.isFile())
|
|
81
|
+
throw new ClaudeSessionIndexCandidateError(`Expected a regular file: ${path}`);
|
|
82
|
+
const handle = await open(path, 'r');
|
|
83
|
+
try {
|
|
84
|
+
const metadata = await handle.stat();
|
|
85
|
+
const byteLength = metadata.size;
|
|
86
|
+
const wholeFileThreshold = CLAUDE_SESSION_INDEX_HEAD_BYTES + CLAUDE_SESSION_INDEX_TAIL_BYTES;
|
|
87
|
+
let lines;
|
|
88
|
+
let newlineTerminated = true;
|
|
89
|
+
if (byteLength <= wholeFileThreshold) {
|
|
90
|
+
const source = await readWindow(handle, byteLength, 0);
|
|
91
|
+
newlineTerminated = source.length === 0 || source.at(-1) === 0x0a;
|
|
92
|
+
lines = completeLines(source, 0, false, !newlineTerminated, 1, true);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const head = await readWindow(handle, CLAUDE_SESSION_INDEX_HEAD_BYTES, 0);
|
|
96
|
+
const tailStart = byteLength - CLAUDE_SESSION_INDEX_TAIL_BYTES;
|
|
97
|
+
const tail = await readWindow(handle, CLAUDE_SESSION_INDEX_TAIL_BYTES, tailStart);
|
|
98
|
+
newlineTerminated = tail.length === 0 || tail.at(-1) === 0x0a;
|
|
99
|
+
lines = [
|
|
100
|
+
...completeLines(head, 0, false, true, 1, false),
|
|
101
|
+
...completeLines(tail, tailStart, true, !newlineTerminated, null, true),
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
const entries = [];
|
|
105
|
+
const tailEntries = [];
|
|
106
|
+
let issue = null;
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
try {
|
|
109
|
+
const entry = schema.parse(UTF8_DECODER.decode(line.source));
|
|
110
|
+
entries.push(entry);
|
|
111
|
+
if (line.tail)
|
|
112
|
+
tailEntries.push(entry);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
issue = {
|
|
116
|
+
lineNumber: line.lineNumber,
|
|
117
|
+
byteOffset: line.byteOffset,
|
|
118
|
+
message: error instanceof Error ? error.message : String(error),
|
|
119
|
+
};
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
entries,
|
|
125
|
+
tailEntries,
|
|
126
|
+
issue,
|
|
127
|
+
byteLength,
|
|
128
|
+
newlineTerminated,
|
|
129
|
+
updatedAt: metadata.mtime.toISOString(),
|
|
130
|
+
tail: {
|
|
131
|
+
byteLength,
|
|
132
|
+
lastLineHash: lines.at(-1) === undefined
|
|
133
|
+
? null
|
|
134
|
+
: createHash('sha256')
|
|
135
|
+
.update(lines.at(-1)?.source ?? Buffer.alloc(0))
|
|
136
|
+
.digest('hex'),
|
|
137
|
+
lastUuid: logicalTailUuid(entries),
|
|
138
|
+
newlineTerminated,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
await handle.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Reads a discovery set with a fixed worker pool so large projects cannot
|
|
147
|
+
* exhaust the process file-descriptor limit. Results preserve input order. */
|
|
148
|
+
export async function readClaudeSessionIndexes(requests, schema, concurrency = CLAUDE_SESSION_INDEX_CONCURRENCY) {
|
|
149
|
+
if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
|
|
150
|
+
throw new Error('Claude session index concurrency must be a positive integer');
|
|
151
|
+
}
|
|
152
|
+
const results = new Array(requests.length);
|
|
153
|
+
let nextIndex = 0;
|
|
154
|
+
const worker = async () => {
|
|
155
|
+
while (nextIndex < requests.length) {
|
|
156
|
+
const index = nextIndex;
|
|
157
|
+
nextIndex += 1;
|
|
158
|
+
const request = requests[index];
|
|
159
|
+
if (request === undefined)
|
|
160
|
+
continue;
|
|
161
|
+
try {
|
|
162
|
+
results[index] = {
|
|
163
|
+
...request,
|
|
164
|
+
index: await readClaudeSessionIndex(request.path, schema),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
results[index] = { ...request, error };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, requests.length) }, async () => worker()));
|
|
173
|
+
return results;
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=claude-session-index.js.map
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import type { ClaudeSchemaAdapter, ClaudeTranscriptEntry } from '../compatibility/claude/schema.js';
|
|
2
|
+
import { type ClaudeSessionIndex } from './claude-session-index.js';
|
|
2
3
|
export interface TranscriptTail {
|
|
3
4
|
byteLength: number;
|
|
4
5
|
lastLineHash: string | null;
|
|
5
6
|
lastUuid: string | null;
|
|
6
7
|
newlineTerminated: boolean;
|
|
7
|
-
branchParentUuid?: string;
|
|
8
|
+
branchParentUuid?: string | null;
|
|
8
9
|
}
|
|
9
10
|
export interface TranscriptSnapshot {
|
|
10
11
|
entries: ClaudeTranscriptEntry[];
|
|
11
12
|
tail: TranscriptTail;
|
|
12
13
|
}
|
|
13
14
|
export interface TranscriptParseIssue {
|
|
14
|
-
lineNumber: number;
|
|
15
|
+
lineNumber: number | null;
|
|
15
16
|
byteOffset: number;
|
|
16
17
|
message: string;
|
|
17
18
|
}
|
|
@@ -46,8 +47,10 @@ export type TranscriptReserveResult = {
|
|
|
46
47
|
};
|
|
47
48
|
export interface ClaudeTranscriptLease {
|
|
48
49
|
load(): Promise<TranscriptSnapshot>;
|
|
50
|
+
loadIndex?(): Promise<ClaudeSessionIndex>;
|
|
49
51
|
append(expectedTail: TranscriptTail, entry: ClaudeTranscriptEntry): Promise<TranscriptAppendResult>;
|
|
50
52
|
appendMany(expectedTail: TranscriptTail, entries: readonly ClaudeTranscriptEntry[]): Promise<TranscriptAppendResult>;
|
|
53
|
+
appendMetadataSnapshot(expectedTail: TranscriptTail, entries: readonly ClaudeTranscriptEntry[]): Promise<TranscriptAppendResult>;
|
|
51
54
|
}
|
|
52
55
|
export type TranscriptLeaseResult<T> = {
|
|
53
56
|
status: 'completed';
|
|
@@ -80,6 +83,7 @@ export declare class ClaudeTranscriptStore {
|
|
|
80
83
|
append(expectedTail: TranscriptTail, entry: ClaudeTranscriptEntry): Promise<TranscriptAppendResult>;
|
|
81
84
|
private appendUnderLease;
|
|
82
85
|
private appendManyUnderLease;
|
|
86
|
+
private appendMetadataSnapshotUnderLease;
|
|
83
87
|
private serializeNewSidechain;
|
|
84
88
|
}
|
|
85
89
|
//# sourceMappingURL=claude-transcript-store.d.ts.map
|
|
@@ -2,8 +2,11 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { mkdir, open, readFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
import { TextDecoder } from 'node:util';
|
|
5
|
+
import { isClaudeDurableLastPromptSnapshot } from '../compatibility/claude/history.js';
|
|
6
|
+
import { isClaudeDurableMetadataType } from '../compatibility/claude/session-metadata.js';
|
|
5
7
|
import { getClaudeContentBlocks, indexClaudeToolLinks, recoverClaudeToolResultLinks, } from '../compatibility/claude/tool-links.js';
|
|
6
8
|
import { ExclusiveFileLease } from '../platform/exclusive-file-lease.js';
|
|
9
|
+
import { readClaudeSessionIndex, } from './claude-session-index.js';
|
|
7
10
|
export class ClaudeTranscriptParseError extends Error {
|
|
8
11
|
lineNumber;
|
|
9
12
|
byteOffset;
|
|
@@ -19,6 +22,7 @@ const NON_TAIL_ENTRY_TYPES = new Set([
|
|
|
19
22
|
'agent-color',
|
|
20
23
|
'agent-name',
|
|
21
24
|
'agent-setting',
|
|
25
|
+
'ai-title',
|
|
22
26
|
'custom-title',
|
|
23
27
|
'file-history-delta',
|
|
24
28
|
'file-history-snapshot',
|
|
@@ -26,6 +30,8 @@ const NON_TAIL_ENTRY_TYPES = new Set([
|
|
|
26
30
|
'pr-link',
|
|
27
31
|
'queue-operation',
|
|
28
32
|
'relocated',
|
|
33
|
+
'mode',
|
|
34
|
+
'tag',
|
|
29
35
|
'worktree-state',
|
|
30
36
|
]);
|
|
31
37
|
export function classifyTranscriptAppend(written, previousByteLength, encodedLine) {
|
|
@@ -63,7 +69,9 @@ function tailsMatch(left, right) {
|
|
|
63
69
|
left.newlineTerminated === right.newlineTerminated);
|
|
64
70
|
}
|
|
65
71
|
function tailLogicalUuid(tail) {
|
|
66
|
-
return
|
|
72
|
+
return 'branchParentUuid' in tail
|
|
73
|
+
? (tail.branchParentUuid ?? null)
|
|
74
|
+
: tail.lastUuid;
|
|
67
75
|
}
|
|
68
76
|
function getEntryUuid(entry) {
|
|
69
77
|
if (typeof entry.uuid === 'string')
|
|
@@ -77,6 +85,10 @@ function findLogicalTailUuid(entries) {
|
|
|
77
85
|
const entry = entries[index];
|
|
78
86
|
if (!entry)
|
|
79
87
|
continue;
|
|
88
|
+
if (entry.type === 'last-prompt' &&
|
|
89
|
+
isClaudeDurableLastPromptSnapshot(entries, index)) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
80
92
|
const uuid = getEntryUuid(entry);
|
|
81
93
|
if (uuid)
|
|
82
94
|
return uuid;
|
|
@@ -173,7 +185,9 @@ export class ClaudeTranscriptStore {
|
|
|
173
185
|
message: error instanceof Error ? error.message : String(error),
|
|
174
186
|
};
|
|
175
187
|
if (!recover) {
|
|
176
|
-
throw new ClaudeTranscriptParseError(
|
|
188
|
+
throw new ClaudeTranscriptParseError(index + 1, byteOffset, {
|
|
189
|
+
cause: error,
|
|
190
|
+
});
|
|
177
191
|
}
|
|
178
192
|
break;
|
|
179
193
|
}
|
|
@@ -266,8 +280,10 @@ export class ClaudeTranscriptStore {
|
|
|
266
280
|
try {
|
|
267
281
|
const value = await operation({
|
|
268
282
|
load: () => this.load(),
|
|
283
|
+
loadIndex: () => readClaudeSessionIndex(this.sessionFile, this.schema),
|
|
269
284
|
append: (expectedTail, entry) => this.appendUnderLease(expectedTail, entry),
|
|
270
285
|
appendMany: (expectedTail, entries) => this.appendManyUnderLease(expectedTail, entries),
|
|
286
|
+
appendMetadataSnapshot: (expectedTail, entries) => this.appendMetadataSnapshotUnderLease(expectedTail, entries),
|
|
271
287
|
});
|
|
272
288
|
return { status: 'completed', value };
|
|
273
289
|
}
|
|
@@ -314,7 +330,11 @@ export class ClaudeTranscriptStore {
|
|
|
314
330
|
}
|
|
315
331
|
}
|
|
316
332
|
else if (entry.type === 'last-prompt') {
|
|
317
|
-
|
|
333
|
+
const isDurableSnapshot = history.some((candidate) => candidate.type === 'last-prompt' &&
|
|
334
|
+
candidate.sessionId === entry.sessionId &&
|
|
335
|
+
candidate.leafUuid === entry.leafUuid &&
|
|
336
|
+
candidate.lastPrompt === entry.lastPrompt);
|
|
337
|
+
if (entry.leafUuid !== logicalTailUuid && !isDurableSnapshot) {
|
|
318
338
|
staleLastPromptLeaf = true;
|
|
319
339
|
continue;
|
|
320
340
|
}
|
|
@@ -378,6 +398,61 @@ export class ClaudeTranscriptStore {
|
|
|
378
398
|
: { ...tail, branchParentUuid },
|
|
379
399
|
};
|
|
380
400
|
}
|
|
401
|
+
async appendMetadataSnapshotUnderLease(expectedTail, entries) {
|
|
402
|
+
if (entries.length === 0) {
|
|
403
|
+
throw new Error('Cannot append an empty Claude metadata snapshot');
|
|
404
|
+
}
|
|
405
|
+
if (entries.some((entry) => !isClaudeDurableMetadataType(entry.type))) {
|
|
406
|
+
throw new Error('Claude metadata snapshot contains a structural entry');
|
|
407
|
+
}
|
|
408
|
+
if (new Set(entries.map((entry) => entry.type)).size !== entries.length ||
|
|
409
|
+
new Set(entries.map((entry) => entry.sessionId)).size !== 1 ||
|
|
410
|
+
typeof entries[0]?.sessionId !== 'string') {
|
|
411
|
+
throw new Error('Claude metadata snapshot must contain one session record per type');
|
|
412
|
+
}
|
|
413
|
+
const current = await readClaudeSessionIndex(this.sessionFile, this.schema);
|
|
414
|
+
if (current.issue !== null) {
|
|
415
|
+
throw new Error('Cannot append metadata to a corrupt Claude transcript');
|
|
416
|
+
}
|
|
417
|
+
if (current.byteLength !== expectedTail.byteLength ||
|
|
418
|
+
current.tail.lastLineHash !== expectedTail.lastLineHash ||
|
|
419
|
+
current.newlineTerminated !== expectedTail.newlineTerminated) {
|
|
420
|
+
return { status: 'conflict', reason: 'tail-changed' };
|
|
421
|
+
}
|
|
422
|
+
if (!current.newlineTerminated) {
|
|
423
|
+
throw new Error('Claude transcript is not newline-terminated');
|
|
424
|
+
}
|
|
425
|
+
const lines = entries.map((entry) => this.schema.serializeForAppend(entry));
|
|
426
|
+
const encoded = Buffer.from(`${lines.join('\n')}\n`);
|
|
427
|
+
const sessionHandle = await open(this.sessionFile, 'a');
|
|
428
|
+
try {
|
|
429
|
+
const before = await sessionHandle.stat();
|
|
430
|
+
if (before.size !== expectedTail.byteLength) {
|
|
431
|
+
return { status: 'conflict', reason: 'tail-changed' };
|
|
432
|
+
}
|
|
433
|
+
await sessionHandle.writeFile(encoded);
|
|
434
|
+
await sessionHandle.sync();
|
|
435
|
+
const after = await sessionHandle.stat();
|
|
436
|
+
if (after.size !== expectedTail.byteLength + encoded.length) {
|
|
437
|
+
return { status: 'conflict', reason: 'interleaved-write' };
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
finally {
|
|
441
|
+
await sessionHandle.close();
|
|
442
|
+
}
|
|
443
|
+
const last = entries.at(-1);
|
|
444
|
+
return {
|
|
445
|
+
status: 'appended',
|
|
446
|
+
tail: {
|
|
447
|
+
byteLength: expectedTail.byteLength + encoded.length,
|
|
448
|
+
lastLineHash: hashLine(Buffer.from(lines.at(-1) ?? '')),
|
|
449
|
+
lastUuid: typeof last?.leafUuid === 'string'
|
|
450
|
+
? last.leafUuid
|
|
451
|
+
: expectedTail.lastUuid,
|
|
452
|
+
newlineTerminated: true,
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
}
|
|
381
456
|
serializeNewSidechain(entries) {
|
|
382
457
|
const lines = [];
|
|
383
458
|
const history = [];
|
|
@@ -47,6 +47,7 @@ export class InMemoryTranscriptStore {
|
|
|
47
47
|
load: () => this.load(),
|
|
48
48
|
append: (expectedTail, entry) => this.appendMany(expectedTail, [entry]),
|
|
49
49
|
appendMany: (expectedTail, entries) => this.appendMany(expectedTail, entries),
|
|
50
|
+
appendMetadataSnapshot: (expectedTail, entries) => this.appendMany(expectedTail, entries),
|
|
50
51
|
});
|
|
51
52
|
return { status: 'completed', value };
|
|
52
53
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "praxis-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "Local-first, single-user general agent for the command line.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "wuqisen",
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
"test:pr-link-compat": "npm run build && node scripts/verify-pr-link-compatibility.mjs",
|
|
81
81
|
"test:resume-at-compat": "npm run build && node scripts/verify-resume-session-at-compatibility.mjs",
|
|
82
82
|
"test:resume-selector-compat": "npm run build && node scripts/verify-resume-selector-compatibility.mjs",
|
|
83
|
+
"test:session-metadata-compat": "npm run build && node scripts/verify-session-metadata-compatibility.mjs",
|
|
83
84
|
"test:thinking-compat": "npm run build && node scripts/verify-thinking-controls.mjs",
|
|
84
85
|
"test:memory-import-compat": "npm run build && node scripts/verify-memory-import-compatibility.mjs",
|
|
85
86
|
"test:tui-compat": "npm run build && node scripts/verify-tui-compatibility.mjs",
|