reflex-state 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +255 -0
  3. package/README_ja.md +253 -0
  4. package/dist/cli_io.d.ts +8 -0
  5. package/dist/cli_io.js +45 -0
  6. package/dist/composition.d.ts +3 -0
  7. package/dist/composition.js +8 -0
  8. package/dist/core/config.d.ts +37 -0
  9. package/dist/core/config.js +27 -0
  10. package/dist/core/config_validation.d.ts +6 -0
  11. package/dist/core/config_validation.js +109 -0
  12. package/dist/core/engine.d.ts +28 -0
  13. package/dist/core/engine.js +72 -0
  14. package/dist/core/events.d.ts +7 -0
  15. package/dist/core/events.js +56 -0
  16. package/dist/core/extraction.d.ts +10 -0
  17. package/dist/core/extraction.js +119 -0
  18. package/dist/core/metrics.d.ts +52 -0
  19. package/dist/core/metrics.js +100 -0
  20. package/dist/core/reducer.d.ts +18 -0
  21. package/dist/core/reducer.js +206 -0
  22. package/dist/core/serialization.d.ts +11 -0
  23. package/dist/core/serialization.js +160 -0
  24. package/dist/core/state_view.d.ts +27 -0
  25. package/dist/core/state_view.js +28 -0
  26. package/dist/core/types.d.ts +188 -0
  27. package/dist/core/types.js +1 -0
  28. package/dist/core/updater.d.ts +31 -0
  29. package/dist/core/updater.js +27 -0
  30. package/dist/core/verification.d.ts +20 -0
  31. package/dist/core/verification.js +234 -0
  32. package/dist/export_trace_cli.d.ts +2 -0
  33. package/dist/export_trace_cli.js +45 -0
  34. package/dist/index.d.ts +13 -0
  35. package/dist/index.js +8 -0
  36. package/dist/pi/commands.d.ts +3 -0
  37. package/dist/pi/commands.js +123 -0
  38. package/dist/pi/configuration.d.ts +12 -0
  39. package/dist/pi/configuration.js +48 -0
  40. package/dist/pi/extension.d.ts +3 -0
  41. package/dist/pi/extension.js +113 -0
  42. package/dist/pi/index.d.ts +2 -0
  43. package/dist/pi/index.js +5 -0
  44. package/dist/pi/normalization.d.ts +31 -0
  45. package/dist/pi/normalization.js +72 -0
  46. package/dist/pi/persistence.d.ts +15 -0
  47. package/dist/pi/persistence.js +65 -0
  48. package/dist/pi/projection.d.ts +11 -0
  49. package/dist/pi/projection.js +159 -0
  50. package/dist/pi/runtime.d.ts +35 -0
  51. package/dist/pi/runtime.js +139 -0
  52. package/dist/pi/state_block.d.ts +10 -0
  53. package/dist/pi/state_block.js +123 -0
  54. package/dist/pi/trace.d.ts +14 -0
  55. package/dist/pi/trace.js +106 -0
  56. package/dist/replay/runner.d.ts +50 -0
  57. package/dist/replay/runner.js +21 -0
  58. package/dist/replay/trace.d.ts +2 -0
  59. package/dist/replay/trace.js +7 -0
  60. package/dist/replay_cli.d.ts +2 -0
  61. package/dist/replay_cli.js +71 -0
  62. package/dist/typesafe/client.d.ts +21 -0
  63. package/dist/typesafe/client.js +49 -0
  64. package/dist/typesafe/deadline.d.ts +8 -0
  65. package/dist/typesafe/deadline.js +35 -0
  66. package/dist/typesafe/decisions.d.ts +5 -0
  67. package/dist/typesafe/decisions.js +94 -0
  68. package/dist/typesafe/gating.d.ts +7 -0
  69. package/dist/typesafe/gating.js +48 -0
  70. package/dist/typesafe/input.d.ts +3 -0
  71. package/dist/typesafe/input.js +115 -0
  72. package/dist/typesafe/questions.d.ts +5 -0
  73. package/dist/typesafe/questions.js +50 -0
  74. package/dist/typesafe/request_plan.d.ts +19 -0
  75. package/dist/typesafe/request_plan.js +96 -0
  76. package/dist/typesafe/updater.d.ts +17 -0
  77. package/dist/typesafe/updater.js +82 -0
  78. package/package.json +97 -0
@@ -0,0 +1,27 @@
1
+ export function emptyDecisions() {
2
+ return { resolvedBlockers: [], relevance: [], telemetry: { questionsAsked: 0, questionIds: [] } };
3
+ }
4
+ export class NoopStateUpdater {
5
+ name = "noop";
6
+ evaluate() {
7
+ return Promise.resolve(emptyDecisions());
8
+ }
9
+ }
10
+ export class RecordedDecisionsUpdater {
11
+ name = "recorded";
12
+ records;
13
+ constructor(records) {
14
+ this.records = new Map(records.map((record) => [record.event.id, record]));
15
+ if (this.records.size !== records.length)
16
+ throw new Error("Duplicate event ID in recording");
17
+ }
18
+ evaluate(context) {
19
+ const record = this.records.get(context.event.id);
20
+ if (!record)
21
+ return Promise.reject(new Error("No recorded decisions for " + context.event.id));
22
+ if (!isDeepStrictEqual(record.event, context.event))
23
+ return Promise.reject(new Error("Recorded event differs: " + context.event.id));
24
+ return Promise.resolve(record.decisions);
25
+ }
26
+ }
27
+ import { isDeepStrictEqual } from "node:util";
@@ -0,0 +1,20 @@
1
+ import type { ReflexStateConfig } from "./config.js";
2
+ import type { AgentEvent, EventId, ToolCallEvent, VerificationFact, VerificationKind } from "./types.js";
3
+ export interface VerificationClassification {
4
+ readonly kind?: VerificationKind;
5
+ readonly command: string;
6
+ readonly cwd: string;
7
+ readonly compound: boolean;
8
+ readonly attributable: boolean;
9
+ readonly checkKey?: string;
10
+ readonly unknownReason?: string;
11
+ }
12
+ export declare function normalizeCwd(cwd: string): string;
13
+ export declare function normalizeCommand(command: string): string;
14
+ export declare function verificationCheckKey(kind: VerificationKind, cwd: string, command: string): string;
15
+ export declare function classifyVerification(command: string, cwd: string, config: ReflexStateConfig, truncated?: boolean): VerificationClassification | undefined;
16
+ export declare function verificationFact(event: AgentEvent, call: ToolCallEvent | undefined, config: ReflexStateConfig, generation: number, pendingChanges: readonly EventId[], started?: {
17
+ readonly eventId: EventId;
18
+ readonly generation: number;
19
+ readonly checkKey?: string;
20
+ }): VerificationFact | undefined;
@@ -0,0 +1,234 @@
1
+ import { createHash } from "node:crypto";
2
+ import { relative, resolve } from "node:path";
3
+ const commands = {
4
+ test: /^(pytest|vitest|jest|cargo\s+test|go\s+test|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test)(?:\s|$)/,
5
+ build: /^(tsc|cargo\s+build|go\s+build|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:build|typecheck))(?:\s|$)/,
6
+ lint: /^(eslint|oxlint|biome|ruff|cargo\s+clippy|clippy|golangci-lint|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?lint)(?:\s|$)/,
7
+ };
8
+ export function normalizeCwd(cwd) {
9
+ const absolute = resolve("/", cwd || ".");
10
+ return relative("/", absolute) ? absolute.replace(/\/$/, "") : "/";
11
+ }
12
+ export function normalizeCommand(command) {
13
+ let result = "";
14
+ let quote = "";
15
+ let escaped = false;
16
+ let pendingSpace = false;
17
+ for (const char of command.trim()) {
18
+ if (escaped) {
19
+ result += char;
20
+ escaped = false;
21
+ pendingSpace = false;
22
+ continue;
23
+ }
24
+ if (char === "\\" && quote !== "'") {
25
+ result += char;
26
+ escaped = true;
27
+ pendingSpace = false;
28
+ continue;
29
+ }
30
+ if (quote) {
31
+ result += char;
32
+ if (char === quote)
33
+ quote = "";
34
+ pendingSpace = false;
35
+ continue;
36
+ }
37
+ if (char === "'" || char === '"') {
38
+ if (pendingSpace)
39
+ result += " ";
40
+ result += char;
41
+ quote = char;
42
+ pendingSpace = false;
43
+ continue;
44
+ }
45
+ if (/\s/.test(char)) {
46
+ pendingSpace = result.length > 0;
47
+ continue;
48
+ }
49
+ if (pendingSpace)
50
+ result += " ";
51
+ result += char;
52
+ pendingSpace = false;
53
+ }
54
+ return result;
55
+ }
56
+ export function verificationCheckKey(kind, cwd, command) {
57
+ const identity = JSON.stringify([kind, normalizeCwd(cwd), normalizeCommand(command)]);
58
+ return "check:" + createHash("sha256").update(identity).digest("hex");
59
+ }
60
+ export function classifyVerification(command, cwd, config, truncated = false) {
61
+ const normalized = normalizeCommand(command);
62
+ const kind = detectKind(normalized, config);
63
+ if (!kind)
64
+ return undefined;
65
+ const shell = shellOperators(normalized);
66
+ const compound = shell.length > 0 || hasUnclosedSyntax(normalized);
67
+ const unknownReason = truncated
68
+ ? "command_truncated"
69
+ : shell.length > 0
70
+ ? "compound_command"
71
+ : compound
72
+ ? "ambiguous_shell_syntax"
73
+ : undefined;
74
+ return {
75
+ kind,
76
+ command,
77
+ cwd: normalizeCwd(cwd),
78
+ compound,
79
+ attributable: !unknownReason,
80
+ ...(unknownReason ? { unknownReason } : {}),
81
+ ...(!unknownReason ? { checkKey: verificationCheckKey(kind, cwd, command) } : {}),
82
+ };
83
+ }
84
+ export function verificationFact(event, call, config, generation, pendingChanges, started) {
85
+ if (!call || call.toolName !== "bash" || typeof call.input.command !== "string")
86
+ return undefined;
87
+ const classification = classifyVerification(call.input.command, call.cwd ?? ".", config, call.commandTruncated);
88
+ if (!classification?.kind)
89
+ return undefined;
90
+ const kind = classification.kind;
91
+ const status = statusFor(event, classification);
92
+ const current = event.type === "tool_call"
93
+ ? true
94
+ : Boolean(started &&
95
+ started.generation === generation &&
96
+ pendingChanges.every((id) => id === started.eventId));
97
+ const freshness = event.type === "tool_call"
98
+ ? "unknown"
99
+ : classification.attributable
100
+ ? current
101
+ ? "current"
102
+ : "stale"
103
+ : "unknown";
104
+ return {
105
+ ...classification,
106
+ kind,
107
+ status,
108
+ ...(started ? { startedEvent: started.eventId, observedGeneration: started.generation } : {}),
109
+ freshness,
110
+ };
111
+ }
112
+ function detectKind(command, config) {
113
+ return candidateSegments(command)
114
+ .map((segment) => detectSingle(segment, config))
115
+ .find(Boolean);
116
+ }
117
+ function detectSingle(command, config) {
118
+ const executable = command.trim().replace(/^(?:npx\s+|(?:npm|pnpm|yarn|bun)\s+exec\s+)/, "");
119
+ return ["test", "build", "lint"].find((kind) => commands[kind].test(executable) ||
120
+ config.verificationCommands[kind].some((pattern) => {
121
+ try {
122
+ return new RegExp(pattern).test(executable);
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }));
128
+ }
129
+ function candidateSegments(command) {
130
+ const segments = [];
131
+ let part = "";
132
+ let quote = "";
133
+ let escaped = false;
134
+ for (const char of command) {
135
+ if (escaped) {
136
+ part += char;
137
+ escaped = false;
138
+ continue;
139
+ }
140
+ if (char === "\\" && quote !== "'") {
141
+ part += char;
142
+ escaped = true;
143
+ continue;
144
+ }
145
+ if (quote) {
146
+ part += char;
147
+ if (char === quote)
148
+ quote = "";
149
+ continue;
150
+ }
151
+ if (char === "'" || char === '"') {
152
+ part += char;
153
+ quote = char;
154
+ continue;
155
+ }
156
+ if (";|&<>\n".includes(char)) {
157
+ if (part.trim())
158
+ segments.push(part.trim());
159
+ part = "";
160
+ continue;
161
+ }
162
+ part += char;
163
+ }
164
+ if (part.trim())
165
+ segments.push(part.trim());
166
+ return segments;
167
+ }
168
+ function shellOperators(command) {
169
+ const operators = [];
170
+ let quote = "";
171
+ let escaped = false;
172
+ for (let index = 0; index < command.length; index++) {
173
+ const char = command[index] ?? "";
174
+ if (escaped) {
175
+ escaped = false;
176
+ continue;
177
+ }
178
+ if (char === "\\" && quote !== "'") {
179
+ escaped = true;
180
+ continue;
181
+ }
182
+ if (quote) {
183
+ if (char === quote)
184
+ quote = "";
185
+ continue;
186
+ }
187
+ if (char === "'" || char === '"') {
188
+ quote = char;
189
+ continue;
190
+ }
191
+ if (char === "$" && command[index + 1] === "(")
192
+ operators.push("substitution");
193
+ else if (char === "`" || ";|&<>\n".includes(char))
194
+ operators.push(char);
195
+ }
196
+ return operators;
197
+ }
198
+ function hasUnclosedSyntax(command) {
199
+ let quote = "";
200
+ let escaped = false;
201
+ for (const char of command) {
202
+ if (escaped) {
203
+ escaped = false;
204
+ continue;
205
+ }
206
+ if (char === "\\" && quote !== "'") {
207
+ escaped = true;
208
+ continue;
209
+ }
210
+ if (quote) {
211
+ if (char === quote)
212
+ quote = "";
213
+ continue;
214
+ }
215
+ if (char === "'" || char === '"')
216
+ quote = char;
217
+ }
218
+ return Boolean(quote || escaped);
219
+ }
220
+ function statusFor(event, classification) {
221
+ if (event.type === "tool_call")
222
+ return "running";
223
+ if (event.type !== "tool_result")
224
+ return "unknown";
225
+ if (!classification.attributable)
226
+ return "unknown";
227
+ if (!event.isError)
228
+ return "passed";
229
+ const text = event.excerpt.head + (event.excerpt.tail ? "\n" + event.excerpt.tail : "");
230
+ const match = /(?:^|\n)Command exited with code (\d+)\s*$/.exec(text);
231
+ if (!match)
232
+ return "unknown";
233
+ return Number(match[1]) === 0 ? "passed" : "failed";
234
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { parseArgs } from "node:util";
4
+ import { jsonLines, readConfigFile, writeArtifacts } from "./cli_io.js";
5
+ import { isRecord, parseJsonLines } from "./core/serialization.js";
6
+ import { exportSession } from "./pi/trace.js";
7
+ import { redact } from "./typesafe/input.js";
8
+ async function main() {
9
+ const { values, positionals } = parseArgs({
10
+ allowPositionals: true,
11
+ options: {
12
+ leaf: { type: "string" },
13
+ config: { type: "string" },
14
+ out: { type: "string", default: "trace-output" },
15
+ },
16
+ });
17
+ const input = positionals[0];
18
+ if (!input || positionals.length !== 1)
19
+ throw new Error("Usage: reflex-state-export <session.jsonl> [--leaf id] [--out dir] [--config file]");
20
+ const entries = parseJsonLines(await readFile(input, "utf8"), (value) => {
21
+ if (!isRecord(value) || typeof value.type !== "string")
22
+ throw new Error("Invalid Pi entry");
23
+ return value;
24
+ });
25
+ const result = exportSession(entries, {
26
+ config: await readConfigFile(values.config),
27
+ ...(values.leaf ? { leaf: values.leaf } : {}),
28
+ });
29
+ await writeArtifacts(values.out, {
30
+ "events.jsonl": jsonLines(result.events),
31
+ "transitions.jsonl": jsonLines(result.transitions),
32
+ "trace_meta.json": JSON.stringify({
33
+ cwd: result.cwd,
34
+ config: result.config,
35
+ leaf: result.leaf,
36
+ formatVersion: result.formatVersion,
37
+ legacy: result.legacy,
38
+ }, null, 2) + "\n",
39
+ }, [input]);
40
+ console.log(result.events.length + " events exported to " + values.out);
41
+ }
42
+ await main().catch((error) => {
43
+ console.error(redact(error instanceof Error ? error.message : "Trace export failed"));
44
+ process.exitCode = 1;
45
+ });
@@ -0,0 +1,13 @@
1
+ export { defaultConfig } from "./core/config.js";
2
+ export type { ReflexStateConfig } from "./core/config.js";
3
+ export { StateEngine } from "./core/engine.js";
4
+ export { extractFacts } from "./core/extraction.js";
5
+ export type { ExtractionContext } from "./core/extraction.js";
6
+ export { classifyVerification, normalizeCommand, normalizeCwd, verificationCheckKey, } from "./core/verification.js";
7
+ export type { VerificationClassification } from "./core/verification.js";
8
+ export { blockerView, stateView, workingSetView } from "./core/state_view.js";
9
+ export { Metrics } from "./core/metrics.js";
10
+ export { initialState, reduce } from "./core/reducer.js";
11
+ export type * from "./core/types.js";
12
+ export { NoopStateUpdater, RecordedDecisionsUpdater } from "./core/updater.js";
13
+ export type { StateUpdater, StateUpdateContext, UpdaterHealth } from "./core/updater.js";
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { defaultConfig } from "./core/config.js";
2
+ export { StateEngine } from "./core/engine.js";
3
+ export { extractFacts } from "./core/extraction.js";
4
+ export { classifyVerification, normalizeCommand, normalizeCwd, verificationCheckKey, } from "./core/verification.js";
5
+ export { blockerView, stateView, workingSetView } from "./core/state_view.js";
6
+ export { Metrics } from "./core/metrics.js";
7
+ export { initialState, reduce } from "./core/reducer.js";
8
+ export { NoopStateUpdater, RecordedDecisionsUpdater } from "./core/updater.js";
@@ -0,0 +1,3 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { SessionRuntime } from "./runtime.js";
3
+ export declare function registerCommands(pi: ExtensionAPI, runtime: () => SessionRuntime | undefined): void;
@@ -0,0 +1,123 @@
1
+ import { decisionEntries } from "../core/metrics.js";
2
+ import { blockerView, workingSetView } from "../core/state_view.js";
3
+ const suggestions = [
4
+ "history",
5
+ "stats",
6
+ "debug",
7
+ "reset",
8
+ "projection on",
9
+ "projection off",
10
+ "jev on",
11
+ "jev off",
12
+ ];
13
+ export function registerCommands(pi, runtime) {
14
+ pi.registerCommand("state", {
15
+ description: "Inspect ReflexState, its evidence, and updater health",
16
+ getArgumentCompletions: (prefix) => suggestions
17
+ .filter((value) => value.startsWith(prefix))
18
+ .map((value) => ({ value, label: value })),
19
+ handler: async (args, ctx) => {
20
+ const session = runtime();
21
+ if (!session) {
22
+ ctx.ui.notify("ReflexState is unavailable for this session.", "warning");
23
+ return;
24
+ }
25
+ await session.engine.idle();
26
+ await handleCommand(args.trim(), session, ctx);
27
+ session.widget(ctx);
28
+ },
29
+ });
30
+ }
31
+ async function handleCommand(args, session, ctx) {
32
+ if (!args) {
33
+ ctx.ui.notify(renderState(session), "info");
34
+ return;
35
+ }
36
+ if (args === "stats") {
37
+ const blockers = blockerView(session.state, session.config);
38
+ const workingSet = workingSetView(session.state, session.config.limits.maxWorkingSetEvents);
39
+ ctx.ui.notify(JSON.stringify({
40
+ ...session.metrics.snapshot(),
41
+ health: session.health,
42
+ workingSet: {
43
+ count: workingSet.total,
44
+ shown: workingSet.shownCount,
45
+ omitted: workingSet.omittedCount,
46
+ cap: session.config.limits.maxWorkingSetEvents,
47
+ },
48
+ blockers: {
49
+ count: blockers.unresolvedTotal,
50
+ shown: blockers.shownCount,
51
+ omitted: blockers.omittedCount,
52
+ cap: session.config.limits.maxProjectedBlockers,
53
+ },
54
+ }, missingMetric, 2), "info");
55
+ return;
56
+ }
57
+ if (args === "debug") {
58
+ ctx.ui.notify(JSON.stringify(session.history.findLast((record) => record.decisions.telemetry.questionsAsked > 0)
59
+ ?.decisions ?? { message: "No semantic decisions yet" }, null, 2), "info");
60
+ return;
61
+ }
62
+ const history = /^history(?:\s+(\d+))?$/.exec(args);
63
+ if (history) {
64
+ const count = Math.min(1000, Math.max(1, Number(history[1] ?? 10)));
65
+ ctx.ui.notify(session.history
66
+ .slice(-count)
67
+ .map((record) => record.id +
68
+ " " +
69
+ record.event.id +
70
+ " " +
71
+ record.event.type +
72
+ "\n" +
73
+ record.changes.join("\n") +
74
+ "\n" +
75
+ decisionEntries(record.decisions)
76
+ .map(([id, decision]) => id + ": " + decision.gate + (decision.shadow ? " (shadow)" : ""))
77
+ .join(", "))
78
+ .join("\n\n") || "No transitions yet", "info");
79
+ return;
80
+ }
81
+ if (args === "reset") {
82
+ await ctx.waitForIdle();
83
+ if (await ctx.ui.confirm("Reset ReflexState?", "The current hot state will be cleared. The original session history remains available."))
84
+ await session.reset();
85
+ return;
86
+ }
87
+ const toggle = /^(projection|jev)\s+(on|off)$/.exec(args);
88
+ if (toggle) {
89
+ await session.toggle(toggle[1], toggle[2] === "on");
90
+ ctx.ui.notify("ReflexState " + args, "info");
91
+ return;
92
+ }
93
+ ctx.ui.notify("Usage: /state [history [n] | stats | debug | reset | projection on|off | jev on|off]", "warning");
94
+ }
95
+ function renderState(session) {
96
+ const state = session.state;
97
+ const blockers = blockerView(state, session.config);
98
+ const workingSet = workingSetView(state, session.config.limits.maxWorkingSetEvents);
99
+ return [
100
+ "goal: " + (state.goal ?? "none"),
101
+ "phase: " + state.phase,
102
+ "task_status: " + state.taskStatus,
103
+ "modified_files: " + JSON.stringify(state.modifiedFiles),
104
+ "relevant_files: " + JSON.stringify(state.relevantFiles),
105
+ "verification: " + JSON.stringify(state.verification),
106
+ "active_blockers: " +
107
+ JSON.stringify({
108
+ unresolved_total: blockers.unresolvedTotal,
109
+ shown: blockers.blockers,
110
+ omitted: blockers.omittedCount,
111
+ }),
112
+ "working_set: " +
113
+ JSON.stringify({
114
+ total: workingSet.total,
115
+ shown: workingSet.events,
116
+ omitted: workingSet.omittedCount,
117
+ }),
118
+ ...(state.stateHealth && state.stateHealth !== "valid" ? [state.stateHealth] : []),
119
+ ].join("\n");
120
+ }
121
+ function missingMetric(_key, value) {
122
+ return value === undefined ? "n/a" : value;
123
+ }
@@ -0,0 +1,12 @@
1
+ interface ConfigOptions {
2
+ readonly cwd: string;
3
+ readonly trusted: boolean;
4
+ readonly globalPath?: string;
5
+ readonly readText?: (path: string) => Promise<string>;
6
+ readonly environment?: Readonly<Record<string, string | undefined>>;
7
+ }
8
+ export declare function loadConfig(options: ConfigOptions): Promise<{
9
+ config: import("../core/config.js").ReflexStateConfig;
10
+ warnings: string[];
11
+ }>;
12
+ export {};
@@ -0,0 +1,48 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { defaultConfig } from "../core/config.js";
5
+ import { mergeConfig } from "../core/config_validation.js";
6
+ import { isRecord } from "../core/serialization.js";
7
+ export async function loadConfig(options) {
8
+ const env = options.environment ?? process.env;
9
+ let config = defaultConfig();
10
+ const warnings = [];
11
+ const files = [
12
+ options.globalPath ??
13
+ join(env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "reflex-state.json"),
14
+ ];
15
+ if (options.trusted)
16
+ files.push(join(options.cwd, ".pi", "reflex-state.json"));
17
+ for (const path of files) {
18
+ try {
19
+ const raw = JSON.parse(await (options.readText ?? ((file) => readFile(file, "utf8")))(path));
20
+ const result = mergeConfig(config, raw);
21
+ config = result.config;
22
+ warnings.push(...result.warnings.map((warning) => path + ": " + warning));
23
+ }
24
+ catch (error) {
25
+ if (isRecord(error) && error.code === "ENOENT")
26
+ continue;
27
+ config = defaultConfig();
28
+ warnings.push("Ignoring unreadable or malformed configuration: " + path);
29
+ }
30
+ }
31
+ config = {
32
+ ...config,
33
+ enabled: env.REFLEX_STATE_DISABLE === "1" ? false : config.enabled,
34
+ jev: {
35
+ ...config.jev,
36
+ enabled: env.REFLEX_STATE_DISABLE_JEV === "1" ? false : config.jev.enabled,
37
+ },
38
+ projection: {
39
+ ...config.projection,
40
+ enabled: env.REFLEX_STATE_PROJECTION === "1"
41
+ ? true
42
+ : env.REFLEX_STATE_PROJECTION === "0"
43
+ ? false
44
+ : config.projection.enabled,
45
+ },
46
+ };
47
+ return { config, warnings };
48
+ }
@@ -0,0 +1,3 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { UpdaterFactory } from "./runtime.js";
3
+ export declare function registerExtension(pi: ExtensionAPI, createUpdater: UpdaterFactory): void;
@@ -0,0 +1,113 @@
1
+ import { textContent } from "../core/events.js";
2
+ import { registerCommands } from "./commands.js";
3
+ import { loadConfig } from "./configuration.js";
4
+ import { projectContext } from "./projection.js";
5
+ import { SessionRuntime } from "./runtime.js";
6
+ export function registerExtension(pi, createUpdater) {
7
+ let runtime;
8
+ const start = async (_event, ctx) => {
9
+ await runtime?.engine.idle();
10
+ runtime = undefined;
11
+ await guarded(ctx, async () => {
12
+ const { config, warnings } = await loadConfig({
13
+ cwd: ctx.cwd,
14
+ trusted: ctx.isProjectTrusted(),
15
+ });
16
+ for (const warning of warnings)
17
+ ctx.ui.notify(warning, "warning");
18
+ runtime = new SessionRuntime({ pi, ctx, config, createUpdater });
19
+ const reason = typeof _event === "object" && _event !== null && "newLeafId" in _event
20
+ ? "branch_switch"
21
+ : "resume";
22
+ await runtime.record(runtime.normalizer.resume(reason), ctx);
23
+ runtime.widget(ctx);
24
+ });
25
+ };
26
+ pi.on("session_start", start);
27
+ pi.on("session_tree", start);
28
+ pi.on("context", async (event, ctx) => {
29
+ if (!runtime || !runtime.projectionSafe)
30
+ return { messages: event.messages };
31
+ const session = runtime;
32
+ await session.engine.idle();
33
+ const result = projectContext(event.messages, {
34
+ state: session.state,
35
+ evidence: session.engine.events,
36
+ config: session.config,
37
+ compacting: session.compacting,
38
+ });
39
+ session.metrics.projection(result.measurement);
40
+ session.widget(ctx);
41
+ return { messages: result.messages };
42
+ });
43
+ pi.on("session_shutdown", async () => {
44
+ await runtime?.engine.idle();
45
+ });
46
+ pi.on("before_agent_start", async (event, ctx) => {
47
+ if (!runtime?.config.enabled)
48
+ return;
49
+ const session = runtime;
50
+ session.compacting = false;
51
+ session.expectedPrompt = event.prompt;
52
+ await guarded(ctx, () => session.record(session.normalizer.prompt(event.prompt), ctx));
53
+ });
54
+ pi.on("tool_call", async (event, ctx) => {
55
+ if (runtime?.config.enabled) {
56
+ const session = runtime;
57
+ await guarded(ctx, () => session.record(session.normalizer.call(event), ctx));
58
+ }
59
+ });
60
+ pi.on("tool_result", async (event, ctx) => {
61
+ if (runtime?.config.enabled) {
62
+ const session = runtime;
63
+ await guarded(ctx, () => session.record(session.normalizer.result(event), ctx));
64
+ }
65
+ });
66
+ pi.on("agent_end", async (event, ctx) => {
67
+ if (runtime?.config.enabled) {
68
+ const session = runtime;
69
+ await guarded(ctx, () => session.record(session.normalizer.end(event.messages), ctx));
70
+ }
71
+ });
72
+ pi.on("message_end", async ({ message }, ctx) => {
73
+ if (!runtime?.config.enabled)
74
+ return;
75
+ const session = runtime;
76
+ if (message.role === "assistant")
77
+ session.metrics.provider(message.usage);
78
+ if (message.role !== "user")
79
+ return;
80
+ const text = textContent(message.content);
81
+ const duplicate = session.expectedPrompt === text;
82
+ session.expectedPrompt = undefined;
83
+ if (!duplicate)
84
+ await guarded(ctx, () => session.record(session.normalizer.prompt(text, message.timestamp), ctx));
85
+ });
86
+ pi.on("session_before_compact", (event) => {
87
+ if (!runtime)
88
+ return;
89
+ const session = runtime;
90
+ session.compacting = true;
91
+ event.signal.addEventListener("abort", () => {
92
+ session.compacting = false;
93
+ }, { once: true });
94
+ });
95
+ pi.on("session_compact", () => {
96
+ if (runtime)
97
+ runtime.compacting = false;
98
+ });
99
+ pi.on("agent_settled", () => {
100
+ if (runtime)
101
+ runtime.compacting = false;
102
+ });
103
+ registerCommands(pi, () => runtime);
104
+ }
105
+ async function guarded(ctx, operation) {
106
+ try {
107
+ await operation();
108
+ }
109
+ catch (error) {
110
+ ctx.ui.notify("ReflexState could not update: " +
111
+ (error instanceof Error ? error.name + ": " + error.message : "unknown error"), "warning");
112
+ }
113
+ }
@@ -0,0 +1,2 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ export default function reflexState(pi: ExtensionAPI): void;
@@ -0,0 +1,5 @@
1
+ import { createUpdater } from "../composition.js";
2
+ import { registerExtension } from "./extension.js";
3
+ export default function reflexState(pi) {
4
+ registerExtension(pi, createUpdater);
5
+ }