@ours.network/fleet 1.2.0-nightly.22 → 1.2.0-nightly.23
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/conversation-history.d.ts +19 -0
- package/dist/application/conversation-history.js +125 -1
- package/dist/application/session-control.d.ts +10 -0
- package/dist/application/session-control.js +35 -2
- package/dist/build-info.json +4 -4
- package/dist/session/control.js +1 -0
- package/dist/web/server.js +14 -5
- package/package.json +1 -1
|
@@ -1,4 +1,23 @@
|
|
|
1
|
+
import type { ConversationEventV1 } from '../session/conversation-types.js';
|
|
1
2
|
import type { ConversationPageView } from './session-control.js';
|
|
3
|
+
/** Whether a ledger event belongs in the conversation: the live generation, or history of the resumed ACP session. */
|
|
4
|
+
export declare function conversationEventVisible(event: ConversationEventV1, sessionGeneration: string, resumedSessionId?: string): boolean;
|
|
5
|
+
export interface ConversationTailPage extends ConversationPageView {
|
|
6
|
+
/** Pass as `before` to read the next older page. */
|
|
7
|
+
olderCursor?: string;
|
|
8
|
+
hasOlder: boolean;
|
|
9
|
+
/** Latest page only: older events the view still needs, the current session's latest capabilities and still-pending permission requests. */
|
|
10
|
+
context: ConversationEventV1[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The newest `limit` visible events before `before` (or the newest overall),
|
|
14
|
+
* read backwards from the ledger so opening a long session costs one page.
|
|
15
|
+
* `nextCursor` is the newest ledger position seen, for forward `after` paging.
|
|
16
|
+
*/
|
|
17
|
+
export declare function conversationTailPage(stateDir: string, request: {
|
|
18
|
+
before?: string;
|
|
19
|
+
limit?: number;
|
|
20
|
+
}, snapshot: ConversationPageView['snapshot'], resumedSessionId?: string): Promise<ConversationTailPage>;
|
|
2
21
|
/** Read the existing ACP session ledger without opening its writer/recovery store. */
|
|
3
22
|
export declare function resumedConversationPage(stateDir: string, request: {
|
|
4
23
|
after?: string;
|
|
@@ -1,7 +1,127 @@
|
|
|
1
1
|
import { createReadStream } from 'node:fs';
|
|
2
|
-
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { open, readFile, readdir } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { createInterface } from 'node:readline';
|
|
5
|
+
const HISTORICAL_KINDS = ['fleet.task_created', 'prompt.admitted', 'message.chunk', 'message.replace', 'tool.upsert', 'tool.content_chunk', 'turn.completed'];
|
|
6
|
+
/** Whether a ledger event belongs in the conversation: the live generation, or history of the resumed ACP session. */
|
|
7
|
+
export function conversationEventVisible(event, sessionGeneration, resumedSessionId) {
|
|
8
|
+
return event.source !== 'agent_replay' && (event.sessionGeneration === sessionGeneration || (!!resumedSessionId && HISTORICAL_KINDS.includes(event.kind) && event.acpSessionId === resumedSessionId));
|
|
9
|
+
}
|
|
10
|
+
/** Newest-first lines of a file, read in chunks from its end; `visit` returns false to stop. */
|
|
11
|
+
async function readLinesBackward(path, visit) {
|
|
12
|
+
const file = await open(path, 'r');
|
|
13
|
+
try {
|
|
14
|
+
let position = (await file.stat()).size;
|
|
15
|
+
let carry = Buffer.alloc(0);
|
|
16
|
+
while (position > 0) {
|
|
17
|
+
const size = Math.min(256 * 1024, position);
|
|
18
|
+
position -= size;
|
|
19
|
+
const chunk = Buffer.alloc(size);
|
|
20
|
+
await file.read(chunk, 0, size, position);
|
|
21
|
+
let buffer = Buffer.concat([chunk, carry]);
|
|
22
|
+
let end = buffer.length;
|
|
23
|
+
for (let i = buffer.length - 1; i >= 0; i--) {
|
|
24
|
+
if (buffer[i] !== 10)
|
|
25
|
+
continue;
|
|
26
|
+
if (end > i + 1 && !visit(buffer.toString('utf8', i + 1, end)))
|
|
27
|
+
return false;
|
|
28
|
+
end = i;
|
|
29
|
+
}
|
|
30
|
+
carry = buffer.subarray(0, end);
|
|
31
|
+
}
|
|
32
|
+
if (carry.length && !visit(carry.toString('utf8')))
|
|
33
|
+
return false;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
await file.close();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** The last event cursor in a segment, read from its tail; undefined when none is readable. */
|
|
41
|
+
async function lastSeq(path) {
|
|
42
|
+
let seq;
|
|
43
|
+
await readLinesBackward(path, line => { try {
|
|
44
|
+
const value = Number(JSON.parse(line).seq);
|
|
45
|
+
if (Number.isSafeInteger(value)) {
|
|
46
|
+
seq = value;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch { } return true; });
|
|
51
|
+
return seq;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The newest `limit` visible events before `before` (or the newest overall),
|
|
55
|
+
* read backwards from the ledger so opening a long session costs one page.
|
|
56
|
+
* `nextCursor` is the newest ledger position seen, for forward `after` paging.
|
|
57
|
+
*/
|
|
58
|
+
export async function conversationTailPage(stateDir, request, snapshot, resumedSessionId) {
|
|
59
|
+
const before = request.before === undefined ? Infinity : Number(request.before);
|
|
60
|
+
if (before !== Infinity && (!Number.isSafeInteger(before) || before < 0))
|
|
61
|
+
throw new Error('before must be a ledger cursor');
|
|
62
|
+
const limit = Math.min(1000, Math.max(1, Number.isFinite(request.limit) ? Math.floor(request.limit) : 200));
|
|
63
|
+
let files = [];
|
|
64
|
+
try {
|
|
65
|
+
files = (await readdir(join(stateDir, '.conversation'))).filter(n => /^events-\d{6}\.jsonl$/.test(n)).sort().reverse();
|
|
66
|
+
}
|
|
67
|
+
catch { }
|
|
68
|
+
const events = [];
|
|
69
|
+
let newest;
|
|
70
|
+
let hasOlder = false;
|
|
71
|
+
const pending = new Set(snapshot.pendingPermissionIds);
|
|
72
|
+
let capabilities;
|
|
73
|
+
const context = [];
|
|
74
|
+
// Context only accompanies the newest page; older pages stop as soon as they are full.
|
|
75
|
+
const wantsContext = () => before === Infinity && (!capabilities || pending.size > 0);
|
|
76
|
+
const generation = `"sessionGeneration":${JSON.stringify(snapshot.sessionGeneration)}`;
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
const more = await readLinesBackward(join(stateDir, '.conversation', file), line => {
|
|
79
|
+
if (hasOlder) {
|
|
80
|
+
// The page is full; only the current generation's context kinds matter. Generations are
|
|
81
|
+
// appended in order, so the first line of an older one ends the search.
|
|
82
|
+
if (!line.includes(generation))
|
|
83
|
+
return !line.includes('"sessionGeneration":') && wantsContext();
|
|
84
|
+
if (!line.includes('"capabilities.updated"') && !line.includes('"permission.requested"'))
|
|
85
|
+
return wantsContext();
|
|
86
|
+
}
|
|
87
|
+
let event;
|
|
88
|
+
try {
|
|
89
|
+
event = JSON.parse(line);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
const seq = Number(event.seq);
|
|
95
|
+
if (!Number.isSafeInteger(seq))
|
|
96
|
+
return true;
|
|
97
|
+
newest ??= seq;
|
|
98
|
+
if (seq < before && conversationEventVisible(event, snapshot.sessionGeneration, resumedSessionId)) {
|
|
99
|
+
const inPage = !hasOlder && events.length < limit;
|
|
100
|
+
if (event.sessionGeneration === snapshot.sessionGeneration) {
|
|
101
|
+
if (event.kind === 'capabilities.updated' && !capabilities) {
|
|
102
|
+
capabilities = event;
|
|
103
|
+
if (!inPage)
|
|
104
|
+
context.push(event);
|
|
105
|
+
}
|
|
106
|
+
if (event.kind === 'permission.requested' && event.permissionId && pending.delete(event.permissionId) && !inPage)
|
|
107
|
+
context.push(event);
|
|
108
|
+
}
|
|
109
|
+
if (inPage)
|
|
110
|
+
events.push(event);
|
|
111
|
+
else
|
|
112
|
+
hasOlder = true;
|
|
113
|
+
}
|
|
114
|
+
return !hasOlder || wantsContext();
|
|
115
|
+
});
|
|
116
|
+
if (!more)
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
events.reverse();
|
|
120
|
+
context.reverse();
|
|
121
|
+
return { events, snapshot, hasOlder, context, hasMore: false,
|
|
122
|
+
...(events.length ? { olderCursor: String(events[0].seq) } : {}),
|
|
123
|
+
...(before === Infinity && newest !== undefined ? { nextCursor: String(newest) } : {}) };
|
|
124
|
+
}
|
|
5
125
|
/** Read the existing ACP session ledger without opening its writer/recovery store. */
|
|
6
126
|
export async function resumedConversationPage(stateDir, request, live, expectedSessionId) {
|
|
7
127
|
let sessionId;
|
|
@@ -24,6 +144,10 @@ export async function resumedConversationPage(stateDir, request, live, expectedS
|
|
|
24
144
|
let first;
|
|
25
145
|
let hasMore = false;
|
|
26
146
|
scan: for (const file of files) {
|
|
147
|
+
// Forward polling asks for the newest few events: skip whole segments that end at or before the cursor.
|
|
148
|
+
const end = await lastSeq(join(stateDir, '.conversation', file));
|
|
149
|
+
if (end !== undefined && end <= after)
|
|
150
|
+
continue;
|
|
27
151
|
const input = createReadStream(join(stateDir, '.conversation', file), { encoding: 'utf8' });
|
|
28
152
|
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
29
153
|
try {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ConversationTailPage } from './conversation-history.js';
|
|
1
2
|
import type { AgentHistoryQuery } from '../agent-ours/correspondence.js';
|
|
2
3
|
import { controlRequest } from '../session/control.js';
|
|
3
4
|
import type { ConversationEventV1, ConversationSnapshot, PromptReceipt } from '../session/conversation-types.js';
|
|
@@ -44,6 +45,11 @@ export interface RoleSessionControl {
|
|
|
44
45
|
after?: string;
|
|
45
46
|
limit?: number;
|
|
46
47
|
}): Promise<ConversationPageView>;
|
|
48
|
+
/** The newest visible events (or those before `before`), read backwards from the ledger. */
|
|
49
|
+
conversationTail?(request: {
|
|
50
|
+
before?: string;
|
|
51
|
+
limit?: number;
|
|
52
|
+
}): Promise<ConversationTailPage>;
|
|
47
53
|
submitPromptV2?(request: {
|
|
48
54
|
commandId: string;
|
|
49
55
|
text: string;
|
|
@@ -94,6 +100,10 @@ export declare class RoleSessionControlAdapter implements RoleSessionControl {
|
|
|
94
100
|
after?: string;
|
|
95
101
|
limit?: number;
|
|
96
102
|
}): Promise<ConversationPageView>;
|
|
103
|
+
conversationTail(request?: {
|
|
104
|
+
before?: string;
|
|
105
|
+
limit?: number;
|
|
106
|
+
}): Promise<ConversationTailPage>;
|
|
97
107
|
submitPromptV2(request: {
|
|
98
108
|
commandId: string;
|
|
99
109
|
text: string;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { conversationTailPage, resumedConversationPage } from './conversation-history.js';
|
|
2
4
|
import { controlRequest, followConversation } from '../session/control.js';
|
|
3
5
|
import { FleetError, normalizeError } from './errors.js';
|
|
4
6
|
const visibleEvents = (events) => events.filter(event => event.kind !== 'thought');
|
|
@@ -78,13 +80,44 @@ export class RoleSessionControlAdapter {
|
|
|
78
80
|
return live;
|
|
79
81
|
}
|
|
80
82
|
}
|
|
83
|
+
async conversationTail(request = {}) {
|
|
84
|
+
const live = await this.call('conversation_page', { limit: 1 });
|
|
85
|
+
const snapshot = await this.snapshot();
|
|
86
|
+
let resumed;
|
|
87
|
+
if (snapshot.backend === 'acp' && snapshot.sessionId) {
|
|
88
|
+
try {
|
|
89
|
+
if ((await readFile(join(this.stateDir, '.acp-session-id'), 'utf8')).trim() === snapshot.sessionId)
|
|
90
|
+
resumed = snapshot.sessionId;
|
|
91
|
+
}
|
|
92
|
+
catch { /* no resumed history: live generation only */ }
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
return await conversationTailPage(this.stateDir, request, live.snapshot, resumed);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
throw new FleetError('invalid_request', error.message);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
81
101
|
async submitPromptV2(request) {
|
|
82
102
|
if (!request.text.trim())
|
|
83
103
|
throw new FleetError('invalid_request', 'text is required');
|
|
84
104
|
if (Buffer.byteLength(request.text) > 32 * 1024)
|
|
85
105
|
throw new FleetError('invalid_request', 'text exceeds 32 KiB');
|
|
106
|
+
// A prompt bound to a session generation (voice or attachments) is checked atomically by supervisors that
|
|
107
|
+
// support it. Older supervisors do not know that command and never answer, so check the generation here
|
|
108
|
+
// and submit an ordinary prompt; its command id keeps retries idempotent either way.
|
|
109
|
+
let command = 'submit_prompt_v2';
|
|
110
|
+
if (request.expectedSessionGeneration) {
|
|
111
|
+
if ((await this.describe()).features.includes('generation_bound_prompts'))
|
|
112
|
+
command = 'submit_voice_prompt';
|
|
113
|
+
else {
|
|
114
|
+
const live = await this.call('conversation_page', { limit: 1 });
|
|
115
|
+
if (live.snapshot?.sessionGeneration !== request.expectedSessionGeneration)
|
|
116
|
+
throw new FleetError('stale_state', 'Agent session changed. Record a new voice message.');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
86
119
|
try {
|
|
87
|
-
return await this.call(
|
|
120
|
+
return await this.call(command, {
|
|
88
121
|
commandId: request.commandId, text: request.text, actor: request.actorBrowserSession,
|
|
89
122
|
source: request.source, expectedSessionGeneration: request.expectedSessionGeneration,
|
|
90
123
|
});
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.2.0-nightly.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "1.2.0-nightly.23",
|
|
3
|
+
"buildId": "84a9c6c0a54c",
|
|
4
|
+
"commit": "75b0bebdb33a4ace4713015f7a7f49adc330f6e4",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-09-
|
|
6
|
+
"builtAt": "2026-09-30T10:20:51.847Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"cowork.http-management-v1",
|
|
9
9
|
"monitor.interrupt.after_tool"
|
package/dist/session/control.js
CHANGED
|
@@ -242,6 +242,7 @@ export class RoleControlServer {
|
|
|
242
242
|
'events_since', 'observer_follow', 'retained_range',
|
|
243
243
|
...(this.correspondence ? ['agent_correspondence'] : []),
|
|
244
244
|
...(this.session.conversationPage ? ['conversation_v3'] : []),
|
|
245
|
+
...(this.session.submitPromptBrowser ? ['generation_bound_prompts'] : []),
|
|
245
246
|
...(this.session.capabilities?.steering ? ['steering'] : []),
|
|
246
247
|
...(this.session.capabilities?.permissions ? ['permissions'] : []),
|
|
247
248
|
...(this.session.capabilities?.messagePhases ? ['message_phases'] : []),
|
package/dist/web/server.js
CHANGED
|
@@ -624,13 +624,22 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
624
624
|
const control = await services.session(request.params.id);
|
|
625
625
|
if (!control.conversationPage)
|
|
626
626
|
throw new FleetError('capability_unavailable', 'this role has no conversation ledger');
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
627
|
+
const limit = request.query.limit ? Number(request.query.limit) : undefined;
|
|
628
|
+
// `latest` / `before` page backwards from the newest event; `after` pages forward for live updates.
|
|
629
|
+
const backward = request.query.latest === '1' || request.query.before !== undefined;
|
|
630
|
+
if (backward && request.query.after !== undefined)
|
|
631
|
+
throw new FleetError('invalid_request', 'after cannot be combined with latest or before');
|
|
632
|
+
if (backward && !control.conversationTail)
|
|
633
|
+
throw new FleetError('capability_unavailable', 'this role cannot page its conversation backwards');
|
|
631
634
|
const role = await services.repository.get(request.params.id);
|
|
632
635
|
const stateDir = role ? services.repository.stateDir(role) : undefined;
|
|
633
|
-
|
|
636
|
+
const present = (event) => presentAttachmentEvent(stateDir, request.params.id, event);
|
|
637
|
+
if (backward) {
|
|
638
|
+
const page = await control.conversationTail({ before: request.query.before, limit });
|
|
639
|
+
return { ...page, events: page.events.map(present), context: page.context.map(present) };
|
|
640
|
+
}
|
|
641
|
+
const page = await control.conversationPage({ after: request.query.after, limit });
|
|
642
|
+
return { ...page, events: page.events.map(present) };
|
|
634
643
|
});
|
|
635
644
|
app.get('/api/v1/roles/:id/attachments/:attachmentId', async (request, reply) => {
|
|
636
645
|
auth.authenticate(request);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "1.2.0-nightly.
|
|
3
|
+
"version": "1.2.0-nightly.23",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|