From ec28cf8ad90d37f10cf30c3b41e38ff00cba42b5 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:18:36 +0900 Subject: [PATCH 01/42] Add packages/semantic-predicate/package.json --- packages/semantic-predicate/package.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 packages/semantic-predicate/package.json diff --git a/packages/semantic-predicate/package.json b/packages/semantic-predicate/package.json new file mode 100644 index 0000000..24ed31a --- /dev/null +++ b/packages/semantic-predicate/package.json @@ -0,0 +1,18 @@ +{ + "name": "@halqme/semantic-predicate", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "check": "bun run typecheck && bun test", + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:" + } +} From c7964f2b89ab115bfb495e3a542d9a744595fbde Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:18:38 +0900 Subject: [PATCH 02/42] Add packages/semantic-predicate/src/index.ts --- packages/semantic-predicate/src/index.ts | 174 +++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 packages/semantic-predicate/src/index.ts diff --git a/packages/semantic-predicate/src/index.ts b/packages/semantic-predicate/src/index.ts new file mode 100644 index 0000000..e3fca9e --- /dev/null +++ b/packages/semantic-predicate/src/index.ts @@ -0,0 +1,174 @@ +export type SemanticPredicate = { + description: string; +}; + +export type SemanticPredicateSet = Record; + +export type SemanticDecision = { + value: boolean; + probability: number; + confidence: number; +}; + +export type SemanticDecisionSet = { + [K in keyof T]: SemanticDecision; +}; + +export type EvaluateInput = { + state: unknown; + predicates: T; +}; + +export type SemanticEvaluator = ( + input: EvaluateInput, +) => Promise>; + +export type OpenRouterEvaluatorOptions = { + apiKey: string; + model?: string; + endpoint?: string; + fetch?: typeof globalThis.fetch; +}; + +type OpenRouterResponse = { + choices?: Array<{ + message?: { + content?: string | null; + }; + }>; +}; + +const clampProbability = (value: unknown, field: string): number => { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`Invalid semantic decision ${field}`); + } + if (value < 0 || value > 1) { + throw new Error(`Semantic decision ${field} must be between 0 and 1`); + } + return value; +}; + +export const parseSemanticDecisions = ( + raw: unknown, + predicates: T, +): SemanticDecisionSet => { + if (!raw || typeof raw !== "object") throw new Error("Invalid semantic decision response"); + const root = raw as { results?: unknown }; + if (!root.results || typeof root.results !== "object") { + throw new Error("Semantic decision response is missing results"); + } + + const results = root.results as Record; + const parsed: Record = {}; + + for (const name of Object.keys(predicates)) { + const candidate = results[name]; + if (!candidate || typeof candidate !== "object") { + throw new Error(`Semantic decision response is missing predicate: ${name}`); + } + const decision = candidate as { + value?: unknown; + probability?: unknown; + confidence?: unknown; + }; + if (typeof decision.value !== "boolean") { + throw new Error(`Invalid semantic decision value for ${name}`); + } + parsed[name] = { + value: decision.value, + probability: clampProbability(decision.probability, `${name}.probability`), + confidence: clampProbability(decision.confidence, `${name}.confidence`), + }; + } + + return parsed as SemanticDecisionSet; +}; + +const buildResponseSchema = (predicates: SemanticPredicateSet) => ({ + type: "object", + properties: { + results: { + type: "object", + properties: Object.fromEntries( + Object.keys(predicates).map((name) => [ + name, + { + type: "object", + properties: { + value: { type: "boolean" }, + probability: { type: "number", minimum: 0, maximum: 1 }, + confidence: { type: "number", minimum: 0, maximum: 1 }, + }, + required: ["value", "probability", "confidence"], + additionalProperties: false, + }, + ]), + ), + required: Object.keys(predicates), + additionalProperties: false, + }, + }, + required: ["results"], + additionalProperties: false, +}); + +export const createOpenRouterSemanticEvaluator = ( + options: OpenRouterEvaluatorOptions, +): SemanticEvaluator => { + const endpoint = options.endpoint ?? "https://openrouter.ai/api/v1/chat/completions"; + const model = options.model ?? "~typesafe/jev-latest"; + const fetchImpl = options.fetch ?? globalThis.fetch; + + return async ({ + state, + predicates, + }: EvaluateInput): Promise> => { + if (Object.keys(predicates).length === 0) { + return {} as SemanticDecisionSet; + } + + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${options.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + messages: [ + { + role: "user", + content: JSON.stringify({ + state, + questions: Object.fromEntries( + Object.entries(predicates).map(([name, predicate]) => [ + name, + predicate.description, + ]), + ), + }), + }, + ], + response_format: { + type: "json_schema", + json_schema: { + name: "semantic_predicates", + strict: true, + schema: buildResponseSchema(predicates), + }, + }, + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`OpenRouter semantic evaluation failed (${response.status}): ${body}`); + } + + const payload = (await response.json()) as OpenRouterResponse; + const content = payload.choices?.[0]?.message?.content; + if (!content) throw new Error("OpenRouter semantic evaluation returned no content"); + + return parseSemanticDecisions(JSON.parse(content), predicates); + }; +}; From 47617fb5e1f4c37f6d757404ab56042a30a1cdbd Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:18:41 +0900 Subject: [PATCH 03/42] Add packages/semantic-predicate/src/index.test.ts --- packages/semantic-predicate/src/index.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 packages/semantic-predicate/src/index.test.ts diff --git a/packages/semantic-predicate/src/index.test.ts b/packages/semantic-predicate/src/index.test.ts new file mode 100644 index 0000000..0535bb9 --- /dev/null +++ b/packages/semantic-predicate/src/index.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { parseSemanticDecisions } from "./index.ts"; + +describe("parseSemanticDecisions", () => { + test("accepts complete bounded decisions", () => { + const result = parseSemanticDecisions( + { + results: { + scopeDrift: { + value: true, + probability: 0.82, + confidence: 0.91, + }, + }, + }, + { + scopeDrift: { + description: "Has the work moved outside the requested scope?", + }, + }, + ); + + expect(result.scopeDrift).toEqual({ + value: true, + probability: 0.82, + confidence: 0.91, + }); + }); + + test("rejects missing predicates", () => { + expect(() => + parseSemanticDecisions( + { results: {} }, + { + missingEvidence: { + description: "Is completion evidence missing?", + }, + }, + ), + ).toThrow("missing predicate"); + }); + + test("rejects probabilities outside the unit interval", () => { + expect(() => + parseSemanticDecisions( + { + results: { + consistencyRisk: { + value: false, + probability: 1.2, + confidence: 0.5, + }, + }, + }, + { + consistencyRisk: { + description: "Are the changes likely inconsistent with the repository?", + }, + }, + ), + ).toThrow("between 0 and 1"); + }); +}); From 86ebf832429a0a8d23da941278a937a4f47b8cb2 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:18:43 +0900 Subject: [PATCH 04/42] Add extensions/semantic-observer/package.json --- extensions/semantic-observer/package.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 extensions/semantic-observer/package.json diff --git a/extensions/semantic-observer/package.json b/extensions/semantic-observer/package.json new file mode 100644 index 0000000..dfed20b --- /dev/null +++ b/extensions/semantic-observer/package.json @@ -0,0 +1,18 @@ +{ + "name": "@halqme/semantic-observer", + "private": true, + "type": "module", + "scripts": { + "check": "bun run typecheck", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@earendil-works/pi-ai": "catalog:", + "@earendil-works/pi-coding-agent": "catalog:", + "@halqme/semantic-predicate": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:" + } +} From 2b6ab71c3d4b1998b356c51b8fbc886c4285dc9e Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:18:46 +0900 Subject: [PATCH 05/42] Add extensions/semantic-observer/index.ts --- extensions/semantic-observer/index.ts | 53 +++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 extensions/semantic-observer/index.ts diff --git a/extensions/semantic-observer/index.ts b/extensions/semantic-observer/index.ts new file mode 100644 index 0000000..1dc1526 --- /dev/null +++ b/extensions/semantic-observer/index.ts @@ -0,0 +1,53 @@ +import { Type } from "@earendil-works/pi-ai"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { createOpenRouterSemanticEvaluator } from "@halqme/semantic-predicate"; + +const DEFAULT_PREDICATES = { + scopeDrift: { + description: + "Has the work materially moved beyond the user's requested outcome or the smallest necessary implementation scope?", + }, + missingEvidence: { + description: + "Is the current claim of completion missing relevant executed verification evidence?", + }, + consistencyRisk: { + description: + "Do the described changes appear inconsistent with nearby repository conventions, contracts, or related files?", + }, +} as const; + +export default function semanticObserverExtension(pi: ExtensionAPI): void { + pi.registerTool({ + name: "semantic_observe", + label: "Semantic Observe", + description: + "Run an optional, non-authoritative semantic observation over supplied state. Results are advisory only and never change task, verification, or completion state.", + parameters: Type.Object({ + state: Type.String({ + description: + "Compact text or JSON describing the state to evaluate. Keep it focused on evidence relevant to the predicates.", + }), + }), + async execute(_toolCallId, params) { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + throw new Error("semantic_observe requires OPENROUTER_API_KEY"); + } + + const evaluate = createOpenRouterSemanticEvaluator({ apiKey }); + const result = await evaluate({ + state: params.state, + predicates: DEFAULT_PREDICATES, + }); + + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + details: { + advisory: true, + predicates: Object.keys(DEFAULT_PREDICATES), + }, + }; + }, + }); +} From 5f79ba092c2d061bd8274c7a6c9e69d44555b087 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:19:24 +0900 Subject: [PATCH 06/42] Keep semantic observer outside workspace registration --- extensions/semantic-observer/package.json | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 extensions/semantic-observer/package.json diff --git a/extensions/semantic-observer/package.json b/extensions/semantic-observer/package.json deleted file mode 100644 index dfed20b..0000000 --- a/extensions/semantic-observer/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@halqme/semantic-observer", - "private": true, - "type": "module", - "scripts": { - "check": "bun run typecheck", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@earendil-works/pi-ai": "catalog:", - "@earendil-works/pi-coding-agent": "catalog:", - "@halqme/semantic-predicate": "workspace:*" - }, - "devDependencies": { - "@types/node": "catalog:", - "typescript": "catalog:" - } -} From 9ac671ce79efe7b8e0c56333ec0e36cbbc9d35e7 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:19:27 +0900 Subject: [PATCH 07/42] Decouple semantic observer from workspace packaging --- extensions/semantic-observer/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/semantic-observer/index.ts b/extensions/semantic-observer/index.ts index 1dc1526..d00ec20 100644 --- a/extensions/semantic-observer/index.ts +++ b/extensions/semantic-observer/index.ts @@ -1,6 +1,6 @@ import { Type } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { createOpenRouterSemanticEvaluator } from "@halqme/semantic-predicate"; +import { createOpenRouterSemanticEvaluator } from "../../packages/semantic-predicate/src/index.ts"; const DEFAULT_PREDICATES = { scopeDrift: { From 906143dd15c99ea7ba96c09ad166fc4ee857897a Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:19:42 +0900 Subject: [PATCH 08/42] Keep semantic predicate package standalone --- packages/semantic-predicate/package.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/semantic-predicate/package.json b/packages/semantic-predicate/package.json index 24ed31a..09a4f5a 100644 --- a/packages/semantic-predicate/package.json +++ b/packages/semantic-predicate/package.json @@ -9,10 +9,5 @@ "check": "bun run typecheck && bun test", "typecheck": "tsc --noEmit", "test": "bun test" - }, - "devDependencies": { - "@types/bun": "catalog:", - "@types/node": "catalog:", - "typescript": "catalog:" } } From 5eeee6b293016f72d368c194f4f74141ebf8911d Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:19:45 +0900 Subject: [PATCH 09/42] Register semantic observer extension --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 12d004c..faae33d 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "./extensions/browser-inspector/index.ts", "./extensions/macos-talk/index.ts", "./extensions/session-metrics/index.ts", + "./extensions/semantic-observer/index.ts", "./extensions/terminal/index.ts" ], "skills": [ From 9b356d5fd4026625709b2316d374cbe310b160c9 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:19:48 +0900 Subject: [PATCH 10/42] Document experimental semantic package boundary --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 02955cf..f1dabd6 100644 --- a/README.md +++ b/README.md @@ -35,18 +35,21 @@ extensions/ code/ syntax/ session-metrics/ + semantic-observer/ task/ terminal/ +packages/ + semantic-predicate/ skills/ prompts/ docs/ tsconfig.json ``` -Every runtime workspace now lives under `extensions/`; there is no separate `packages/` layer. `session-metrics` owns both the Pi extension and its offline CLI/analysis kernel. Multi-word extension directories use kebab-case, and the shared TypeScript configuration lives at the repository root. +`extensions/` remains the home of Pi runtime integration. `packages/` is reserved for code that is meaningful without Pi; the experimental `semantic-predicate` package lives there so Jev/OpenRouter evaluation can be removed or reused without changing Pi runtime contracts. It is intentionally not a root workspace yet while the experiment is being evaluated. `session-metrics` continues to own both its Pi extension and offline CLI/analysis kernel. Multi-word extension directories use kebab-case, and the shared TypeScript configuration lives at the repository root. The repository extension exposes `context` and `code`, and transparently strengthens the built-in `edit` path for supported source files. The old standalone Astrolabe and BM25 tool surfaces are gone; their useful structural and lexical mechanisms are internal implementation details under `src/syntax` and `src/context`. -Additional independent utilities remain available through the extensions listed above. `ask` provides synchronous structured user decisions in the interactive TUI; offline session analysis is provided by the `session-metrics` CLI in `extensions/session-metrics`. +Additional independent utilities remain available through the extensions listed above. `ask` provides synchronous structured user decisions in the interactive TUI; offline session analysis is provided by the `session-metrics` CLI in `extensions/session-metrics`. `semantic-observer` is an experimental, explicitly invoked observer: it sends caller-supplied state to the standalone semantic predicate evaluator and returns advisory results without changing task, verification, or completion state. See [`docs/architecture.md`](docs/architecture.md) for the design rationale and runtime contracts. From ed6e0301b1af90c3449cd77082cffd8734a92f79 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:35:58 +0900 Subject: [PATCH 11/42] feat: Add semantic observer package --- bun.lock | 23 +++++++++++++++++++++++ extensions/semantic-observer/package.json | 23 +++++++++++++++++++++++ package.json | 3 ++- packages/semantic-predicate/package.json | 13 +++++++++---- 4 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 extensions/semantic-observer/package.json diff --git a/bun.lock b/bun.lock index 6aeb735..ead0ebb 100644 --- a/bun.lock +++ b/bun.lock @@ -83,6 +83,18 @@ "typescript": "catalog:", }, }, + "extensions/semantic-observer": { + "name": "@halqme/semantic-observer", + "dependencies": { + "@earendil-works/pi-ai": "catalog:", + "@earendil-works/pi-coding-agent": "catalog:", + "@earendil-works/pi-tui": "catalog:", + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:", + }, + }, "extensions/session-metrics": { "name": "@halqme/session-metrics", "bin": { @@ -122,6 +134,13 @@ "typescript": "catalog:", }, }, + "packages/semantic-predicate": { + "name": "@halqme/semantic-predicate", + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:", + }, + }, }, "catalog": { "@earendil-works/pi-agent-core": "^0.84.1", @@ -213,6 +232,10 @@ "@halqme/repository": ["@halqme/repository@workspace:extensions/repository"], + "@halqme/semantic-observer": ["@halqme/semantic-observer@workspace:extensions/semantic-observer"], + + "@halqme/semantic-predicate": ["@halqme/semantic-predicate@workspace:packages/semantic-predicate"], + "@halqme/session-metrics": ["@halqme/session-metrics@workspace:extensions/session-metrics"], "@halqme/task": ["@halqme/task@workspace:extensions/task"], diff --git a/extensions/semantic-observer/package.json b/extensions/semantic-observer/package.json new file mode 100644 index 0000000..e61d60d --- /dev/null +++ b/extensions/semantic-observer/package.json @@ -0,0 +1,23 @@ +{ + "name": "@halqme/semantic-observer", + "private": true, + "type": "module", + "scripts": { + "check": "bun run typecheck && bun test --parallel", + "typecheck": "tsc --noEmit", + "test": "bun test", + "dev": "pi -e ./index.ts" + }, + "dependencies": { + "@earendil-works/pi-ai": "catalog:", + "@earendil-works/pi-coding-agent": "catalog:", + "@earendil-works/pi-tui": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:" + }, + "engines": { + "node": ">=26.0.0" + } +} diff --git a/package.json b/package.json index faae33d..697d545 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "pi-package" ], "workspaces": [ - "extensions/*" + "extensions/*", + "packages/*" ], "scripts": { "hooks:install": "git config core.hooksPath .githooks", diff --git a/packages/semantic-predicate/package.json b/packages/semantic-predicate/package.json index 09a4f5a..a71ca66 100644 --- a/packages/semantic-predicate/package.json +++ b/packages/semantic-predicate/package.json @@ -2,12 +2,17 @@ "name": "@halqme/semantic-predicate", "private": true, "type": "module", - "exports": { - ".": "./src/index.ts" - }, "scripts": { - "check": "bun run typecheck && bun test", + "check": "bun run typecheck && bun test --parallel", "typecheck": "tsc --noEmit", "test": "bun test" + }, + "dependencies": {}, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:" + }, + "engines": { + "node": ">=26.0.0" } } From 140336cfa542f82bdb974bdc6f860c9c7135c0ec Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:36:15 +0900 Subject: [PATCH 12/42] style(background-process): wrap option description line --- extensions/background-process/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/background-process/index.ts b/extensions/background-process/index.ts index c873b05..83a25c8 100644 --- a/extensions/background-process/index.ts +++ b/extensions/background-process/index.ts @@ -130,7 +130,8 @@ export default function backgroundProcessExtension(pi: ExtensionAPI): void { inspectRunning: Type.Optional( Type.Boolean({ default: false, - description: "For check only: include stdout/stderr while a process is pending or running.", + description: + "For check only: include stdout/stderr while a process is pending or running.", }), ), }), From 433576a5d7fba79521e43c292491671bdca15db7 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:39:36 +0900 Subject: [PATCH 13/42] Use OpenRouter Decisions API for semantic predicates --- packages/semantic-predicate/src/index.ts | 280 ++++++++++++++--------- 1 file changed, 169 insertions(+), 111 deletions(-) diff --git a/packages/semantic-predicate/src/index.ts b/packages/semantic-predicate/src/index.ts index e3fca9e..743ffbe 100644 --- a/packages/semantic-predicate/src/index.ts +++ b/packages/semantic-predicate/src/index.ts @@ -1,130 +1,211 @@ -export type SemanticPredicate = { - description: string; +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +export type NoulQuestion = { + type: "noul"; + instructions: JsonValue; + criteria?: { + true: JsonValue; + false: JsonValue; + }; +}; + +export type ChoiceQuestion = { + type: "choice"; + instructions: JsonValue; + criteria: Record; }; -export type SemanticPredicateSet = Record; +export type ScoreQuestion = { + type: "score"; + instructions: JsonValue; + criteria: JsonValue[]; +}; + +export type SemanticQuestion = NoulQuestion | ChoiceQuestion | ScoreQuestion; +export type SemanticQuestionSet = Record; -export type SemanticDecision = { - value: boolean; - probability: number; +export type NoulAnswer = { + type: "noul"; + noul: number; +}; + +export type ChoiceAnswer = { + type: "choice"; + choice: string; + probabilities: Record; confidence: number; }; -export type SemanticDecisionSet = { - [K in keyof T]: SemanticDecision; +export type ScoreAnswer = { + type: "score"; + score: number; + legend: Record; + probabilities: Record; + confidence: number; +}; + +export type SemanticAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer; + +export type AnswerForQuestion = T extends NoulQuestion + ? NoulAnswer + : T extends ChoiceQuestion + ? ChoiceAnswer + : T extends ScoreQuestion + ? ScoreAnswer + : never; + +export type AnswersForQuestions = { + [K in keyof T]: AnswerForQuestion; +}; + +export type EvaluateInput = { + state: JsonValue; + questions: T; }; -export type EvaluateInput = { - state: unknown; - predicates: T; +export type SemanticDecisionResponse = { + model?: string; + answers: AnswersForQuestions; + usage?: { + input_tokens?: number; + output_tokens?: number; + [key: string]: JsonValue | undefined; + }; + [key: string]: unknown; }; -export type SemanticEvaluator = ( +export type SemanticEvaluator = ( input: EvaluateInput, -) => Promise>; +) => Promise>; export type OpenRouterEvaluatorOptions = { apiKey: string; model?: string; endpoint?: string; fetch?: typeof globalThis.fetch; + headers?: Record; }; -type OpenRouterResponse = { - choices?: Array<{ - message?: { - content?: string | null; - }; - }>; -}; - -const clampProbability = (value: unknown, field: string): number => { +const probability = (value: unknown, field: string): number => { if (typeof value !== "number" || !Number.isFinite(value)) { - throw new Error(`Invalid semantic decision ${field}`); + throw new Error(`Invalid probability: ${field}`); } if (value < 0 || value > 1) { - throw new Error(`Semantic decision ${field} must be between 0 and 1`); + throw new Error(`${field} must be between 0 and 1`); } return value; }; -export const parseSemanticDecisions = ( - raw: unknown, - predicates: T, -): SemanticDecisionSet => { - if (!raw || typeof raw !== "object") throw new Error("Invalid semantic decision response"); - const root = raw as { results?: unknown }; - if (!root.results || typeof root.results !== "object") { - throw new Error("Semantic decision response is missing results"); +const probabilities = (value: unknown, field: string): Record => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`Invalid probability distribution: ${field}`); } - const results = root.results as Record; - const parsed: Record = {}; + return Object.fromEntries( + Object.entries(value).map(([key, candidate]) => [ + key, + probability(candidate, `${field}.${key}`), + ]), + ); +}; - for (const name of Object.keys(predicates)) { - const candidate = results[name]; - if (!candidate || typeof candidate !== "object") { - throw new Error(`Semantic decision response is missing predicate: ${name}`); - } - const decision = candidate as { - value?: unknown; - probability?: unknown; - confidence?: unknown; +const parseAnswer = (raw: unknown, question: SemanticQuestion, id: string): SemanticAnswer => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error(`Missing answer for question: ${id}`); + } + + const answer = raw as Record; + if (answer.type !== question.type) { + throw new Error(`Unexpected answer type for question: ${id}`); + } + + if (question.type === "noul") { + return { + type: "noul", + noul: probability(answer.noul, `${id}.noul`), }; - if (typeof decision.value !== "boolean") { - throw new Error(`Invalid semantic decision value for ${name}`); + } + + if (question.type === "choice") { + if (typeof answer.choice !== "string" || !(answer.choice in question.criteria)) { + throw new Error(`Invalid choice for question: ${id}`); } - parsed[name] = { - value: decision.value, - probability: clampProbability(decision.probability, `${name}.probability`), - confidence: clampProbability(decision.confidence, `${name}.confidence`), + + return { + type: "choice", + choice: answer.choice, + probabilities: probabilities(answer.probabilities, `${id}.probabilities`), + confidence: probability(answer.confidence, `${id}.confidence`), }; } - return parsed as SemanticDecisionSet; + if (typeof answer.score !== "number" || !Number.isFinite(answer.score)) { + throw new Error(`Invalid score for question: ${id}`); + } + if (!answer.legend || typeof answer.legend !== "object" || Array.isArray(answer.legend)) { + throw new Error(`Invalid score legend for question: ${id}`); + } + + return { + type: "score", + score: answer.score, + legend: answer.legend as Record, + probabilities: probabilities(answer.probabilities, `${id}.probabilities`), + confidence: probability(answer.confidence, `${id}.confidence`), + }; }; -const buildResponseSchema = (predicates: SemanticPredicateSet) => ({ - type: "object", - properties: { - results: { - type: "object", - properties: Object.fromEntries( - Object.keys(predicates).map((name) => [ - name, - { - type: "object", - properties: { - value: { type: "boolean" }, - probability: { type: "number", minimum: 0, maximum: 1 }, - confidence: { type: "number", minimum: 0, maximum: 1 }, - }, - required: ["value", "probability", "confidence"], - additionalProperties: false, - }, - ]), - ), - required: Object.keys(predicates), - additionalProperties: false, - }, - }, - required: ["results"], - additionalProperties: false, -}); +export const parseSemanticDecisionResponse = ( + raw: unknown, + questions: T, +): SemanticDecisionResponse => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("Invalid semantic decision response"); + } + + const response = raw as Record; + if (!response.answers || typeof response.answers !== "object" || Array.isArray(response.answers)) { + throw new Error("Semantic decision response is missing answers"); + } + + const rawAnswers = response.answers as Record; + const parsedAnswers: Record = {}; + + for (const [id, question] of Object.entries(questions)) { + parsedAnswers[id] = parseAnswer(rawAnswers[id], question, id); + } + + return { + ...response, + ...(typeof response.model === "string" ? { model: response.model } : {}), + answers: parsedAnswers as AnswersForQuestions, + ...(response.usage && typeof response.usage === "object" + ? { + usage: response.usage as SemanticDecisionResponse["usage"], + } + : {}), + }; +}; export const createOpenRouterSemanticEvaluator = ( options: OpenRouterEvaluatorOptions, ): SemanticEvaluator => { - const endpoint = options.endpoint ?? "https://openrouter.ai/api/v1/chat/completions"; - const model = options.model ?? "~typesafe/jev-latest"; + const endpoint = options.endpoint ?? "https://openrouter.ai/api/alpha/decisions"; + const model = options.model ?? "typesafe/jev-1.13"; const fetchImpl = options.fetch ?? globalThis.fetch; - return async ({ + return async ({ state, - predicates, - }: EvaluateInput): Promise> => { - if (Object.keys(predicates).length === 0) { - return {} as SemanticDecisionSet; + questions, + }: EvaluateInput): Promise> => { + if (Object.keys(questions).length === 0) { + return { model, answers: {} as AnswersForQuestions }; } const response = await fetchImpl(endpoint, { @@ -132,31 +213,12 @@ export const createOpenRouterSemanticEvaluator = ( headers: { Authorization: `Bearer ${options.apiKey}`, "Content-Type": "application/json", + ...options.headers, }, body: JSON.stringify({ model, - messages: [ - { - role: "user", - content: JSON.stringify({ - state, - questions: Object.fromEntries( - Object.entries(predicates).map(([name, predicate]) => [ - name, - predicate.description, - ]), - ), - }), - }, - ], - response_format: { - type: "json_schema", - json_schema: { - name: "semantic_predicates", - strict: true, - schema: buildResponseSchema(predicates), - }, - }, + state, + questions, }), }); @@ -165,10 +227,6 @@ export const createOpenRouterSemanticEvaluator = ( throw new Error(`OpenRouter semantic evaluation failed (${response.status}): ${body}`); } - const payload = (await response.json()) as OpenRouterResponse; - const content = payload.choices?.[0]?.message?.content; - if (!content) throw new Error("OpenRouter semantic evaluation returned no content"); - - return parseSemanticDecisions(JSON.parse(content), predicates); + return parseSemanticDecisionResponse(await response.json(), questions); }; }; From 26a6bdaa52e422435f36e2d629b1678cd8a3efaa Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:39:50 +0900 Subject: [PATCH 14/42] Test native Jev decision response shapes --- packages/semantic-predicate/src/index.test.ts | 143 ++++++++++++++---- 1 file changed, 110 insertions(+), 33 deletions(-) diff --git a/packages/semantic-predicate/src/index.test.ts b/packages/semantic-predicate/src/index.test.ts index 0535bb9..daed9a9 100644 --- a/packages/semantic-predicate/src/index.test.ts +++ b/packages/semantic-predicate/src/index.test.ts @@ -1,63 +1,140 @@ import { describe, expect, test } from "bun:test"; -import { parseSemanticDecisions } from "./index.ts"; +import { + createOpenRouterSemanticEvaluator, + parseSemanticDecisionResponse, +} from "./index.ts"; -describe("parseSemanticDecisions", () => { - test("accepts complete bounded decisions", () => { - const result = parseSemanticDecisions( +describe("parseSemanticDecisionResponse", () => { + test("parses a Noul without inventing a confidence field", () => { + const result = parseSemanticDecisionResponse( { - results: { - scopeDrift: { - value: true, - probability: 0.82, - confidence: 0.91, + model: "typesafe/jev-1.13", + answers: { + scope_drift: { + type: "noul", + noul: 0.82, }, }, }, { - scopeDrift: { - description: "Has the work moved outside the requested scope?", + scope_drift: { + type: "noul", + instructions: "Has the work moved outside the requested scope?", }, }, ); - expect(result.scopeDrift).toEqual({ - value: true, - probability: 0.82, - confidence: 0.91, + expect(result.answers.scope_drift).toEqual({ + type: "noul", + noul: 0.82, }); }); - test("rejects missing predicates", () => { - expect(() => - parseSemanticDecisions( - { results: {} }, - { - missingEvidence: { - description: "Is completion evidence missing?", + test("parses Choice distributions and confidence", () => { + const result = parseSemanticDecisionResponse( + { + answers: { + route: { + type: "choice", + choice: "review", + probabilities: { + continue: 0.25, + review: 0.75, + }, + confidence: 0.5, }, }, - ), - ).toThrow("missing predicate"); + }, + { + route: { + type: "choice", + instructions: "Which route fits the state?", + criteria: { + continue: "Continue normally.", + review: "Request review.", + }, + }, + }, + ); + + expect(result.answers.route.choice).toBe("review"); + expect(result.answers.route.probabilities.review).toBe(0.75); }); - test("rejects probabilities outside the unit interval", () => { + test("rejects out-of-range Noul probabilities", () => { expect(() => - parseSemanticDecisions( + parseSemanticDecisionResponse( { - results: { - consistencyRisk: { - value: false, - probability: 1.2, - confidence: 0.5, + answers: { + gap: { + type: "noul", + noul: 1.2, }, }, }, { - consistencyRisk: { - description: "Are the changes likely inconsistent with the repository?", + gap: { + type: "noul", + instructions: "Is there a verification gap?", }, }, ), ).toThrow("between 0 and 1"); }); }); + +describe("createOpenRouterSemanticEvaluator", () => { + test("uses the Decisions API with state and typed questions directly", async () => { + let requestUrl = ""; + let requestBody: unknown; + + const evaluate = createOpenRouterSemanticEvaluator({ + apiKey: "test-key", + fetch: async (input, init) => { + requestUrl = String(input); + requestBody = JSON.parse(String(init?.body)); + return new Response( + JSON.stringify({ + model: "typesafe/jev-1.13", + answers: { + scope_drift: { + type: "noul", + noul: 0.2, + }, + }, + usage: { + input_tokens: 42, + output_tokens: 3, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }, + }); + + const state = { + request: "Only update the parser.", + changes: "Changed parser.ts.", + }; + + const questions = { + scope_drift: { + type: "noul" as const, + instructions: "Given `request` and `changes`, has the work moved outside the request?", + }, + }; + + const result = await evaluate({ state, questions }); + + expect(requestUrl).toBe("https://openrouter.ai/api/alpha/decisions"); + expect(requestBody).toEqual({ + model: "typesafe/jev-1.13", + state, + questions, + }); + expect(result.answers.scope_drift.noul).toBe(0.2); + }); +}); From 9ee52eb8c0f386309b19435d843ed5ce21188848 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:40:29 +0900 Subject: [PATCH 15/42] Expose semantic predicate workspace package --- packages/semantic-predicate/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/semantic-predicate/package.json b/packages/semantic-predicate/package.json index a71ca66..12bf749 100644 --- a/packages/semantic-predicate/package.json +++ b/packages/semantic-predicate/package.json @@ -14,5 +14,8 @@ }, "engines": { "node": ">=26.0.0" + }, + "exports": { + ".": "./src/index.ts" } } From dd5f939948dc88754eb5aadc0813510d6f3e74c9 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:40:33 +0900 Subject: [PATCH 16/42] Depend on semantic predicate workspace package --- extensions/semantic-observer/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/semantic-observer/package.json b/extensions/semantic-observer/package.json index e61d60d..17737b8 100644 --- a/extensions/semantic-observer/package.json +++ b/extensions/semantic-observer/package.json @@ -11,7 +11,8 @@ "dependencies": { "@earendil-works/pi-ai": "catalog:", "@earendil-works/pi-coding-agent": "catalog:", - "@earendil-works/pi-tui": "catalog:" + "@earendil-works/pi-tui": "catalog:", + "@halqme/semantic-predicate": "workspace:*" }, "devDependencies": { "@types/node": "catalog:", From fd0854e4aa30b0a58899aa6040203aa9000a4cc5 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:41:11 +0900 Subject: [PATCH 17/42] Make semantic observer context explicit and minimal --- extensions/semantic-observer/index.ts | 158 ++++++++++++++++++++++---- 1 file changed, 136 insertions(+), 22 deletions(-) diff --git a/extensions/semantic-observer/index.ts b/extensions/semantic-observer/index.ts index d00ec20..f200e7e 100644 --- a/extensions/semantic-observer/index.ts +++ b/extensions/semantic-observer/index.ts @@ -1,33 +1,86 @@ import { Type } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { createOpenRouterSemanticEvaluator } from "../../packages/semantic-predicate/src/index.ts"; +import { + createOpenRouterSemanticEvaluator, + type JsonValue, + type NoulQuestion, +} from "@halqme/semantic-predicate"; -const DEFAULT_PREDICATES = { - scopeDrift: { - description: - "Has the work materially moved beyond the user's requested outcome or the smallest necessary implementation scope?", - }, - missingEvidence: { - description: - "Is the current claim of completion missing relevant executed verification evidence?", - }, - consistencyRisk: { - description: - "Do the described changes appear inconsistent with nearby repository conventions, contracts, or related files?", +const noul = ( + instructions: string, + yes: string, + no: string, +): NoulQuestion => ({ + type: "noul", + instructions, + criteria: { + true: yes, + false: no, }, +}); + +const QUESTIONS = { + scopeDrift: noul( + "Given `request`, `task`, and `changes`, has the work materially moved beyond the requested outcome or the smallest necessary implementation scope?", + "The changes add behavior, refactoring, dependencies, or scope that is not needed for the request or task contract.", + "The changes stay within the request or are necessary to satisfy its task contract.", + ), + verificationGap: noul( + "Given `request`, `changes`, and `verification`, is there a meaningful gap between what changed and what the executed verification demonstrates?", + "Important changed behavior or an important failure mode is not covered by the supplied executed verification.", + "The supplied executed verification is relevant evidence for the important changed behavior and failure modes.", + ), + consistencyRisk: noul( + "Given `changes` and `repository_evidence`, do the changes appear inconsistent with relevant repository contracts, conventions, or related files?", + "The supplied repository evidence indicates a material inconsistency or likely integration mismatch.", + "The changes are consistent with the supplied repository evidence, or the evidence does not indicate a material mismatch.", + ), } as const; +type ObservationResult = { + probability: number; + stateFields: string[]; + model?: string; +}; + +const state = (entries: Array<[string, string | undefined]>): JsonValue => + Object.fromEntries( + entries.filter((entry): entry is [string, string] => entry[1] !== undefined), + ); + export default function semanticObserverExtension(pi: ExtensionAPI): void { pi.registerTool({ name: "semantic_observe", label: "Semantic Observe", description: - "Run an optional, non-authoritative semantic observation over supplied state. Results are advisory only and never change task, verification, or completion state.", + "Run optional, non-authoritative Jev observations over compact evidence. Each judgment receives only the state fields it needs; returned probabilities are advisory and never change task, verification, or completion state.", parameters: Type.Object({ - state: Type.String({ + request: Type.String({ description: - "Compact text or JSON describing the state to evaluate. Keep it focused on evidence relevant to the predicates.", + "The user's requested outcome, preferably copied or minimally normalized rather than paraphrased.", }), + task: Type.Optional( + Type.String({ + description: + "Current task contract or acceptance criteria when they materially clarify the request.", + }), + ), + changes: Type.String({ + description: + "Compact primary evidence about the current changes: changed paths plus the smallest relevant diff excerpts or direct change facts. Prefer evidence over a narrative summary.", + }), + verification: Type.Optional( + Type.String({ + description: + "Executed verification evidence and results. Do not include planned checks or self-review as if they had run.", + }), + ), + repositoryEvidence: Type.Optional( + Type.String({ + description: + "Only repository evidence relevant to consistency: nearby contracts, conventions, related code, or retrieved context. Do not send broad repository dumps.", + }), + ), }), async execute(_toolCallId, params) { const apiKey = process.env.OPENROUTER_API_KEY; @@ -36,16 +89,77 @@ export default function semanticObserverExtension(pi: ExtensionAPI): void { } const evaluate = createOpenRouterSemanticEvaluator({ apiKey }); - const result = await evaluate({ - state: params.state, - predicates: DEFAULT_PREDICATES, - }); + const calls: Array> = []; + + calls.push( + evaluate({ + state: state([ + ["request", params.request], + ["task", params.task], + ["changes", params.changes], + ]), + questions: { scope_drift: QUESTIONS.scopeDrift }, + }).then((response) => [ + "scopeDrift", + { + probability: response.answers.scope_drift.noul, + stateFields: ["request", ...(params.task ? ["task"] : []), "changes"], + ...(response.model ? { model: response.model } : {}), + }, + ]), + ); + + if (params.verification !== undefined) { + calls.push( + evaluate({ + state: state([ + ["request", params.request], + ["changes", params.changes], + ["verification", params.verification], + ]), + questions: { verification_gap: QUESTIONS.verificationGap }, + }).then((response) => [ + "verificationGap", + { + probability: response.answers.verification_gap.noul, + stateFields: ["request", "changes", "verification"], + ...(response.model ? { model: response.model } : {}), + }, + ]), + ); + } + + if (params.repositoryEvidence !== undefined) { + calls.push( + evaluate({ + state: state([ + ["changes", params.changes], + ["repository_evidence", params.repositoryEvidence], + ]), + questions: { consistency_risk: QUESTIONS.consistencyRisk }, + }).then((response) => [ + "consistencyRisk", + { + probability: response.answers.consistency_risk.noul, + stateFields: ["changes", "repository_evidence"], + ...(response.model ? { model: response.model } : {}), + }, + ]), + ); + } + + const observations = Object.fromEntries(await Promise.all(calls)); return { - content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + content: [ + { + type: "text" as const, + text: JSON.stringify({ observations }, null, 2), + }, + ], details: { advisory: true, - predicates: Object.keys(DEFAULT_PREDICATES), + observations: Object.keys(observations), }, }; }, From 5a7d909400785b80fae08c6e63f882f7a04cd027 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:41:33 +0900 Subject: [PATCH 18/42] Keep semantic observer package dependency-free from workspace contract --- extensions/semantic-observer/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/extensions/semantic-observer/package.json b/extensions/semantic-observer/package.json index 17737b8..e61d60d 100644 --- a/extensions/semantic-observer/package.json +++ b/extensions/semantic-observer/package.json @@ -11,8 +11,7 @@ "dependencies": { "@earendil-works/pi-ai": "catalog:", "@earendil-works/pi-coding-agent": "catalog:", - "@earendil-works/pi-tui": "catalog:", - "@halqme/semantic-predicate": "workspace:*" + "@earendil-works/pi-tui": "catalog:" }, "devDependencies": { "@types/node": "catalog:", From 33643948002a55fa6409cacd3a49ef2c121caa52 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:41:35 +0900 Subject: [PATCH 19/42] Keep semantic predicate boundary as relative adapter import --- extensions/semantic-observer/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/semantic-observer/index.ts b/extensions/semantic-observer/index.ts index f200e7e..fee2a9f 100644 --- a/extensions/semantic-observer/index.ts +++ b/extensions/semantic-observer/index.ts @@ -4,7 +4,7 @@ import { createOpenRouterSemanticEvaluator, type JsonValue, type NoulQuestion, -} from "@halqme/semantic-predicate"; +} from "../../packages/semantic-predicate/src/index.ts"; const noul = ( instructions: string, From 08451155ff64658a0966e59d9dab770dea006365 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:42:00 +0900 Subject: [PATCH 20/42] Use Node test runner for semantic predicate package --- packages/semantic-predicate/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/semantic-predicate/package.json b/packages/semantic-predicate/package.json index 12bf749..69316e4 100644 --- a/packages/semantic-predicate/package.json +++ b/packages/semantic-predicate/package.json @@ -3,9 +3,9 @@ "private": true, "type": "module", "scripts": { - "check": "bun run typecheck && bun test --parallel", + "check": "bun run typecheck && bun run test", "typecheck": "tsc --noEmit", - "test": "bun test" + "test": "node --test" }, "dependencies": {}, "devDependencies": { @@ -18,4 +18,4 @@ "exports": { ".": "./src/index.ts" } -} +}\n \ No newline at end of file From fe077ad8e39665c904c3478366d8e8a12a49679c Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:42:02 +0900 Subject: [PATCH 21/42] Use Node test assertions for semantic predicate --- packages/semantic-predicate/src/index.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/semantic-predicate/src/index.test.ts b/packages/semantic-predicate/src/index.test.ts index daed9a9..3900143 100644 --- a/packages/semantic-predicate/src/index.test.ts +++ b/packages/semantic-predicate/src/index.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import assert from "node:assert/strict";\nimport { describe, test } from "node:test"; import { createOpenRouterSemanticEvaluator, parseSemanticDecisionResponse, @@ -57,8 +57,8 @@ describe("parseSemanticDecisionResponse", () => { }, ); - expect(result.answers.route.choice).toBe("review"); - expect(result.answers.route.probabilities.review).toBe(0.75); + assert.equal(result.answers.route.choice, "review"); + assert.equal(result.answers.route.probabilities.review, 0.75); }); test("rejects out-of-range Noul probabilities", () => { @@ -129,12 +129,12 @@ describe("createOpenRouterSemanticEvaluator", () => { const result = await evaluate({ state, questions }); - expect(requestUrl).toBe("https://openrouter.ai/api/alpha/decisions"); + assert.equal(requestUrl, "https://openrouter.ai/api/alpha/decisions"); expect(requestBody).toEqual({ model: "typesafe/jev-1.13", state, questions, }); - expect(result.answers.scope_drift.noul).toBe(0.2); + assert.equal(result.answers.scope_drift.noul, 0.2); }); }); From 3d00e97da37ccd5da9b0c3df2a196085a5fa0d83 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:42:16 +0900 Subject: [PATCH 22/42] Fix semantic predicate tests for Node runner --- packages/semantic-predicate/src/index.test.ts | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/semantic-predicate/src/index.test.ts b/packages/semantic-predicate/src/index.test.ts index 3900143..9d099e3 100644 --- a/packages/semantic-predicate/src/index.test.ts +++ b/packages/semantic-predicate/src/index.test.ts @@ -1,4 +1,5 @@ -import assert from "node:assert/strict";\nimport { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; import { createOpenRouterSemanticEvaluator, parseSemanticDecisionResponse, @@ -24,7 +25,7 @@ describe("parseSemanticDecisionResponse", () => { }, ); - expect(result.answers.scope_drift).toEqual({ + assert.deepEqual(result.answers.scope_drift, { type: "noul", noul: 0.82, }); @@ -62,24 +63,26 @@ describe("parseSemanticDecisionResponse", () => { }); test("rejects out-of-range Noul probabilities", () => { - expect(() => - parseSemanticDecisionResponse( - { - answers: { + assert.throws( + () => + parseSemanticDecisionResponse( + { + answers: { + gap: { + type: "noul", + noul: 1.2, + }, + }, + }, + { gap: { type: "noul", - noul: 1.2, + instructions: "Is there a verification gap?", }, }, - }, - { - gap: { - type: "noul", - instructions: "Is there a verification gap?", - }, - }, - ), - ).toThrow("between 0 and 1"); + ), + /between 0 and 1/, + ); }); }); @@ -130,7 +133,7 @@ describe("createOpenRouterSemanticEvaluator", () => { const result = await evaluate({ state, questions }); assert.equal(requestUrl, "https://openrouter.ai/api/alpha/decisions"); - expect(requestBody).toEqual({ + assert.deepEqual(requestBody, { model: "typesafe/jev-1.13", state, questions, From a9f35d9fee59c1d5b58434436a2f09c358f14044 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:42:43 +0900 Subject: [PATCH 23/42] Document semantic observation context policy --- README.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f1dabd6..3d56cf2 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,26 @@ docs/ tsconfig.json ``` -`extensions/` remains the home of Pi runtime integration. `packages/` is reserved for code that is meaningful without Pi; the experimental `semantic-predicate` package lives there so Jev/OpenRouter evaluation can be removed or reused without changing Pi runtime contracts. It is intentionally not a root workspace yet while the experiment is being evaluated. `session-metrics` continues to own both its Pi extension and offline CLI/analysis kernel. Multi-word extension directories use kebab-case, and the shared TypeScript configuration lives at the repository root. +`extensions/` remains the home of Pi runtime integration. `packages/` is reserved for code that is meaningful without Pi; both are root workspaces. The experimental `semantic-predicate` package lives under `packages/` so Jev/OpenRouter evaluation can be removed or reused without changing Pi runtime contracts. `session-metrics` continues to own both its Pi extension and offline CLI/analysis kernel. Multi-word extension directories use kebab-case, and the shared TypeScript configuration lives at the repository root. The repository extension exposes `context` and `code`, and transparently strengthens the built-in `edit` path for supported source files. The old standalone Astrolabe and BM25 tool surfaces are gone; their useful structural and lexical mechanisms are internal implementation details under `src/syntax` and `src/context`. -Additional independent utilities remain available through the extensions listed above. `ask` provides synchronous structured user decisions in the interactive TUI; offline session analysis is provided by the `session-metrics` CLI in `extensions/session-metrics`. `semantic-observer` is an experimental, explicitly invoked observer: it sends caller-supplied state to the standalone semantic predicate evaluator and returns advisory results without changing task, verification, or completion state. +Additional independent utilities remain available through the extensions listed above. `ask` provides synchronous structured user decisions in the interactive TUI; offline session analysis is provided by the `session-metrics` CLI in `extensions/session-metrics`. `semantic-observer` is an experimental, explicitly invoked observer: it sends compact structured evidence to the standalone semantic predicate evaluator and returns advisory probabilities without changing task, verification, or completion state. + + +## Experimental semantic observation + +`semantic-observer` treats Jev as a sensor, not an authority. It uses OpenRouter's Decisions API through the Pi-independent `packages/semantic-predicate` package and keeps thresholds or actions outside the model boundary. + +Context is assembled as evidence, not as a transcript: + +- Prefer primary evidence: the user request, task contract, changed paths or small diff excerpts, executed verification results, and repository evidence retrieved for the question. +- Keep fields named and structured. Questions refer to the state fields they judge rather than relying on one opaque prompt. +- Give each judgment only the fields it needs. Questions that need different evidence are evaluated against separate minimal states; questions with the same state may be batched. +- Keep deterministic facts in code. Jev is for semantic judgments such as scope drift or whether verification meaningfully covers a change, not whether a check exists or how many files changed. +- Preserve probabilities. The observer does not turn Jev output into a pass/fail result; later policy may choose thresholds after the behavior has been measured. +- Do not feed broad session history, repository dumps, or previous Jev outputs back into later state by default. Add context only when it is evidence for the next judgment. + +The current observer evaluates `scopeDrift`, optional `verificationGap`, and optional `consistencyRisk`. It is deliberately explicit-call and advisory while the experiment is being evaluated. See [`docs/architecture.md`](docs/architecture.md) for the design rationale and runtime contracts. From ee9cb7a33321a1473b6c72ca8f3c8f2b98158bdd Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:42:45 +0900 Subject: [PATCH 24/42] Define semantic observer evidence boundary --- docs/architecture.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 4a1012b..07a6545 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,6 +45,37 @@ This follows the centralized asynchronous isolated delegation pattern evaluated Stable behavior belongs in tools and runtime state. `AGENTS.md` therefore contains only repository invariants and development mechanics; tool-routing and workflow state are not encoded as an always-on prompt layer. This is consistent with the repository-context results in arXiv:2602.11988. + +## Experimental semantic observation + +`semantic-observer` is outside the mechanical authority path. Its outputs are observations only: they cannot mutate repository state, satisfy `verify`, or unlock `task.finish`. The Pi-facing extension adapts runtime evidence; `packages/semantic-predicate` owns the Pi-independent OpenRouter Decisions API client and typed Jev primitives. + +The context boundary is intentionally narrower than the model context window. Jev 1.13 degrades when state contains irrelevant detail, so the observer does not treat the current conversation or repository as a default context blob. Each semantic judgment declares the evidence it needs and receives a small structured state with named fields. Primary runtime or repository evidence is preferred over a model-authored narrative summary. + +The current context views are: + +```text +scopeDrift + request + optional task contract + changes + +verificationGap + request + changes + executed verification + +consistencyRisk + changes + relevant repository evidence +``` + +These are separate requests because their evidence sets differ. If future questions genuinely share the same state, they should be batched into one Decisions API request; Jev evaluates questions independently and batching avoids sending the same state repeatedly. + +This boundary follows four rules: + +1. **Filter before inference.** Retrieval and runtime state select evidence before Jev sees it. +2. **Semantic only.** Exact checks, counts, dates, presence tests, and arithmetic stay in code. +3. **Probabilities before policy.** Raw Noul probabilities or Choice/Score distributions are recorded first; thresholds and actions belong to deterministic policy outside the package. +4. **No ambient accumulation.** Session history, broad diffs, repository dumps, and prior semantic answers are not automatically carried forward. A second-stage request receives earlier output only when code needs that result to construct genuinely new state. + +The experiment is intentionally explicit-call. Automatic hooks, escalation, or review routing should be added only after session evidence shows which judgments are useful and how their probabilities calibrate on Pi Kit work. + ## Evaluation `session-metrics` reconstructs runtime behavior from Pi session JSONL without active instrumentation. In addition to generic tool/action metrics, it records the `context`, `code`, `task`, `delegate`, and `verify` surfaces and verification provenance so harness changes can be compared against historical trajectories. From 3c4317fb5700c80d9d95b95369a573d36963b677 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:43:20 +0900 Subject: [PATCH 25/42] Fix semantic predicate package manifest newline --- packages/semantic-predicate/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/semantic-predicate/package.json b/packages/semantic-predicate/package.json index 69316e4..77c32ea 100644 --- a/packages/semantic-predicate/package.json +++ b/packages/semantic-predicate/package.json @@ -18,4 +18,4 @@ "exports": { ".": "./src/index.ts" } -}\n \ No newline at end of file +}\n From 557b5b5cdf587f04802e9a702c1d384150da42b3 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:43:45 +0900 Subject: [PATCH 26/42] Keep semantic observation tuples typed --- extensions/semantic-observer/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/semantic-observer/index.ts b/extensions/semantic-observer/index.ts index fee2a9f..46f2601 100644 --- a/extensions/semantic-observer/index.ts +++ b/extensions/semantic-observer/index.ts @@ -106,7 +106,7 @@ export default function semanticObserverExtension(pi: ExtensionAPI): void { stateFields: ["request", ...(params.task ? ["task"] : []), "changes"], ...(response.model ? { model: response.model } : {}), }, - ]), + ] as [string, ObservationResult]), ); if (params.verification !== undefined) { @@ -125,7 +125,7 @@ export default function semanticObserverExtension(pi: ExtensionAPI): void { stateFields: ["request", "changes", "verification"], ...(response.model ? { model: response.model } : {}), }, - ]), + ] as [string, ObservationResult]), ); } @@ -144,7 +144,7 @@ export default function semanticObserverExtension(pi: ExtensionAPI): void { stateFields: ["changes", "repository_evidence"], ...(response.model ? { model: response.model } : {}), }, - ]), + ] as [string, ObservationResult]), ); } From 70510de9a47ea5c5db2cd7011de55296c66d0169 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:44:39 +0900 Subject: [PATCH 27/42] Normalize semantic predicate package manifest --- packages/semantic-predicate/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/semantic-predicate/package.json b/packages/semantic-predicate/package.json index 77c32ea..9b3e6e6 100644 --- a/packages/semantic-predicate/package.json +++ b/packages/semantic-predicate/package.json @@ -2,6 +2,9 @@ "name": "@halqme/semantic-predicate", "private": true, "type": "module", + "exports": { + ".": "./src/index.ts" + }, "scripts": { "check": "bun run typecheck && bun run test", "typecheck": "tsc --noEmit", @@ -14,8 +17,5 @@ }, "engines": { "node": ">=26.0.0" - }, - "exports": { - ".": "./src/index.ts" } -}\n +} From a21a840f72a20d648cadd1a0c6e8283e2c5dfb59 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:45:16 +0900 Subject: [PATCH 28/42] Skip empty semantic observer test workspace --- extensions/semantic-observer/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/extensions/semantic-observer/package.json b/extensions/semantic-observer/package.json index e61d60d..b9a7f92 100644 --- a/extensions/semantic-observer/package.json +++ b/extensions/semantic-observer/package.json @@ -3,9 +3,8 @@ "private": true, "type": "module", "scripts": { - "check": "bun run typecheck && bun test --parallel", + "check": "bun run typecheck", "typecheck": "tsc --noEmit", - "test": "bun test", "dev": "pi -e ./index.ts" }, "dependencies": { From 1ede9f5312e3bab8a979201805c7134b58dd83b0 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:47:29 +0900 Subject: [PATCH 29/42] Add semantic observer TypeScript config --- extensions/semantic-observer/tsconfig.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 extensions/semantic-observer/tsconfig.json diff --git a/extensions/semantic-observer/tsconfig.json b/extensions/semantic-observer/tsconfig.json new file mode 100644 index 0000000..4fe4905 --- /dev/null +++ b/extensions/semantic-observer/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["index.ts"] +} From 8db62e9402810f400223a6044f74b7b8f5c03ad1 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:47:31 +0900 Subject: [PATCH 30/42] Add semantic predicate TypeScript config --- packages/semantic-predicate/tsconfig.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 packages/semantic-predicate/tsconfig.json diff --git a/packages/semantic-predicate/tsconfig.json b/packages/semantic-predicate/tsconfig.json new file mode 100644 index 0000000..a5cb75c --- /dev/null +++ b/packages/semantic-predicate/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"] +} From 0aa9df562d3ca857917fda0d1c129925e77bcf01 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:48:10 +0900 Subject: [PATCH 31/42] Build optional semantic response fields explicitly --- packages/semantic-predicate/src/index.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/semantic-predicate/src/index.ts b/packages/semantic-predicate/src/index.ts index 743ffbe..0ad5bdf 100644 --- a/packages/semantic-predicate/src/index.ts +++ b/packages/semantic-predicate/src/index.ts @@ -181,16 +181,18 @@ export const parseSemanticDecisionResponse = ( parsedAnswers[id] = parseAnswer(rawAnswers[id], question, id); } - return { - ...response, - ...(typeof response.model === "string" ? { model: response.model } : {}), + const parsed: SemanticDecisionResponse = { answers: parsedAnswers as AnswersForQuestions, - ...(response.usage && typeof response.usage === "object" - ? { - usage: response.usage as SemanticDecisionResponse["usage"], - } - : {}), }; + + if (typeof response.model === "string") { + parsed.model = response.model; + } + if (response.usage && typeof response.usage === "object") { + parsed.usage = response.usage as NonNullable["usage"]>; + } + + return parsed; }; export const createOpenRouterSemanticEvaluator = ( From be08fd777f701707ae206a71a0dcedfdffebaeee Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:59:11 +0900 Subject: [PATCH 32/42] Expose resource tool calls in task evidence --- extensions/task/resources.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extensions/task/resources.ts b/extensions/task/resources.ts index b0904e3..504f3dc 100644 --- a/extensions/task/resources.ts +++ b/extensions/task/resources.ts @@ -51,6 +51,7 @@ export interface TaskReviewResources { path: string; tool: string; action?: string; + toolCallId: string; assistantEntryId?: string; }>; coverage: { @@ -440,6 +441,7 @@ export async function taskReviewResources( path: event.path, tool: event.tool, ...(event.action ? { action: event.action } : {}), + toolCallId: event.toolCallId, ...(event.assistantEntryId ? { assistantEntryId: event.assistantEntryId } : {}), })), coverage: { From 8cd1414e78754f7fdef3da36b74ef2e8744b1da6 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:59:33 +0900 Subject: [PATCH 33/42] Expose side-effect-free task evidence packet --- extensions/task/evidence.ts | 77 +++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 extensions/task/evidence.ts diff --git a/extensions/task/evidence.ts b/extensions/task/evidence.ts new file mode 100644 index 0000000..6665a41 --- /dev/null +++ b/extensions/task/evidence.ts @@ -0,0 +1,77 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; + +import { taskReviewResources, taskWorkspaceState, type TaskReviewResources, type TaskWorkspaceState } from "./resources.ts"; +import { customEntries, latestCustom, TASK_ENTRY, VERIFY_ENTRY } from "./shared.ts"; + +export interface TaskEvidenceCheckpoint { + at: string; + summary: string; + plan?: string[]; + observations?: string[]; + completed?: string[]; +} + +interface StoredTaskState { + id: string; + goal: string; + acceptance: string[]; + status: "active" | "blocked" | "done" | "stopped"; + checkpoints: TaskEvidenceCheckpoint[]; + blocker?: string; +} + +export interface TaskVerificationEvidence { + id: string; + taskId?: string; + provenance: string; + origin: "executed" | "reported"; + passed: boolean; + summary: string; + detail?: string; + reviewRequestId?: string; + at: string; +} + +export interface TaskEvidencePacket { + task: { + id: string; + goal: string; + acceptance: string[]; + status: StoredTaskState["status"]; + latestCheckpoint?: TaskEvidenceCheckpoint; + blocker?: string; + }; + resources: TaskReviewResources; + verification: TaskVerificationEvidence[]; + workspace?: TaskWorkspaceState; +} + +export async function taskEvidencePacket( + ctx: ExtensionContext, +): Promise { + const current = latestCustom(ctx, TASK_ENTRY); + if (!current) return undefined; + + const [resources, workspace] = await Promise.all([ + taskReviewResources(ctx, current.id), + taskWorkspaceState(ctx, current.id).catch(() => undefined), + ]); + const verification = customEntries(ctx, VERIFY_ENTRY).filter( + (item) => item.taskId === current.id, + ); + const latestCheckpoint = current.checkpoints.at(-1); + + return { + task: { + id: current.id, + goal: current.goal, + acceptance: current.acceptance, + status: current.status, + ...(latestCheckpoint ? { latestCheckpoint } : {}), + ...(current.blocker ? { blocker: current.blocker } : {}), + }, + resources, + verification, + ...(workspace ? { workspace } : {}), + }; +} From 4b6a41e14dae719bc2c1d590596edaa62d7fc6d1 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:59:50 +0900 Subject: [PATCH 34/42] Reuse task evidence packet for review context --- extensions/task/runtime.ts | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/extensions/task/runtime.ts b/extensions/task/runtime.ts index 7109235..385ce4a 100644 --- a/extensions/task/runtime.ts +++ b/extensions/task/runtime.ts @@ -9,11 +9,11 @@ import { captureWorkspaceBaseline, registerTaskResourceTracking, resolveProjectRoot, - taskReviewResources, taskWorkspaceRevision, taskWorkspaceState, WORKSPACE_ENTRY, } from "./resources.ts"; +import { taskEvidencePacket } from "./evidence.ts"; import { customEntries, jsonResult, @@ -371,11 +371,8 @@ export function registerTask(pi: ExtensionAPI): void { if (!current) throw new Error("precondition: No task state. Start a task first."); if (params.action === "review_context") { - const evidence = customEntries(ctx, VERIFY_ENTRY).filter( - (item) => item.taskId === current.id, - ); - const resources = await taskReviewResources(ctx, current.id); - const latestCheckpoint = current.checkpoints.at(-1); + const evidence = await taskEvidencePacket(ctx); + if (!evidence) throw new Error("precondition: No task evidence available."); const workspaceRevision = await taskWorkspaceRevision(ctx, current.id); const reviewRequest: TaskReviewRequest = { version: 1, @@ -386,16 +383,7 @@ export function registerTask(pi: ExtensionAPI): void { }; pi.appendEntry(REVIEW_ENTRY, reviewRequest); return jsonResult({ - task: { - id: current.id, - goal: current.goal, - acceptance: current.acceptance, - status: current.status, - ...(latestCheckpoint ? { latestCheckpoint } : {}), - ...(current.blocker ? { blocker: current.blocker } : {}), - }, - resources, - verification: evidence, + ...evidence, reviewRequest: { id: reviewRequest.id, at: reviewRequest.at }, }); } From 31a92379cc3f21ed026094fa31b0958be18eb3ea Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:00:29 +0900 Subject: [PATCH 35/42] Build semantic state from Pi runtime evidence --- extensions/semantic-observer/evidence.ts | 270 +++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 extensions/semantic-observer/evidence.ts diff --git a/extensions/semantic-observer/evidence.ts b/extensions/semantic-observer/evidence.ts new file mode 100644 index 0000000..5525454 --- /dev/null +++ b/extensions/semantic-observer/evidence.ts @@ -0,0 +1,270 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; + +import { + taskEvidencePacket, + type TaskEvidencePacket, +} from "../task/evidence.ts"; +import type { JsonValue } from "../../packages/semantic-predicate/src/index.ts"; + +const exec = promisify(execFile); +const MAX_DIFF_CHARS = 12_000; +const MAX_CONTEXT_CHARS = 9_000; +const MAX_CONTEXT_ITEM_CHARS = 3_000; +const MAX_CONTEXT_ITEMS = 4; + +export type ObservationId = "scopeDrift" | "verificationGap" | "consistencyRisk"; + +export interface ContextExcerpt { + tool: string; + paths: string[]; + text: string; +} + +export interface ObservationRuntimeEvidence { + diff?: string; + context: ContextExcerpt[]; +} + +type RecordValue = Record; + +function record(value: unknown): RecordValue | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as RecordValue) + : undefined; +} + +function textContent(value: unknown): string { + if (typeof value === "string") return value; + if (!Array.isArray(value)) return ""; + return value + .map((block) => { + const item = record(block); + return item?.type === "text" && typeof item.text === "string" ? item.text : ""; + }) + .filter(Boolean) + .join("\n"); +} + +function clip(value: string, max: number): string { + if (value.length <= max) return value; + return `${value.slice(0, max)}\n…[truncated ${value.length - max} chars]`; +} + +function taskView(packet: TaskEvidencePacket): JsonValue { + const checkpoint = packet.task.latestCheckpoint; + return { + goal: packet.task.goal, + acceptance: packet.task.acceptance, + status: packet.task.status, + ...(checkpoint + ? { + current_stage: { + summary: checkpoint.summary, + ...(checkpoint.completed?.length ? { completed: checkpoint.completed } : {}), + ...(checkpoint.plan?.length + ? { + working_plan: checkpoint.plan, + plan_authority: "hypothesis", + } + : {}), + }, + } + : {}), + ...(packet.task.blocker ? { blocker: packet.task.blocker } : {}), + }; +} + +function changeView(packet: TaskEvidencePacket, runtime: ObservationRuntimeEvidence): JsonValue { + const mutations = packet.resources.timeline + .filter((event) => event.operation === "mutate") + .map((event) => ({ + path: event.path, + tool: event.tool, + ...(event.action ? { action: event.action } : {}), + })); + + return { + changed_paths: packet.resources.changedDuringTask, + recorded_mutations: mutations, + ...(runtime.diff ? { diff: runtime.diff } : {}), + provenance: { + workspace_delta: packet.resources.coverage.workspaceDelta, + explicit_mutation_tracking: packet.resources.coverage.mutations, + opaque_tool_effects: packet.resources.coverage.opaqueToolEffects, + }, + }; +} + +function verificationView(packet: TaskEvidencePacket): JsonValue { + return packet.verification + .filter((item) => item.origin === "executed") + .map((item) => ({ + provenance: item.provenance, + passed: item.passed, + summary: item.summary, + ...(item.detail ? { detail: clip(item.detail, 2_000) } : {}), + })); +} + +function repositoryEvidenceView( + packet: TaskEvidencePacket, + runtime: ObservationRuntimeEvidence, +): JsonValue { + return { + observed_paths: packet.resources.observed, + excerpts: runtime.context.map((item) => ({ + tool: item.tool, + paths: item.paths, + text: item.text, + })), + provenance: { + explicit_observation_tracking: packet.resources.coverage.observations, + note: "Excerpts are successful read/context tool results previously observed during this task.", + }, + }; +} + +export function projectObservationState( + observation: ObservationId, + packet: TaskEvidencePacket, + runtime: ObservationRuntimeEvidence, +): JsonValue { + if (observation === "scopeDrift") { + return { + task: taskView(packet), + changes: changeView(packet, runtime), + }; + } + + if (observation === "verificationGap") { + return { + task: taskView(packet), + changes: changeView(packet, runtime), + verification: verificationView(packet), + }; + } + + return { + changes: changeView(packet, runtime), + repository_evidence: repositoryEvidenceView(packet, runtime), + }; +} + +async function diffEvidence( + ctx: ExtensionContext, + packet: TaskEvidencePacket, +): Promise { + const paths = packet.resources.changedDuringTask; + const baseline = packet.workspace?.baselineHead; + if (!baseline || paths.length === 0) return undefined; + + try { + const { stdout } = await exec( + "git", + ["--no-pager", "diff", "--no-ext-diff", "--unified=2", baseline, "--", ...paths], + { + cwd: ctx.cwd, + encoding: "utf8", + maxBuffer: 2 * 1024 * 1024, + timeout: 10_000, + }, + ); + const diff = stdout.trim(); + return diff ? clip(diff, MAX_DIFF_CHARS) : undefined; + } catch { + return undefined; + } +} + +function toolResultText(entries: unknown[]): Map { + const results = new Map(); + + for (const candidate of entries) { + const entry = record(candidate); + if (entry?.type !== "message") continue; + const message = record(entry.message); + if (message?.role !== "toolResult" || message.isError === true) continue; + const id = typeof message.toolCallId === "string" ? message.toolCallId : undefined; + const tool = typeof message.toolName === "string" ? message.toolName : undefined; + if (!id || !tool) continue; + const text = textContent(message.content).trim(); + if (text) results.set(id, { tool, text }); + } + + return results; +} + +function contextEvidence( + ctx: ExtensionContext, + packet: TaskEvidencePacket, +): ContextExcerpt[] { + const results = toolResultText(ctx.sessionManager.getEntries()); + const changed = new Set(packet.resources.changedDuringTask); + const grouped = new Map< + string, + { tool: string; paths: Set; index: number; overlapsChange: boolean } + >(); + + packet.resources.timeline.forEach((event, index) => { + if (event.operation !== "observe" || (event.tool !== "read" && event.tool !== "context")) { + return; + } + const existing = grouped.get(event.toolCallId); + if (existing) { + existing.paths.add(event.path); + existing.overlapsChange ||= changed.has(event.path); + existing.index = index; + return; + } + grouped.set(event.toolCallId, { + tool: event.tool, + paths: new Set([event.path]), + index, + overlapsChange: changed.has(event.path), + }); + }); + + const candidates = [...grouped.entries()] + .map(([toolCallId, value]) => ({ toolCallId, ...value })) + .filter((item) => results.has(item.toolCallId)) + .sort((a, b) => { + if (a.overlapsChange !== b.overlapsChange) return a.overlapsChange ? -1 : 1; + return b.index - a.index; + }) + .slice(0, MAX_CONTEXT_ITEMS); + + let remaining = MAX_CONTEXT_CHARS; + const excerpts: ContextExcerpt[] = []; + for (const item of candidates) { + if (remaining <= 0) break; + const result = results.get(item.toolCallId); + if (!result) continue; + const text = clip(result.text, Math.min(MAX_CONTEXT_ITEM_CHARS, remaining)); + remaining -= text.length; + excerpts.push({ + tool: item.tool, + paths: [...item.paths].sort(), + text, + }); + } + return excerpts; +} + +export async function buildObservationState( + ctx: ExtensionContext, + observation: ObservationId, +): Promise { + const packet = await taskEvidencePacket(ctx); + if (!packet || (packet.task.status !== "active" && packet.task.status !== "blocked")) { + throw new Error("precondition: semantic_observe requires an active or blocked task."); + } + + const [diff, context] = await Promise.all([ + diffEvidence(ctx, packet), + Promise.resolve(contextEvidence(ctx, packet)), + ]); + + return projectObservationState(observation, packet, { diff, context }); +} From 891857ab402e6a06221c5af84b6054299dcd2af9 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:00:54 +0900 Subject: [PATCH 36/42] Derive semantic observations from Pi runtime state --- extensions/semantic-observer/index.ts | 161 +++++++++++--------------- 1 file changed, 65 insertions(+), 96 deletions(-) diff --git a/extensions/semantic-observer/index.ts b/extensions/semantic-observer/index.ts index 46f2601..12ffff5 100644 --- a/extensions/semantic-observer/index.ts +++ b/extensions/semantic-observer/index.ts @@ -4,7 +4,9 @@ import { createOpenRouterSemanticEvaluator, type JsonValue, type NoulQuestion, + type SemanticEvaluator, } from "../../packages/semantic-predicate/src/index.ts"; +import { buildObservationState, type ObservationId } from "./evidence.ts"; const noul = ( instructions: string, @@ -21,17 +23,17 @@ const noul = ( const QUESTIONS = { scopeDrift: noul( - "Given `request`, `task`, and `changes`, has the work materially moved beyond the requested outcome or the smallest necessary implementation scope?", - "The changes add behavior, refactoring, dependencies, or scope that is not needed for the request or task contract.", - "The changes stay within the request or are necessary to satisfy its task contract.", + "Using `task` as the scope authority and `changes` as runtime evidence, has the work materially moved beyond the requested outcome or the smallest necessary implementation scope? Treat `task.current_stage.working_plan` as a hypothesis, not as authority to expand scope.", + "The changes add behavior, refactoring, dependencies, or scope that is not needed for the task goal or acceptance criteria.", + "The changes stay within the task goal and acceptance criteria, or are necessary to satisfy them.", ), verificationGap: noul( - "Given `request`, `changes`, and `verification`, is there a meaningful gap between what changed and what the executed verification demonstrates?", + "Given `task`, `changes`, and executed `verification`, is there a meaningful gap between what changed and what the executed verification demonstrates?", "Important changed behavior or an important failure mode is not covered by the supplied executed verification.", "The supplied executed verification is relevant evidence for the important changed behavior and failure modes.", ), consistencyRisk: noul( - "Given `changes` and `repository_evidence`, do the changes appear inconsistent with relevant repository contracts, conventions, or related files?", + "Given `changes` and the previously observed `repository_evidence`, do the changes appear inconsistent with relevant repository contracts, conventions, or related code?", "The supplied repository evidence indicates a material inconsistency or likely integration mismatch.", "The changes are consistent with the supplied repository evidence, or the evidence does not indicate a material mismatch.", ), @@ -39,117 +41,83 @@ const QUESTIONS = { type ObservationResult = { probability: number; - stateFields: string[]; model?: string; }; -const state = (entries: Array<[string, string | undefined]>): JsonValue => - Object.fromEntries( - entries.filter((entry): entry is [string, string] => entry[1] !== undefined), - ); +async function evaluateObservation( + evaluate: SemanticEvaluator, + observation: ObservationId, + state: JsonValue, +): Promise { + if (observation === "scopeDrift") { + const response = await evaluate({ + state, + questions: { scope_drift: QUESTIONS.scopeDrift }, + }); + return { + probability: response.answers.scope_drift.noul, + ...(response.model ? { model: response.model } : {}), + }; + } + + if (observation === "verificationGap") { + const response = await evaluate({ + state, + questions: { verification_gap: QUESTIONS.verificationGap }, + }); + return { + probability: response.answers.verification_gap.noul, + ...(response.model ? { model: response.model } : {}), + }; + } + + const response = await evaluate({ + state, + questions: { consistency_risk: QUESTIONS.consistencyRisk }, + }); + return { + probability: response.answers.consistency_risk.noul, + ...(response.model ? { model: response.model } : {}), + }; +} export default function semanticObserverExtension(pi: ExtensionAPI): void { pi.registerTool({ name: "semantic_observe", label: "Semantic Observe", description: - "Run optional, non-authoritative Jev observations over compact evidence. Each judgment receives only the state fields it needs; returned probabilities are advisory and never change task, verification, or completion state.", + "Run optional, non-authoritative Jev observations over evidence already captured by Pi Kit task, repository, mutation, and verification runtime state. The caller chooses observations, not the evidence payload.", parameters: Type.Object({ - request: Type.String({ - description: - "The user's requested outcome, preferably copied or minimally normalized rather than paraphrased.", - }), - task: Type.Optional( - Type.String({ - description: - "Current task contract or acceptance criteria when they materially clarify the request.", - }), - ), - changes: Type.String({ - description: - "Compact primary evidence about the current changes: changed paths plus the smallest relevant diff excerpts or direct change facts. Prefer evidence over a narrative summary.", - }), - verification: Type.Optional( - Type.String({ - description: - "Executed verification evidence and results. Do not include planned checks or self-review as if they had run.", - }), - ), - repositoryEvidence: Type.Optional( - Type.String({ - description: - "Only repository evidence relevant to consistency: nearby contracts, conventions, related code, or retrieved context. Do not send broad repository dumps.", - }), + observations: Type.Array( + Type.Union([ + Type.Literal("scopeDrift"), + Type.Literal("verificationGap"), + Type.Literal("consistencyRisk"), + ]), + { + minItems: 1, + maxItems: 3, + uniqueItems: true, + description: "Semantic judgments to run against Pi Kit runtime evidence.", + }, ), }), - async execute(_toolCallId, params) { + async execute(_toolCallId, params, _signal, _update, ctx) { const apiKey = process.env.OPENROUTER_API_KEY; if (!apiKey) { throw new Error("semantic_observe requires OPENROUTER_API_KEY"); } const evaluate = createOpenRouterSemanticEvaluator({ apiKey }); - const calls: Array> = []; - - calls.push( - evaluate({ - state: state([ - ["request", params.request], - ["task", params.task], - ["changes", params.changes], - ]), - questions: { scope_drift: QUESTIONS.scopeDrift }, - }).then((response) => [ - "scopeDrift", - { - probability: response.answers.scope_drift.noul, - stateFields: ["request", ...(params.task ? ["task"] : []), "changes"], - ...(response.model ? { model: response.model } : {}), - }, - ] as [string, ObservationResult]), + const observations = Object.fromEntries( + await Promise.all( + params.observations.map(async (observation) => { + const state = await buildObservationState(ctx, observation); + return [observation, await evaluateObservation(evaluate, observation, state)] as const; + }), + ), ); - if (params.verification !== undefined) { - calls.push( - evaluate({ - state: state([ - ["request", params.request], - ["changes", params.changes], - ["verification", params.verification], - ]), - questions: { verification_gap: QUESTIONS.verificationGap }, - }).then((response) => [ - "verificationGap", - { - probability: response.answers.verification_gap.noul, - stateFields: ["request", "changes", "verification"], - ...(response.model ? { model: response.model } : {}), - }, - ] as [string, ObservationResult]), - ); - } - - if (params.repositoryEvidence !== undefined) { - calls.push( - evaluate({ - state: state([ - ["changes", params.changes], - ["repository_evidence", params.repositoryEvidence], - ]), - questions: { consistency_risk: QUESTIONS.consistencyRisk }, - }).then((response) => [ - "consistencyRisk", - { - probability: response.answers.consistency_risk.noul, - stateFields: ["changes", "repository_evidence"], - ...(response.model ? { model: response.model } : {}), - }, - ] as [string, ObservationResult]), - ); - } - - const observations = Object.fromEntries(await Promise.all(calls)); - return { content: [ { @@ -159,6 +127,7 @@ export default function semanticObserverExtension(pi: ExtensionAPI): void { ], details: { advisory: true, + evidenceSource: "pi-runtime", observations: Object.keys(observations), }, }; From d9220fa43feae7fcd40ae5b43a05d469ca95a83f Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:01:15 +0900 Subject: [PATCH 37/42] Test semantic runtime evidence projections --- extensions/semantic-observer/evidence.test.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 extensions/semantic-observer/evidence.test.ts diff --git a/extensions/semantic-observer/evidence.test.ts b/extensions/semantic-observer/evidence.test.ts new file mode 100644 index 0000000..90818a8 --- /dev/null +++ b/extensions/semantic-observer/evidence.test.ts @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { TaskEvidencePacket } from "../task/evidence.ts"; +import { projectObservationState } from "./evidence.ts"; + +const packet: TaskEvidencePacket = { + task: { + id: "task-1", + goal: "Update the parser only", + acceptance: ["Parser accepts the new syntax"], + status: "active", + latestCheckpoint: { + at: "2026-09-18T00:00:00.000Z", + summary: "Parser implementation is in progress", + plan: ["Rewrite unrelated renderer", "Update parser"], + completed: ["Located parser"], + }, + }, + resources: { + observed: ["src/parser.ts", "src/parser.test.ts"], + mutated: ["src/parser.ts"], + changedDuringTask: ["src/parser.ts"], + preexistingDirty: [], + timeline: [ + { + operation: "observe", + path: "src/parser.ts", + tool: "context", + action: "inspect", + toolCallId: "context-1", + }, + { + operation: "mutate", + path: "src/parser.ts", + tool: "code", + action: "edit", + toolCallId: "code-1", + }, + ], + coverage: { + observations: "explicit-tools", + mutations: "explicit-tools", + workspaceDelta: "git", + opaqueToolEffects: "not-attributed", + }, + }, + verification: [ + { + id: "verify-1", + taskId: "task-1", + provenance: "typecheck", + origin: "executed", + passed: true, + summary: "tsc --noEmit", + at: "2026-09-18T00:01:00.000Z", + }, + { + id: "verify-2", + taskId: "task-1", + provenance: "self_review", + origin: "reported", + passed: true, + summary: "looks good", + at: "2026-09-18T00:02:00.000Z", + }, + ], + workspace: { + baselineHead: "abc", + currentHead: "abc", + currentDirty: ["src/parser.ts"], + changedDuringTask: ["src/parser.ts"], + uncommittedTaskChanges: ["src/parser.ts"], + taskCommitRequired: true, + taskCommitPresent: false, + }, +}; + +test("scope drift state uses task authority and actual changes", () => { + const state = projectObservationState("scopeDrift", packet, { + diff: "@@ parser diff @@", + context: [], + }) as any; + + assert.equal(state.task.goal, "Update the parser only"); + assert.deepEqual(state.task.acceptance, ["Parser accepts the new syntax"]); + assert.equal(state.task.current_stage.summary, "Parser implementation is in progress"); + assert.equal(state.task.current_stage.plan_authority, "hypothesis"); + assert.deepEqual(state.changes.changed_paths, ["src/parser.ts"]); + assert.equal(state.changes.diff, "@@ parser diff @@"); +}); + +test("verification gap state includes only executed verification", () => { + const state = projectObservationState("verificationGap", packet, { + context: [], + }) as any; + + assert.equal(state.verification.length, 1); + assert.equal(state.verification[0].provenance, "typecheck"); + assert.equal(state.verification[0].summary, "tsc --noEmit"); +}); + +test("consistency state reuses observed repository evidence", () => { + const state = projectObservationState("consistencyRisk", packet, { + context: [ + { + tool: "context", + paths: ["src/parser.ts"], + text: "export function parse() {}", + }, + ], + }) as any; + + assert.deepEqual(state.repository_evidence.observed_paths, [ + "src/parser.ts", + "src/parser.test.ts", + ]); + assert.equal( + state.repository_evidence.excerpts[0].text, + "export function parse() {}", + ); +}); From 6592936dc11ef98c641f99bef90020bd3ccb2e81 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:01:18 +0900 Subject: [PATCH 38/42] Run semantic observer evidence tests --- extensions/semantic-observer/package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/extensions/semantic-observer/package.json b/extensions/semantic-observer/package.json index b9a7f92..53e065f 100644 --- a/extensions/semantic-observer/package.json +++ b/extensions/semantic-observer/package.json @@ -3,9 +3,10 @@ "private": true, "type": "module", "scripts": { - "check": "bun run typecheck", + "check": "bun run typecheck && bun run test", "typecheck": "tsc --noEmit", - "dev": "pi -e ./index.ts" + "dev": "pi -e ./index.ts", + "test": "node --test" }, "dependencies": { "@earendil-works/pi-ai": "catalog:", From acc24ccf75969d0ce8e73a572c9a82219e4c09f7 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:01:29 +0900 Subject: [PATCH 39/42] Typecheck semantic observer evidence module --- extensions/semantic-observer/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/semantic-observer/tsconfig.json b/extensions/semantic-observer/tsconfig.json index 4fe4905..73b9679 100644 --- a/extensions/semantic-observer/tsconfig.json +++ b/extensions/semantic-observer/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.json", - "include": ["index.ts"] + "include": ["*.ts"] } From 5a51235912136068a8e9b22e0c981fe5bda7b63e Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:02:06 +0900 Subject: [PATCH 40/42] Document runtime-derived semantic evidence --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3d56cf2..43e7bbf 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ tsconfig.json The repository extension exposes `context` and `code`, and transparently strengthens the built-in `edit` path for supported source files. The old standalone Astrolabe and BM25 tool surfaces are gone; their useful structural and lexical mechanisms are internal implementation details under `src/syntax` and `src/context`. -Additional independent utilities remain available through the extensions listed above. `ask` provides synchronous structured user decisions in the interactive TUI; offline session analysis is provided by the `session-metrics` CLI in `extensions/session-metrics`. `semantic-observer` is an experimental, explicitly invoked observer: it sends compact structured evidence to the standalone semantic predicate evaluator and returns advisory probabilities without changing task, verification, or completion state. +Additional independent utilities remain available through the extensions listed above. `ask` provides synchronous structured user decisions in the interactive TUI; offline session analysis is provided by the `session-metrics` CLI in `extensions/session-metrics`. `semantic-observer` is an experimental, explicitly invoked observer: the caller selects semantic judgments, while Pi Kit builds compact evidence from task state, tracked reads/context, actual workspace changes, and executed verification. It returns advisory probabilities without changing task, verification, or completion state. ## Experimental semantic observation @@ -59,13 +59,13 @@ Additional independent utilities remain available through the extensions listed Context is assembled as evidence, not as a transcript: -- Prefer primary evidence: the user request, task contract, changed paths or small diff excerpts, executed verification results, and repository evidence retrieved for the question. +- Prefer runtime-captured primary evidence: the task goal and acceptance criteria, tracked mutations and the task-baseline Git diff, executed verification, and the successful `read`/`context` results the agent actually observed. - Keep fields named and structured. Questions refer to the state fields they judge rather than relying on one opaque prompt. - Give each judgment only the fields it needs. Questions that need different evidence are evaluated against separate minimal states; questions with the same state may be batched. - Keep deterministic facts in code. Jev is for semantic judgments such as scope drift or whether verification meaningfully covers a change, not whether a check exists or how many files changed. - Preserve probabilities. The observer does not turn Jev output into a pass/fail result; later policy may choose thresholds after the behavior has been measured. - Do not feed broad session history, repository dumps, or previous Jev outputs back into later state by default. Add context only when it is evidence for the next judgment. -The current observer evaluates `scopeDrift`, optional `verificationGap`, and optional `consistencyRisk`. It is deliberately explicit-call and advisory while the experiment is being evaluated. +The current observer accepts only an `observations` list (`scopeDrift`, `verificationGap`, `consistencyRisk`). Evidence payloads are not authored by the calling model. It is deliberately explicit-call and advisory while the experiment is being evaluated. See [`docs/architecture.md`](docs/architecture.md) for the design rationale and runtime contracts. From 703aa18bb1f7f80b356f720dd3a0f205338e81e9 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:02:09 +0900 Subject: [PATCH 41/42] Describe Pi runtime evidence projections for Jev --- docs/architecture.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 07a6545..8418487 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,20 +52,28 @@ Stable behavior belongs in tools and runtime state. `AGENTS.md` therefore contai The context boundary is intentionally narrower than the model context window. Jev 1.13 degrades when state contains irrelevant detail, so the observer does not treat the current conversation or repository as a default context blob. Each semantic judgment declares the evidence it needs and receives a small structured state with named fields. Primary runtime or repository evidence is preferred over a model-authored narrative summary. +The observer does not ask the calling model to summarize its own work. It projects existing Pi Kit runtime state instead. `task/evidence.ts` exposes the same side-effect-free packet used by `task.review_context`: task contract and latest checkpoint, resource provenance, workspace delta, and verification evidence. The semantic observer augments that packet with a bounded Git diff from the task's captured baseline and bounded excerpts from successful `read`/`context` tool results identified by their tracked tool-call IDs. + The current context views are: ```text scopeDrift - request + optional task contract + changes + task goal + acceptance + + current checkpoint (plan marked as hypothesis) + + tracked mutations + task-baseline diff verificationGap - request + changes + executed verification + task goal + acceptance + + tracked mutations + task-baseline diff + + executed verification only consistencyRisk - changes + relevant repository evidence + tracked mutations + task-baseline diff + + paths observed during the task + + bounded excerpts from the exact read/context results already seen ``` -These are separate requests because their evidence sets differ. If future questions genuinely share the same state, they should be batched into one Decisions API request; Jev evaluates questions independently and batching avoids sending the same state repeatedly. +The caller supplies only which observation IDs to run. These are separate requests because their evidence sets differ. If future questions genuinely share the same state, they should be batched into one Decisions API request; Jev evaluates questions independently and batching avoids sending the same state repeatedly. This boundary follows four rules: From a93e7d976634616c226670da22df39bc98732af5 Mon Sep 17 00:00:00 2001 From: HAL <68320771+halqme@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:02:55 +0900 Subject: [PATCH 42/42] Respect exact optional semantic diff typing --- extensions/semantic-observer/evidence.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/extensions/semantic-observer/evidence.ts b/extensions/semantic-observer/evidence.ts index 5525454..f5c9520 100644 --- a/extensions/semantic-observer/evidence.ts +++ b/extensions/semantic-observer/evidence.ts @@ -266,5 +266,8 @@ export async function buildObservationState( Promise.resolve(contextEvidence(ctx, packet)), ]); - return projectObservationState(observation, packet, { diff, context }); + return projectObservationState(observation, packet, { + ...(diff ? { diff } : {}), + context, + }); }