@sanqianx/ai-coding-event-bridge-console 0.5.0 → 0.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/console/index.html +1 -0
- package/package.json +2 -5
- package/src/agent-detect.js +1 -1
- package/src/bin.js +23 -4
- package/src/bridge/bin/commit-boundary.js +64 -0
- package/src/bridge/connectors/capture-guard.js +24 -0
- package/src/bridge/connectors/claude-code/hook-entry.js +45 -0
- package/src/bridge/connectors/claude-code/notify.js +57 -0
- package/src/bridge/connectors/codex/cursor-store.js +83 -0
- package/src/bridge/connectors/codex/hook-entry.js +215 -0
- package/src/bridge/connectors/codex/session-parser.js +100 -0
- package/src/bridge/connectors/opencode/hook-entry.js +55 -0
- package/src/bridge/core/bridge.js +205 -0
- package/src/bridge/core/commit-projection.js +288 -0
- package/src/bridge/core/compaction.js +23 -0
- package/src/bridge/core/consumer-registry.js +101 -0
- package/src/bridge/core/event-schema.js +127 -0
- package/src/bridge/core/fs-utils.js +81 -0
- package/src/bridge/core/journal-router.js +39 -0
- package/src/bridge/core/journal.js +507 -0
- package/src/bridge/core/lock.js +94 -0
- package/src/bridge/core/normalizer.js +114 -0
- package/src/bridge/core/paths.js +12 -0
- package/src/bridge/core/project-registry.js +176 -0
- package/src/bridge/core/repo-context.js +188 -0
- package/src/bridge/core/runtime-home.js +188 -0
- package/src/bridge/core/semver.js +23 -0
- package/src/bridge/core/turn-identity.js +127 -0
- package/src/bridge/index.js +86 -0
- package/src/bridge/installers/claude-code/installer.js +181 -0
- package/src/bridge/installers/codex/installer.js +114 -0
- package/src/bridge/installers/opencode/installer.js +96 -0
- package/src/bridge/installers/zcode/installer.js +188 -0
- package/src/bridge/query/conversation-query.js +194 -0
- package/src/server.js +83 -26
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { ensureRuntimeHome } = require('../../core/runtime-home');
|
|
7
|
+
const { ConsumerRegistry } = require('../../core/consumer-registry');
|
|
8
|
+
const { readJsonIfExists, writeJsonAtomicSync } = require('../../core/fs-utils');
|
|
9
|
+
|
|
10
|
+
const HOOK_EVENTS = ['UserPromptSubmit', 'Stop'];
|
|
11
|
+
|
|
12
|
+
// ZCode configuration-file hooks are disabled by default: the runner only
|
|
13
|
+
// exists when hooks.enabled is true (or a plugin contributes hooks, which we
|
|
14
|
+
// must not rely on). The managed entries therefore force it on; uninstall
|
|
15
|
+
// leaves the flag alone because third-party config hooks may depend on it.
|
|
16
|
+
class InstallerError extends Error {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'InstallerError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function shimPathFor(homeDir) {
|
|
24
|
+
return path.join(homeDir, 'bin', 'bridge-hook.cjs');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// process hooks (argument vector, no shell) are the portable form; bare
|
|
28
|
+
// `node` survives Node upgrades, unlike a baked-in absolute executable path.
|
|
29
|
+
function managedHook(shimPath) {
|
|
30
|
+
return { type: 'process', command: 'node', args: [shimPath], timeoutMs: 15000 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isManagedHook(hook, shimPath) {
|
|
34
|
+
if (!hook || typeof hook !== 'object') return false;
|
|
35
|
+
if (hook.type === 'process') {
|
|
36
|
+
return Array.isArray(hook.args) && hook.args.some((arg) => typeof arg === 'string' && arg.includes(shimPath));
|
|
37
|
+
}
|
|
38
|
+
return typeof hook.command === 'string' && hook.command.includes(shimPath);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readConfig(configFile) {
|
|
42
|
+
const config = readJsonIfExists(configFile);
|
|
43
|
+
if (config === null) {
|
|
44
|
+
if (fs.existsSync(configFile)) {
|
|
45
|
+
throw new InstallerError(`config file is not valid JSON: ${configFile}`);
|
|
46
|
+
}
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
|
|
50
|
+
throw new InstallerError(`config file must contain a JSON object: ${configFile}`);
|
|
51
|
+
}
|
|
52
|
+
return config;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function eventsOf(config) {
|
|
56
|
+
if (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)) {
|
|
57
|
+
config.hooks = {};
|
|
58
|
+
}
|
|
59
|
+
if (!config.hooks.events || typeof config.hooks.events !== 'object' || Array.isArray(config.hooks.events)) {
|
|
60
|
+
config.hooks.events = {};
|
|
61
|
+
}
|
|
62
|
+
return config.hooks.events;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function upsertManagedEntries(config, shimPath) {
|
|
66
|
+
const events = eventsOf(config);
|
|
67
|
+
const changed = [];
|
|
68
|
+
for (const eventName of HOOK_EVENTS) {
|
|
69
|
+
if (!Array.isArray(events[eventName])) {
|
|
70
|
+
events[eventName] = [];
|
|
71
|
+
}
|
|
72
|
+
const alreadyManaged = events[eventName].some(
|
|
73
|
+
(group) => group && Array.isArray(group.hooks) && group.hooks.some((hook) => isManagedHook(hook, shimPath))
|
|
74
|
+
);
|
|
75
|
+
if (alreadyManaged) continue;
|
|
76
|
+
events[eventName].push({ hooks: [managedHook(shimPath)] });
|
|
77
|
+
changed.push(eventName);
|
|
78
|
+
}
|
|
79
|
+
if (config.hooks.enabled !== true) {
|
|
80
|
+
config.hooks.enabled = true;
|
|
81
|
+
}
|
|
82
|
+
return changed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function removeManagedEntries(config, shimPath) {
|
|
86
|
+
const removed = [];
|
|
87
|
+
for (const eventName of Object.keys(config.hooks.events)) {
|
|
88
|
+
const groups = config.hooks.events[eventName];
|
|
89
|
+
if (!Array.isArray(groups)) continue;
|
|
90
|
+
const kept = groups.filter(
|
|
91
|
+
(group) =>
|
|
92
|
+
!(group && Array.isArray(group.hooks) && group.hooks.some((hook) => isManagedHook(hook, shimPath)))
|
|
93
|
+
);
|
|
94
|
+
if (kept.length !== groups.length) removed.push(eventName);
|
|
95
|
+
if (kept.length === 0) {
|
|
96
|
+
delete config.hooks.events[eventName];
|
|
97
|
+
} else {
|
|
98
|
+
config.hooks.events[eventName] = kept;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return removed;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Host-level consumer registration is owned by the host; the installer mutates
|
|
105
|
+
// the registry only on explicit opt-in (same contract as the other connectors).
|
|
106
|
+
async function installZcodeHook({ homeDir, consumerName, consumerMeta = {}, registerConsumer = false, configFile, version } = {}) {
|
|
107
|
+
const home = homeDir || path.join(os.homedir(), '.ai-coding-event-bridge');
|
|
108
|
+
const target = configFile || path.join(os.homedir(), '.zcode', 'cli', 'config.json');
|
|
109
|
+
const runtime = await ensureRuntimeHome({ homeDir: home, version });
|
|
110
|
+
const registry = new ConsumerRegistry(home);
|
|
111
|
+
if (consumerName && registerConsumer) {
|
|
112
|
+
await registry.registerConsumer(consumerName, consumerMeta);
|
|
113
|
+
}
|
|
114
|
+
const config = readConfig(target);
|
|
115
|
+
const added = upsertManagedEntries(config, shimPathFor(home));
|
|
116
|
+
writeJsonAtomicSync(target, config);
|
|
117
|
+
return { added, configFile: target, runtime, consumers: (await registry.getConsumers()).map((c) => c.name) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function statusZcodeHook({ homeDir, configFile } = {}) {
|
|
121
|
+
const home = homeDir || path.join(os.homedir(), '.ai-coding-event-bridge');
|
|
122
|
+
const target = configFile || path.join(os.homedir(), '.zcode', 'cli', 'config.json');
|
|
123
|
+
const config = readConfig(target);
|
|
124
|
+
const events = config.hooks && typeof config.hooks === 'object' ? config.hooks.events || {} : {};
|
|
125
|
+
const shimPath = shimPathFor(home);
|
|
126
|
+
const managedEvents = [];
|
|
127
|
+
let thirdPartyCount = 0;
|
|
128
|
+
for (const eventName of Object.keys(events)) {
|
|
129
|
+
const groups = events[eventName];
|
|
130
|
+
if (!Array.isArray(groups)) continue;
|
|
131
|
+
let hasManaged = false;
|
|
132
|
+
for (const group of groups) {
|
|
133
|
+
for (const hook of (group && group.hooks) || []) {
|
|
134
|
+
if (isManagedHook(hook, shimPath)) {
|
|
135
|
+
hasManaged = true;
|
|
136
|
+
} else {
|
|
137
|
+
thirdPartyCount++;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (hasManaged) managedEvents.push(eventName);
|
|
142
|
+
}
|
|
143
|
+
const registry = new ConsumerRegistry(home);
|
|
144
|
+
return {
|
|
145
|
+
installed:
|
|
146
|
+
HOOK_EVENTS.every((eventName) => managedEvents.includes(eventName)) &&
|
|
147
|
+
Boolean(config.hooks && config.hooks.enabled),
|
|
148
|
+
managedEvents,
|
|
149
|
+
thirdPartyCount,
|
|
150
|
+
consumers: (await registry.getConsumers()).map((c) => c.name)
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function repairZcodeHook({ homeDir, consumerName, consumerMeta = {}, registerConsumer = false, configFile, version } = {}) {
|
|
155
|
+
return installZcodeHook({ homeDir, consumerName, consumerMeta, registerConsumer, configFile, version });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function uninstallZcodeHook({ homeDir, consumerName, unregisterConsumer = false, configFile } = {}) {
|
|
159
|
+
const home = homeDir || path.join(os.homedir(), '.ai-coding-event-bridge');
|
|
160
|
+
const target = configFile || path.join(os.homedir(), '.zcode', 'cli', 'config.json');
|
|
161
|
+
const registry = new ConsumerRegistry(home);
|
|
162
|
+
const config = readConfig(target);
|
|
163
|
+
const removed = config.hooks && config.hooks.events ? removeManagedEntries(config, shimPathFor(home)) : [];
|
|
164
|
+
writeJsonAtomicSync(target, config);
|
|
165
|
+
let consumerUnregistered = false;
|
|
166
|
+
if (consumerName && unregisterConsumer) {
|
|
167
|
+
await registry.unregisterConsumer(consumerName);
|
|
168
|
+
consumerUnregistered = true;
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
removed: true,
|
|
172
|
+
removedEvents: removed,
|
|
173
|
+
configFile: target,
|
|
174
|
+
consumerUnregistered,
|
|
175
|
+
consumers: (await registry.getConsumers()).map((c) => c.name)
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = {
|
|
180
|
+
HOOK_EVENTS,
|
|
181
|
+
InstallerError,
|
|
182
|
+
installZcodeHook,
|
|
183
|
+
statusZcodeHook,
|
|
184
|
+
repairZcodeHook,
|
|
185
|
+
uninstallZcodeHook,
|
|
186
|
+
managedHook,
|
|
187
|
+
isManagedHook
|
|
188
|
+
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const { Journal } = require('../core/journal');
|
|
6
|
+
const { repoIdentityKey } = require('../core/repo-context');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Headless conversation query over the durable journal. This is the controlled
|
|
10
|
+
* business query surface: it may return full user/assistant bodies, supports
|
|
11
|
+
* cursor pagination for virtualized UIs, and never depends on a host database.
|
|
12
|
+
*/
|
|
13
|
+
class ConversationQuery {
|
|
14
|
+
constructor({ journalDir, journal } = {}) {
|
|
15
|
+
this.journal = journal || new Journal(journalDir);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async _events() {
|
|
19
|
+
return this.journal.readEvents({});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_isConversationEvent(record) {
|
|
23
|
+
return (
|
|
24
|
+
record.schema === 'ai-coding-event/v1'
|
|
25
|
+
&& (record.eventType === 'user_prompt' || record.eventType === 'assistant_response')
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async listProjects() {
|
|
30
|
+
const events = (await this._events()).filter(e => this._isConversationEvent(e) && repoIdentityKey(e.repoIdentity));
|
|
31
|
+
const byProject = new Map();
|
|
32
|
+
for (const event of events) {
|
|
33
|
+
const key = repoIdentityKey(event.repoIdentity);
|
|
34
|
+
const entry = byProject.get(key) || {
|
|
35
|
+
repoIdentity: event.repoIdentity,
|
|
36
|
+
projectPath: event.projectPath || null,
|
|
37
|
+
sources: new Set(),
|
|
38
|
+
eventCount: 0,
|
|
39
|
+
lastSequence: 0,
|
|
40
|
+
lastCapturedAt: null
|
|
41
|
+
};
|
|
42
|
+
entry.sources.add(event.source);
|
|
43
|
+
entry.eventCount += 1;
|
|
44
|
+
if (event.sequence > entry.lastSequence) {
|
|
45
|
+
entry.lastSequence = event.sequence;
|
|
46
|
+
entry.lastCapturedAt = event.capturedAt || null;
|
|
47
|
+
}
|
|
48
|
+
byProject.set(key, entry);
|
|
49
|
+
}
|
|
50
|
+
return [...byProject.values()]
|
|
51
|
+
.map(entry => ({ ...entry, sources: [...entry.sources] }))
|
|
52
|
+
.sort((a, b) => b.lastSequence - a.lastSequence);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async listSessions({ project } = {}) {
|
|
56
|
+
const projectKey = project === undefined || project === null ? null : repoIdentityKey(project) || project;
|
|
57
|
+
const events = (await this._events()).filter(
|
|
58
|
+
e => this._isConversationEvent(e) && (!projectKey || repoIdentityKey(e.repoIdentity) === projectKey) && e.sessionId
|
|
59
|
+
);
|
|
60
|
+
const bySession = new Map();
|
|
61
|
+
for (const event of events) {
|
|
62
|
+
const entry = bySession.get(event.sessionId) || {
|
|
63
|
+
sessionId: event.sessionId,
|
|
64
|
+
repoIdentity: event.repoIdentity,
|
|
65
|
+
source: event.source,
|
|
66
|
+
eventCount: 0,
|
|
67
|
+
firstSequence: event.sequence,
|
|
68
|
+
lastSequence: event.sequence
|
|
69
|
+
};
|
|
70
|
+
entry.eventCount += 1;
|
|
71
|
+
entry.lastSequence = Math.max(entry.lastSequence, event.sequence);
|
|
72
|
+
entry.firstSequence = Math.min(entry.firstSequence, event.sequence);
|
|
73
|
+
bySession.set(event.sessionId, entry);
|
|
74
|
+
}
|
|
75
|
+
return [...bySession.values()].sort((a, b) => b.lastSequence - a.lastSequence);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_localDay(iso) {
|
|
79
|
+
const d = new Date(iso);
|
|
80
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
81
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_projectTurns(events) {
|
|
85
|
+
// Turn assembly by turnId; assistant events without turnId bind to the
|
|
86
|
+
// single open turn of their session (same rule as the journal projection).
|
|
87
|
+
const turns = [];
|
|
88
|
+
const open = new Map();
|
|
89
|
+
for (const event of events) {
|
|
90
|
+
if (event.eventType === 'user_prompt' && event.turnId) {
|
|
91
|
+
const turn = {
|
|
92
|
+
turnId: event.turnId,
|
|
93
|
+
sessionId: event.sessionId || null,
|
|
94
|
+
source: event.source,
|
|
95
|
+
repoIdentity: event.repoIdentity,
|
|
96
|
+
day: this._localDay(event.capturedAt),
|
|
97
|
+
startSequence: event.sequence,
|
|
98
|
+
endSequence: event.sequence,
|
|
99
|
+
userEvents: [],
|
|
100
|
+
assistantEvents: []
|
|
101
|
+
};
|
|
102
|
+
turn.userEvents.push(this._projectEvent(event));
|
|
103
|
+
turns.push(turn);
|
|
104
|
+
open.set(event.turnId, turn);
|
|
105
|
+
} else if (event.eventType === 'assistant_response') {
|
|
106
|
+
let turn = event.turnId ? open.get(event.turnId) : null;
|
|
107
|
+
if (!turn && event.sessionId) {
|
|
108
|
+
const openForSession = [...open.values()].filter(t => t.sessionId === event.sessionId);
|
|
109
|
+
if (openForSession.length === 1) turn = openForSession[0];
|
|
110
|
+
}
|
|
111
|
+
if (turn) {
|
|
112
|
+
turn.assistantEvents.push(this._projectEvent(event));
|
|
113
|
+
turn.endSequence = event.sequence;
|
|
114
|
+
open.delete(turn.turnId);
|
|
115
|
+
}
|
|
116
|
+
// Orphan assistant evidence is intentionally not surfaced as a turn.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return turns;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_projectEvent(event) {
|
|
123
|
+
// Controlled projection: full conversation bodies, journal internals stripped.
|
|
124
|
+
return {
|
|
125
|
+
sequence: event.sequence,
|
|
126
|
+
eventId: event.eventId,
|
|
127
|
+
role: event.role,
|
|
128
|
+
content: event.content === undefined ? null : event.content,
|
|
129
|
+
source: event.source,
|
|
130
|
+
sessionId: event.sessionId || null,
|
|
131
|
+
turnId: event.turnId || null,
|
|
132
|
+
capturedAt: event.capturedAt || null,
|
|
133
|
+
identityConfidence: event.identityConfidence || 'unavailable',
|
|
134
|
+
captureStatus: event.captureStatus || 'complete'
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_encodeCursor(turns, lastIndex) {
|
|
139
|
+
return Buffer.from(
|
|
140
|
+
JSON.stringify({ v: 1, last: turns[lastIndex] ? turns[lastIndex].startSequence : 0, n: lastIndex + 1 })
|
|
141
|
+
).toString('base64url');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async turns({ project, date, cursor, limit = 50 } = {}) {
|
|
145
|
+
const projectKey = project === undefined || project === null ? null : repoIdentityKey(project) || project;
|
|
146
|
+
const all = (await this._events()).filter(
|
|
147
|
+
e => this._isConversationEvent(e) && (!projectKey || repoIdentityKey(e.repoIdentity) === projectKey)
|
|
148
|
+
);
|
|
149
|
+
let turns = this._projectTurns(all);
|
|
150
|
+
if (date) turns = turns.filter(t => t.day === date);
|
|
151
|
+
turns.sort((a, b) => a.startSequence - b.startSequence);
|
|
152
|
+
let start = 0;
|
|
153
|
+
if (cursor) {
|
|
154
|
+
let decoded;
|
|
155
|
+
try {
|
|
156
|
+
decoded = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));
|
|
157
|
+
} catch (_) {
|
|
158
|
+
decoded = null;
|
|
159
|
+
}
|
|
160
|
+
if (!decoded || decoded.v !== 1 || typeof decoded.last !== 'number') {
|
|
161
|
+
const err = new Error('conversation cursor is invalid; restart the query');
|
|
162
|
+
err.code = 'LOG_CURSOR_EXPIRED';
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
start = turns.findIndex(t => t.startSequence > decoded.last);
|
|
166
|
+
if (start === -1) start = turns.length;
|
|
167
|
+
}
|
|
168
|
+
const pageSize = Math.max(1, Math.min(Number(limit) || 50, 200));
|
|
169
|
+
const page = turns.slice(start, start + pageSize);
|
|
170
|
+
return {
|
|
171
|
+
project: project || null,
|
|
172
|
+
date: date || null,
|
|
173
|
+
turns: page,
|
|
174
|
+
nextCursor: start + pageSize < turns.length ? this._encodeCursor(turns, start + pageSize - 1) : null,
|
|
175
|
+
totalTurns: turns.length
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async searchConversations({ project, q, limit = 20 } = {}) {
|
|
180
|
+
const needle = String(q || '').toLowerCase();
|
|
181
|
+
const projectKey = project === undefined || project === null ? null : repoIdentityKey(project) || project;
|
|
182
|
+
const events = (await this._events()).filter(
|
|
183
|
+
e => this._isConversationEvent(e) && (!projectKey || repoIdentityKey(e.repoIdentity) === projectKey) && typeof e.content === 'string' && e.content.toLowerCase().includes(needle)
|
|
184
|
+
);
|
|
185
|
+
return {
|
|
186
|
+
matches: events
|
|
187
|
+
.slice(-Math.max(1, Math.min(Number(limit) || 20, 100)))
|
|
188
|
+
.reverse()
|
|
189
|
+
.map(e => this._projectEvent(e))
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = { ConversationQuery };
|
package/src/server.js
CHANGED
|
@@ -13,10 +13,10 @@ const {
|
|
|
13
13
|
globalJournalDir,
|
|
14
14
|
projectJournalDir,
|
|
15
15
|
repoIdentityKey
|
|
16
|
-
} = require('
|
|
16
|
+
} = require('./bridge');
|
|
17
17
|
const { pickFolder } = require('./pick-folder');
|
|
18
18
|
const { detectAgents } = require('./agent-detect');
|
|
19
|
-
const corePackage = require('
|
|
19
|
+
const corePackage = require('../package.json');
|
|
20
20
|
|
|
21
21
|
const BOUNDARY_SCHEMA = 'git-commit-boundary/v1';
|
|
22
22
|
const EVENT_SCHEMA = 'ai-coding-event/v1';
|
|
@@ -60,6 +60,16 @@ function decodeCursor(raw) {
|
|
|
60
60
|
return null;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function decodeSeqCursor(raw) {
|
|
64
|
+
try {
|
|
65
|
+
const decoded = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'));
|
|
66
|
+
if (decoded && decoded.v === 1 && typeof decoded.seq === 'number') return decoded;
|
|
67
|
+
} catch (_) {
|
|
68
|
+
// fall through
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
63
73
|
function pickFolderEnabled() {
|
|
64
74
|
return process.env.BRIDGE_CONSOLE_PICK_FOLDER !== '0';
|
|
65
75
|
}
|
|
@@ -294,7 +304,7 @@ function createConsoleServer({ home }) {
|
|
|
294
304
|
* project journal after its last commit boundary. Everything earlier is
|
|
295
305
|
* archived under <store>/commits and served by the commit-doc endpoints.
|
|
296
306
|
*/
|
|
297
|
-
async function currentTurns(project, { limit, cursorRaw }) {
|
|
307
|
+
async function currentTurns(project, { limit, cursorRaw, order }) {
|
|
298
308
|
const dir = projectJournalDir(project);
|
|
299
309
|
const journal = existingJournal(dir);
|
|
300
310
|
if (!journal) {
|
|
@@ -307,24 +317,43 @@ function createConsoleServer({ home }) {
|
|
|
307
317
|
const records = await journal.readEvents({ fromSequence: lastBoundary + 1 });
|
|
308
318
|
const turns = queryAt(dir)._projectTurns(records.filter(isConversationEvent));
|
|
309
319
|
turns.sort((a, b) => a.startSequence - b.startSequence);
|
|
310
|
-
let
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
320
|
+
let pageAsc;
|
|
321
|
+
let nextCursor = null;
|
|
322
|
+
if (order === 'desc') {
|
|
323
|
+
// Newest on top: page 1 holds the newest turns; each cursor step pages
|
|
324
|
+
// further into the past.
|
|
325
|
+
let start;
|
|
326
|
+
if (cursorRaw) {
|
|
327
|
+
const decoded = decodeSeqCursor(cursorRaw);
|
|
328
|
+
if (!decoded || (decoded.o && decoded.o !== 'desc')) return null;
|
|
329
|
+
start = turns.findIndex((turn) => turn.startSequence < decoded.seq);
|
|
330
|
+
if (start === -1) start = turns.length;
|
|
331
|
+
} else {
|
|
332
|
+
start = Math.max(0, turns.length - limit);
|
|
333
|
+
}
|
|
334
|
+
pageAsc = turns.slice(start, start + limit);
|
|
335
|
+
if (start > 0) {
|
|
336
|
+
nextCursor = encodeCursor({ v: 1, seq: turns[start].startSequence, o: 'desc' });
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
let start = 0;
|
|
340
|
+
if (cursorRaw) {
|
|
341
|
+
const decoded = decodeSeqCursor(cursorRaw);
|
|
342
|
+
if (!decoded || (decoded.o && decoded.o !== 'asc')) return null;
|
|
343
|
+
start = turns.findIndex((turn) => turn.startSequence > decoded.seq);
|
|
344
|
+
if (start === -1) start = turns.length;
|
|
345
|
+
}
|
|
346
|
+
pageAsc = turns.slice(start, start + limit);
|
|
347
|
+
if (start + limit < turns.length && pageAsc.length) {
|
|
348
|
+
nextCursor = encodeCursor({ v: 1, seq: pageAsc[pageAsc.length - 1].startSequence, o: 'asc' });
|
|
317
349
|
}
|
|
318
|
-
if (!decoded || decoded.v !== 1 || typeof decoded.seq !== 'number') return null;
|
|
319
|
-
start = turns.findIndex((turn) => turn.startSequence > decoded.seq);
|
|
320
|
-
if (start === -1) start = turns.length;
|
|
321
350
|
}
|
|
322
|
-
const page =
|
|
351
|
+
const page = order === 'desc' ? pageAsc.slice().reverse() : pageAsc;
|
|
323
352
|
return {
|
|
324
353
|
project: project.id,
|
|
325
354
|
date: null,
|
|
326
355
|
turns: page,
|
|
327
|
-
nextCursor
|
|
356
|
+
nextCursor,
|
|
328
357
|
totalTurns: turns.length,
|
|
329
358
|
annotations: {}
|
|
330
359
|
};
|
|
@@ -618,11 +647,12 @@ function createConsoleServer({ home }) {
|
|
|
618
647
|
|
|
619
648
|
if (route === 'GET /api/turns') {
|
|
620
649
|
const limit = Math.max(1, Math.min(Number(url.searchParams.get('limit')) || 50, 200));
|
|
650
|
+
const order = url.searchParams.get('order') === 'desc' ? 'desc' : 'asc';
|
|
621
651
|
if (!project.auto) {
|
|
622
652
|
// Registered projects: the journal is a conveyor — serve only the
|
|
623
653
|
// current (uncommitted) conversation and keep it maintained.
|
|
624
654
|
await maintainProject(project);
|
|
625
|
-
const current = await currentTurns(project, { limit, cursorRaw: url.searchParams.get('cursor') });
|
|
655
|
+
const current = await currentTurns(project, { limit, cursorRaw: url.searchParams.get('cursor'), order });
|
|
626
656
|
if (!current) {
|
|
627
657
|
sendJson(res, 400, { error: { code: 'CURSOR_INVALID', message: 'cursor is invalid; restart the query' } });
|
|
628
658
|
return;
|
|
@@ -633,22 +663,49 @@ function createConsoleServer({ home }) {
|
|
|
633
663
|
const date = url.searchParams.get('date') || null;
|
|
634
664
|
const merged = await mergedTurns(project, { date });
|
|
635
665
|
const cursor = url.searchParams.get('cursor');
|
|
636
|
-
let
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
666
|
+
let pageAsc;
|
|
667
|
+
let nextCursor = null;
|
|
668
|
+
if (order === 'desc') {
|
|
669
|
+
// Newest on top: page 1 holds the newest turns; each cursor step
|
|
670
|
+
// pages further into the past.
|
|
671
|
+
let start;
|
|
672
|
+
if (cursor) {
|
|
673
|
+
const decoded = decodeCursor(cursor);
|
|
674
|
+
if (!decoded || (decoded.o && decoded.o !== 'desc')) {
|
|
675
|
+
sendJson(res, 400, { error: { code: 'CURSOR_INVALID', message: 'cursor is invalid; restart the query' } });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
start = merged.findIndex((turn) => turnSortKey(turn) < decoded.key);
|
|
679
|
+
if (start === -1) start = merged.length;
|
|
680
|
+
} else {
|
|
681
|
+
start = Math.max(0, merged.length - limit);
|
|
682
|
+
}
|
|
683
|
+
pageAsc = merged.slice(start, start + limit);
|
|
684
|
+
if (start > 0) {
|
|
685
|
+
nextCursor = encodeCursor({ v: 1, key: turnSortKey(merged[start]), o: 'desc' });
|
|
686
|
+
}
|
|
687
|
+
} else {
|
|
688
|
+
let start = 0;
|
|
689
|
+
if (cursor) {
|
|
690
|
+
const decoded = decodeCursor(cursor);
|
|
691
|
+
if (!decoded || (decoded.o && decoded.o !== 'asc')) {
|
|
692
|
+
sendJson(res, 400, { error: { code: 'CURSOR_INVALID', message: 'cursor is invalid; restart the query' } });
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
start = merged.findIndex((turn) => turnSortKey(turn) > decoded.key);
|
|
696
|
+
if (start === -1) start = merged.length;
|
|
697
|
+
}
|
|
698
|
+
pageAsc = merged.slice(start, start + limit);
|
|
699
|
+
if (start + limit < merged.length && pageAsc.length) {
|
|
700
|
+
nextCursor = encodeCursor({ v: 1, key: turnSortKey(pageAsc[pageAsc.length - 1]), o: 'asc' });
|
|
642
701
|
}
|
|
643
|
-
start = merged.findIndex((turn) => turnSortKey(turn) > decoded.key);
|
|
644
|
-
if (start === -1) start = merged.length;
|
|
645
702
|
}
|
|
646
|
-
const page =
|
|
703
|
+
const page = order === 'desc' ? pageAsc.slice().reverse() : pageAsc;
|
|
647
704
|
sendJson(res, 200, {
|
|
648
705
|
project: project.id,
|
|
649
706
|
date,
|
|
650
707
|
turns: page,
|
|
651
|
-
nextCursor
|
|
708
|
+
nextCursor,
|
|
652
709
|
totalTurns: merged.length,
|
|
653
710
|
annotations: await annotationsFor(project, page)
|
|
654
711
|
});
|