@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.
@@ -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 { resumedConversationPage } from './conversation-history.js';
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(request.expectedSessionGeneration ? 'submit_voice_prompt' : 'submit_prompt_v2', {
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
  });
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.22",
3
- "buildId": "f5776b7613c5",
4
- "commit": "769fba35bc2918e792c353d2234d0fe5e2a48f57",
2
+ "version": "1.2.0-nightly.23",
3
+ "buildId": "84a9c6c0a54c",
4
+ "commit": "75b0bebdb33a4ace4713015f7a7f49adc330f6e4",
5
5
  "dirty": true,
6
- "builtAt": "2026-09-30T07:56:23.700Z",
6
+ "builtAt": "2026-09-30T10:20:51.847Z",
7
7
  "capabilities": [
8
8
  "cowork.http-management-v1",
9
9
  "monitor.interrupt.after_tool"
@@ -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'] : []),
@@ -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 page = await control.conversationPage({
628
- after: request.query.after,
629
- limit: request.query.limit ? Number(request.query.limit) : undefined,
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
- return { ...page, events: page.events.map(event => presentAttachmentEvent(stateDir, request.params.id, event)) };
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.22",
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",