@dudousxd/nestjs-agent-opencode 0.2.0 → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  import { AgentDepsFactory, ChatQueueService, AgentEngine } from '@dudousxd/nestjs-agent';
2
2
  import { ConflictException, Logger, OnApplicationShutdown, Type, InjectionToken, Provider } from '@nestjs/common';
3
- import { ElicitationRequest, CostSource, AgentRunInput, SinkWriter, AgentStore, ApprovalRequirement, MessageUsage, AgentUiComponent, HumanReply, Actor, StoredMessage, ToolRegistry, TokenStreamSink, SkillsConfig, MemoryConfig, AgentPricingStore, PriceCatalogOptions, RolesPolicy, RememberToolInput, AiToolCtx } from '@dudousxd/nestjs-agent-core';
3
+ import { ElicitationRequest, CostSource, AgentRunInput, SinkWriter, AgentStore, ApprovalRequirement, ToolPresentation, MessageUsage, AgentUiComponent, HumanReply, Actor, StoredMessage, ToolRegistry, TokenStreamSink, SkillsConfig, MemoryConfig, AgentPricingStore, PriceCatalogOptions, RolesPolicy, RememberToolInput, AiToolCtx } from '@dudousxd/nestjs-agent-core';
4
4
  import { IncomingMessage, ServerResponse } from 'node:http';
5
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
6
 
@@ -40,6 +40,9 @@ interface OpenCodeClient {
40
40
  metadata?: {
41
41
  readonly [key: string]: OpenCodeJson;
42
42
  };
43
+ /** What the session spent so far, every model call OpenCode made for it (the title too). */
44
+ cost?: number;
45
+ tokens?: OpenCodeTokens;
43
46
  }>;
44
47
  /** Resolves when the session goes idle. Optional: a safety net for a missed terminal event. */
45
48
  wait?(args: {
@@ -204,6 +207,16 @@ interface OpenCodeFormField {
204
207
  /** The user may type their own answer instead of picking one of `options`. */
205
208
  custom?: boolean;
206
209
  }
210
+ /** A token count as OpenCode reports it: `input` is the uncached input, the cache beside it. */
211
+ interface OpenCodeTokens {
212
+ input: number;
213
+ output: number;
214
+ reasoning?: number;
215
+ cache?: {
216
+ read: number;
217
+ write: number;
218
+ };
219
+ }
207
220
 
208
221
  /** How an OpenCode execution ended. */
209
222
  type TurnOutcome = {
@@ -284,6 +297,15 @@ interface OpenCodeTurnArgs {
284
297
  store: AgentStore;
285
298
  /** What an action needs before it runs (the module's `ApprovalPolicy`). */
286
299
  approvalFor: (action: string) => Promise<ApprovalRequirement>;
300
+ /**
301
+ * The app tool a permission `action` names, with its presentation: its `confirm` templates (filled
302
+ * from the call's arguments) word the approval card. `undefined` (or omitted) → an OpenCode
303
+ * built-in, worded from its name.
304
+ */
305
+ approvalPresentation?: (action: string) => {
306
+ name: string;
307
+ presentation?: ToolPresentation;
308
+ } | undefined;
287
309
  /** The model label usage is recorded under until OpenCode names the step's model. */
288
310
  modelLabel: string;
289
311
  /**
@@ -344,6 +366,10 @@ declare class OpenCodeTurn {
344
366
  private reported;
345
367
  private priced;
346
368
  private model;
369
+ /** What the session had spent when this turn prompted it; what OpenCode reported since. */
370
+ private baseline;
371
+ private readonly seen;
372
+ private compacted;
347
373
  private stepOpen;
348
374
  private sawText;
349
375
  private separator;
@@ -352,6 +378,16 @@ declare class OpenCodeTurn {
352
378
  private ended;
353
379
  private stepError;
354
380
  constructor(a: OpenCodeTurnArgs);
381
+ /**
382
+ * What the session had spent before this turn prompted it (`session.get`). When the execution
383
+ * ends, what the session spent since that this turn's events did not report — the title OpenCode
384
+ * generates, a compaction: OpenCode records those calls on the session without streaming them — is
385
+ * recorded as a usage row of its own.
386
+ */
387
+ setBaseline(info: {
388
+ cost?: number;
389
+ tokens?: OpenCodeTokens;
390
+ } | undefined): void;
355
391
  /** What the run spent so far: carried from before this turn object, plus what it saw. */
356
392
  totalUsage(): OpenCodeUsage;
357
393
  get sessionId(): string;
@@ -418,6 +454,8 @@ declare class OpenCodeTurn {
418
454
  private onForm;
419
455
  private answered;
420
456
  private addResult;
457
+ /** What the session spent since the baseline that no event of this turn reported. */
458
+ private unreported;
421
459
  private end;
422
460
  }
423
461
  /**
@@ -790,6 +828,11 @@ declare class OpenCodeTurns implements OnApplicationShutdown {
790
828
  }): Promise<StepCost>;
791
829
  /** The tools server's name in OpenCode. */
792
830
  get toolsServer(): string;
831
+ /** The app tool an OpenCode permission `action` names, and its presentation — `undefined` for a built-in. */
832
+ presentationOf(action: string): {
833
+ name: string;
834
+ presentation?: ToolPresentation;
835
+ } | undefined;
793
836
  /** The roles policy a turn's tools are gated by (the agent's own). */
794
837
  rolesPolicyFor(input: Pick<AgentRunInput, 'agentName'>): RolesPolicy;
795
838
  /** The tools a turn may reach: the agent's allow-list, narrowed by its persona's. */
@@ -889,6 +932,8 @@ declare class OpenCodeTurns implements OnApplicationShutdown {
889
932
  */
890
933
  reply(runId: string, input: AgentRunInput, handle: SessionHandle, ask: PendingAsk, reply: HumanReply, spent?: OpenCodeUsage): Promise<SessionHandle>;
891
934
  private continueAfterRestart;
935
+ /** What a session spent so far (`session.get`), or nothing when OpenCode does not say. */
936
+ private sessionUsage;
892
937
  /** Interrupt the thread's session (cancel). */
893
938
  interrupt(input: AgentRunInput): Promise<void>;
894
939
  private ensureLive;
@@ -1,6 +1,6 @@
1
1
  import { AgentDepsFactory, ChatQueueService, AgentEngine } from '@dudousxd/nestjs-agent';
2
2
  import { ConflictException, Logger, OnApplicationShutdown, Type, InjectionToken, Provider } from '@nestjs/common';
3
- import { ElicitationRequest, CostSource, AgentRunInput, SinkWriter, AgentStore, ApprovalRequirement, MessageUsage, AgentUiComponent, HumanReply, Actor, StoredMessage, ToolRegistry, TokenStreamSink, SkillsConfig, MemoryConfig, AgentPricingStore, PriceCatalogOptions, RolesPolicy, RememberToolInput, AiToolCtx } from '@dudousxd/nestjs-agent-core';
3
+ import { ElicitationRequest, CostSource, AgentRunInput, SinkWriter, AgentStore, ApprovalRequirement, ToolPresentation, MessageUsage, AgentUiComponent, HumanReply, Actor, StoredMessage, ToolRegistry, TokenStreamSink, SkillsConfig, MemoryConfig, AgentPricingStore, PriceCatalogOptions, RolesPolicy, RememberToolInput, AiToolCtx } from '@dudousxd/nestjs-agent-core';
4
4
  import { IncomingMessage, ServerResponse } from 'node:http';
5
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
6
 
@@ -40,6 +40,9 @@ interface OpenCodeClient {
40
40
  metadata?: {
41
41
  readonly [key: string]: OpenCodeJson;
42
42
  };
43
+ /** What the session spent so far, every model call OpenCode made for it (the title too). */
44
+ cost?: number;
45
+ tokens?: OpenCodeTokens;
43
46
  }>;
44
47
  /** Resolves when the session goes idle. Optional: a safety net for a missed terminal event. */
45
48
  wait?(args: {
@@ -204,6 +207,16 @@ interface OpenCodeFormField {
204
207
  /** The user may type their own answer instead of picking one of `options`. */
205
208
  custom?: boolean;
206
209
  }
210
+ /** A token count as OpenCode reports it: `input` is the uncached input, the cache beside it. */
211
+ interface OpenCodeTokens {
212
+ input: number;
213
+ output: number;
214
+ reasoning?: number;
215
+ cache?: {
216
+ read: number;
217
+ write: number;
218
+ };
219
+ }
207
220
 
208
221
  /** How an OpenCode execution ended. */
209
222
  type TurnOutcome = {
@@ -284,6 +297,15 @@ interface OpenCodeTurnArgs {
284
297
  store: AgentStore;
285
298
  /** What an action needs before it runs (the module's `ApprovalPolicy`). */
286
299
  approvalFor: (action: string) => Promise<ApprovalRequirement>;
300
+ /**
301
+ * The app tool a permission `action` names, with its presentation: its `confirm` templates (filled
302
+ * from the call's arguments) word the approval card. `undefined` (or omitted) → an OpenCode
303
+ * built-in, worded from its name.
304
+ */
305
+ approvalPresentation?: (action: string) => {
306
+ name: string;
307
+ presentation?: ToolPresentation;
308
+ } | undefined;
287
309
  /** The model label usage is recorded under until OpenCode names the step's model. */
288
310
  modelLabel: string;
289
311
  /**
@@ -344,6 +366,10 @@ declare class OpenCodeTurn {
344
366
  private reported;
345
367
  private priced;
346
368
  private model;
369
+ /** What the session had spent when this turn prompted it; what OpenCode reported since. */
370
+ private baseline;
371
+ private readonly seen;
372
+ private compacted;
347
373
  private stepOpen;
348
374
  private sawText;
349
375
  private separator;
@@ -352,6 +378,16 @@ declare class OpenCodeTurn {
352
378
  private ended;
353
379
  private stepError;
354
380
  constructor(a: OpenCodeTurnArgs);
381
+ /**
382
+ * What the session had spent before this turn prompted it (`session.get`). When the execution
383
+ * ends, what the session spent since that this turn's events did not report — the title OpenCode
384
+ * generates, a compaction: OpenCode records those calls on the session without streaming them — is
385
+ * recorded as a usage row of its own.
386
+ */
387
+ setBaseline(info: {
388
+ cost?: number;
389
+ tokens?: OpenCodeTokens;
390
+ } | undefined): void;
355
391
  /** What the run spent so far: carried from before this turn object, plus what it saw. */
356
392
  totalUsage(): OpenCodeUsage;
357
393
  get sessionId(): string;
@@ -418,6 +454,8 @@ declare class OpenCodeTurn {
418
454
  private onForm;
419
455
  private answered;
420
456
  private addResult;
457
+ /** What the session spent since the baseline that no event of this turn reported. */
458
+ private unreported;
421
459
  private end;
422
460
  }
423
461
  /**
@@ -790,6 +828,11 @@ declare class OpenCodeTurns implements OnApplicationShutdown {
790
828
  }): Promise<StepCost>;
791
829
  /** The tools server's name in OpenCode. */
792
830
  get toolsServer(): string;
831
+ /** The app tool an OpenCode permission `action` names, and its presentation — `undefined` for a built-in. */
832
+ presentationOf(action: string): {
833
+ name: string;
834
+ presentation?: ToolPresentation;
835
+ } | undefined;
793
836
  /** The roles policy a turn's tools are gated by (the agent's own). */
794
837
  rolesPolicyFor(input: Pick<AgentRunInput, 'agentName'>): RolesPolicy;
795
838
  /** The tools a turn may reach: the agent's allow-list, narrowed by its persona's. */
@@ -889,6 +932,8 @@ declare class OpenCodeTurns implements OnApplicationShutdown {
889
932
  */
890
933
  reply(runId: string, input: AgentRunInput, handle: SessionHandle, ask: PendingAsk, reply: HumanReply, spent?: OpenCodeUsage): Promise<SessionHandle>;
891
934
  private continueAfterRestart;
935
+ /** What a session spent so far (`session.get`), or nothing when OpenCode does not say. */
936
+ private sessionUsage;
892
937
  /** Interrupt the thread's session (cancel). */
893
938
  interrupt(input: AgentRunInput): Promise<void>;
894
939
  private ensureLive;
package/dist/index.cjs CHANGED
@@ -395,6 +395,17 @@ var OpenCodeTurn = class {
395
395
  reported = emptyUsage();
396
396
  priced = false;
397
397
  model;
398
+ /** What the session had spent when this turn prompted it; what OpenCode reported since. */
399
+ baseline;
400
+ seen = {
401
+ cost: 0,
402
+ input: 0,
403
+ output: 0,
404
+ reasoning: 0,
405
+ read: 0,
406
+ write: 0
407
+ };
408
+ compacted = false;
398
409
  stepOpen = false;
399
410
  sawText = false;
400
411
  separator = false;
@@ -406,6 +417,19 @@ var OpenCodeTurn = class {
406
417
  this.a = a;
407
418
  this.model = modelOfLabel(a.input.model);
408
419
  }
420
+ /**
421
+ * What the session had spent before this turn prompted it (`session.get`). When the execution
422
+ * ends, what the session spent since that this turn's events did not report — the title OpenCode
423
+ * generates, a compaction: OpenCode records those calls on the session without streaming them — is
424
+ * recorded as a usage row of its own.
425
+ */
426
+ setBaseline(info) {
427
+ if (info?.tokens === void 0) return;
428
+ this.baseline = {
429
+ cost: Number(info.cost) || 0,
430
+ tokens: info.tokens
431
+ };
432
+ }
409
433
  /** What the run spent so far: carried from before this turn object, plus what it saw. */
410
434
  totalUsage() {
411
435
  return addUsage(this.a.carried, this.spent);
@@ -662,6 +686,10 @@ var OpenCodeTurn = class {
662
686
  }
663
687
  if (this.ended) return;
664
688
  switch (e.type) {
689
+ case "session.compaction.started":
690
+ case "session.compaction.ended":
691
+ this.compacted = true;
692
+ return;
665
693
  case "session.step.started":
666
694
  case "session.model.selected": {
667
695
  const model = d.model;
@@ -831,6 +859,13 @@ ${delta}` : delta;
831
859
  async spend(d, purpose) {
832
860
  const usage = usageOf(d.tokens);
833
861
  const reportedCostUsd = typeof d.cost === "number" && Number.isFinite(d.cost) && d.cost >= 0 ? d.cost : void 0;
862
+ const n = /* @__PURE__ */ __name((v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0, "n");
863
+ this.seen.cost += reportedCostUsd ?? 0;
864
+ this.seen.input += n(d.tokens?.input);
865
+ this.seen.output += n(d.tokens?.output);
866
+ this.seen.reasoning += n(d.tokens?.reasoning);
867
+ this.seen.read += n(d.tokens?.cache?.read);
868
+ this.seen.write += n(d.tokens?.cache?.write);
834
869
  const fallback = reportedCostUsd !== void 0 ? {
835
870
  costUsd: reportedCostUsd,
836
871
  costSource: "provider"
@@ -1036,6 +1071,8 @@ ${delta}` : delta;
1036
1071
  }
1037
1072
  const ttlMs = requirement.ttlMs !== void 0 && Number.isFinite(requirement.ttlMs) && requirement.ttlMs > 0 ? Math.floor(requirement.ttlMs) : void 0;
1038
1073
  const expiresAt = ttlMs !== void 0 ? new Date(Date.now() + ttlMs).toISOString() : void 0;
1074
+ const tool = this.a.approvalPresentation?.(action);
1075
+ const confirmation = tool !== void 0 ? (0, import_nestjs_agent_core2.resolveToolConfirmation)(tool.name, args, tool.presentation) : void 0;
1039
1076
  this.segment.calls.push({
1040
1077
  id: req.id,
1041
1078
  name: action,
@@ -1050,6 +1087,9 @@ ${delta}` : delta;
1050
1087
  toolType: "action",
1051
1088
  input: args,
1052
1089
  status: "pending_approval",
1090
+ ...confirmation !== void 0 ? {
1091
+ confirmation
1092
+ } : {},
1053
1093
  runId: this.a.runId,
1054
1094
  approver: requirement.approver,
1055
1095
  ...expiresAt !== void 0 ? {
@@ -1067,6 +1107,9 @@ ${delta}` : delta;
1067
1107
  kind: "approval-requested",
1068
1108
  id: req.id,
1069
1109
  approver: requirement.approver,
1110
+ ...confirmation !== void 0 ? {
1111
+ confirmation
1112
+ } : {},
1070
1113
  ...expiresAt !== void 0 ? {
1071
1114
  expiresAt
1072
1115
  } : {}
@@ -1298,9 +1341,39 @@ ${delta}` : delta;
1298
1341
  this.messageResults.set(messageId, results);
1299
1342
  await this.a.store.setMessageToolResults(messageId, results);
1300
1343
  }
1344
+ /** What the session spent since the baseline that no event of this turn reported. */
1345
+ async unreported() {
1346
+ const baseline = this.baseline;
1347
+ if (baseline === void 0) return;
1348
+ this.baseline = void 0;
1349
+ const info = await this.a.client.session.get?.({
1350
+ sessionID: this.a.sessionId
1351
+ }).catch(() => void 0);
1352
+ const after = info?.tokens;
1353
+ if (after === void 0) return;
1354
+ const before = baseline.tokens;
1355
+ const left = /* @__PURE__ */ __name((now, then, seen) => Math.max(0, (Number(now) || 0) - (Number(then) || 0) - seen), "left");
1356
+ const tokens = {
1357
+ input: left(after.input, before.input, this.seen.input),
1358
+ output: left(after.output, before.output, this.seen.output),
1359
+ reasoning: left(after.reasoning, before.reasoning, this.seen.reasoning),
1360
+ cache: {
1361
+ read: left(after.cache?.read, before.cache?.read, this.seen.read),
1362
+ write: left(after.cache?.write, before.cache?.write, this.seen.write)
1363
+ }
1364
+ };
1365
+ const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write;
1366
+ if (total === 0) return;
1367
+ const cost = left(info?.cost, baseline.cost, this.seen.cost);
1368
+ await this.spend({
1369
+ tokens,
1370
+ cost: cost < 1e-9 ? 0 : cost
1371
+ }, this.compacted ? "history_summary" : "title");
1372
+ }
1301
1373
  async end(outcome) {
1302
1374
  if (this.ended) return;
1303
1375
  this.ended = true;
1376
+ await this.unreported().catch((error) => this.a.logger.warn(`run ${this.a.runId}: ${errorText(error, "could not read the session usage")}`));
1304
1377
  if (outcome.status !== "failed" || this.segment.text || this.segment.calls.length > 0 || this.segment.ui.length > 0) {
1305
1378
  await this.flush(true);
1306
1379
  }
@@ -1817,6 +1890,25 @@ var OpenCodeTurns = class {
1817
1890
  get toolsServer() {
1818
1891
  return this.settings.tools?.server ?? "aviary";
1819
1892
  }
1893
+ /** The app tool an OpenCode permission `action` names, and its presentation — `undefined` for a built-in. */
1894
+ presentationOf(action) {
1895
+ if (this.settings.tools === void 0) return void 0;
1896
+ const server = this.toolsServer;
1897
+ for (const prefix of [
1898
+ `${server}.`,
1899
+ `${server}_`
1900
+ ]) {
1901
+ if (!action.startsWith(prefix)) continue;
1902
+ const spec = this.registry.spec(action.slice(prefix.length));
1903
+ if (spec !== void 0) return {
1904
+ name: spec.name,
1905
+ ...spec.presentation !== void 0 ? {
1906
+ presentation: spec.presentation
1907
+ } : {}
1908
+ };
1909
+ }
1910
+ return void 0;
1911
+ }
1820
1912
  /** The roles policy a turn's tools are gated by (the agent's own). */
1821
1913
  rolesPolicyFor(input) {
1822
1914
  return this.deps.forAgent(input.agentName).rolesPolicy;
@@ -2417,6 +2509,7 @@ ${body}
2417
2509
  })
2418
2510
  } : {}
2419
2511
  };
2512
+ live.turn.setBaseline(await this.sessionUsage(live.client, handle.sessionId));
2420
2513
  await live.client.session.prompt({
2421
2514
  sessionID: handle.sessionId,
2422
2515
  text: prompt.text,
@@ -2496,12 +2589,23 @@ ${body}
2496
2589
  await live.turn.decide(ask, reply, {
2497
2590
  tellOpenCode: false
2498
2591
  });
2592
+ live.turn.setBaseline(await this.sessionUsage(live.client, handle.sessionId));
2499
2593
  await live.client.session.prompt({
2500
2594
  sessionID: handle.sessionId,
2501
2595
  text: note
2502
2596
  });
2503
2597
  return handle;
2504
2598
  }
2599
+ /** What a session spent so far (`session.get`), or nothing when OpenCode does not say. */
2600
+ async sessionUsage(client, sessionId) {
2601
+ try {
2602
+ return await client.session.get?.({
2603
+ sessionID: sessionId
2604
+ });
2605
+ } catch {
2606
+ return void 0;
2607
+ }
2608
+ }
2505
2609
  /** Interrupt the thread's session (cancel). */
2506
2610
  async interrupt(input) {
2507
2611
  const known = await this.sessions.get(input.threadId);
@@ -2537,6 +2641,9 @@ ${body}
2537
2641
  agentName: input.agentName
2538
2642
  } : {}
2539
2643
  }), "approvalFor"),
2644
+ // An app tool reaches OpenCode as `<server>.<name>` / `<server>_<name>`: its own presentation
2645
+ // words the approval card. OpenCode's built-ins (`bash`, `edit`) have none.
2646
+ approvalPresentation: /* @__PURE__ */ __name((action) => this.presentationOf(action), "approvalPresentation"),
2540
2647
  modelLabel: input.model ?? "opencode",
2541
2648
  priceStep: /* @__PURE__ */ __name((step) => this.priceStep(step), "priceStep"),
2542
2649
  ...spent !== void 0 ? {