@aexol/spectral 0.9.221 → 0.9.222
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/index.d.ts +7 -3
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +35 -8
- package/dist/extensions/aexol-backend-client.d.ts +85 -0
- package/dist/extensions/aexol-backend-client.d.ts.map +1 -0
- package/dist/extensions/aexol-backend-client.js +159 -0
- package/dist/extensions/aexol-mcp.d.ts +10 -6
- package/dist/extensions/aexol-mcp.d.ts.map +1 -1
- package/dist/extensions/aexol-mcp.js +21 -106
- package/dist/extensions/logic-trace-backend-draft.d.ts +68 -0
- package/dist/extensions/logic-trace-backend-draft.d.ts.map +1 -0
- package/dist/extensions/logic-trace-backend-draft.js +171 -0
- package/dist/extensions/logic-trace-validate.d.ts +33 -0
- package/dist/extensions/logic-trace-validate.d.ts.map +1 -0
- package/dist/extensions/logic-trace-validate.js +73 -0
- package/dist/extensions/logic-trace.d.ts +213 -0
- package/dist/extensions/logic-trace.d.ts.map +1 -0
- package/dist/extensions/logic-trace.js +1098 -0
- package/dist/generated/parser/lexer.d.ts +148 -0
- package/dist/generated/parser/lexer.d.ts.map +1 -0
- package/dist/generated/parser/lexer.js +877 -0
- package/dist/generated/parser/mod.d.ts +26 -0
- package/dist/generated/parser/mod.d.ts.map +1 -0
- package/dist/generated/parser/mod.js +30 -0
- package/dist/generated/parser/parser.d.ts +161 -0
- package/dist/generated/parser/parser.d.ts.map +1 -0
- package/dist/generated/parser/parser.js +754 -0
- package/dist/generated/parser/tokenizer.d.ts +107 -0
- package/dist/generated/parser/tokenizer.d.ts.map +1 -0
- package/dist/generated/parser/tokenizer.js +202 -0
- package/dist/generated/parser/validator.d.ts +45 -0
- package/dist/generated/parser/validator.d.ts.map +1 -0
- package/dist/generated/parser/validator.js +202 -0
- package/dist/generated/zeus/const.d.ts.map +1 -1
- package/dist/generated/zeus/const.js +4 -170
- package/dist/generated/zeus/index.d.ts +8 -873
- package/dist/generated/zeus/index.d.ts.map +1 -1
- package/dist/generated/zeus/index.js +0 -7
- package/dist/mcp/ui-stream-types.d.ts +2 -2
- package/dist/memory/tools/share-project-observation.js +1 -1
- package/dist/memory/tools/write-project-observation.d.ts.map +1 -1
- package/dist/memory/tools/write-project-observation.js +4 -3
- package/dist/sdk/agent-core/harness/types.d.ts +0 -1
- package/dist/sdk/agent-core/harness/types.d.ts.map +1 -1
- package/dist/sdk/agent-core/index.d.ts +0 -8
- package/dist/sdk/agent-core/index.d.ts.map +1 -1
- package/dist/sdk/agent-core/index.js +0 -9
- package/dist/sdk/coding-agent/core/extensions/index.d.ts +1 -1
- package/dist/sdk/coding-agent/core/extensions/index.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/native-extensions.js +2 -2
- package/dist/sdk/coding-agent/core/extensions/types.d.ts +20 -1
- package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/tool-definition-wrapper.d.ts +9 -4
- package/dist/sdk/coding-agent/core/tools/tool-definition-wrapper.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/tool-definition-wrapper.js +15 -2
- package/dist/sdk/coding-agent/index.d.ts +1 -1
- package/dist/sdk/coding-agent/index.d.ts.map +1 -1
- package/dist/server/agent-bridge/headless-ui.d.ts +20 -0
- package/dist/server/agent-bridge/headless-ui.d.ts.map +1 -0
- package/dist/server/agent-bridge/headless-ui.js +85 -0
- package/dist/server/agent-bridge/pricing.d.ts +37 -0
- package/dist/server/agent-bridge/pricing.d.ts.map +1 -0
- package/dist/server/agent-bridge/pricing.js +202 -0
- package/dist/server/agent-bridge/rehydrate.d.ts +35 -0
- package/dist/server/agent-bridge/rehydrate.d.ts.map +1 -0
- package/dist/server/agent-bridge/rehydrate.js +122 -0
- package/dist/server/agent-bridge/synthetic-model.d.ts +27 -0
- package/dist/server/agent-bridge/synthetic-model.d.ts.map +1 -0
- package/dist/server/agent-bridge/synthetic-model.js +92 -0
- package/dist/server/agent-bridge.d.ts +11 -1
- package/dist/server/agent-bridge.d.ts.map +1 -1
- package/dist/server/agent-bridge.js +42 -481
- package/dist/server/session-stream/compaction-events.d.ts +13 -0
- package/dist/server/session-stream/compaction-events.d.ts.map +1 -0
- package/dist/server/session-stream/compaction-events.js +32 -0
- package/dist/server/session-stream/history-prune.d.ts +35 -0
- package/dist/server/session-stream/history-prune.d.ts.map +1 -0
- package/dist/server/session-stream/history-prune.js +182 -0
- package/dist/server/session-stream/memory.d.ts +48 -0
- package/dist/server/session-stream/memory.d.ts.map +1 -0
- package/dist/server/session-stream/memory.js +136 -0
- package/dist/server/session-stream/replay-tail.d.ts +4 -0
- package/dist/server/session-stream/replay-tail.d.ts.map +1 -0
- package/dist/server/session-stream/replay-tail.js +39 -0
- package/dist/server/session-stream/turn-events.d.ts +33 -0
- package/dist/server/session-stream/turn-events.d.ts.map +1 -0
- package/dist/server/session-stream/turn-events.js +65 -0
- package/dist/server/session-stream/turn-silence.d.ts +8 -0
- package/dist/server/session-stream/turn-silence.d.ts.map +1 -0
- package/dist/server/session-stream/turn-silence.js +72 -0
- package/dist/server/session-stream.d.ts +137 -36
- package/dist/server/session-stream.d.ts.map +1 -1
- package/dist/server/session-stream.js +18 -509
- package/dist/server/storage/observation-ranking.d.ts +14 -0
- package/dist/server/storage/observation-ranking.d.ts.map +1 -0
- package/dist/server/storage/observation-ranking.js +37 -0
- package/dist/server/storage/preflight.d.ts +20 -0
- package/dist/server/storage/preflight.d.ts.map +1 -0
- package/dist/server/storage/preflight.js +22 -0
- package/dist/server/storage/project-binding.d.ts +5 -0
- package/dist/server/storage/project-binding.d.ts.map +1 -0
- package/dist/server/storage/project-binding.js +32 -0
- package/dist/server/storage/rows.d.ts +104 -0
- package/dist/server/storage/rows.d.ts.map +1 -0
- package/dist/server/storage/rows.js +2 -0
- package/dist/server/storage/schema.d.ts +20 -0
- package/dist/server/storage/schema.d.ts.map +1 -0
- package/dist/server/storage/schema.js +134 -0
- package/dist/server/storage/types.d.ts +66 -0
- package/dist/server/storage/types.d.ts.map +1 -0
- package/dist/server/storage/types.js +1 -0
- package/dist/server/storage.d.ts +7 -94
- package/dist/server/storage.d.ts.map +1 -1
- package/dist/server/storage.js +6 -224
- package/dist/server/wire.d.ts +6 -0
- package/dist/server/wire.d.ts.map +1 -1
- package/package.json +2 -1
- package/dist/sdk/agent-core/harness/agent-harness.d.ts +0 -92
- package/dist/sdk/agent-core/harness/agent-harness.d.ts.map +0 -1
- package/dist/sdk/agent-core/harness/agent-harness.js +0 -899
- package/dist/sdk/agent-core/harness/env/nodejs.d.ts +0 -51
- package/dist/sdk/agent-core/harness/env/nodejs.d.ts.map +0 -1
- package/dist/sdk/agent-core/harness/env/nodejs.js +0 -485
- package/dist/sdk/agent-core/harness/prompt-templates.d.ts +0 -48
- package/dist/sdk/agent-core/harness/prompt-templates.d.ts.map +0 -1
- package/dist/sdk/agent-core/harness/prompt-templates.js +0 -229
- package/dist/sdk/agent-core/harness/session/jsonl-repo.d.ts +0 -26
- package/dist/sdk/agent-core/harness/session/jsonl-repo.d.ts.map +0 -1
- package/dist/sdk/agent-core/harness/session/jsonl-repo.js +0 -100
- package/dist/sdk/agent-core/harness/session/jsonl-storage.d.ts +0 -33
- package/dist/sdk/agent-core/harness/session/jsonl-storage.d.ts.map +0 -1
- package/dist/sdk/agent-core/harness/session/jsonl-storage.js +0 -230
- package/dist/sdk/agent-core/harness/session/memory-repo.d.ts +0 -18
- package/dist/sdk/agent-core/harness/session/memory-repo.d.ts.map +0 -1
- package/dist/sdk/agent-core/harness/session/memory-repo.js +0 -41
- package/dist/sdk/agent-core/harness/session/memory-storage.d.ts +0 -25
- package/dist/sdk/agent-core/harness/session/memory-storage.d.ts.map +0 -1
- package/dist/sdk/agent-core/harness/session/memory-storage.js +0 -113
- package/dist/sdk/agent-core/harness/session/repo-utils.d.ts +0 -11
- package/dist/sdk/agent-core/harness/session/repo-utils.d.ts.map +0 -1
- package/dist/sdk/agent-core/harness/session/repo-utils.js +0 -38
- package/dist/sdk/agent-core/harness/skills.d.ts +0 -44
- package/dist/sdk/agent-core/harness/skills.d.ts.map +0 -1
- package/dist/sdk/agent-core/harness/skills.js +0 -310
- package/dist/sdk/agent-core/harness/utils/shell-output.d.ts +0 -14
- package/dist/sdk/agent-core/harness/utils/shell-output.d.ts.map +0 -1
- package/dist/sdk/agent-core/harness/utils/shell-output.js +0 -125
- package/dist/sdk/agent-core/node.d.ts +0 -3
- package/dist/sdk/agent-core/node.d.ts.map +0 -1
- package/dist/sdk/agent-core/node.js +0 -2
- package/dist/sdk/agent-core/proxy.d.ts +0 -69
- package/dist/sdk/agent-core/proxy.d.ts.map +0 -1
- package/dist/sdk/agent-core/proxy.js +0 -297
|
@@ -0,0 +1,1098 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logic trace extension.
|
|
3
|
+
*
|
|
4
|
+
* Maintains EXACTLY ONE cloud-hosted file, `logic.aexol`, inside the Aexol
|
|
5
|
+
* Studio project the current directory is bound to (`.aexol/aexol.jsonc`).
|
|
6
|
+
* The file mirrors the project's business logic (types / enums / roles /
|
|
7
|
+
* workflows / agents) as a valid Aexol specification.
|
|
8
|
+
*
|
|
9
|
+
* Automatic refresh works the same way observational memory refreshes
|
|
10
|
+
* observations (see `../memory/hooks/observer-trigger.ts`):
|
|
11
|
+
* - `turn_end` hook fires the refresh attempt,
|
|
12
|
+
* - a token threshold gates how much new material must exist before a run,
|
|
13
|
+
* - an in-flight singleton prevents concurrent runs,
|
|
14
|
+
* - a passive flag (`PI_LOGIC_TRACE_PASSIVE`) disables it entirely.
|
|
15
|
+
*
|
|
16
|
+
* A `session_before_compact` hook additionally forces one flush attempt with
|
|
17
|
+
* the accumulated delta before the context is compacted away (mirroring the
|
|
18
|
+
* flush-before-compact semantics of `../memory/hooks/compaction-hook.ts`).
|
|
19
|
+
*
|
|
20
|
+
* Each refresh fetches the CURRENT remote `logic.aexol` and lets the model
|
|
21
|
+
* return the FULL updated spec (merge, never append-only) through a single
|
|
22
|
+
* `submit_logic_trace` tool call. Pushes are skipped when the model reports
|
|
23
|
+
* no change or when the content hash is unchanged (hashId).
|
|
24
|
+
*
|
|
25
|
+
* This is a SEPARATE mechanism from memory/observations — it only reuses
|
|
26
|
+
* generic helpers from `../memory/` and never writes any memory state.
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { Type } from "../sdk/ai/index.js";
|
|
31
|
+
import { agentLoop, uuidv7, } from "../sdk/agent-core/index.js";
|
|
32
|
+
import { getAgentDir, } from "../sdk/coding-agent/index.js";
|
|
33
|
+
import { readStudioBinding } from "../studio-binding.js";
|
|
34
|
+
import { createAexolBackendClient, } from "./aexol-backend-client.js";
|
|
35
|
+
import { createBackendRefineDraftRunner, } from "./logic-trace-backend-draft.js";
|
|
36
|
+
// Re-used (read-only) helpers from the observational-memory package so the
|
|
37
|
+
// recent-turn delta collection stays in lockstep with how the observer
|
|
38
|
+
// serializes conversation tails. Nothing here modifies memory state.
|
|
39
|
+
import { firstRawIdAfter, lastObservationCoverEndIdx, rawTailEntriesBetween, rawTokensSinceLastBound, } from "../memory/branch.js";
|
|
40
|
+
import { serializeSourceAddressedBranchEntries } from "../memory/serialize.js";
|
|
41
|
+
import { hashId } from "../memory/ids.js";
|
|
42
|
+
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../memory/model-budget.js";
|
|
43
|
+
import { formatSpecErrors, validateLogicTraceSpec } from "./logic-trace-validate.js";
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Constants & config
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
/** The single remote file this extension maintains. */
|
|
48
|
+
export const LOGIC_TRACE_REMOTE_PATH = "logic.aexol";
|
|
49
|
+
const LOGIC_TRACE_SETTINGS_KEY = "logic-trace";
|
|
50
|
+
const LOGIC_TRACE_PASSIVE_ENV = "PI_LOGIC_TRACE_PASSIVE";
|
|
51
|
+
const LOGIC_TRACE_DEBUG_ENV = "PI_LOGIC_TRACE_DEBUG";
|
|
52
|
+
/** Default token amount of new material required before an automatic sync. */
|
|
53
|
+
export const DEFAULT_THRESHOLD_TOKENS = 800;
|
|
54
|
+
/** Default cadence between backend refine task polls (draftBackend path). */
|
|
55
|
+
export const DEFAULT_DRAFT_BACKEND_POLL_INTERVAL_MS = 2_000;
|
|
56
|
+
/** Default total wait for a backend refine task to reach a terminal status. */
|
|
57
|
+
export const DEFAULT_DRAFT_BACKEND_TIMEOUT_MS = 120_000;
|
|
58
|
+
/** Serialized recent-turn delta cap so one huge turn cannot overflow the draft call. */
|
|
59
|
+
const MAX_DELTA_CHARS = 20_000;
|
|
60
|
+
function normalizeModelRef(value) {
|
|
61
|
+
if (!value || typeof value !== "object")
|
|
62
|
+
return undefined;
|
|
63
|
+
const raw = value;
|
|
64
|
+
if (typeof raw.provider !== "string" || typeof raw.id !== "string")
|
|
65
|
+
return undefined;
|
|
66
|
+
return { provider: raw.provider, id: raw.id };
|
|
67
|
+
}
|
|
68
|
+
function readSettingsFile(path) {
|
|
69
|
+
if (!existsSync(path))
|
|
70
|
+
return {};
|
|
71
|
+
try {
|
|
72
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
73
|
+
const nested = raw[LOGIC_TRACE_SETTINGS_KEY];
|
|
74
|
+
return nested && typeof nested === "object" ? nested : {};
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Resolve settings from `~/.spectral/settings.json` and `<cwd>/.spectral/settings.json`
|
|
82
|
+
* (project wins), then apply the `PI_LOGIC_TRACE_PASSIVE` env override — the
|
|
83
|
+
* same layered scheme as `../memory/config.ts` under its own namespaced key.
|
|
84
|
+
*/
|
|
85
|
+
export function loadLogicTraceConfig(cwd, env = process.env) {
|
|
86
|
+
const merged = {
|
|
87
|
+
...readSettingsFile(join(getAgentDir(), "settings.json")),
|
|
88
|
+
...readSettingsFile(join(cwd, ".spectral", "settings.json")),
|
|
89
|
+
};
|
|
90
|
+
let passive = merged.passive === true;
|
|
91
|
+
const envRaw = env[LOGIC_TRACE_PASSIVE_ENV]?.trim().toLowerCase();
|
|
92
|
+
if (envRaw !== undefined) {
|
|
93
|
+
if (["1", "true", "yes", "on"].includes(envRaw))
|
|
94
|
+
passive = true;
|
|
95
|
+
if (["0", "false", "no", "off"].includes(envRaw))
|
|
96
|
+
passive = false;
|
|
97
|
+
}
|
|
98
|
+
let thresholdTokens = DEFAULT_THRESHOLD_TOKENS;
|
|
99
|
+
if (typeof merged.thresholdTokens === "number" &&
|
|
100
|
+
Number.isInteger(merged.thresholdTokens) &&
|
|
101
|
+
merged.thresholdTokens > 0) {
|
|
102
|
+
thresholdTokens = merged.thresholdTokens;
|
|
103
|
+
}
|
|
104
|
+
const draftBackend = merged.draftBackend === true;
|
|
105
|
+
let draftBackendPollIntervalMs = DEFAULT_DRAFT_BACKEND_POLL_INTERVAL_MS;
|
|
106
|
+
if (typeof merged.draftBackendPollIntervalMs === "number" &&
|
|
107
|
+
Number.isInteger(merged.draftBackendPollIntervalMs) &&
|
|
108
|
+
merged.draftBackendPollIntervalMs > 0) {
|
|
109
|
+
draftBackendPollIntervalMs = merged.draftBackendPollIntervalMs;
|
|
110
|
+
}
|
|
111
|
+
let draftBackendTimeoutMs = DEFAULT_DRAFT_BACKEND_TIMEOUT_MS;
|
|
112
|
+
if (typeof merged.draftBackendTimeoutMs === "number" &&
|
|
113
|
+
Number.isInteger(merged.draftBackendTimeoutMs) &&
|
|
114
|
+
merged.draftBackendTimeoutMs > 0) {
|
|
115
|
+
draftBackendTimeoutMs = merged.draftBackendTimeoutMs;
|
|
116
|
+
}
|
|
117
|
+
const draftBackendMaxRetries = typeof merged.draftBackendMaxRetries === "number" &&
|
|
118
|
+
Number.isInteger(merged.draftBackendMaxRetries) &&
|
|
119
|
+
merged.draftBackendMaxRetries > 0
|
|
120
|
+
? merged.draftBackendMaxRetries
|
|
121
|
+
: undefined;
|
|
122
|
+
const model = normalizeModelRef(merged.model);
|
|
123
|
+
return {
|
|
124
|
+
passive,
|
|
125
|
+
thresholdTokens,
|
|
126
|
+
draftBackend,
|
|
127
|
+
draftBackendPollIntervalMs,
|
|
128
|
+
draftBackendTimeoutMs,
|
|
129
|
+
...(draftBackendMaxRetries !== undefined ? { draftBackendMaxRetries } : {}),
|
|
130
|
+
...(model ? { model } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function debugEnabled() {
|
|
134
|
+
const v = process.env[LOGIC_TRACE_DEBUG_ENV]?.trim().toLowerCase();
|
|
135
|
+
return v === "1" || v === "true" || v === "yes" || v === "on";
|
|
136
|
+
}
|
|
137
|
+
function debug(line, extra) {
|
|
138
|
+
if (!debugEnabled())
|
|
139
|
+
return;
|
|
140
|
+
process.stderr.write(`[logic-trace] ${line}${extra !== undefined ? ` ${JSON.stringify(extra)}` : ""}\n`);
|
|
141
|
+
}
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Grammar summary & starter template embedded into the draft prompt
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
/** Exported: also embedded into the backend-refine prompt (draftBackend path). */
|
|
146
|
+
export const LOGIC_TRACE_GRAMMAR = `.aexol quick grammar (one file, top-level blocks in any order, '#' lines are comments):
|
|
147
|
+
- type Name { fieldName: Type ... } — one field per line. Scalar types: string, number, boolean, datetime.
|
|
148
|
+
A capitalized identifier references another type or enum; a trailing [] marks a list (e.g. items: Task[]).
|
|
149
|
+
- enum Name { values, one per line, then '}' on its own line }.
|
|
150
|
+
- role Name { permission resource { actions } } — actions from: create, read, update, delete.
|
|
151
|
+
- workflow Name { a single 'initial stateA' line, then one 'state X -> Y, Z' line per transition }.
|
|
152
|
+
- agent Name: BaseRole { can ..., must ..., should ... } — ' : BaseRole' is optional.
|
|
153
|
+
- visitor { can ... } — optional.
|
|
154
|
+
- Reserved grammar words (visitor, type, enum, role, workflow, agent, permission, action, state, transition, …) must NOT appear as identifiers: e.g. do not name a visitor/test step 'state' — write 'can inspect sync status', never 'can inspect sync state'.`;
|
|
155
|
+
const LOGIC_TRACE_TEMPLATE = `# Project Logic
|
|
156
|
+
|
|
157
|
+
type ChecklistItem {
|
|
158
|
+
id: string
|
|
159
|
+
title: string
|
|
160
|
+
done: boolean
|
|
161
|
+
createdAt: datetime
|
|
162
|
+
assignee: string
|
|
163
|
+
status: ItemState
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
enum ItemState {
|
|
167
|
+
planned
|
|
168
|
+
started
|
|
169
|
+
done
|
|
170
|
+
archived
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
workflow ItemLifecycle {
|
|
174
|
+
initial planned
|
|
175
|
+
state planned -> started, archived
|
|
176
|
+
state started -> done, planned
|
|
177
|
+
state done -> done
|
|
178
|
+
state archived -> archived
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
role DomainAdmin {
|
|
182
|
+
permission checklist { create, read, update, delete }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
agent ChecklistAgent: DomainAdmin {
|
|
186
|
+
can plan items
|
|
187
|
+
must validate titles
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
visitor {
|
|
191
|
+
can view items
|
|
192
|
+
}`;
|
|
193
|
+
/**
|
|
194
|
+
* Leak-rule bullet list shared by LOGIC_TRACE_SYSTEM (local draft) and the
|
|
195
|
+
* backend refine adapter's MERGE_INSTRUCTIONS — defined once so the two
|
|
196
|
+
* prompts cannot drift apart.
|
|
197
|
+
*/
|
|
198
|
+
export const LOGIC_TRACE_LEAK_RULES = `- NEVER include secrets: no API keys or tokens (sk-…, xox…, ghp_…, AKIA…, JWTs), no Bearer/Authorization headers, and no KEY=value environment-variable-style assignments.
|
|
199
|
+
- NEVER include absolute filesystem paths (/home/…, /Users/…, /root/…, C:\\Users\\…) or localhost URLs; refer to resources by project-relative names. Redact or omit anything sensitive.`;
|
|
200
|
+
export const LOGIC_TRACE_SYSTEM = `You maintain the ONE logic.aexol file of the current Aexol Studio project: a valid, human-readable Aexol specification that mirrors the project's business logic (domain types, enums, roles, workflows, agents). It is always a FULL spec, never a changelog.
|
|
201
|
+
|
|
202
|
+
${LOGIC_TRACE_GRAMMAR}
|
|
203
|
+
|
|
204
|
+
Reference template (shape only — not content you must reproduce):
|
|
205
|
+
"""
|
|
206
|
+
${LOGIC_TRACE_TEMPLATE}
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
Rules:
|
|
210
|
+
- Return the COMPLETE updated file every time. Merge new facts into the existing structure ("merge, never append-only"): extend or reorganize existing types/agents/workflows instead of duplicating them.
|
|
211
|
+
- HARD CONSTRAINT: the submitted file MUST parse with the Aexol parser (types, enums, workflows, roles, agents, visitors). Invalid syntax is rejected with parser errors you must fix and resubmit.
|
|
212
|
+
- Keep the existing correct definitions; remove one only when it is clearly superseded by the new information.
|
|
213
|
+
- Keep the file compact and grammar-valid. Validity beats exhaustiveness; do not invent facts beyond the recent activity.
|
|
214
|
+
${LOGIC_TRACE_LEAK_RULES}
|
|
215
|
+
- If the recent activity does not change business logic meaningfully (docs, prose, file paths, UI cosmetics), submit changed=false.
|
|
216
|
+
- Call submit_logic_trace exactly once with the full content, then reply with a short plain-text confirmation to end the run.`;
|
|
217
|
+
async function resolveLogicModel(config, sessionModel, modelRegistry) {
|
|
218
|
+
let model = sessionModel;
|
|
219
|
+
if (config.model && modelRegistry) {
|
|
220
|
+
const configured = modelRegistry.find?.(config.model.provider, config.model.id);
|
|
221
|
+
if (configured)
|
|
222
|
+
model = configured;
|
|
223
|
+
}
|
|
224
|
+
if (!model) {
|
|
225
|
+
return { ok: false, reason: "no model available (session has no model and no logic-trace model configured)" };
|
|
226
|
+
}
|
|
227
|
+
const registry = modelRegistry;
|
|
228
|
+
const auth = await registry?.getApiKeyAndHeaders?.(model);
|
|
229
|
+
if (!auth || !auth.ok || !auth.apiKey) {
|
|
230
|
+
const provider = model.provider ?? "unknown";
|
|
231
|
+
return { ok: false, reason: `no API key for provider "${provider}"` };
|
|
232
|
+
}
|
|
233
|
+
return { ok: true, model, apiKey: auth.apiKey, headers: auth.headers };
|
|
234
|
+
}
|
|
235
|
+
function firstJson(content) {
|
|
236
|
+
const first = content[0];
|
|
237
|
+
if (first && first.json !== undefined && typeof first.json === "object") {
|
|
238
|
+
return first.json;
|
|
239
|
+
}
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
function normalizeRemotePath(value) {
|
|
243
|
+
if (typeof value !== "string")
|
|
244
|
+
return "";
|
|
245
|
+
return value.replace(/^\.\//, "").replace(/^\//, "");
|
|
246
|
+
}
|
|
247
|
+
/** Tolerantly pull the file array out of a list_project_files JSON payload. */
|
|
248
|
+
function extractFileArray(json) {
|
|
249
|
+
if (!json)
|
|
250
|
+
return [];
|
|
251
|
+
if (Array.isArray(json))
|
|
252
|
+
return json;
|
|
253
|
+
for (const key of ["files", "projectFiles", "items", "data"]) {
|
|
254
|
+
const value = json[key];
|
|
255
|
+
if (Array.isArray(value))
|
|
256
|
+
return value;
|
|
257
|
+
}
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* List-then-find the current remote `logic.aexol`.
|
|
262
|
+
*
|
|
263
|
+
* The project-file listing is MANDATORY on every sync: the backend's
|
|
264
|
+
* `remote_create_project_file` is a SILENT NO-OP when the file already
|
|
265
|
+
* exists, so create-vs-update can never be decided from a local cache alone.
|
|
266
|
+
*/
|
|
267
|
+
async function fetchRemoteLogicFile(client, projectId, teamId) {
|
|
268
|
+
// Inject the binding teamId on EVERY remote_* call: with user-JWT auth the
|
|
269
|
+
// backend otherwise falls back to the user's FIRST team membership
|
|
270
|
+
// (backend/src/routes/mcp/index.ts), which can differ from the team of the
|
|
271
|
+
// bound project.
|
|
272
|
+
const teamArg = teamId ? { teamId } : {};
|
|
273
|
+
const listing = await client.callTool("remote_list_project_files", { projectId, ...teamArg });
|
|
274
|
+
if (listing.isError) {
|
|
275
|
+
throw new Error("remote_list_project_files reported an error");
|
|
276
|
+
}
|
|
277
|
+
const files = extractFileArray(firstJson(listing.content));
|
|
278
|
+
for (const file of files) {
|
|
279
|
+
const path = normalizeRemotePath(file.path ?? file.name);
|
|
280
|
+
if (path !== LOGIC_TRACE_REMOTE_PATH)
|
|
281
|
+
continue;
|
|
282
|
+
const id = typeof file.id === "string" ? file.id : undefined;
|
|
283
|
+
let content = typeof file.content === "string" ? file.content : undefined;
|
|
284
|
+
if (content === undefined && id) {
|
|
285
|
+
const got = await client.callTool("remote_get_project_file", { id, projectId, ...teamArg });
|
|
286
|
+
if (!got.isError) {
|
|
287
|
+
const record = ((firstJson(got.content)?.file ?? firstJson(got.content)) ?? {});
|
|
288
|
+
if (typeof record.content === "string")
|
|
289
|
+
content = record.content;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { id: id ?? null, content, found: true };
|
|
293
|
+
}
|
|
294
|
+
return { id: null, content: undefined, found: false };
|
|
295
|
+
}
|
|
296
|
+
function extractCreatedFileId(content) {
|
|
297
|
+
const json = firstJson(content);
|
|
298
|
+
if (!json)
|
|
299
|
+
return undefined;
|
|
300
|
+
if (typeof json.id === "string")
|
|
301
|
+
return json.id;
|
|
302
|
+
const file = json.file;
|
|
303
|
+
if (file && typeof file === "object" && typeof file.id === "string") {
|
|
304
|
+
return file.id;
|
|
305
|
+
}
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
function toolResultText(res) {
|
|
309
|
+
const first = res.content[0];
|
|
310
|
+
if (first?.json !== undefined) {
|
|
311
|
+
try {
|
|
312
|
+
return JSON.stringify(first.json).slice(0, 300);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return "tool error";
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return typeof first?.text === "string" ? first.text.slice(0, 300) : "tool error";
|
|
319
|
+
}
|
|
320
|
+
function capDelta(text) {
|
|
321
|
+
if (text.length <= MAX_DELTA_CHARS)
|
|
322
|
+
return text;
|
|
323
|
+
return `[... older serialized entries truncated ...]\n${text.slice(-MAX_DELTA_CHARS)}`;
|
|
324
|
+
}
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Leak guard (mechanical pre-push pass; the prompt rules live in
|
|
327
|
+
// LOGIC_TRACE_SYSTEM and this catches whatever the model leaks anyway)
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
const SECRET_REDACTED = "[REDACTED]";
|
|
330
|
+
const PATH_REDACTED = "[REDACTED-PATH]";
|
|
331
|
+
const URL_REDACTED = "[REDACTED-URL]";
|
|
332
|
+
/**
|
|
333
|
+
* Strip secret/host-specific material from spec content before it is sent to
|
|
334
|
+
* remote_update_project_file / remote_create_project_file. Best-effort,
|
|
335
|
+
* format-preserving token pass: anything that looks like an API key
|
|
336
|
+
* (sk-…, xox*-, ghp…) a Bearer token, a JWT, an AWS access key, a
|
|
337
|
+
* `KEY=value` environment assignment, an absolute filesystem path, or a
|
|
338
|
+
* localhost URL is replaced in place with a redaction marker.
|
|
339
|
+
*/
|
|
340
|
+
export function sanitizeLogicTraceContent(content) {
|
|
341
|
+
return content
|
|
342
|
+
.replace(/\bsk-[A-Za-z0-9_-]{8,}/g, SECRET_REDACTED)
|
|
343
|
+
.replace(/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, SECRET_REDACTED)
|
|
344
|
+
.replace(/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g, SECRET_REDACTED)
|
|
345
|
+
.replace(/\bAKIA[0-9A-Z]{16}\b/g, SECRET_REDACTED)
|
|
346
|
+
.replace(/\bBearer[ \t]+[A-Za-z0-9._~+/=-]{16,}/gi, `Bearer ${SECRET_REDACTED}`)
|
|
347
|
+
.replace(/\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+/g, SECRET_REDACTED)
|
|
348
|
+
.replace(
|
|
349
|
+
// Line-based `KEY=value` assignment: snake/SCREAMING_CASE env-style
|
|
350
|
+
// keys plus explicit secret names in any casing (password/passwd,
|
|
351
|
+
// api_key/apiKey/API-KEY, secret, token, credential). Colon-style
|
|
352
|
+
// .aexol field lines are never touched.
|
|
353
|
+
/^([ \t]*(?:export[ \t]+)?(?:[A-Za-z][A-Za-z0-9_]*(?:_[A-Za-z0-9_]+)+|[A-Z][A-Z0-9_]{2,}|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Pp]ass(?:word|wd)|[Ss]ecret|[Tt]oken|[Cc]redential)[ \t]*=)[ \t]*\S.*$/gm, `$1${SECRET_REDACTED}`)
|
|
354
|
+
.replace(/\/(?:home|Users|root|private|workspace|tmp|var|etc|opt|mnt)\/[^\s"'`,;:\)\]}]*/g, PATH_REDACTED)
|
|
355
|
+
.replace(/[A-Za-z]:\\(?:Users|Documents(?: and Settings)?|Windows|Program Files)\\[^\s"'`,;:\)\]}]*/g, PATH_REDACTED)
|
|
356
|
+
.replace(/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|::1|\[::1\])(?::\d+)?(?:\/[^\s"'`,;:\)\]}]*)?/gi, URL_REDACTED)
|
|
357
|
+
.replace(/\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0):\d+(?:\/[^\s"'`,;:\)\]}]*)?/g, URL_REDACTED);
|
|
358
|
+
}
|
|
359
|
+
const SUBMIT_TOOL_NAME = "submit_logic_trace";
|
|
360
|
+
const SubmitLogicTraceSchema = Type.Object({
|
|
361
|
+
content: Type.String({
|
|
362
|
+
minLength: 1,
|
|
363
|
+
description: "The COMPLETE updated .aexol specification (full file contents, not a diff).",
|
|
364
|
+
}),
|
|
365
|
+
changed: Type.Boolean({
|
|
366
|
+
description: "true when the recent activity changed business logic and the CURRENT SPEC must be updated; false otherwise.",
|
|
367
|
+
}),
|
|
368
|
+
});
|
|
369
|
+
/**
|
|
370
|
+
* Build the draft-loop `submit_logic_trace` tool. Exported (like the internal
|
|
371
|
+
* `buildReadTool` / `buildWriteTool` builders, but visible to tests) so the
|
|
372
|
+
* parser-backed gate is unit-testable without an LLM: `collect` fires exactly
|
|
373
|
+
* once per VALID submission; invalid ones are rejected with parser diagnostics
|
|
374
|
+
* and never collected. Runtime wiring in `runLogicTraceDraft` is unchanged.
|
|
375
|
+
*/
|
|
376
|
+
export function buildSubmitTool(collect) {
|
|
377
|
+
const submitTool = {
|
|
378
|
+
name: SUBMIT_TOOL_NAME,
|
|
379
|
+
label: "Submit logic trace",
|
|
380
|
+
description: "Submit the COMPLETE updated logic.aexol specification. The content MUST parse as an .aexol spec: " +
|
|
381
|
+
"invalid syntax is returned as an error with parser diagnostics to fix, and the corrected FULL file must be resubmitted. " +
|
|
382
|
+
"Call this tool exactly once with the final valid spec; then reply with a short plain-text confirmation to end the run.",
|
|
383
|
+
parameters: SubmitLogicTraceSchema,
|
|
384
|
+
execute: async (_id, params) => {
|
|
385
|
+
// Parser-backed gate INSIDE the draft loop: an invalid submission is
|
|
386
|
+
// returned as an error with diagnostics (and NOT collected), so the
|
|
387
|
+
// model fixes them and resubmits within the same loop.
|
|
388
|
+
const sanitized = sanitizeLogicTraceContent((params.content ?? "").trim());
|
|
389
|
+
const validation = validateLogicTraceSpec(sanitized);
|
|
390
|
+
if (!validation.ok) {
|
|
391
|
+
debug("submit_logic_trace rejected: spec does not parse", { errors: validation.errors });
|
|
392
|
+
return {
|
|
393
|
+
content: [{
|
|
394
|
+
type: "text",
|
|
395
|
+
text: "Error: the submitted spec does not parse. Fix the issues and submit the FULL corrected file.\n" +
|
|
396
|
+
formatSpecErrors(validation),
|
|
397
|
+
}],
|
|
398
|
+
details: { isError: true },
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
collect({ content: params.content, changed: params.changed === true });
|
|
402
|
+
return {
|
|
403
|
+
content: [{ type: "text", text: "Received. Reply with a short plain-text confirmation to finish." }],
|
|
404
|
+
details: {},
|
|
405
|
+
};
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
return submitTool;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Default draft runner: ONE agentLoop call with a single submit tool.
|
|
412
|
+
* Structurally mirrors `../memory/observer.ts` (drain events, await result,
|
|
413
|
+
* collect via the tool's execute) but accumulates exactly one submission.
|
|
414
|
+
*/
|
|
415
|
+
export async function runLogicTraceDraft(input) {
|
|
416
|
+
const submissions = [];
|
|
417
|
+
const submitTool = buildSubmitTool((submission) => submissions.push(submission));
|
|
418
|
+
const userText = `CURRENT SPEC (${input.currentContent === undefined ? "not created yet" : "current logic.aexol"}):
|
|
419
|
+
${input.currentContent ?? "(the file does not exist yet — author a starting spec)"}
|
|
420
|
+
|
|
421
|
+
RECENT AGENT ACTIVITY:
|
|
422
|
+
${input.delta}
|
|
423
|
+
|
|
424
|
+
return the FULL updated spec with the recent changes merged in ("merge, never append-only"). If the activity does not change business logic meaningfully, call submit_logic_trace with changed=false and the current spec unchanged.`;
|
|
425
|
+
const prompts = [
|
|
426
|
+
{
|
|
427
|
+
role: "user",
|
|
428
|
+
content: [{ type: "text", text: userText }],
|
|
429
|
+
timestamp: Date.now(),
|
|
430
|
+
},
|
|
431
|
+
];
|
|
432
|
+
const context = {
|
|
433
|
+
systemPrompt: LOGIC_TRACE_SYSTEM,
|
|
434
|
+
messages: [],
|
|
435
|
+
tools: [submitTool],
|
|
436
|
+
};
|
|
437
|
+
const model = input.model;
|
|
438
|
+
const reasoning = model.reasoning;
|
|
439
|
+
const config = {
|
|
440
|
+
model,
|
|
441
|
+
apiKey: input.apiKey,
|
|
442
|
+
headers: input.headers,
|
|
443
|
+
maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
|
|
444
|
+
convertToLlm: (msgs) => msgs,
|
|
445
|
+
toolExecution: "sequential",
|
|
446
|
+
cacheRetention: "none",
|
|
447
|
+
sessionId: uuidv7(),
|
|
448
|
+
...(reasoning ? { reasoning: "high" } : {}),
|
|
449
|
+
};
|
|
450
|
+
const stream = agentLoop(prompts, context, config, input.signal);
|
|
451
|
+
for await (const _event of stream) {
|
|
452
|
+
// Drain events; the tool's execute already collected the submission.
|
|
453
|
+
}
|
|
454
|
+
await stream.result();
|
|
455
|
+
const last = submissions[submissions.length - 1];
|
|
456
|
+
return last ? { submitted: last } : {};
|
|
457
|
+
}
|
|
458
|
+
const serverStateByCwd = new Map();
|
|
459
|
+
function stateFor(cwd) {
|
|
460
|
+
let state = serverStateByCwd.get(cwd);
|
|
461
|
+
if (!state) {
|
|
462
|
+
state = {
|
|
463
|
+
binding: null,
|
|
464
|
+
bindingMtimeMs: null,
|
|
465
|
+
remote: null,
|
|
466
|
+
syncedTokens: 0,
|
|
467
|
+
syncInFlight: false,
|
|
468
|
+
syncPromise: null,
|
|
469
|
+
};
|
|
470
|
+
serverStateByCwd.set(cwd, state);
|
|
471
|
+
}
|
|
472
|
+
return state;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Await/diagnose the in-flight sync for a cwd (null when idle). Exposed for
|
|
476
|
+
* deterministic tests and diagnostics; production hooks stay fire-and-forget.
|
|
477
|
+
*/
|
|
478
|
+
export function pendingLogicTraceSync(cwd) {
|
|
479
|
+
return stateFor(cwd).syncPromise;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Threshold evaluation WITHOUT mutating per-cwd state (used by cheap pre-gates
|
|
483
|
+
* that must not consume material). Equals the delta computed inside
|
|
484
|
+
* attemptLogicTraceSync after its re-baseline step.
|
|
485
|
+
*/
|
|
486
|
+
function unsyncedTokens(state, tokens) {
|
|
487
|
+
return Math.max(0, tokens - state.syncedTokens);
|
|
488
|
+
}
|
|
489
|
+
/** Per-cwd binding reader — same mtime-cache pattern as aexol-mcp.ts. */
|
|
490
|
+
async function refreshBindingForCwd(cwd) {
|
|
491
|
+
const state = stateFor(cwd);
|
|
492
|
+
const bindingFile = join(cwd, ".aexol", "aexol.jsonc");
|
|
493
|
+
let mtimeMs;
|
|
494
|
+
try {
|
|
495
|
+
mtimeMs = statSync(bindingFile).mtimeMs;
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
const code = err.code;
|
|
499
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
500
|
+
state.binding = null;
|
|
501
|
+
state.bindingMtimeMs = null;
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
return state.binding;
|
|
505
|
+
}
|
|
506
|
+
if (mtimeMs === state.bindingMtimeMs)
|
|
507
|
+
return state.binding;
|
|
508
|
+
try {
|
|
509
|
+
state.binding = await readStudioBinding(cwd);
|
|
510
|
+
state.bindingMtimeMs = mtimeMs;
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
debug("binding file parse failed", { cwd });
|
|
514
|
+
state.bindingMtimeMs = null;
|
|
515
|
+
}
|
|
516
|
+
return state.binding;
|
|
517
|
+
}
|
|
518
|
+
const SKIPPED = (reason) => ({ pushed: false, ok: true, reason });
|
|
519
|
+
const FAILED = (reason) => ({ pushed: false, ok: false, reason });
|
|
520
|
+
function notifyWith(ui, message, type = "info") {
|
|
521
|
+
debug(`notify: ${message}`);
|
|
522
|
+
if (ui.hasUI && ui.ui)
|
|
523
|
+
ui.ui.notify(`Logic trace: ${message}`, type);
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Run one guarded sync attempt. The in-flight singleton guard is checked AND
|
|
527
|
+
* set synchronously so two back-to-back hook invocations can never race into
|
|
528
|
+
* a second concurrent run. The returned promise never rejects; concurrent
|
|
529
|
+
* callers while a run is in flight get `null` instead.
|
|
530
|
+
*/
|
|
531
|
+
export function runLogicTraceSync(deps) {
|
|
532
|
+
const { state } = deps;
|
|
533
|
+
if (state.syncInFlight) {
|
|
534
|
+
debug("sync skipped: already in flight");
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
state.syncInFlight = true;
|
|
538
|
+
let promise;
|
|
539
|
+
promise = (async () => {
|
|
540
|
+
try {
|
|
541
|
+
return await attemptLogicTraceSync(deps);
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
545
|
+
debug("sync failed", { errorMessage: msg });
|
|
546
|
+
return FAILED(msg);
|
|
547
|
+
}
|
|
548
|
+
finally {
|
|
549
|
+
state.syncInFlight = false;
|
|
550
|
+
if (state.syncPromise === promise)
|
|
551
|
+
state.syncPromise = null;
|
|
552
|
+
}
|
|
553
|
+
})();
|
|
554
|
+
state.syncPromise = promise;
|
|
555
|
+
return promise;
|
|
556
|
+
}
|
|
557
|
+
async function attemptLogicTraceSync(deps) {
|
|
558
|
+
const { cwd, config, state, draftRunner, signal, ui, model, modelRegistry, entries, leafId } = deps;
|
|
559
|
+
const binding = await refreshBindingForCwd(cwd);
|
|
560
|
+
if (!binding) {
|
|
561
|
+
debug("sync skipped: not bound to a Studio project");
|
|
562
|
+
return SKIPPED("not-bound");
|
|
563
|
+
}
|
|
564
|
+
// Serialize the recent-turn delta — mirrors ../memory/hooks/observer-trigger.ts:
|
|
565
|
+
// bound-relative token estimate → bound-relative raw tail → source-addressed
|
|
566
|
+
// serialization.
|
|
567
|
+
const tokens = rawTokensSinceLastBound(entries);
|
|
568
|
+
if (tokens < state.syncedTokens)
|
|
569
|
+
state.syncedTokens = tokens; // bound moved backward → re-baseline
|
|
570
|
+
if (!deps.force) {
|
|
571
|
+
const deltaTokens = unsyncedTokens(state, tokens);
|
|
572
|
+
if (deltaTokens < config.thresholdTokens) {
|
|
573
|
+
debug("sync skipped: threshold not reached", { deltaTokens, thresholdTokens: config.thresholdTokens });
|
|
574
|
+
return SKIPPED("threshold-not-reached");
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// The skip-gate watermark is consumed ONLY when this attempt's draft+push
|
|
578
|
+
// cycle completes (the `consume()` call sites below). A failed draft, a
|
|
579
|
+
// failed push, or an unavailable backend must leave it untouched so the
|
|
580
|
+
// next turn_end re-attempts the same material even without fresh tokens;
|
|
581
|
+
// the MAX_DELTA_CHARS cap bounds how much of it is re-serialized. Nested
|
|
582
|
+
// one-shot decisions that DID process the material (draft ran, nothing to
|
|
583
|
+
// push) consume like the memory observer advancing its bound on empty.
|
|
584
|
+
const consume = () => {
|
|
585
|
+
state.syncedTokens = tokens;
|
|
586
|
+
};
|
|
587
|
+
// Backend bootstrap only AFTER the cheap gates above: bootstrapping opens
|
|
588
|
+
// the OAuth browser flow when the token is expired, so it must never run
|
|
589
|
+
// for turns that fail the binding or threshold gate. Real 401/403s from
|
|
590
|
+
// live calls keep their client-side re-auth recovery in
|
|
591
|
+
// aexol-backend-client.ts (untouched).
|
|
592
|
+
const bootstrap = await deps.getBootstrap(cwd);
|
|
593
|
+
if (!bootstrap.ok) {
|
|
594
|
+
debug("sync skipped: backend unavailable", { reason: bootstrap.reason });
|
|
595
|
+
return FAILED("backend-unavailable");
|
|
596
|
+
}
|
|
597
|
+
const client = bootstrap.client;
|
|
598
|
+
const coversFromId = firstRawIdAfter(entries, lastObservationCoverEndIdx(entries));
|
|
599
|
+
if (!coversFromId)
|
|
600
|
+
return SKIPPED("no-source-entries");
|
|
601
|
+
const tail = rawTailEntriesBetween(entries, coversFromId, leafId ?? coversFromId);
|
|
602
|
+
if (tail.length === 0)
|
|
603
|
+
return SKIPPED("empty-tail");
|
|
604
|
+
const serialized = serializeSourceAddressedBranchEntries(tail);
|
|
605
|
+
if (!serialized.text.trim())
|
|
606
|
+
return SKIPPED("empty-tail");
|
|
607
|
+
const delta = capDelta(serialized.text);
|
|
608
|
+
const resolved = await resolveLogicModel(config, model, modelRegistry);
|
|
609
|
+
if (!resolved.ok) {
|
|
610
|
+
debug("sync skipped: model unavailable", { reason: resolved.reason });
|
|
611
|
+
return SKIPPED(resolved.reason);
|
|
612
|
+
}
|
|
613
|
+
const projectId = binding.projectId;
|
|
614
|
+
const teamArg = binding.teamId ? { teamId: binding.teamId } : {};
|
|
615
|
+
let remote;
|
|
616
|
+
try {
|
|
617
|
+
remote = await fetchRemoteLogicFile(client, projectId, binding.teamId);
|
|
618
|
+
}
|
|
619
|
+
catch (err) {
|
|
620
|
+
debug("remote list/get failed", { errorMessage: err instanceof Error ? err.message : String(err) });
|
|
621
|
+
return FAILED("remote-list-failed");
|
|
622
|
+
}
|
|
623
|
+
// Refresh the per-cwd remote cache from the (mandatory) listing.
|
|
624
|
+
state.remote = {
|
|
625
|
+
id: remote.id,
|
|
626
|
+
contentHash: remote.content !== undefined ? hashId(remote.content) : undefined,
|
|
627
|
+
};
|
|
628
|
+
if (remote.found && (remote.content === undefined || remote.content.trim() === "")) {
|
|
629
|
+
// The file EXISTS remotely but its content could not be read (fetch
|
|
630
|
+
// swallows get failures) or is empty (e.g. externally truncated). Do
|
|
631
|
+
// NOT let the draft author a spec from scratch here — that would
|
|
632
|
+
// clobber externally-edited content; a plain
|
|
633
|
+
// remote_create_project_file would be a silent no-op anyway.
|
|
634
|
+
debug("sync skipped: remote file found but content unreadable");
|
|
635
|
+
return SKIPPED("remote-content-unreadable");
|
|
636
|
+
}
|
|
637
|
+
let draft;
|
|
638
|
+
try {
|
|
639
|
+
const draftInput = {
|
|
640
|
+
model: resolved.model,
|
|
641
|
+
apiKey: resolved.apiKey,
|
|
642
|
+
headers: resolved.headers,
|
|
643
|
+
currentContent: remote.content,
|
|
644
|
+
delta,
|
|
645
|
+
signal,
|
|
646
|
+
};
|
|
647
|
+
// Opt-in backend draft: delegate the .aexol authoring step to the
|
|
648
|
+
// backend from/refine pipeline. The adapter never throws past the seam
|
|
649
|
+
// and returns {} on FAILED/CANCELLED/timeout — an empty outcome (or a
|
|
650
|
+
// throw, defensively) falls back to the local draft runner below, so
|
|
651
|
+
// the downstream sanitize → parser gate → dedupe → push flow is shared
|
|
652
|
+
// by both paths. With draftBackend=false this is exactly the previous
|
|
653
|
+
// behavior (local runner only).
|
|
654
|
+
const runLocalDraft = () => draftRunner(draftInput);
|
|
655
|
+
// A forced/compaction flush must never block compaction on the backend
|
|
656
|
+
// refine pipeline: the adapter polls up to draftBackendTimeoutMs
|
|
657
|
+
// (default 120s) and session_before_compact awaits this sync, so a
|
|
658
|
+
// stuck refine task would stall compaction for minutes. Forced flushes
|
|
659
|
+
// (force: true — the threshold bypass above) go straight to the local
|
|
660
|
+
// draft runner; the pre-compact material is still flushed, just without
|
|
661
|
+
// the backend round-trip.
|
|
662
|
+
if (config.draftBackend && !deps.force) {
|
|
663
|
+
let backendDraft = {};
|
|
664
|
+
try {
|
|
665
|
+
backendDraft = await deps.backendDraftFactory({
|
|
666
|
+
client,
|
|
667
|
+
projectId,
|
|
668
|
+
...(binding.teamId ? { teamId: binding.teamId } : {}),
|
|
669
|
+
pollIntervalMs: config.draftBackendPollIntervalMs,
|
|
670
|
+
pollTimeoutMs: config.draftBackendTimeoutMs,
|
|
671
|
+
...(config.draftBackendMaxRetries !== undefined
|
|
672
|
+
? { maxRetries: config.draftBackendMaxRetries }
|
|
673
|
+
: {}),
|
|
674
|
+
promptRules: LOGIC_TRACE_GRAMMAR,
|
|
675
|
+
})(draftInput);
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
debug("backend draft runner threw (falling back)", { errorMessage: err instanceof Error ? err.message : String(err) });
|
|
679
|
+
}
|
|
680
|
+
if (backendDraft.submitted) {
|
|
681
|
+
draft = backendDraft;
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
debug("backend draft empty → local draft fallback");
|
|
685
|
+
draft = await runLocalDraft();
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
draft = await runLocalDraft();
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
catch (err) {
|
|
693
|
+
debug("draft runner failed", { errorMessage: err instanceof Error ? err.message : String(err) });
|
|
694
|
+
return FAILED("draft-failed");
|
|
695
|
+
}
|
|
696
|
+
const submitted = draft.submitted;
|
|
697
|
+
if (!submitted) {
|
|
698
|
+
consume(); // draft cycle completed → don't re-run the same material next turn
|
|
699
|
+
return SKIPPED("draft-no-submission");
|
|
700
|
+
}
|
|
701
|
+
if (!submitted.changed) {
|
|
702
|
+
debug("draft says unchanged → skip push");
|
|
703
|
+
consume();
|
|
704
|
+
return SKIPPED("draft-unchanged");
|
|
705
|
+
}
|
|
706
|
+
const content = sanitizeLogicTraceContent(submitted.content.trim());
|
|
707
|
+
if (!content) {
|
|
708
|
+
consume();
|
|
709
|
+
return SKIPPED("draft-empty-content");
|
|
710
|
+
}
|
|
711
|
+
// Parser-backed gate before any hash/dedupe/push: content the Aexol
|
|
712
|
+
// parser rejects must never reach the remote file. Mirrors the
|
|
713
|
+
// 'draft-failed' FAILED pattern; FAILED('draft-invalid') deliberately
|
|
714
|
+
// leaves the skip-gate watermark unconsumed so the next turn_end
|
|
715
|
+
// re-attempts the same material.
|
|
716
|
+
const validation = validateLogicTraceSpec(content);
|
|
717
|
+
if (!validation.ok) {
|
|
718
|
+
debug("draft rejected: content does not parse", { errors: formatSpecErrors(validation) });
|
|
719
|
+
return FAILED("draft-invalid");
|
|
720
|
+
}
|
|
721
|
+
const nextHash = hashId(content);
|
|
722
|
+
const currentHash = remote.content !== undefined ? hashId(remote.content) : state.remote?.contentHash;
|
|
723
|
+
if (currentHash && nextHash === currentHash) {
|
|
724
|
+
debug("hash unchanged → skip push", { nextHash });
|
|
725
|
+
consume();
|
|
726
|
+
return SKIPPED("hash-unchanged");
|
|
727
|
+
}
|
|
728
|
+
let pushedId;
|
|
729
|
+
let pushError = false;
|
|
730
|
+
if (remote.id) {
|
|
731
|
+
const res = await client.callTool("remote_update_project_file", {
|
|
732
|
+
id: remote.id,
|
|
733
|
+
content,
|
|
734
|
+
projectId,
|
|
735
|
+
...teamArg,
|
|
736
|
+
});
|
|
737
|
+
if (res.isError) {
|
|
738
|
+
notifyWith(ui, `failed to update logic.aexol: ${toolResultText(res)}`, "warning");
|
|
739
|
+
pushError = true;
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
pushedId = remote.id;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
// The listing ran before this push, so "not found" + create is safe:
|
|
747
|
+
// remote_create_project_file is a silent no-op only when the path exists.
|
|
748
|
+
const res = await client.callTool("remote_create_project_file", {
|
|
749
|
+
path: LOGIC_TRACE_REMOTE_PATH,
|
|
750
|
+
content,
|
|
751
|
+
projectId,
|
|
752
|
+
...teamArg,
|
|
753
|
+
});
|
|
754
|
+
if (res.isError) {
|
|
755
|
+
notifyWith(ui, `failed to create logic.aexol: ${toolResultText(res)}`, "warning");
|
|
756
|
+
pushError = true;
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
pushedId = extractCreatedFileId(res.content);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (pushError) {
|
|
763
|
+
// A stale cached id is the most likely cause; clear the cache to force a
|
|
764
|
+
// clean re-list next time.
|
|
765
|
+
state.remote = null;
|
|
766
|
+
return FAILED("push-failed");
|
|
767
|
+
}
|
|
768
|
+
// On success refresh BOTH cached id and hash so the next sync dedupes.
|
|
769
|
+
state.remote = { id: pushedId ?? remote.id ?? null, contentHash: nextHash };
|
|
770
|
+
consume(); // completed draft+push cycle → the watermark advances
|
|
771
|
+
notifyWith(ui, `logic.aexol updated in project ${binding.name ?? binding.projectId}`);
|
|
772
|
+
return { pushed: true, ok: true };
|
|
773
|
+
}
|
|
774
|
+
// ---------------------------------------------------------------------------
|
|
775
|
+
// Agent tools
|
|
776
|
+
// ---------------------------------------------------------------------------
|
|
777
|
+
const LOGIC_TRACE_READ_TOOL_NAME = "remote_logic_trace_read";
|
|
778
|
+
const LOGIC_TRACE_WRITE_TOOL_NAME = "remote_logic_trace_write";
|
|
779
|
+
const UNBOUND_TEXT = "No Aexol Studio project binding found in this directory.\n" +
|
|
780
|
+
"Use local_bind_project to link this directory to a Studio project (then read/write logic.aexol), " +
|
|
781
|
+
"or `spectral bind <project-id>` from the terminal.";
|
|
782
|
+
const NOT_CREATED_TEXT = `"${LOGIC_TRACE_REMOTE_PATH}" has not been created in this Studio project yet.\n` +
|
|
783
|
+
`It is auto-refreshed by the logic trace mechanism after meaningful business-logic work; ` +
|
|
784
|
+
`you can also create it with ${LOGIC_TRACE_WRITE_TOOL_NAME}.`;
|
|
785
|
+
function textResult(text, isError = false) {
|
|
786
|
+
return {
|
|
787
|
+
content: [{ type: "text", text }],
|
|
788
|
+
details: { isError },
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
const ReadParamsSchema = Type.Object({});
|
|
792
|
+
function buildReadTool(ensureBackend) {
|
|
793
|
+
return {
|
|
794
|
+
name: LOGIC_TRACE_READ_TOOL_NAME,
|
|
795
|
+
label: "Read Logic Trace",
|
|
796
|
+
description: `Read the current ${LOGIC_TRACE_REMOTE_PATH} file from the bound Aexol Studio project. ` +
|
|
797
|
+
`It mirrors the project's business logic as a compact .aexol specification (types, enums, roles, workflows, agents). ` +
|
|
798
|
+
`Call this BEFORE writing to it, to see what already exists.`,
|
|
799
|
+
promptSnippet: `Use ${LOGIC_TRACE_READ_TOOL_NAME}() to see the current one-file business-logic spec (${LOGIC_TRACE_REMOTE_PATH}) in the bound Studio project.`,
|
|
800
|
+
promptGuidelines: [
|
|
801
|
+
`There is EXACTLY ONE logic file per project: ${LOGIC_TRACE_REMOTE_PATH}. Never create extra spec files.`,
|
|
802
|
+
"Read it before writing; the automatic refresh keeps it current at the end of each turn.",
|
|
803
|
+
],
|
|
804
|
+
parameters: Type.Object({}),
|
|
805
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
806
|
+
// An explicit agent action: retry the bootstrap (may re-run the OAuth
|
|
807
|
+
// re-auth) even when the automatic path is blocked by a memoized
|
|
808
|
+
// bootstrap failure; success here unlocks the session memo.
|
|
809
|
+
const backend = await ensureBackend(ctx.cwd, { retry: true });
|
|
810
|
+
if (!backend.ok) {
|
|
811
|
+
return textResult(`Aexol backend unavailable: ${backend.message}`, true);
|
|
812
|
+
}
|
|
813
|
+
const binding = await refreshBindingForCwd(ctx.cwd);
|
|
814
|
+
if (!binding)
|
|
815
|
+
return textResult(UNBOUND_TEXT, true);
|
|
816
|
+
try {
|
|
817
|
+
const remote = await fetchRemoteLogicFile(backend.client, binding.projectId, binding.teamId);
|
|
818
|
+
if (!remote.found)
|
|
819
|
+
return textResult(NOT_CREATED_TEXT);
|
|
820
|
+
return textResult(remote.content ?? "(empty file)");
|
|
821
|
+
}
|
|
822
|
+
catch (err) {
|
|
823
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
824
|
+
return textResult(`Failed to read ${LOGIC_TRACE_REMOTE_PATH}: ${msg}`, true);
|
|
825
|
+
}
|
|
826
|
+
},
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
function buildWriteTool(ensureBackend) {
|
|
830
|
+
const WriteSchema = Type.Object({
|
|
831
|
+
content: Type.String({
|
|
832
|
+
minLength: 1,
|
|
833
|
+
description: "The COMPLETE .aexol spec to store as the ONE project logic file (full contents).",
|
|
834
|
+
}),
|
|
835
|
+
reason: Type.String({
|
|
836
|
+
minLength: 1,
|
|
837
|
+
description: "Why this full-spec update is needed (echoed in the ack).",
|
|
838
|
+
}),
|
|
839
|
+
});
|
|
840
|
+
return {
|
|
841
|
+
name: LOGIC_TRACE_WRITE_TOOL_NAME,
|
|
842
|
+
label: "Write Logic Trace",
|
|
843
|
+
description: `Manually upsert the ONE ${LOGIC_TRACE_REMOTE_PATH} file in the bound Aexol Studio project with a COMPLETE .aexol spec. ` +
|
|
844
|
+
`Merge, never append-only: read it first, then write the full updated file. Refresh it after meaningful business-logic changes.`,
|
|
845
|
+
promptSnippet: `Use ${LOGIC_TRACE_WRITE_TOOL_NAME}(content, reason) to fully regenerate ${LOGIC_TRACE_REMOTE_PATH} (the ONE business-logic spec in the bound Studio project) after meaningful business-logic changes.`,
|
|
846
|
+
promptGuidelines: [
|
|
847
|
+
`There is EXACTLY ONE logic file per project: ${LOGIC_TRACE_REMOTE_PATH}. Never create extra spec files; never append changelogs into it.`,
|
|
848
|
+
`Always ${LOGIC_TRACE_READ_TOOL_NAME} before writing, and write the FULL merged spec — not a diff, not an appendix.`,
|
|
849
|
+
"Only full-spec writes; this tool upserts (updates the existing file or creates it when missing) and never deletes it.",
|
|
850
|
+
"Use it after meaningful business-logic changes (new/changed types, enums, roles, workflows, agents); automatic refresh handles the rest.",
|
|
851
|
+
"The tool returns ✓ when the complete spec parses, and an Error with parser diagnostics when it does not.",
|
|
852
|
+
],
|
|
853
|
+
parameters: WriteSchema,
|
|
854
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
855
|
+
// Same explicit retry semantics as remote_logic_trace_read: this is
|
|
856
|
+
// the agent deliberately acting, so a memoized bootstrap failure may
|
|
857
|
+
// be re-attempted (and is unlocked on success).
|
|
858
|
+
const backend = await ensureBackend(ctx.cwd, { retry: true });
|
|
859
|
+
if (!backend.ok) {
|
|
860
|
+
return textResult(`Aexol backend unavailable: ${backend.message}`, true);
|
|
861
|
+
}
|
|
862
|
+
const binding = await refreshBindingForCwd(ctx.cwd);
|
|
863
|
+
if (!binding)
|
|
864
|
+
return textResult(UNBOUND_TEXT, true);
|
|
865
|
+
// EXPLICIT agent action: the content the agent deliberately wrote is
|
|
866
|
+
// stored verbatim (trim only) — the redaction pass is reserved for
|
|
867
|
+
// automatically drafted sync content, not for the agent's own
|
|
868
|
+
// deliberate full-spec write.
|
|
869
|
+
const content = (params.content ?? "").trim();
|
|
870
|
+
if (!content) {
|
|
871
|
+
return textResult("Error: content (the complete .aexol spec) is required.", true);
|
|
872
|
+
}
|
|
873
|
+
// Parser-backed gate BEFORE any remote call: invalid content must
|
|
874
|
+
// neither mutate the cloud file nor even probe the project-file
|
|
875
|
+
// listing, so writes fail fast with parser diagnostics instead of
|
|
876
|
+
// storing a spec the Aexol toolchain cannot read.
|
|
877
|
+
const validation = validateLogicTraceSpec(content);
|
|
878
|
+
if (!validation.ok) {
|
|
879
|
+
return textResult("Error: content is not a valid .aexol spec.\n" + formatSpecErrors(validation), true);
|
|
880
|
+
}
|
|
881
|
+
try {
|
|
882
|
+
// Manual upsert: list (mandatory — create silently no-ops when the
|
|
883
|
+
// path already exists) then update-or-create. No LLM, no dedupe.
|
|
884
|
+
const remote = await fetchRemoteLogicFile(backend.client, binding.projectId, binding.teamId);
|
|
885
|
+
const state = stateFor(ctx.cwd);
|
|
886
|
+
state.remote = {
|
|
887
|
+
id: remote.id,
|
|
888
|
+
contentHash: remote.content !== undefined ? hashId(remote.content) : undefined,
|
|
889
|
+
};
|
|
890
|
+
// Every remote_* call carries the binding ids (projectId + teamId
|
|
891
|
+
// when present) so team access resolves to the BOUND project, not
|
|
892
|
+
// to the first team membership of the user token.
|
|
893
|
+
const teamArg = binding.teamId ? { teamId: binding.teamId } : {};
|
|
894
|
+
let id;
|
|
895
|
+
if (remote.id) {
|
|
896
|
+
const res = await backend.client.callTool("remote_update_project_file", {
|
|
897
|
+
id: remote.id,
|
|
898
|
+
content,
|
|
899
|
+
projectId: binding.projectId,
|
|
900
|
+
...teamArg,
|
|
901
|
+
});
|
|
902
|
+
if (res.isError)
|
|
903
|
+
return textResult(`Failed to update ${LOGIC_TRACE_REMOTE_PATH}: ${toolResultText(res)}`, true);
|
|
904
|
+
id = remote.id;
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
const res = await backend.client.callTool("remote_create_project_file", {
|
|
908
|
+
path: LOGIC_TRACE_REMOTE_PATH,
|
|
909
|
+
content,
|
|
910
|
+
projectId: binding.projectId,
|
|
911
|
+
...teamArg,
|
|
912
|
+
});
|
|
913
|
+
if (res.isError)
|
|
914
|
+
return textResult(`Failed to create ${LOGIC_TRACE_REMOTE_PATH}: ${toolResultText(res)}`, true);
|
|
915
|
+
id = extractCreatedFileId(res.content);
|
|
916
|
+
}
|
|
917
|
+
const contentHash = hashId(content);
|
|
918
|
+
state.remote = { id: id ?? null, contentHash };
|
|
919
|
+
notifyWith({ hasUI: ctx.hasUI, ui: ctx.ui }, `logic.aexol updated in project ${binding.name ?? binding.projectId}`);
|
|
920
|
+
return textResult(`✓ ${LOGIC_TRACE_REMOTE_PATH} ${remote.id ? "updated" : "created"} ` +
|
|
921
|
+
`(project ${binding.name ?? binding.projectId}, reason: ${params.reason.trim()}).`);
|
|
922
|
+
}
|
|
923
|
+
catch (err) {
|
|
924
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
925
|
+
return textResult(`Failed to write ${LOGIC_TRACE_REMOTE_PATH}: ${msg}`, true);
|
|
926
|
+
}
|
|
927
|
+
},
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Logic trace extension entry point: automatic refresh hooks
|
|
932
|
+
* (`turn_end`, `session_before_compact`) plus the two agent tools.
|
|
933
|
+
*/
|
|
934
|
+
export function logicTraceExtension(ext, options = {}) {
|
|
935
|
+
const draftRunner = options.draftRunner ?? runLogicTraceDraft;
|
|
936
|
+
const backendDraftFactory = options.backendDraftFactory ?? createBackendRefineDraftRunner;
|
|
937
|
+
const clientFactory = options.clientFactory ?? createAexolBackendClient;
|
|
938
|
+
let cachedBootstrap = null;
|
|
939
|
+
let bootstrapPromise = null;
|
|
940
|
+
// Memoized bootstrap FAILURE, held for the REST OF THE SESSION (per cwd):
|
|
941
|
+
// bootstrap performs the OAuth browser flow when the stored token is
|
|
942
|
+
// expired, so re-attempting it from the automatic turn hooks would reopen
|
|
943
|
+
// a browser on every turn_end. The memo is replayed by the sync path
|
|
944
|
+
// only and never expires mid-session; it unlocks when an explicit agent
|
|
945
|
+
// action re-bootstraps successfully — the remote_logic_trace_read/write
|
|
946
|
+
// tools call ensureBackend with `{ retry: true }`, and that retry runs
|
|
947
|
+
// the explicit OAuth re-auth. Real 401/403s from live calls keep their
|
|
948
|
+
// existing client-side re-auth recovery in aexol-backend-client.ts
|
|
949
|
+
// (untouched).
|
|
950
|
+
let failureMemo = null;
|
|
951
|
+
async function ensureBackend(cwd, options = {}) {
|
|
952
|
+
if (cachedBootstrap)
|
|
953
|
+
return { ok: true, ...cachedBootstrap };
|
|
954
|
+
if (!options.retry && failureMemo && failureMemo.cwd === cwd) {
|
|
955
|
+
debug("bootstrap skipped: memoized failure (unlock via an explicit agent tool call)");
|
|
956
|
+
return failureMemo.outcome;
|
|
957
|
+
}
|
|
958
|
+
bootstrapPromise ??= clientFactory({
|
|
959
|
+
warn: (line) => process.stderr.write(`[logic-trace] ${line}\n`),
|
|
960
|
+
});
|
|
961
|
+
let result;
|
|
962
|
+
try {
|
|
963
|
+
result = await bootstrapPromise;
|
|
964
|
+
}
|
|
965
|
+
catch (err) {
|
|
966
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
967
|
+
result = { ok: false, reason: "catalog-failed", message: msg };
|
|
968
|
+
}
|
|
969
|
+
if (result.ok) {
|
|
970
|
+
// Memoize success for the session; a failure memo is cleared (unlock).
|
|
971
|
+
cachedBootstrap = result;
|
|
972
|
+
failureMemo = null;
|
|
973
|
+
}
|
|
974
|
+
else {
|
|
975
|
+
failureMemo = { cwd, outcome: { ...result } };
|
|
976
|
+
}
|
|
977
|
+
bootstrapPromise = null;
|
|
978
|
+
return result;
|
|
979
|
+
}
|
|
980
|
+
function makeNotifier(hasUI, ui) {
|
|
981
|
+
return { hasUI, ui };
|
|
982
|
+
}
|
|
983
|
+
try {
|
|
984
|
+
ext.registerTool(buildReadTool(ensureBackend));
|
|
985
|
+
}
|
|
986
|
+
catch (err) {
|
|
987
|
+
process.stderr.write(`[logic-trace] Failed to register ${LOGIC_TRACE_READ_TOOL_NAME}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
988
|
+
}
|
|
989
|
+
try {
|
|
990
|
+
ext.registerTool(buildWriteTool(ensureBackend));
|
|
991
|
+
}
|
|
992
|
+
catch (err) {
|
|
993
|
+
process.stderr.write(`[logic-trace] Failed to register ${LOGIC_TRACE_WRITE_TOOL_NAME}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
994
|
+
}
|
|
995
|
+
ext.on("session_start", async (_event, ctx) => {
|
|
996
|
+
const config = loadLogicTraceConfig(ctx.cwd);
|
|
997
|
+
if (config.passive)
|
|
998
|
+
return;
|
|
999
|
+
try {
|
|
1000
|
+
const binding = await refreshBindingForCwd(ctx.cwd);
|
|
1001
|
+
if (binding) {
|
|
1002
|
+
debug("session_start: project binding found", { projectId: binding.projectId });
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
catch (err) {
|
|
1006
|
+
debug("session_start binding read failed", { errorMessage: err instanceof Error ? err.message : String(err) });
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
ext.on("turn_end", async (_event, ctx) => {
|
|
1010
|
+
const config = loadLogicTraceConfig(ctx.cwd);
|
|
1011
|
+
if (config.passive) {
|
|
1012
|
+
debug("turn_end skipped: passive");
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
// Snapshot ctx synchronously — the async work below may outlive it.
|
|
1016
|
+
const ui = makeNotifier(ctx.hasUI, ctx.ui);
|
|
1017
|
+
const model = ctx.model;
|
|
1018
|
+
const modelRegistry = ctx.modelRegistry;
|
|
1019
|
+
const entries = ctx.sessionManager.getBranch();
|
|
1020
|
+
const leafId = ctx.sessionManager.getLeafId?.();
|
|
1021
|
+
// Fire-and-forget: the outcome is never thrown; failures surface via
|
|
1022
|
+
// debug logs / notify inside runLogicTraceSync. The binding check and
|
|
1023
|
+
// the token-threshold gate run INSIDE the sync before it touches the
|
|
1024
|
+
// backend, so no turn bootstrap()s a backend without passing them.
|
|
1025
|
+
void runLogicTraceSync({
|
|
1026
|
+
cwd: ctx.cwd,
|
|
1027
|
+
config,
|
|
1028
|
+
state: stateFor(ctx.cwd),
|
|
1029
|
+
getBootstrap: ensureBackend,
|
|
1030
|
+
draftRunner,
|
|
1031
|
+
backendDraftFactory,
|
|
1032
|
+
ui,
|
|
1033
|
+
model,
|
|
1034
|
+
modelRegistry,
|
|
1035
|
+
entries,
|
|
1036
|
+
leafId,
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
1039
|
+
ext.on("session_before_compact", async (event, ctx) => {
|
|
1040
|
+
const config = loadLogicTraceConfig(ctx.cwd);
|
|
1041
|
+
if (config.passive)
|
|
1042
|
+
return undefined;
|
|
1043
|
+
try {
|
|
1044
|
+
const state = stateFor(ctx.cwd);
|
|
1045
|
+
// A fire-and-forget sync may still be mid-flight; WAIT for it instead
|
|
1046
|
+
// of skipping so the pre-compact delta is flushed before the raw
|
|
1047
|
+
// material is compacted away.
|
|
1048
|
+
const pending = pendingLogicTraceSync(ctx.cwd);
|
|
1049
|
+
if (pending) {
|
|
1050
|
+
debug("compaction flush: awaiting in-flight sync first");
|
|
1051
|
+
await pending.catch(() => undefined);
|
|
1052
|
+
}
|
|
1053
|
+
// Flush the entries that are about to be summarized away (everything
|
|
1054
|
+
// before the compaction's firstKeptEntryId keeps its material alive in
|
|
1055
|
+
// logic.aexol before the raw tail disappears from context).
|
|
1056
|
+
const branchEntries = (event.branchEntries ?? []);
|
|
1057
|
+
let entriesToFlush = branchEntries;
|
|
1058
|
+
const firstKeptEntryId = event.preparation?.firstKeptEntryId;
|
|
1059
|
+
if (typeof firstKeptEntryId === "string") {
|
|
1060
|
+
const idx = branchEntries.findIndex((e) => e.id === firstKeptEntryId);
|
|
1061
|
+
if (idx > 0)
|
|
1062
|
+
entriesToFlush = branchEntries.slice(0, idx);
|
|
1063
|
+
}
|
|
1064
|
+
if (entriesToFlush.length === 0)
|
|
1065
|
+
return undefined;
|
|
1066
|
+
const ui = makeNotifier(ctx.hasUI, ctx.ui);
|
|
1067
|
+
await runLogicTraceSync({
|
|
1068
|
+
cwd: ctx.cwd,
|
|
1069
|
+
config,
|
|
1070
|
+
state,
|
|
1071
|
+
getBootstrap: ensureBackend,
|
|
1072
|
+
draftRunner,
|
|
1073
|
+
backendDraftFactory,
|
|
1074
|
+
ui,
|
|
1075
|
+
model: ctx.model,
|
|
1076
|
+
modelRegistry: ctx.modelRegistry,
|
|
1077
|
+
entries: entriesToFlush,
|
|
1078
|
+
leafId: lastSourceEntryId(entriesToFlush),
|
|
1079
|
+
force: true, // compaction may destroy the material — flush regardless of threshold
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
catch (err) {
|
|
1083
|
+
// Never influence the compaction result; failures are diagnostic only.
|
|
1084
|
+
debug("compaction flush failed", { errorMessage: err instanceof Error ? err.message : String(err) });
|
|
1085
|
+
}
|
|
1086
|
+
return undefined;
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
function lastSourceEntryId(entries) {
|
|
1090
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1091
|
+
const type = entries[i].type;
|
|
1092
|
+
if (type === "message" || type === "custom_message" || type === "branch_summary") {
|
|
1093
|
+
return entries[i].id;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
return undefined;
|
|
1097
|
+
}
|
|
1098
|
+
export default logicTraceExtension;
|