opencode-orchestrator-uni 0.4.0 → 0.5.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/lib/tools.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * tools — registration of the kit's deterministic, read-only audit tools.
3
3
  *
4
- * `registerAuditTools(ctx)` publishes four tools in the `orch_uni` namespace
4
+ * `registerAuditTools(ctx)` publishes five tools in the `orch_uni` namespace
5
5
  * (effective names `orch_uni_validate_report`, `orch_uni_context_lint`,
6
- * `orch_uni_snapshot`, `orch_uni_sessions`) and returns a cleanup that stops the
7
- * background session-index subscription:
6
+ * `orch_uni_snapshot`, `orch_uni_sessions`, `orch_uni_session_stats`) and returns
7
+ * a cleanup that stops the background session-index subscription:
8
8
  *
9
9
  * - `validate_report` — extract the SGR JSON block of an agent report, resolve
10
10
  * its schema, validate it and report prompt-level warnings;
@@ -14,11 +14,12 @@
14
14
  * - `snapshot` — deterministic revision of a directory tree (per-file sha256 +
15
15
  * aggregate hash) for "the state the verdict was fixed on";
16
16
  * - `sessions` — child sessions of a session (agent, model, cost, tokens, time),
17
- * read through the plugin's session API only.
17
+ * read through the plugin's session API only;
18
+ * - `session_stats` — the session-protocol counters and limits of one session.
18
19
  *
19
20
  * Every tool answers with a JSON text body. Failures are typed (`E_*`) codes in
20
21
  * the body rather than thrown tool errors, so an agent can branch on the code.
21
- * All four are read-only: they never write to the workspace or the config; the
22
+ * All five are read-only: they never write to the workspace or the config; the
22
23
  * only state `sessions` keeps is a plugin-scoped session index in `ctx.storage`
23
24
  * (see the `sessions` tool notes below).
24
25
  */
@@ -28,6 +29,7 @@ import { fileURLToPath } from "node:url"
28
29
  import type { Plugin } from "@opencode/plugin"
29
30
  import { lintContext } from "./context-lint"
30
31
  import { checkReport, type JsonSchemaNode, type ValidationCode } from "./report-schema"
32
+ import { compactionLimitReached, type SessionBudgetReport } from "./session-budget"
31
33
  import { DEFAULT_EXCLUDE, isSnapshotFailure, snapshotTree } from "./snapshot"
32
34
 
33
35
  /** Tool namespace; effective names become `orch_uni_<name>`. */
@@ -39,6 +41,7 @@ export const TOOL_IDS: readonly string[] = [
39
41
  "orch_uni_context_lint",
40
42
  "orch_uni_snapshot",
41
43
  "orch_uni_sessions",
44
+ "orch_uni_session_stats",
42
45
  ]
43
46
 
44
47
  /** Directory holding the shipped report schemas (`schemas/*.json`). */
@@ -144,6 +147,31 @@ function asRecord(input: unknown): Record<string, unknown> | undefined {
144
147
  : undefined
145
148
  }
146
149
 
150
+ /**
151
+ * `session.get` failures that mean the session is genuinely absent.
152
+ *
153
+ * A live host may throw with an empty message but a typed `_tag`/`name`/`code`;
154
+ * the message regex is only a narrow fallback, so a transient `unknown database`
155
+ * or `unknown host` stays `E_INTERNAL` and cannot masquerade as a missing session.
156
+ */
157
+ function isNotFoundError(error: unknown): boolean {
158
+ const record = asRecord(error)
159
+ if (record !== undefined) {
160
+ for (const field of ["_tag", "name", "code"] as const) {
161
+ const value = record[field]
162
+ if (
163
+ value === "Session.NotFoundError" ||
164
+ value === "SessionNotFoundError" ||
165
+ value === "NotFoundError" ||
166
+ value === "ENOENT"
167
+ ) {
168
+ return true
169
+ }
170
+ }
171
+ }
172
+ return /session.*not.?found|unknown session/i.test(errorMessage(error))
173
+ }
174
+
147
175
  /** `validate_report` input contract. */
148
176
  const VALIDATE_REPORT_INPUT = {
149
177
  type: "object",
@@ -207,6 +235,14 @@ const SESSIONS_INPUT = {
207
235
  },
208
236
  } as const
209
237
 
238
+ /** `session_stats` input contract. */
239
+ const SESSION_STATS_INPUT = {
240
+ type: "object",
241
+ properties: {
242
+ sessionID: { type: "string", description: "Session to report. Default: the calling session." },
243
+ },
244
+ } as const
245
+
210
246
  interface SessionInfoLike {
211
247
  readonly id?: string
212
248
  readonly parentID?: string | null
@@ -389,10 +425,12 @@ export function resolveRoot(directory: string, input: string): string {
389
425
  return path.isAbsolute(input) ? path.normalize(input) : path.resolve(directory, input)
390
426
  }
391
427
 
392
- /** Optional seams for tests; the plugin calls this with no options. */
428
+ /** Optional seams for tests; the plugin calls this with the session-budget report. */
393
429
  export interface RegisterAuditToolsOptions {
394
430
  /** Directory to read `schemas/*.json` from. Defaults to the shipped `schemas/`. */
395
431
  readonly schemasDirectory?: string
432
+ /** Session counters from the runtime session guard; absent in isolation. */
433
+ readonly sessionBudget?: SessionBudgetReport
396
434
  }
397
435
 
398
436
  /**
@@ -646,13 +684,99 @@ export async function registerAuditTools(
646
684
  } catch (error) {
647
685
  const message = error instanceof Error ? error.message : String(error)
648
686
  if (message === "E_ABORTED" || toolContext.signal.aborted) return failure("E_ABORTED", "listing aborted")
649
- if (getFn !== undefined && /not ?found|unknown/i.test(message)) {
687
+ if (getFn !== undefined && isNotFoundError(error)) {
650
688
  return failure("E_NOT_FOUND", `session ${parentID} was not found: ${message}`, { sessionID: parentID })
651
689
  }
652
690
  return failure("E_INTERNAL", message, { sessionID: parentID })
653
691
  }
654
692
  }
655
693
 
694
+ const sessionStatsTool = async (
695
+ input: unknown,
696
+ toolContext: { readonly signal: AbortSignal; readonly sessionID: string },
697
+ ): Promise<{ content: string }> => {
698
+ const record = input === undefined ? {} : asRecord(input)
699
+ if (record === undefined) return failure("E_INPUT", "input must be an object ({sessionID?})")
700
+ const { sessionID } = record
701
+ if (sessionID !== undefined && (typeof sessionID !== "string" || sessionID === "")) {
702
+ return failure("E_INPUT", '"sessionID" must be a non-empty string')
703
+ }
704
+ if (toolContext.signal.aborted) return failure("E_ABORTED", "report aborted")
705
+ const target = (sessionID as string | undefined) ?? toolContext.sessionID
706
+ const budget = options.sessionBudget
707
+ if (budget === undefined) {
708
+ return failure("E_UNAVAILABLE", "the session guard is not registered in this plugin instance", {
709
+ sessionID: target,
710
+ })
711
+ }
712
+ if (!budget.available) {
713
+ return failure("E_UNAVAILABLE", "the session guard is unavailable: its hooks could not be registered", {
714
+ sessionID: target,
715
+ })
716
+ }
717
+ const tracked = budget.tracked(target)
718
+ const counters = budget.stats(target)
719
+ const { session: sessionConfig, toolOutput } = budget.config
720
+ const guard = budget.guard()
721
+ const getFn = sessionMethod<SessionGetFn>(session, "get")
722
+ if (getFn === undefined && !tracked) {
723
+ return failure(
724
+ "E_NO_API",
725
+ "this OpenCode build exposes no session.get() and the session is not tracked by this plugin instance",
726
+ { sessionID: target },
727
+ )
728
+ }
729
+ let info: Record<string, unknown> | undefined
730
+ let getError: unknown
731
+ let getMissing = false
732
+ if (getFn !== undefined) {
733
+ try {
734
+ const fetched: SessionInfoLike | null | undefined = await getFn({ sessionID: target })
735
+ if (fetched === undefined || fetched === null) getMissing = true
736
+ else info = sessionSummary(fetched)
737
+ } catch (error) {
738
+ getError = error
739
+ }
740
+ }
741
+ // A session this instance never observed is only "not found" when the store
742
+ // says so; a transient error (lock, permission, transport) stays `E_INTERNAL`
743
+ // instead of masquerading as a missing session. A known session with zero
744
+ // requests is a valid `tracked:false` report. `session.get` returning
745
+ // `undefined` without an error is the store's "no such session" answer.
746
+ if (info === undefined && !tracked) {
747
+ if (getError !== undefined) {
748
+ const message = errorMessage(getError)
749
+ if (isNotFoundError(getError)) {
750
+ return failure("E_NOT_FOUND", `session ${target} was not found: ${message}`, { sessionID: target })
751
+ }
752
+ return failure("E_INTERNAL", message, { sessionID: target })
753
+ }
754
+ if (getMissing) {
755
+ return failure("E_NOT_FOUND", `session ${target} was not found`, { sessionID: target })
756
+ }
757
+ }
758
+ return json({
759
+ ok: true,
760
+ sessionID: target,
761
+ counters,
762
+ limits: {
763
+ promptTokensSoft: sessionConfig.promptTokensSoft,
764
+ maxCompactions: sessionConfig.maxCompactions,
765
+ maxToolOutputChars: toolOutput.maxChars,
766
+ },
767
+ overBudget: counters.promptTokensMax > sessionConfig.promptTokensSoft,
768
+ compactionLimitReached: compactionLimitReached(budget.config, counters.compactions),
769
+ toolsPerTurn: counters.requests > 0 ? Math.round((counters.toolCalls / counters.requests) * 100) / 100 : 0,
770
+ tracked,
771
+ guard,
772
+ ...(getFn === undefined
773
+ ? { session: null, limitations: ["session.get is unavailable in this build"] }
774
+ : info === undefined
775
+ ? {}
776
+ : { session: info }),
777
+ })
778
+ }
779
+
656
780
  await context.tool.transform((editor) => {
657
781
  editor.namespace({
658
782
  name: TOOL_NAMESPACE,
@@ -707,6 +831,20 @@ export async function registerAuditTools(
707
831
  options: { namespace: TOOL_NAMESPACE, codemode: true },
708
832
  execute: sessionsTool,
709
833
  })
834
+ editor.add({
835
+ name: "session_stats",
836
+ description:
837
+ "Session protocol counters of a session: requests, estimated prompt tokens (last/max), budget band, confirmed " +
838
+ "compactions and compaction attempts, failed compactions, the live compaction source, reminders injected, tool " +
839
+ "calls and capped tool outputs (totals and per normalized tool name), chars kept out of context, plus the " +
840
+ "configured limits and toolsPerTurn. Read-only; counters belong to this plugin instance. `sessionID` may name any " +
841
+ "session on this server, not only a child of the caller. Returns JSON {ok, sessionID, counters, limits, overBudget, " +
842
+ "compactionLimitReached, toolsPerTurn, tracked, guard, session?, limitations?} or a typed code " +
843
+ "(E_INPUT|E_NOT_FOUND|E_INTERNAL|E_NO_API|E_UNAVAILABLE|E_ABORTED).",
844
+ input: SESSION_STATS_INPUT,
845
+ options: { namespace: TOOL_NAMESPACE, codemode: true },
846
+ execute: sessionStatsTool,
847
+ })
710
848
  })
711
849
 
712
850
  return () => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-orchestrator-uni",
4
- "version": "0.4.0",
4
+ "version": "0.5.0",
5
5
  "description": "Native OpenCode V2 plugin that registers the orchestrator agent kit (root orchestrator, audit subagents, fix-runner) and exposes the kit's process documents as the audit-kit reference.",
6
6
  "type": "module",
7
7
  "license": "MIT",