@adhdev/daemon-core 0.9.82-rc.540 → 0.9.82-rc.541
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/cli-adapters/cli-state-engine.d.ts +12 -0
- package/dist/commands/chat-commands-read.d.ts +3 -0
- package/dist/index.js +740 -588
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +747 -595
- package/dist/index.mjs.map +1 -1
- package/dist/providers/spec/types.d.ts +57 -2
- package/package.json +3 -3
- package/src/cli-adapters/cli-state-engine.ts +76 -1
- package/src/commands/chat-commands-read.ts +28 -1
- package/src/mesh/coordinator-prompt.ts +2 -1
- package/src/providers/spec/native-history-executor.ts +174 -9
- package/src/providers/spec/types.ts +54 -2
|
@@ -71,8 +71,62 @@ export interface NativeHistoryJsonlSource {
|
|
|
71
71
|
path: string;
|
|
72
72
|
file_pattern?: string;
|
|
73
73
|
recent_window_ms?: number;
|
|
74
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Where to read the provider session id from.
|
|
76
|
+
* 'filename_uuid' (default) — the transcript FILE basename embeds the
|
|
77
|
+
* uuid (`<uuid>.jsonl`, cursor-agent).
|
|
78
|
+
* 'first_record' — jsonpath (`session_id_path`) into the first record.
|
|
79
|
+
* 'dir_uuid' — a PARENT DIRECTORY segment embeds the uuid, and the
|
|
80
|
+
* leaf file has a fixed name. kimi persists every session at
|
|
81
|
+
* `~/.kimi-code/sessions/<wdKey>/session_<uuid>/agents/main/wire.jsonl`
|
|
82
|
+
* — the file is always `wire.jsonl`, so the uuid can only come from the
|
|
83
|
+
* `session_<uuid>` directory segment. The nearest ancestor segment
|
|
84
|
+
* containing a uuid wins.
|
|
85
|
+
*/
|
|
86
|
+
session_id_from?: 'filename_uuid' | 'first_record' | 'dir_uuid';
|
|
75
87
|
session_id_path?: string;
|
|
88
|
+
/**
|
|
89
|
+
* Fallback workspace attribution from a per-session SIDECAR json file when
|
|
90
|
+
* the transcript itself carries no `session_meta` cwd record AND the on-disk
|
|
91
|
+
* directory slug is irreversible. kimi's `wire.jsonl` has no cwd line and the
|
|
92
|
+
* `wd_<slug>_<sha12>` directory segment is a lossy slug + hash that cannot be
|
|
93
|
+
* reversed to the real workspace. But kimi writes a sibling `state.json` next
|
|
94
|
+
* to the session dir carrying the authoritative `workDir`. This option names
|
|
95
|
+
* that sidecar (relative to the resolved wire file's directory) and the json
|
|
96
|
+
* path to the workspace inside it; the executor reads it, stamps the value as
|
|
97
|
+
* the transcript workspace on every message, and — for workspace-scoped file
|
|
98
|
+
* selection (no pinned session id yet, the antigravity/opencode first-read
|
|
99
|
+
* case) — only accepts a candidate wire file whose sidecar workDir matches the
|
|
100
|
+
* input workspace. Mirrors cursor's `workspace_from_input` and opencode's
|
|
101
|
+
* per-row `message_map.workspace`, for a jsonl store whose workspace lives in
|
|
102
|
+
* a sidecar rather than in the transcript or a reversible slug.
|
|
103
|
+
*/
|
|
104
|
+
workspace_from_sidecar?: {
|
|
105
|
+
/** Path to the sidecar json, relative to the resolved wire file's
|
|
106
|
+
* directory. e.g. `../../state.json` for
|
|
107
|
+
* `session_<uuid>/agents/main/wire.jsonl` → `session_<uuid>/state.json`. */
|
|
108
|
+
rel_path: string;
|
|
109
|
+
/** jsonpath-lite to the workspace string inside the sidecar. e.g.
|
|
110
|
+
* `$.workDir`. */
|
|
111
|
+
workspace_path: string;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Multi-shape record projection. A jsonl store whose user turns and
|
|
115
|
+
* assistant turns are DIFFERENT record types (so a single `message_map`
|
|
116
|
+
* can't extract both roles) declares one matcher per shape here. For each
|
|
117
|
+
* on-disk record the executor picks the FIRST entry whose `where` matches
|
|
118
|
+
* and projects the record with that entry's `message_map`; a record that
|
|
119
|
+
* matches no entry is dropped. kimi stores user turns as
|
|
120
|
+
* `type=="turn.prompt"` (`$.input[*].text`, role=user) and assistant text as
|
|
121
|
+
* `type=="context.append_loop_event"` content.part events
|
|
122
|
+
* (`$.event.part.text`, role=assistant) — two shapes with different role and
|
|
123
|
+
* content paths. Absent → the top-level single `message_map` (+ optional
|
|
124
|
+
* `message_filter`) path is used unchanged.
|
|
125
|
+
*/
|
|
126
|
+
records?: Array<{
|
|
127
|
+
where?: string;
|
|
128
|
+
message_map: NativeHistoryMessageMap;
|
|
129
|
+
}>;
|
|
76
130
|
message_filter?: {
|
|
77
131
|
where: string;
|
|
78
132
|
};
|
|
@@ -93,7 +147,8 @@ export interface NativeHistoryJsonlSource {
|
|
|
93
147
|
* closed as before.
|
|
94
148
|
*/
|
|
95
149
|
workspace_from_input?: boolean;
|
|
96
|
-
|
|
150
|
+
/** Single-shape projection. Required unless `records` (multi-shape) is set. */
|
|
151
|
+
message_map?: NativeHistoryMessageMap;
|
|
97
152
|
}
|
|
98
153
|
export interface NativeHistorySqliteSource {
|
|
99
154
|
kind: 'sqlite';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.541",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"author": "vilmire",
|
|
48
48
|
"license": "AGPL-3.0-or-later",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
51
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
50
|
+
"@adhdev/mesh-shared": "0.9.82-rc.541",
|
|
51
|
+
"@adhdev/session-host-core": "0.9.82-rc.541",
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
53
53
|
"ajv": "^8.20.0",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
|
@@ -112,6 +112,21 @@ const IDLE_CONFIRMATION_GRACE_MS = 2_000;
|
|
|
112
112
|
// so a genuinely-finished turn can never be held past this bound (no infinite defer).
|
|
113
113
|
const APPROVAL_RESUME_IDLE_DEFER_CAP_MS = 18_000;
|
|
114
114
|
|
|
115
|
+
// FALSE-IDLE (screen-quiet gate): the generating→idle transition (both the
|
|
116
|
+
// candidate-confirmed finish AND the idleFinish timeout finish) requires the
|
|
117
|
+
// VISIBLE TERMINAL SCREEN CONTENT to have been byte-identical for at least this
|
|
118
|
+
// long, continuously. `snap.lastScreenChangeAt` is bumped by the adapter every
|
|
119
|
+
// time the normalized screen snapshot changes (a spinner frame, streaming
|
|
120
|
+
// command output, etc.), so `now - lastScreenChangeAt` is the real screen-diff
|
|
121
|
+
// quiet age. A worker that is WAITING on a long-running foreground command
|
|
122
|
+
// (e.g. a `curl :3847/health` poll loop) keeps repainting the screen, so its
|
|
123
|
+
// quiet age never reaches this threshold and it can never be declared idle.
|
|
124
|
+
// This is a NECESSARY gate layered on top of the existing settle conditions —
|
|
125
|
+
// it only ever prevents a finish, never forces one. Owner-chosen at 5s (8s felt
|
|
126
|
+
// too long). Screen snapshots are read at most every 250ms
|
|
127
|
+
// (SCREEN_SNAPSHOT_MIN_INTERVAL_MS), so the granularity is well under 5s.
|
|
128
|
+
const SCREEN_QUIET_IDLE_MS = 5_000;
|
|
129
|
+
|
|
115
130
|
// ─── Engine ────────────────────────────────────────────────────────────────
|
|
116
131
|
|
|
117
132
|
export class CliStateEngine {
|
|
@@ -773,6 +788,13 @@ export class CliStateEngine {
|
|
|
773
788
|
this.idleTimeout = setTimeout(() => {
|
|
774
789
|
if (this.isWaitingForResponse && !this.hasActionableApproval()) {
|
|
775
790
|
if (this.shouldDeferIdleTimeoutFinish()) return;
|
|
791
|
+
// FALSE-IDLE (screen-quiet gate): a recent_activity_hold worker still
|
|
792
|
+
// repainting (spinner + streaming command output) must NOT be finished
|
|
793
|
+
// by this timeout. Re-arm and re-evaluate until the screen is quiet.
|
|
794
|
+
if (!this.hasScreenBeenQuietForIdle(Date.now())) {
|
|
795
|
+
this.evaluateSettled(this.transport.getSnapshot());
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
776
798
|
this.finishResponse();
|
|
777
799
|
}
|
|
778
800
|
}, this.timeouts.generatingIdle);
|
|
@@ -794,6 +816,12 @@ export class CliStateEngine {
|
|
|
794
816
|
this.idleTimeout = setTimeout(() => {
|
|
795
817
|
if (this.isWaitingForResponse && !this.hasActionableApproval()) {
|
|
796
818
|
if (this.shouldDeferIdleTimeoutFinish()) return;
|
|
819
|
+
// FALSE-IDLE (screen-quiet gate): do not finish while the screen
|
|
820
|
+
// is still repainting (see applyHoldGenerating).
|
|
821
|
+
if (!this.hasScreenBeenQuietForIdle(Date.now())) {
|
|
822
|
+
this.evaluateSettled(this.transport.getSnapshot());
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
797
825
|
this.finishResponse();
|
|
798
826
|
}
|
|
799
827
|
}, this.timeouts.generatingIdle);
|
|
@@ -922,6 +950,15 @@ export class CliStateEngine {
|
|
|
922
950
|
this.idleTimeout = setTimeout(() => {
|
|
923
951
|
if (this.isWaitingForResponse) {
|
|
924
952
|
if (this.shouldDeferIdleTimeoutFinish()) return;
|
|
953
|
+
// FALSE-IDLE (screen-quiet gate): the generating→idle timeout must NOT
|
|
954
|
+
// finish while the visible screen is still changing (spinner + streaming
|
|
955
|
+
// command output). This is the primary false-idle path — a worker WAITING
|
|
956
|
+
// on a long-running foreground command sits in generating with a live,
|
|
957
|
+
// repainting screen. Re-evaluate until the screen has been quiet >= 5s.
|
|
958
|
+
if (!this.hasScreenBeenQuietForIdle(Date.now())) {
|
|
959
|
+
this.evaluateSettled(this.transport.getSnapshot());
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
925
962
|
this.finishResponse();
|
|
926
963
|
}
|
|
927
964
|
}, this.timeouts.generatingIdle);
|
|
@@ -1007,7 +1044,15 @@ export class CliStateEngine {
|
|
|
1007
1044
|
const assistantLength = (lastParsedAssistant as any)?.content?.length || 0;
|
|
1008
1045
|
const idleFinishConfirmMs = this.timeouts.idleFinishConfirm;
|
|
1009
1046
|
const idleQuietThresholdMs = Math.max(idleFinishConfirmMs, this.timeouts.outputSettle);
|
|
1010
|
-
|
|
1047
|
+
// FALSE-IDLE (screen-quiet gate): NECESSARY condition — the visible screen
|
|
1048
|
+
// content must have been byte-identical for >= SCREEN_QUIET_IDLE_MS. A worker
|
|
1049
|
+
// still repainting (spinner frame, streaming command output) resets
|
|
1050
|
+
// lastScreenChangeAt on every change, so its quiet age never reaches the
|
|
1051
|
+
// threshold and it can never arm/confirm idle. Layered on top of the existing
|
|
1052
|
+
// conditions — it only ever prevents a finish, never forces one.
|
|
1053
|
+
const screenQuietForIdle = screenStableMs >= SCREEN_QUIET_IDLE_MS;
|
|
1054
|
+
const idleReady = !modal && hasAssistantTurn && quietForMs >= idleQuietThresholdMs
|
|
1055
|
+
&& screenStableMs >= idleFinishConfirmMs && screenQuietForIdle;
|
|
1011
1056
|
const candidate = this.idleFinishCandidate;
|
|
1012
1057
|
const candidateQuiet = !!candidate && candidate.responseEpoch === this.responseEpoch
|
|
1013
1058
|
&& candidate.lastOutputAt === snap.lastOutputAt
|
|
@@ -1060,6 +1105,19 @@ export class CliStateEngine {
|
|
|
1060
1105
|
return;
|
|
1061
1106
|
}
|
|
1062
1107
|
if (this.shouldDeferIdleTimeoutFinish()) return;
|
|
1108
|
+
// FALSE-IDLE (screen-quiet gate): the idleFinish timeout must NOT
|
|
1109
|
+
// finish while the visible screen is still changing. A worker
|
|
1110
|
+
// WAITING on a long-running foreground command keeps repainting the
|
|
1111
|
+
// screen (spinner + streaming output), so lastScreenChangeAt stays
|
|
1112
|
+
// fresh and the quiet age never reaches SCREEN_QUIET_IDLE_MS. Re-arm
|
|
1113
|
+
// and re-evaluate instead of emitting a weak/false completion.
|
|
1114
|
+
if (!this.hasScreenBeenQuietForIdle(Date.now())) {
|
|
1115
|
+
if (this.idleTimeout) clearTimeout(this.idleTimeout);
|
|
1116
|
+
this.idleTimeout = setTimeout(() => {
|
|
1117
|
+
if (this.isWaitingForResponse) this.evaluateSettled(this.transport.getSnapshot());
|
|
1118
|
+
}, this.timeouts.idleFinish);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1063
1121
|
const parsed = this.runParseSession(this.transport.getSnapshot());
|
|
1064
1122
|
if (this.shouldDeferFinishForTranscript(parsed)) {
|
|
1065
1123
|
this.rescheduleTranscriptFinishCheck('transcript_idle_timeout_not_final');
|
|
@@ -1071,6 +1129,23 @@ export class CliStateEngine {
|
|
|
1071
1129
|
}, this.timeouts.idleFinish);
|
|
1072
1130
|
}
|
|
1073
1131
|
|
|
1132
|
+
/**
|
|
1133
|
+
* FALSE-IDLE (screen-quiet gate): has the visible terminal screen content been
|
|
1134
|
+
* byte-identical for at least SCREEN_QUIET_IDLE_MS continuously?
|
|
1135
|
+
*
|
|
1136
|
+
* `lastScreenChangeAt` is bumped by the adapter every time the normalized screen
|
|
1137
|
+
* snapshot changes (spinner frame, streaming command output, etc.), so
|
|
1138
|
+
* `now - lastScreenChangeAt` is the real screen-diff quiet age. Reads the LIVE
|
|
1139
|
+
* transport snapshot so the deferred idleFinish timeout re-checks current screen
|
|
1140
|
+
* state, not the stale snapshot from when the timer was armed. A never-changed
|
|
1141
|
+
* screen (lastScreenChangeAt === 0) is treated as quiet.
|
|
1142
|
+
*/
|
|
1143
|
+
private hasScreenBeenQuietForIdle(now: number): boolean {
|
|
1144
|
+
const lastChange = this.transport.getSnapshot().lastScreenChangeAt;
|
|
1145
|
+
if (!lastChange) return true;
|
|
1146
|
+
return (now - lastChange) >= SCREEN_QUIET_IDLE_MS;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1074
1149
|
/**
|
|
1075
1150
|
* FALSE-IDLE (Fix 2): should applyIdle suppress the idle/finish for the current
|
|
1076
1151
|
* turn because we are inside the post-approval resume grace?
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* native-history / source-resolution / normalization helpers they use.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import * as fs from 'node:fs';
|
|
6
7
|
import * as path from 'node:path';
|
|
7
8
|
import type { CommandResult, CommandHelpers } from './handler.js';
|
|
8
9
|
import type { CliAdapter } from '../cli-adapter-types.js';
|
|
@@ -1151,7 +1152,33 @@ function readExactRuntimeMirrorMessages(args: {
|
|
|
1151
1152
|
function normalizeComparableWorkspace(value: unknown): string {
|
|
1152
1153
|
const text = typeof value === 'string' ? value.trim() : '';
|
|
1153
1154
|
if (!text) return '';
|
|
1154
|
-
|
|
1155
|
+
// Canonicalize via realpath so symlink aliases compare equal. On macOS
|
|
1156
|
+
// `/tmp` is a symlink to `/private/tmp`: a provider whose on-disk workspace
|
|
1157
|
+
// record is stored realpath'd (kimi's state.json workDir → `/private/tmp/…`)
|
|
1158
|
+
// must still match an ADHDev session workspace passed as `/tmp/…`. Without
|
|
1159
|
+
// this the native-history workspace-safety gate (workspace_from_sidecar) saw
|
|
1160
|
+
// a false mismatch, marked the read unsafe, and fell back to the PTY parser.
|
|
1161
|
+
// realpath throws when the path doesn't exist (e.g. a stale/never-created
|
|
1162
|
+
// workspace) — fall back to the lexical resolve then, never crash the read
|
|
1163
|
+
// path. Fail-closed cross-workspace safety is preserved: two genuinely
|
|
1164
|
+
// different directories still realpath to different paths, and the lexical
|
|
1165
|
+
// fallback is unchanged from the prior behaviour.
|
|
1166
|
+
const lexical = path.resolve(text);
|
|
1167
|
+
try {
|
|
1168
|
+
return fs.realpathSync.native(lexical);
|
|
1169
|
+
} catch {
|
|
1170
|
+
try {
|
|
1171
|
+
return fs.realpathSync(lexical);
|
|
1172
|
+
} catch {
|
|
1173
|
+
return lexical;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/** Test hook for the symlink-safe workspace comparison used by the
|
|
1179
|
+
* native-history workspace-safety gate. */
|
|
1180
|
+
export function __normalizeComparableWorkspaceForTest(value: unknown): string {
|
|
1181
|
+
return normalizeComparableWorkspace(value);
|
|
1155
1182
|
}
|
|
1156
1183
|
function isCurrentRuntimePtySafelyAttributed(args: {
|
|
1157
1184
|
adapter: CliAdapter;
|
|
@@ -842,7 +842,8 @@ function buildRulesSection(coordinatorCliType?: string): string {
|
|
|
842
842
|
|
|
843
843
|
return `## Rules
|
|
844
844
|
|
|
845
|
-
- **Route, don't implement.** Delegate all code reading, analysis, and execution to node agents. Never read source files or run commands in the coordinator — keep context lean.
|
|
845
|
+
- **Route, don't implement.** Delegate all code reading, analysis, and execution to node agents. Never read source files or run commands in the coordinator — keep context lean. See also: **Never use local sub-agents** below.
|
|
846
|
+
- **Never use local sub-agents.** Do NOT spawn your runtime's own sub-agents (e.g. Claude Code's Task/Explore/Agent tools, or any equivalent in-process agent-spawning tool) to read code, investigate, run RCA, or implement. Such sub-agents execute on the coordinator's machine, outside the mesh — they escape mesh parallelism, the ledger/audit trail, node capability profiles, and worktree isolation, and leave no \`mesh_task_history\` record. ALL code reading, analysis, RCA, and implementation must be delegated to mesh nodes via \`mesh_enqueue_task\` / \`mesh_send_task\` (use \`task_mode: "live_debug_readonly"\` for read-only investigation), or cross-verified via \`mesh_magi_review\` for read-only fan-out. The coordinator's own actions are limited to \`mesh_*\` tool orchestration and synthesizing results.
|
|
846
847
|
- **Front-load task messages.** Include everything the agent needs (files, problem, expected fix) in \`mesh_enqueue_task\` / \`mesh_send_task\`. Append a structured result request at the end: ask the worker to conclude with a JSON block containing \`status\`, \`changedFiles\`, \`gitStatus\`, \`validationResults\`, \`errors\`, \`nextAction\`. The daemon parses this automatically; you can read it from \`mesh_task_history\`.
|
|
847
848
|
- **Reuse idle sessions.** For follow-up, retry, commit/push, or cleanup on the same issue, send only the delta to the existing idle session. Start a fresh session only when: (a) branch/worktree isolation is required, (b) the existing session had a dispatch failure or provider mismatch, (c) the transcript/runtime is contaminated or interrupted, or (d) the user explicitly asks for a different provider/session. Continuation of the same issue in an already-idle session is allowed and preferred — this rule blocks concurrent unrelated work interleaved into a live (still-generating) session, not sequential same-issue follow-ups.
|
|
848
849
|
- **Worktree affinity.** A worktree is a durable per-branch workspace; keep all of a branch's code_change/fix/review work on its worktree node by targeting \`required_tags: ["worktree=<branch>"]\` or \`target_node_id\`. Get the id/branch from the \`mesh_clone_node\` result or a live \`mesh_status\` — the Configured Nodes snapshot won't list a worktree cloned after launch. Untargeted same-branch follow-ups drift to the base node. Only \`convergence\` (merge/push) runs on the base, never pinned to the worktree.
|
|
@@ -109,29 +109,40 @@ function executeJsonl(src: NativeHistoryJsonlSource, input: NativeHistoryInput):
|
|
|
109
109
|
// Prefer an in-transcript session_meta cwd; fall back to the input workspace
|
|
110
110
|
// only when the spec opts in AND the resolved file lives under that
|
|
111
111
|
// workspace's project slug (cursor-agent writes no session_meta and hides the
|
|
112
|
-
// workspace in the lossy on-disk slug — see workspace_from_input).
|
|
112
|
+
// workspace in the lossy on-disk slug — see workspace_from_input). A store
|
|
113
|
+
// whose workspace lives in a per-session sidecar json (kimi's state.json,
|
|
114
|
+
// whose `wd_<slug>_<sha12>` dir is irreversible) reads it from there.
|
|
113
115
|
const transcriptWorkspace = readSessionMetaWorkspace(lines)
|
|
116
|
+
?? (src.workspace_from_sidecar ? readSidecarWorkspace(sourcePath, src.workspace_from_sidecar) : undefined)
|
|
114
117
|
?? (src.workspace_from_input ? workspaceFromInputIfSlugMatches(sourcePath, input) : undefined);
|
|
115
118
|
|
|
116
|
-
// session id: filename uuid or extracted from
|
|
119
|
+
// session id: filename uuid, a parent directory uuid, or extracted from the
|
|
120
|
+
// first record.
|
|
117
121
|
let providerSessionId: string | undefined;
|
|
118
122
|
if (src.session_id_from === 'first_record' && src.session_id_path) {
|
|
119
123
|
const v = jsonPathGet(lines[0], src.session_id_path);
|
|
120
124
|
if (typeof v === 'string' && v) providerSessionId = v;
|
|
125
|
+
} else if (src.session_id_from === 'dir_uuid') {
|
|
126
|
+
providerSessionId = dirUuid(sourcePath) || undefined;
|
|
121
127
|
} else if (src.session_id_from === 'filename_uuid' || !src.session_id_from) {
|
|
122
128
|
const m = path.basename(sourcePath).match(UUID_RE);
|
|
123
129
|
if (m) providerSessionId = m[1];
|
|
124
130
|
}
|
|
125
131
|
|
|
132
|
+
// Compare requested vs resolved by embedded uuid so a `session_<uuid>` pin
|
|
133
|
+
// (kimi's on-disk session id carries a `session_` prefix) still matches the
|
|
134
|
+
// bare uuid the executor extracts from the directory segment.
|
|
126
135
|
const requested = readRequestedSessionId(input) || '';
|
|
127
|
-
if (requested && providerSessionId && providerSessionId
|
|
136
|
+
if (requested && providerSessionId && !sameSessionUuid(providerSessionId, requested)) return null;
|
|
128
137
|
|
|
129
|
-
|
|
138
|
+
// Multi-shape (records[]) vs single-shape (message_map) projection.
|
|
139
|
+
const shapes = compileRecordShapes(src);
|
|
130
140
|
const messages: NativeHistoryMessage[] = [];
|
|
131
141
|
for (let i = 0; i < lines.length; i += 1) {
|
|
132
142
|
const rec = lines[i];
|
|
133
|
-
|
|
134
|
-
|
|
143
|
+
const shape = shapes.pick(rec);
|
|
144
|
+
if (!shape) continue;
|
|
145
|
+
for (const msg of projectMessages(rec, shape.map, i, lines.length, mtime)) {
|
|
135
146
|
if (transcriptWorkspace) msg.workspace = transcriptWorkspace;
|
|
136
147
|
messages.push(msg);
|
|
137
148
|
}
|
|
@@ -188,9 +199,31 @@ export function resolveJsonlSourcePath(src: NativeHistoryJsonlSource, input: Nat
|
|
|
188
199
|
const workspaceHint = typeof input.workspace === 'string' && input.workspace.trim() ? input.workspace.trim() : '';
|
|
189
200
|
let sourcePath: string | null = null;
|
|
190
201
|
if (resolved.includes('*')) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
202
|
+
// dir_uuid + sidecar-workspace stores (kimi): the session id lives in a
|
|
203
|
+
// parent directory segment (not the fixed leaf filename) and the
|
|
204
|
+
// workspace lives in a per-session sidecar json (not the irreversible
|
|
205
|
+
// `wd_<slug>_<sha12>` dir, not the transcript). The filename-uuid pickers
|
|
206
|
+
// can't match here, so select by the directory uuid when pinned, else by
|
|
207
|
+
// the sidecar workDir + recency when workspace-scoped.
|
|
208
|
+
if (src.session_id_from === 'dir_uuid' || src.workspace_from_sidecar) {
|
|
209
|
+
// Pinned → match by directory uuid. Unpinned + a workspace hint →
|
|
210
|
+
// scope to the sidecar workDir and FAIL CLOSED (no workspace-blind
|
|
211
|
+
// newest-file fallback) so another workspace's session is never
|
|
212
|
+
// aliased. Only when there is neither a pin nor a workspace hint do
|
|
213
|
+
// we fall back to newest-recent (single-session dev/test case).
|
|
214
|
+
sourcePath = pickDirUuidFileAcrossGlob(resolved, filePat, requestedSessionId);
|
|
215
|
+
if (!sourcePath && !requestedSessionId) {
|
|
216
|
+
if (src.workspace_from_sidecar && workspaceHint) {
|
|
217
|
+
sourcePath = pickSidecarWorkspaceFileAcrossGlob(resolved, filePat, windowMs, sessionFloor, workspaceHint, src.workspace_from_sidecar);
|
|
218
|
+
} else {
|
|
219
|
+
sourcePath = newestRecentFileAcrossGlob(resolved, filePat, windowMs, sessionFloor);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
sourcePath = pickExactSessionFileAcrossGlob(resolved, filePat, requestedSessionId)
|
|
224
|
+
|| pickSessionBoundFileAcrossGlob(resolved, filePat, windowMs, sessionFloor, workspaceHint)
|
|
225
|
+
|| newestRecentFileAcrossGlob(resolved, filePat, windowMs, sessionFloor);
|
|
226
|
+
}
|
|
194
227
|
} else {
|
|
195
228
|
let stat: fs.Stats | null = null;
|
|
196
229
|
try { stat = fs.statSync(resolved); } catch { /* fall through to date-walk fallback */ }
|
|
@@ -253,6 +286,82 @@ function readSessionMetaWorkspace(lines: any[]): string | undefined {
|
|
|
253
286
|
return undefined;
|
|
254
287
|
}
|
|
255
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Read the workspace from a per-session sidecar json file (kimi's state.json).
|
|
291
|
+
* `rel_path` is resolved relative to the wire file's directory and the workspace
|
|
292
|
+
* is pulled out via `workspace_path` (jsonpath-lite). Returns undefined on any
|
|
293
|
+
* miss so the caller falls through to the next attribution strategy.
|
|
294
|
+
*/
|
|
295
|
+
function readSidecarWorkspace(
|
|
296
|
+
sourcePath: string,
|
|
297
|
+
cfg: { rel_path: string; workspace_path: string },
|
|
298
|
+
): string | undefined {
|
|
299
|
+
try {
|
|
300
|
+
const sidecar = path.resolve(path.dirname(sourcePath), cfg.rel_path);
|
|
301
|
+
const parsed = JSON.parse(fs.readFileSync(sidecar, 'utf8'));
|
|
302
|
+
const v = jsonPathGet(parsed, cfg.workspace_path);
|
|
303
|
+
return typeof v === 'string' && v.trim() ? v.trim() : undefined;
|
|
304
|
+
} catch {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Extract a uuid from the nearest ancestor DIRECTORY segment of a file path
|
|
311
|
+
* (kimi's `.../session_<uuid>/agents/main/wire.jsonl`). Walks from the leaf's
|
|
312
|
+
* parent upward and returns the first uuid found, or '' when none.
|
|
313
|
+
*/
|
|
314
|
+
function dirUuid(filePath: string): string {
|
|
315
|
+
const segs = path.dirname(filePath).split(path.sep);
|
|
316
|
+
for (let i = segs.length - 1; i >= 0; i -= 1) {
|
|
317
|
+
const m = segs[i].match(UUID_RE);
|
|
318
|
+
if (m) return m[1];
|
|
319
|
+
}
|
|
320
|
+
return '';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Compare two session ids by their embedded uuid, ignoring any prefix/suffix
|
|
324
|
+
* (kimi's `session_<uuid>` pin vs the bare `<uuid>` the executor extracts). */
|
|
325
|
+
function sameSessionUuid(a: string, b: string): boolean {
|
|
326
|
+
if (a === b) return true;
|
|
327
|
+
const ua = a.match(UUID_RE)?.[1]?.toLowerCase();
|
|
328
|
+
const ub = b.match(UUID_RE)?.[1]?.toLowerCase();
|
|
329
|
+
return !!ua && !!ub && ua === ub;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Resolve the projection strategy for a jsonl source. Multi-shape (`records[]`)
|
|
334
|
+
* picks the first entry whose `where` matches a record; single-shape falls back
|
|
335
|
+
* to the top-level `message_map` gated by the optional `message_filter`.
|
|
336
|
+
*/
|
|
337
|
+
function compileRecordShapes(src: NativeHistoryJsonlSource): {
|
|
338
|
+
pick: (record: any) => { map: NativeHistoryMessageMap } | null;
|
|
339
|
+
} {
|
|
340
|
+
if (Array.isArray(src.records) && src.records.length > 0) {
|
|
341
|
+
const compiled = src.records.map((r) => ({
|
|
342
|
+
where: r.where ? compileWhere(r.where) : null,
|
|
343
|
+
map: r.message_map,
|
|
344
|
+
}));
|
|
345
|
+
return {
|
|
346
|
+
pick: (record: any) => {
|
|
347
|
+
for (const shape of compiled) {
|
|
348
|
+
if (!shape.where || shape.where(record)) return { map: shape.map };
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const filter = src.message_filter ? compileWhere(src.message_filter.where) : null;
|
|
355
|
+
const map = src.message_map;
|
|
356
|
+
return {
|
|
357
|
+
pick: (record: any) => {
|
|
358
|
+
if (!map) return null;
|
|
359
|
+
if (filter && !filter(record)) return null;
|
|
360
|
+
return { map };
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
256
365
|
/**
|
|
257
366
|
* Return `input.workspace` when the resolved transcript file provably lives
|
|
258
367
|
* under that workspace's project-slug directory, else undefined.
|
|
@@ -835,6 +944,62 @@ function pickExactSessionFileAcrossGlob(template: string, pattern: RegExp, reque
|
|
|
835
944
|
return matches[0] || null;
|
|
836
945
|
}
|
|
837
946
|
|
|
947
|
+
/**
|
|
948
|
+
* dir_uuid exact pick across a glob: the requested session uuid is embedded in a
|
|
949
|
+
* parent DIRECTORY segment (kimi's `session_<uuid>/…/wire.jsonl`), not the leaf
|
|
950
|
+
* filename. Match the file whose ancestor path carries the requested uuid.
|
|
951
|
+
*/
|
|
952
|
+
function pickDirUuidFileAcrossGlob(template: string, pattern: RegExp, requestedSessionId: string): string | null {
|
|
953
|
+
if (!requestedSessionId) return null;
|
|
954
|
+
const wantUuid = requestedSessionId.match(UUID_RE)?.[1]?.toLowerCase();
|
|
955
|
+
if (!wantUuid) return null;
|
|
956
|
+
const dirs = expandDirGlob(template);
|
|
957
|
+
const matches: string[] = [];
|
|
958
|
+
for (const d of dirs) {
|
|
959
|
+
for (const p of listMatchingFiles(d, pattern)) {
|
|
960
|
+
if (dirUuid(p).toLowerCase() === wantUuid) matches.push(p);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
matches.sort((a, b) => safeMtimeMs(b) - safeMtimeMs(a));
|
|
964
|
+
return matches[0] || null;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Workspace-scoped pick across a glob for a sidecar-workspace store (kimi's
|
|
969
|
+
* first read, before a session id is pinned): among candidate wire files within
|
|
970
|
+
* the recency window, keep only those whose sidecar `state.json` workDir matches
|
|
971
|
+
* the input workspace, then take the newest. Fails closed (null) when no sidecar
|
|
972
|
+
* matches so an unrelated workspace's session is never aliased.
|
|
973
|
+
*/
|
|
974
|
+
function pickSidecarWorkspaceFileAcrossGlob(
|
|
975
|
+
template: string,
|
|
976
|
+
pattern: RegExp,
|
|
977
|
+
windowMs: number,
|
|
978
|
+
sessionFloorMs: number,
|
|
979
|
+
workspaceHint: string,
|
|
980
|
+
sidecar?: { rel_path: string; workspace_path: string },
|
|
981
|
+
): string | null {
|
|
982
|
+
if (!sidecar || !workspaceHint) return null;
|
|
983
|
+
let wsResolved = workspaceHint;
|
|
984
|
+
try { wsResolved = fs.realpathSync(workspaceHint); } catch { /* keep raw */ }
|
|
985
|
+
const dirs = expandDirGlob(template);
|
|
986
|
+
const cutoff = Math.max(Date.now() - windowMs, sessionFloorMs);
|
|
987
|
+
let best: { p: string; mtime: number } | null = null;
|
|
988
|
+
for (const d of dirs) {
|
|
989
|
+
for (const p of listMatchingFiles(d, pattern)) {
|
|
990
|
+
const mtime = safeMtimeMs(p);
|
|
991
|
+
if (mtime < cutoff) continue;
|
|
992
|
+
const ws = readSidecarWorkspace(p, sidecar);
|
|
993
|
+
if (!ws) continue;
|
|
994
|
+
let wsReal = ws;
|
|
995
|
+
try { wsReal = fs.realpathSync(ws); } catch { /* keep raw */ }
|
|
996
|
+
if (ws !== workspaceHint && wsReal !== wsResolved) continue;
|
|
997
|
+
if (!best || mtime > best.mtime) best = { p, mtime };
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return best ? best.p : null;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
838
1003
|
function pickExactSessionFileAcrossDateWindow(
|
|
839
1004
|
template: string,
|
|
840
1005
|
input: NativeHistoryInput,
|
|
@@ -92,8 +92,59 @@ export interface NativeHistoryJsonlSource {
|
|
|
92
92
|
path: string;
|
|
93
93
|
file_pattern?: string;
|
|
94
94
|
recent_window_ms?: number;
|
|
95
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Where to read the provider session id from.
|
|
97
|
+
* 'filename_uuid' (default) — the transcript FILE basename embeds the
|
|
98
|
+
* uuid (`<uuid>.jsonl`, cursor-agent).
|
|
99
|
+
* 'first_record' — jsonpath (`session_id_path`) into the first record.
|
|
100
|
+
* 'dir_uuid' — a PARENT DIRECTORY segment embeds the uuid, and the
|
|
101
|
+
* leaf file has a fixed name. kimi persists every session at
|
|
102
|
+
* `~/.kimi-code/sessions/<wdKey>/session_<uuid>/agents/main/wire.jsonl`
|
|
103
|
+
* — the file is always `wire.jsonl`, so the uuid can only come from the
|
|
104
|
+
* `session_<uuid>` directory segment. The nearest ancestor segment
|
|
105
|
+
* containing a uuid wins.
|
|
106
|
+
*/
|
|
107
|
+
session_id_from?: 'filename_uuid' | 'first_record' | 'dir_uuid';
|
|
96
108
|
session_id_path?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Fallback workspace attribution from a per-session SIDECAR json file when
|
|
111
|
+
* the transcript itself carries no `session_meta` cwd record AND the on-disk
|
|
112
|
+
* directory slug is irreversible. kimi's `wire.jsonl` has no cwd line and the
|
|
113
|
+
* `wd_<slug>_<sha12>` directory segment is a lossy slug + hash that cannot be
|
|
114
|
+
* reversed to the real workspace. But kimi writes a sibling `state.json` next
|
|
115
|
+
* to the session dir carrying the authoritative `workDir`. This option names
|
|
116
|
+
* that sidecar (relative to the resolved wire file's directory) and the json
|
|
117
|
+
* path to the workspace inside it; the executor reads it, stamps the value as
|
|
118
|
+
* the transcript workspace on every message, and — for workspace-scoped file
|
|
119
|
+
* selection (no pinned session id yet, the antigravity/opencode first-read
|
|
120
|
+
* case) — only accepts a candidate wire file whose sidecar workDir matches the
|
|
121
|
+
* input workspace. Mirrors cursor's `workspace_from_input` and opencode's
|
|
122
|
+
* per-row `message_map.workspace`, for a jsonl store whose workspace lives in
|
|
123
|
+
* a sidecar rather than in the transcript or a reversible slug.
|
|
124
|
+
*/
|
|
125
|
+
workspace_from_sidecar?: {
|
|
126
|
+
/** Path to the sidecar json, relative to the resolved wire file's
|
|
127
|
+
* directory. e.g. `../../state.json` for
|
|
128
|
+
* `session_<uuid>/agents/main/wire.jsonl` → `session_<uuid>/state.json`. */
|
|
129
|
+
rel_path: string;
|
|
130
|
+
/** jsonpath-lite to the workspace string inside the sidecar. e.g.
|
|
131
|
+
* `$.workDir`. */
|
|
132
|
+
workspace_path: string;
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* Multi-shape record projection. A jsonl store whose user turns and
|
|
136
|
+
* assistant turns are DIFFERENT record types (so a single `message_map`
|
|
137
|
+
* can't extract both roles) declares one matcher per shape here. For each
|
|
138
|
+
* on-disk record the executor picks the FIRST entry whose `where` matches
|
|
139
|
+
* and projects the record with that entry's `message_map`; a record that
|
|
140
|
+
* matches no entry is dropped. kimi stores user turns as
|
|
141
|
+
* `type=="turn.prompt"` (`$.input[*].text`, role=user) and assistant text as
|
|
142
|
+
* `type=="context.append_loop_event"` content.part events
|
|
143
|
+
* (`$.event.part.text`, role=assistant) — two shapes with different role and
|
|
144
|
+
* content paths. Absent → the top-level single `message_map` (+ optional
|
|
145
|
+
* `message_filter`) path is used unchanged.
|
|
146
|
+
*/
|
|
147
|
+
records?: Array<{ where?: string; message_map: NativeHistoryMessageMap }>;
|
|
97
148
|
message_filter?: { where: string };
|
|
98
149
|
/**
|
|
99
150
|
* Fallback workspace attribution when the transcript carries no
|
|
@@ -112,7 +163,8 @@ export interface NativeHistoryJsonlSource {
|
|
|
112
163
|
* closed as before.
|
|
113
164
|
*/
|
|
114
165
|
workspace_from_input?: boolean;
|
|
115
|
-
|
|
166
|
+
/** Single-shape projection. Required unless `records` (multi-shape) is set. */
|
|
167
|
+
message_map?: NativeHistoryMessageMap;
|
|
116
168
|
}
|
|
117
169
|
|
|
118
170
|
export interface NativeHistorySqliteSource {
|