nexus-agentd 0.2.2 → 0.2.3

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.
@@ -14,6 +14,8 @@ export declare class AcpProcessRuntime {
14
14
  private readonly promptTimeoutMs;
15
15
  private promptCapabilities;
16
16
  private inputDirectory?;
17
+ private workspace?;
18
+ private promptOutput;
17
19
  constructor(driver: AgentDriver, sink: AcpSessionSink, maxStderrChunkChars?: number, promptTimeoutMs?: number);
18
20
  start(workspace: string): Promise<void>;
19
21
  prompt(message: string, attachments?: AgentdInputAttachment[]): Promise<void>;
@@ -27,6 +29,7 @@ export declare class AcpProcessRuntime {
27
29
  private sessionUpdate;
28
30
  private captureToolArtifacts;
29
31
  private captureArtifact;
32
+ private captureMediaMarkers;
30
33
  private captureStderr;
31
34
  private onProcessFailure;
32
35
  private requireSessionId;
@@ -1,11 +1,12 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { Readable, Writable } from 'node:stream';
5
- import { pathToFileURL } from 'node:url';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import * as acp from '@agentclientprotocol/sdk';
7
7
  import { terminateProcessTree } from '../process-tree.js';
8
8
  import { TURN_COMPLETION_CONTRACT } from '../completion-contract.js';
9
+ const MAX_MEDIA_MARKER_FILE_BYTES = 32 * 1024 * 1024;
9
10
  export class AcpProcessRuntime {
10
11
  driver;
11
12
  sink;
@@ -19,6 +20,8 @@ export class AcpProcessRuntime {
19
20
  promptTimeoutMs;
20
21
  promptCapabilities = {};
21
22
  inputDirectory;
23
+ workspace;
24
+ promptOutput = '';
22
25
  constructor(driver, sink, maxStderrChunkChars = 16 * 1024, promptTimeoutMs = 30 * 60 * 1000) {
23
26
  this.driver = driver;
24
27
  this.sink = sink;
@@ -31,6 +34,7 @@ export class AcpProcessRuntime {
31
34
  async start(workspace) {
32
35
  if (this.process)
33
36
  throw new Error('ACP runtime is already started');
37
+ this.workspace = workspace;
34
38
  this.inputDirectory = path.join(workspace, '.nexus-inputs', this.sink.id);
35
39
  const child = this.driver.spawn(workspace);
36
40
  this.process = child;
@@ -86,6 +90,7 @@ export class AcpProcessRuntime {
86
90
  throw new Error('ACP session is already processing a prompt');
87
91
  const sessionId = this.requireSessionId();
88
92
  this.prompting = true;
93
+ this.promptOutput = '';
89
94
  this.sink.clearPending();
90
95
  this.sink.setState('running');
91
96
  try {
@@ -98,6 +103,7 @@ export class AcpProcessRuntime {
98
103
  }
99
104
  switch (response.stopReason) {
100
105
  case 'end_turn':
106
+ await this.captureMediaMarkers();
101
107
  this.sink.completeTurn({
102
108
  source: 'acp_prompt_response',
103
109
  stopReason: response.stopReason
@@ -356,6 +362,7 @@ export class AcpProcessRuntime {
356
362
  switch (update.sessionUpdate) {
357
363
  case 'agent_message_chunk':
358
364
  if (update.content.type === 'text') {
365
+ this.promptOutput += update.content.text;
359
366
  this.sink.appendOutput(update.content.text);
360
367
  this.sink.emit('assistant_chunk', {
361
368
  messageId: update.messageId,
@@ -410,6 +417,29 @@ export class AcpProcessRuntime {
410
417
  if (artifact)
411
418
  this.sink.addArtifact(artifact);
412
419
  }
420
+ async captureMediaMarkers() {
421
+ const markers = mediaMarkers(this.promptOutput);
422
+ if (!markers.length)
423
+ return;
424
+ this.sink.replaceOutput(withoutMediaMarkers(this.promptOutput));
425
+ const seen = new Set();
426
+ for (const marker of markers) {
427
+ if (seen.has(marker))
428
+ continue;
429
+ seen.add(marker);
430
+ try {
431
+ const artifact = await artifactFromMediaMarker(marker, `media:${randomUUID()}`, this.workspace);
432
+ this.sink.addArtifact(artifact);
433
+ this.sink.emit('artifact', artifact);
434
+ }
435
+ catch (error) {
436
+ this.sink.emit('terminal_output', {
437
+ stream: 'system',
438
+ text: `Could not attach MEDIA artifact ${safeMarkerName(marker)}: ${error instanceof Error ? error.message : String(error)}`
439
+ });
440
+ }
441
+ }
442
+ }
413
443
  captureStderr(child) {
414
444
  let buffer = '';
415
445
  let flushed = false;
@@ -619,6 +649,93 @@ function filenameFromValue(value) {
619
649
  function usableUrl(value) {
620
650
  return value && /^(?:https?|file):/i.test(value) ? value : undefined;
621
651
  }
652
+ function mediaMarkers(output) {
653
+ return output
654
+ .split(/\r?\n/)
655
+ .map((line) => line.match(/^\s*MEDIA:\s*(.+?)\s*$/i)?.[1]?.trim())
656
+ .filter((value) => Boolean(value));
657
+ }
658
+ function withoutMediaMarkers(output) {
659
+ return output
660
+ .split(/\r?\n/)
661
+ .filter((line) => !/^\s*MEDIA:\s*(.+?)\s*$/i.test(line))
662
+ .join('\n')
663
+ .trim();
664
+ }
665
+ async function artifactFromMediaMarker(marker, id, workspace) {
666
+ if (/^https?:\/\//i.test(marker)) {
667
+ const filename = artifactFilename(marker, undefined, 'resource', undefined);
668
+ return {
669
+ id,
670
+ name: filename,
671
+ filename,
672
+ url: marker,
673
+ metadata: { source: 'acp_media_marker' }
674
+ };
675
+ }
676
+ const localPath = marker.startsWith('file:')
677
+ ? fileURLToPath(marker)
678
+ : marker;
679
+ const resolved = await realpath(path.isAbsolute(localPath)
680
+ ? localPath
681
+ : path.resolve(workspace || process.cwd(), localPath));
682
+ const details = await stat(resolved);
683
+ if (!details.isFile()) {
684
+ throw new Error('declared path is not a regular file');
685
+ }
686
+ if (details.size > MAX_MEDIA_MARKER_FILE_BYTES) {
687
+ throw new Error(`declared file exceeds the ${MAX_MEDIA_MARKER_FILE_BYTES} byte limit`);
688
+ }
689
+ const filename = path.basename(resolved);
690
+ return {
691
+ id,
692
+ name: filename,
693
+ filename,
694
+ mediaType: mediaTypeForPath(filename),
695
+ bytesBase64: (await readFile(resolved)).toString('base64'),
696
+ metadata: {
697
+ source: 'acp_media_marker',
698
+ size: details.size
699
+ }
700
+ };
701
+ }
702
+ function safeMarkerName(marker) {
703
+ try {
704
+ return path.basename(marker.startsWith('file:') ? fileURLToPath(marker) : marker);
705
+ }
706
+ catch {
707
+ return 'declared artifact';
708
+ }
709
+ }
710
+ function mediaTypeForPath(value) {
711
+ switch (path.extname(value).toLowerCase()) {
712
+ case '.pptx':
713
+ return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
714
+ case '.docx':
715
+ return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
716
+ case '.xlsx':
717
+ return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
718
+ case '.pdf':
719
+ return 'application/pdf';
720
+ case '.png':
721
+ return 'image/png';
722
+ case '.jpg':
723
+ case '.jpeg':
724
+ return 'image/jpeg';
725
+ case '.gif':
726
+ return 'image/gif';
727
+ case '.webp':
728
+ return 'image/webp';
729
+ case '.mp3':
730
+ return 'audio/mpeg';
731
+ case '.wav':
732
+ return 'audio/wav';
733
+ case '.mp4':
734
+ return 'video/mp4';
735
+ default:
736
+ return 'application/octet-stream';
737
+ }
738
+ }
622
739
  function artifactIndex(id) {
623
740
  return id.split(':').pop() || 'output';
624
741
  }
@@ -4,6 +4,6 @@ export const TURN_COMPLETION_CONTRACT = [
4
4
  'If you need user input or permission, use the protocol input/permission request and wait for the response.',
5
5
  'Before ending the turn, verify the requested work and provide a non-empty final response.',
6
6
  'The final response must summarize the outcome, verification performed, and workspace-relative paths for generated files.',
7
- 'Attach generated resources or artifacts through the protocol when supported.',
7
+ 'Attach generated resources or artifacts through the protocol when supported. If the runtime only reports a local deliverable path, put each requested file on its own final line as MEDIA:<path>; Agent Nexus will snapshot that explicitly declared file as an artifact before the turn completes.',
8
8
  '</agent_nexus_completion_contract>'
9
9
  ].join('\n');
@@ -7,6 +7,7 @@ export interface AgentSessionSink {
7
7
  setState(state: AgentdSessionState, error?: string): void;
8
8
  completeTurn(proof: AgentdTurnCompletionProof): boolean;
9
9
  appendOutput(text: string): void;
10
+ replaceOutput(text: string): void;
10
11
  addArtifact(artifact: AgentdArtifact): void;
11
12
  setPending(request: AgentdPendingRequest): void;
12
13
  clearPending(): void;
package/dist/session.d.ts CHANGED
@@ -34,6 +34,7 @@ export declare class ManagedSession implements AgentSessionSink, AcpSessionSink
34
34
  setState(state: AgentdSessionState, error?: string): void;
35
35
  completeTurn(proof: AgentdTurnCompletionProof): boolean;
36
36
  appendOutput(text: string): void;
37
+ replaceOutput(text: string): void;
37
38
  addArtifact(artifact: AgentdArtifact): void;
38
39
  publishFile(filePath: string): Promise<AgentdArtifact>;
39
40
  setPending(request: AgentdPendingRequest): void;
package/dist/session.js CHANGED
@@ -130,6 +130,11 @@ export class ManagedSession {
130
130
  this.updatedAt = Date.now();
131
131
  this.syncRun({ output: this.output });
132
132
  }
133
+ replaceOutput(text) {
134
+ this.output = String(text || '').slice(0, this.maxOutputChars);
135
+ this.updatedAt = Date.now();
136
+ this.syncRun({ output: this.output || undefined });
137
+ }
133
138
  addArtifact(artifact) {
134
139
  const next = structuredClone(artifact);
135
140
  const existingIndex = next.id
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexus-agentd",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Local Nexus Gateway daemon that exposes allowlisted Coding Agents through HTTP/SSE and ACP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",