sortie-dogs 0.2.12 → 0.2.13
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/plugin/config.d.ts +15 -0
- package/dist/plugin/config.js +23 -1
- package/dist/plugin/index.d.ts +6 -0
- package/dist/plugin/index.js +165 -3
- package/dist/reflection/config.d.ts +7 -0
- package/dist/reflection/config.js +32 -0
- package/dist/reflection/index.d.ts +3 -0
- package/dist/reflection/index.js +2 -0
- package/dist/reflection/store.d.ts +76 -0
- package/dist/reflection/store.js +321 -0
- package/package.json +1 -1
package/dist/plugin/config.d.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import { type ModelCatalog, type ModelRoutingConfig, type ModelTarget } from "./model-routing.js";
|
|
2
|
+
export interface ReflectionConfiguration {
|
|
3
|
+
readonly enabled: boolean;
|
|
4
|
+
readonly layers: {
|
|
5
|
+
readonly run: boolean;
|
|
6
|
+
readonly project: boolean;
|
|
7
|
+
readonly global: boolean;
|
|
8
|
+
};
|
|
9
|
+
readonly maxInjectedEntries: number;
|
|
10
|
+
readonly maxInjectedTokens: number;
|
|
11
|
+
}
|
|
12
|
+
export type ReflectionPolicyInput = Partial<Omit<ReflectionConfiguration, "layers">> & {
|
|
13
|
+
layers?: Partial<ReflectionConfiguration["layers"]>;
|
|
14
|
+
};
|
|
2
15
|
export interface SortieDogsPluginOptions {
|
|
3
16
|
operationManifestPath?: string;
|
|
4
17
|
handoffPaths?: readonly string[];
|
|
@@ -22,6 +35,7 @@ export interface SortieDogsPluginOptions {
|
|
|
22
35
|
* only states this to raise the ceiling, choose a compaction model, or switch continuation off.
|
|
23
36
|
*/
|
|
24
37
|
continuation?: ContinuationPolicyInput;
|
|
38
|
+
reflection?: ReflectionPolicyInput;
|
|
25
39
|
}
|
|
26
40
|
export type ContinuationPolicyInput = Partial<ContinuationConfiguration>;
|
|
27
41
|
export interface ContinuationConfiguration {
|
|
@@ -65,6 +79,7 @@ export interface ConfiguredPlugin {
|
|
|
65
79
|
freeTierFallbackModels: readonly string[];
|
|
66
80
|
consultation: ConsultationPolicy;
|
|
67
81
|
continuation: ContinuationConfiguration;
|
|
82
|
+
reflection: ReflectionConfiguration;
|
|
68
83
|
}
|
|
69
84
|
export type PluginConfiguration = ConfiguredPlugin | {
|
|
70
85
|
kind: "invalid";
|
package/dist/plugin/config.js
CHANGED
|
@@ -32,6 +32,7 @@ export const DEFAULT_PLUGIN_OPTIONS = {
|
|
|
32
32
|
capability: CONTINUATION_CAPABILITY,
|
|
33
33
|
maxAutoContinues: DEFAULT_MAX_AUTO_CONTINUES,
|
|
34
34
|
}),
|
|
35
|
+
reflection: Object.freeze({ enabled: false, layers: Object.freeze({ run: true, project: true, global: false }), maxInjectedEntries: 3, maxInjectedTokens: 500 }),
|
|
35
36
|
};
|
|
36
37
|
function isRecord(value) {
|
|
37
38
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -204,7 +205,7 @@ function parseLayer(value) {
|
|
|
204
205
|
return undefined;
|
|
205
206
|
if (Object.keys(value).some((key) => ![
|
|
206
207
|
"operationManifestPath", "handoffPaths", "readOnlyTools", "dedicatedWorkerModel",
|
|
207
|
-
"modelRouting", "modelCatalog", "freeTierFallbackModels", "consultation", "continuation",
|
|
208
|
+
"modelRouting", "modelCatalog", "freeTierFallbackModels", "consultation", "continuation", "reflection",
|
|
208
209
|
].includes(key))) {
|
|
209
210
|
return undefined;
|
|
210
211
|
}
|
|
@@ -229,6 +230,22 @@ function parseLayer(value) {
|
|
|
229
230
|
const continuation = value.continuation === undefined
|
|
230
231
|
? undefined
|
|
231
232
|
: parseContinuationPolicy(value.continuation);
|
|
233
|
+
const reflectionValue = value.reflection;
|
|
234
|
+
let reflection;
|
|
235
|
+
if (reflectionValue !== undefined) {
|
|
236
|
+
if (!isRecord(reflectionValue) || Object.keys(reflectionValue).some((key) => !["enabled", "layers", "maxInjectedEntries", "maxInjectedTokens"].includes(key)))
|
|
237
|
+
return undefined;
|
|
238
|
+
const layers = reflectionValue.layers;
|
|
239
|
+
if (layers !== undefined && (!isRecord(layers) || Object.keys(layers).some((key) => !["run", "project", "global"].includes(key)) || Object.values(layers).some((item) => typeof item !== "boolean")))
|
|
240
|
+
return undefined;
|
|
241
|
+
if (reflectionValue.enabled !== undefined && typeof reflectionValue.enabled !== "boolean")
|
|
242
|
+
return undefined;
|
|
243
|
+
if (reflectionValue.maxInjectedEntries !== undefined && (!positiveInteger(reflectionValue.maxInjectedEntries) || reflectionValue.maxInjectedEntries > 3))
|
|
244
|
+
return undefined;
|
|
245
|
+
if (reflectionValue.maxInjectedTokens !== undefined && (!positiveInteger(reflectionValue.maxInjectedTokens) || reflectionValue.maxInjectedTokens > 500))
|
|
246
|
+
return undefined;
|
|
247
|
+
reflection = { ...(reflectionValue.enabled === undefined ? {} : { enabled: reflectionValue.enabled }), ...(layers === undefined ? {} : { layers: layers }), ...(reflectionValue.maxInjectedEntries === undefined ? {} : { maxInjectedEntries: reflectionValue.maxInjectedEntries }), ...(reflectionValue.maxInjectedTokens === undefined ? {} : { maxInjectedTokens: reflectionValue.maxInjectedTokens }) };
|
|
248
|
+
}
|
|
232
249
|
if (manifestPath !== undefined && (typeof manifestPath !== "string" || manifestPath.length === 0)) {
|
|
233
250
|
return undefined;
|
|
234
251
|
}
|
|
@@ -262,6 +279,7 @@ function parseLayer(value) {
|
|
|
262
279
|
freeTierFallbackModels: freeTierFallbackModels,
|
|
263
280
|
consultation,
|
|
264
281
|
continuation,
|
|
282
|
+
reflection,
|
|
265
283
|
};
|
|
266
284
|
}
|
|
267
285
|
/** Merge defaults, optional project/env configuration, then the host override. */
|
|
@@ -275,6 +293,7 @@ export function resolvePluginConfiguration(...values) {
|
|
|
275
293
|
let freeTierFallbackModels = DEFAULT_PLUGIN_OPTIONS.freeTierFallbackModels;
|
|
276
294
|
let consultation = DEFAULT_PLUGIN_OPTIONS.consultation;
|
|
277
295
|
let continuation = DEFAULT_PLUGIN_OPTIONS.continuation;
|
|
296
|
+
let reflection = DEFAULT_PLUGIN_OPTIONS.reflection;
|
|
278
297
|
const configuredRoles = new Set();
|
|
279
298
|
for (const value of values) {
|
|
280
299
|
const layer = parseLayer(value);
|
|
@@ -314,6 +333,8 @@ export function resolvePluginConfiguration(...values) {
|
|
|
314
333
|
if (layer.continuation !== undefined) {
|
|
315
334
|
continuation = Object.freeze({ ...continuation, ...layer.continuation });
|
|
316
335
|
}
|
|
336
|
+
if (layer.reflection !== undefined)
|
|
337
|
+
reflection = Object.freeze({ ...reflection, ...layer.reflection, layers: Object.freeze({ ...reflection.layers, ...(layer.reflection.layers ?? {}) }) });
|
|
317
338
|
}
|
|
318
339
|
modelRouting = {
|
|
319
340
|
...Object.fromEntries(Object.entries(modelRouting).filter(([role]) => !isFixedModelRole(role))),
|
|
@@ -346,6 +367,7 @@ export function resolvePluginConfiguration(...values) {
|
|
|
346
367
|
freeTierFallbackModels,
|
|
347
368
|
consultation,
|
|
348
369
|
continuation,
|
|
370
|
+
reflection,
|
|
349
371
|
};
|
|
350
372
|
}
|
|
351
373
|
/** Resolve the plugin's fixed source boundaries: project-local first, environment and host global. */
|
package/dist/plugin/index.d.ts
CHANGED
|
@@ -28,6 +28,12 @@ export interface OpenCodeHooks {
|
|
|
28
28
|
"tool.execute.before"?: (input: ToolExecuteBeforeInput, output: ToolExecuteBeforeOutput) => Promise<void>;
|
|
29
29
|
"tool.execute.after"?: (input: TaskToolExecuteAfterInput, output: TaskResultRepairOutput) => Promise<void>;
|
|
30
30
|
"chat.message"?: OpenCodeChatMessageHook;
|
|
31
|
+
"experimental.chat.system.transform"?: (input: {
|
|
32
|
+
sessionID: string;
|
|
33
|
+
}, output: {
|
|
34
|
+
system?: string[];
|
|
35
|
+
model?: unknown;
|
|
36
|
+
}) => Promise<void>;
|
|
31
37
|
/** Continuation observes the coordinator's completed final text to honour its fallback markers. */
|
|
32
38
|
"experimental.text.complete"?: (input: {
|
|
33
39
|
sessionID: string;
|
package/dist/plugin/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile, stat } from "node:fs/promises";
|
|
3
|
-
import { isAbsolute, resolve, sep } from "node:path";
|
|
3
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
4
4
|
import { RUNTIME_ASSET_VERSION } from "../asset-version.js";
|
|
5
5
|
import { normalizeRelativePath, RelativePathError } from "../core/path.js";
|
|
6
6
|
import { validateManifest } from "../core/validate-manifest.js";
|
|
@@ -10,6 +10,7 @@ import { CONTINUATION_CAPABILITY, createContinuationHooks, } from "./continuatio
|
|
|
10
10
|
import { WriteDeniedError, createProjectPaths, createWriteGate, describeUnclassifiedCommand, isKnownReadOnlyTool, normalizeCommand, resolveProjectRoot, safePath, } from "./gate.js";
|
|
11
11
|
import { createModelRoutingHook, } from "./model-routing-hook.js";
|
|
12
12
|
import { createTaskResultRepairHook, } from "./task-result-repair.js";
|
|
13
|
+
import { configRoot, nearestPackageVersion, reflectionEnabled, ReflectionError, ReflectionStore } from "../reflection/index.js";
|
|
13
14
|
const INPUT_LIMITS = { config: 64 * 1024, manifest: 512 * 1024, handoff: 2 * 1024 * 1024 };
|
|
14
15
|
const INSPECTION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
|
|
15
16
|
const ACTIVE_SESSION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
|
|
@@ -228,6 +229,7 @@ function loadConfigured(config, handoffBase, client) {
|
|
|
228
229
|
readOnlyTools: new Set(config.readOnlyTools.map((tool) => tool.toLowerCase())),
|
|
229
230
|
modelRoutingHook,
|
|
230
231
|
continuation: config.continuation,
|
|
232
|
+
reflection: config.reflection,
|
|
231
233
|
};
|
|
232
234
|
}
|
|
233
235
|
function samePath(left, right) {
|
|
@@ -392,12 +394,51 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
392
394
|
const defineTool = validToolCandidate
|
|
393
395
|
? toolCandidate
|
|
394
396
|
: Object.assign((definition) => definition, { schema: { string: () => ({ type: "string" }) } });
|
|
397
|
+
const optionalString = () => {
|
|
398
|
+
const stringSchema = defineTool.schema.string();
|
|
399
|
+
if (isRecord(stringSchema) && typeof stringSchema.optional === "function") {
|
|
400
|
+
return stringSchema.optional();
|
|
401
|
+
}
|
|
402
|
+
return typeof defineTool.schema.optional === "function" ? defineTool.schema.optional(stringSchema) : stringSchema;
|
|
403
|
+
};
|
|
395
404
|
let project;
|
|
405
|
+
let reflectionStartup = false;
|
|
406
|
+
let reflectionConfiguration;
|
|
407
|
+
let reflectionVersion;
|
|
408
|
+
let reflectionStore;
|
|
396
409
|
let loaded;
|
|
397
410
|
let loadFailure;
|
|
398
411
|
let loading;
|
|
399
412
|
let manifestAbsent = false;
|
|
400
413
|
let assetVersionReported = false;
|
|
414
|
+
// Project config read is required discovery for its opt-in; no reflection storage/version read
|
|
415
|
+
// occurs unless that resolved config enables reflection. It stays isolated from write-gate load.
|
|
416
|
+
try {
|
|
417
|
+
project = await createProjectPaths(resolveProjectRoot(input));
|
|
418
|
+
const probed = resolvePluginConfigurationSources(await readOptionalProjectConfig(project), readEnvironmentConfig(), options);
|
|
419
|
+
if (probed.kind === "configured" && reflectionEnabled(probed.reflection)) {
|
|
420
|
+
reflectionVersion = await nearestPackageVersion();
|
|
421
|
+
reflectionConfiguration = probed.reflection;
|
|
422
|
+
reflectionStore = new ReflectionStore(join(configRoot(), "sortie-dogs", "reflection"), project.root, {
|
|
423
|
+
warn: (code) => {
|
|
424
|
+
const log = input.client?.app;
|
|
425
|
+
if (!isRecord(log) || typeof log.log !== "function")
|
|
426
|
+
return;
|
|
427
|
+
try {
|
|
428
|
+
log.log({ level: "warn", service: "sortie-dogs", message: code });
|
|
429
|
+
}
|
|
430
|
+
catch { /* host logging is best effort */ }
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
reflectionStartup = true;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
reflectionStartup = false;
|
|
438
|
+
reflectionConfiguration = undefined;
|
|
439
|
+
reflectionVersion = undefined;
|
|
440
|
+
reflectionStore = undefined;
|
|
441
|
+
}
|
|
401
442
|
/*
|
|
402
443
|
* Continuation must be callable before the first lazy configuration load completes, so it reads
|
|
403
444
|
* the effective policy at call time and falls back to the shipped default until then.
|
|
@@ -484,6 +525,10 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
484
525
|
const bindingPins = new Map();
|
|
485
526
|
const activeSessions = new Map();
|
|
486
527
|
const coordinatorRoots = new Map();
|
|
528
|
+
const reflectionOwnedRoots = new Set();
|
|
529
|
+
const reflectionClosingRoots = new Set();
|
|
530
|
+
const reflectionInFlight = new Map();
|
|
531
|
+
const reflectionWaiters = new Map();
|
|
487
532
|
const bindingDenials = new Map();
|
|
488
533
|
const expiredSessions = new Set();
|
|
489
534
|
const sessionParents = new Map();
|
|
@@ -1117,6 +1162,7 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
1117
1162
|
return {
|
|
1118
1163
|
...(typeof payload.agent === "string" ? { agent: payload.agent } : {}),
|
|
1119
1164
|
...(typeof payload.parentID === "string" ? { parentID: payload.parentID } : {}),
|
|
1165
|
+
parentPresent: "parentID" in payload,
|
|
1120
1166
|
};
|
|
1121
1167
|
}
|
|
1122
1168
|
catch {
|
|
@@ -1135,7 +1181,7 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
1135
1181
|
if (child?.parentID === undefined)
|
|
1136
1182
|
return undefined;
|
|
1137
1183
|
const parent = await hostSessionIdentity(child.parentID);
|
|
1138
|
-
if (parent?.agent !== COORDINATOR_AGENT || parent.
|
|
1184
|
+
if (parent?.agent !== COORDINATOR_AGENT || parent.parentPresent)
|
|
1139
1185
|
return undefined;
|
|
1140
1186
|
await rememberCoordinatorRoot(child.parentID);
|
|
1141
1187
|
rememberParent(sessionID, child.parentID);
|
|
@@ -1151,7 +1197,51 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
1151
1197
|
}
|
|
1152
1198
|
}
|
|
1153
1199
|
}
|
|
1154
|
-
|
|
1200
|
+
async function reflectionPermitted(sessionID, agent) {
|
|
1201
|
+
if (!reflectionStartup || reflectionStore === undefined || reflectionVersion === undefined || process.env.SORTIE_REFLECTION === "0")
|
|
1202
|
+
return false;
|
|
1203
|
+
if (agent !== undefined && agent !== COORDINATOR_AGENT)
|
|
1204
|
+
return false;
|
|
1205
|
+
if (!isCoordinatorSession(sessionID) || coordinatorRootForSession(sessionID) !== sessionID || sessionParents.has(sessionID))
|
|
1206
|
+
return false;
|
|
1207
|
+
const identity = await hostSessionIdentity(sessionID);
|
|
1208
|
+
if (identity?.agent !== COORDINATOR_AGENT || identity.parentPresent)
|
|
1209
|
+
return false;
|
|
1210
|
+
return true;
|
|
1211
|
+
}
|
|
1212
|
+
async function beginReflection(sessionID, agent) {
|
|
1213
|
+
if (!(await reflectionPermitted(sessionID, agent)) || reflectionClosingRoots.has(sessionID))
|
|
1214
|
+
return false;
|
|
1215
|
+
reflectionOwnedRoots.add(sessionID);
|
|
1216
|
+
reflectionInFlight.set(sessionID, (reflectionInFlight.get(sessionID) ?? 0) + 1);
|
|
1217
|
+
return true;
|
|
1218
|
+
}
|
|
1219
|
+
function endReflection(sessionID) {
|
|
1220
|
+
const remaining = (reflectionInFlight.get(sessionID) ?? 1) - 1;
|
|
1221
|
+
if (remaining > 0) {
|
|
1222
|
+
reflectionInFlight.set(sessionID, remaining);
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
reflectionInFlight.delete(sessionID);
|
|
1226
|
+
for (const resolve of reflectionWaiters.get(sessionID) ?? [])
|
|
1227
|
+
resolve();
|
|
1228
|
+
reflectionWaiters.delete(sessionID);
|
|
1229
|
+
}
|
|
1230
|
+
async function waitForReflections(sessionID) {
|
|
1231
|
+
if ((reflectionInFlight.get(sessionID) ?? 0) === 0)
|
|
1232
|
+
return;
|
|
1233
|
+
await new Promise((resolve) => (reflectionWaiters.get(sessionID) ?? reflectionWaiters.set(sessionID, []).get(sessionID)).push(resolve));
|
|
1234
|
+
}
|
|
1235
|
+
function reflectionWarning(code) {
|
|
1236
|
+
const log = input.client?.app;
|
|
1237
|
+
if (!isRecord(log) || typeof log.log !== "function")
|
|
1238
|
+
return;
|
|
1239
|
+
try {
|
|
1240
|
+
log.log({ level: "warn", service: "sortie-dogs", message: code });
|
|
1241
|
+
}
|
|
1242
|
+
catch { /* host logging is best effort */ }
|
|
1243
|
+
}
|
|
1244
|
+
const hooks = {
|
|
1155
1245
|
tool: {
|
|
1156
1246
|
sortie_bind_write_gate: defineTool({
|
|
1157
1247
|
description: "Bind this active session to one project-relative operation manifest without changing files.",
|
|
@@ -1194,6 +1284,36 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
1194
1284
|
return await continuation.tool.execute({}, context);
|
|
1195
1285
|
},
|
|
1196
1286
|
}),
|
|
1287
|
+
...(reflectionStartup ? {
|
|
1288
|
+
sortie_reflection: defineTool({
|
|
1289
|
+
description: "Record, promote, or clear a bounded process reflection.",
|
|
1290
|
+
args: { action: defineTool.schema.string(), layer: defineTool.schema.string(), scope: optionalString(), trigger: optionalString(), cause: optionalString(), prevention: optionalString(), evidence: optionalString(), evidenceRef: optionalString(), id: optionalString(), promotedRef: optionalString(), confirmation: optionalString() },
|
|
1291
|
+
async execute(args, context) {
|
|
1292
|
+
if (!(await beginReflection(context.sessionID, context.agent)))
|
|
1293
|
+
return "reflection_not_permitted";
|
|
1294
|
+
const layer = args.layer;
|
|
1295
|
+
try {
|
|
1296
|
+
if (!["run", "project", "global"].includes(layer))
|
|
1297
|
+
return "reflection_invalid_layer";
|
|
1298
|
+
if (!(reflectionConfiguration?.layers[layer] ?? false))
|
|
1299
|
+
return "reflection_not_permitted";
|
|
1300
|
+
if (args.action === "record")
|
|
1301
|
+
return JSON.stringify(await reflectionStore.record(layer, context.sessionID, args, reflectionVersion));
|
|
1302
|
+
if (args.action === "promote")
|
|
1303
|
+
return await reflectionStore.promote(layer, context.sessionID, args.id, args.promotedRef, reflectionVersion);
|
|
1304
|
+
if (args.action === "clear")
|
|
1305
|
+
return await reflectionStore.clear(layer, context.sessionID, args.confirmation, reflectionVersion);
|
|
1306
|
+
return "reflection_invalid_action";
|
|
1307
|
+
}
|
|
1308
|
+
catch (error) {
|
|
1309
|
+
return error instanceof ReflectionError ? error.code : "reflection_storage_error";
|
|
1310
|
+
}
|
|
1311
|
+
finally {
|
|
1312
|
+
endReflection(context.sessionID);
|
|
1313
|
+
}
|
|
1314
|
+
},
|
|
1315
|
+
}),
|
|
1316
|
+
} : {}),
|
|
1197
1317
|
},
|
|
1198
1318
|
"experimental.text.complete": async (textInput, textOutput) => {
|
|
1199
1319
|
await continuation.textComplete(textInput, textOutput);
|
|
@@ -1238,6 +1358,25 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
1238
1358
|
await ensureLoaded();
|
|
1239
1359
|
await loaded?.modelRoutingHook?.(chatInput, output);
|
|
1240
1360
|
},
|
|
1361
|
+
...(reflectionStartup ? { "experimental.chat.system.transform": async (transformInput, transformOutput) => {
|
|
1362
|
+
if (!(await beginReflection(transformInput.sessionID)))
|
|
1363
|
+
return;
|
|
1364
|
+
const config = reflectionConfiguration;
|
|
1365
|
+
try {
|
|
1366
|
+
if (!config)
|
|
1367
|
+
return;
|
|
1368
|
+
const buckets = ["run", "project", "global"]
|
|
1369
|
+
.filter((layer) => config.layers[layer])
|
|
1370
|
+
.map((layer) => ({ layer, ...(layer === "global" ? {} : { run: transformInput.sessionID }) }));
|
|
1371
|
+
const text = await reflectionStore.injectBuckets(buckets, config.maxInjectedEntries, config.maxInjectedTokens, reflectionVersion);
|
|
1372
|
+
if (text)
|
|
1373
|
+
transformOutput.system = [...(transformOutput.system ?? []), text];
|
|
1374
|
+
}
|
|
1375
|
+
catch { /* reflection is strictly non-invasive */ }
|
|
1376
|
+
finally {
|
|
1377
|
+
endReflection(transformInput.sessionID);
|
|
1378
|
+
}
|
|
1379
|
+
} } : {}),
|
|
1241
1380
|
"permission.ask": async (permission) => {
|
|
1242
1381
|
if (permission.permission !== "edit")
|
|
1243
1382
|
return;
|
|
@@ -1348,6 +1487,28 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
1348
1487
|
return;
|
|
1349
1488
|
}
|
|
1350
1489
|
if (event.type === "session.deleted") {
|
|
1490
|
+
if (reflectionStore !== undefined && reflectionConfiguration?.layers.run && reflectionOwnedRoots.has(eventSessionID)) {
|
|
1491
|
+
reflectionClosingRoots.add(eventSessionID);
|
|
1492
|
+
await waitForReflections(eventSessionID);
|
|
1493
|
+
let deleted = false;
|
|
1494
|
+
for (const delay of [0, 50, 250, 1_000, 5_000]) {
|
|
1495
|
+
if (delay > 0)
|
|
1496
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1497
|
+
try {
|
|
1498
|
+
await reflectionStore.deleteRun(eventSessionID);
|
|
1499
|
+
deleted = true;
|
|
1500
|
+
}
|
|
1501
|
+
catch { /* bounded retry below */ }
|
|
1502
|
+
if (deleted)
|
|
1503
|
+
break;
|
|
1504
|
+
}
|
|
1505
|
+
if (deleted) {
|
|
1506
|
+
reflectionOwnedRoots.delete(eventSessionID);
|
|
1507
|
+
reflectionClosingRoots.delete(eventSessionID);
|
|
1508
|
+
}
|
|
1509
|
+
else
|
|
1510
|
+
reflectionWarning("reflection_cleanup_failed");
|
|
1511
|
+
}
|
|
1351
1512
|
evictSession(eventSessionID);
|
|
1352
1513
|
continuation.forgetSession(eventSessionID);
|
|
1353
1514
|
return;
|
|
@@ -1377,5 +1538,6 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
1377
1538
|
}
|
|
1378
1539
|
},
|
|
1379
1540
|
};
|
|
1541
|
+
return hooks;
|
|
1380
1542
|
};
|
|
1381
1543
|
export { InvalidModelTargetError, ModelRoutingDeniedError } from "./model-routing-hook.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ReflectionConfiguration } from "../plugin/config.js";
|
|
2
|
+
export type ReflectionLayer = "run" | "project" | "global";
|
|
3
|
+
export declare const DEFAULT_REFLECTION: ReflectionConfiguration;
|
|
4
|
+
export declare function reflectionEnabled(config: ReflectionConfiguration, env?: string | undefined): boolean;
|
|
5
|
+
export declare function projectKey(root: string): string;
|
|
6
|
+
export declare function configRoot(explicit?: string): string;
|
|
7
|
+
export declare function nearestPackageVersion(): Promise<string>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
export const DEFAULT_REFLECTION = Object.freeze({ enabled: false, layers: Object.freeze({ run: true, project: true, global: false }), maxInjectedEntries: 3, maxInjectedTokens: 500 });
|
|
7
|
+
export function reflectionEnabled(config, env = process.env.SORTIE_REFLECTION) {
|
|
8
|
+
return env === "0" ? false : config.enabled;
|
|
9
|
+
}
|
|
10
|
+
export function projectKey(root) {
|
|
11
|
+
const normalized = resolve(root).replaceAll("\\", "/").replace(/\/+$/u, "");
|
|
12
|
+
return createHash("sha256").update(process.platform === "win32" ? normalized.toLowerCase() : normalized).digest("hex").slice(0, 16);
|
|
13
|
+
}
|
|
14
|
+
export function configRoot(explicit) {
|
|
15
|
+
if (explicit)
|
|
16
|
+
return resolve(explicit);
|
|
17
|
+
if (process.env.XDG_CONFIG_HOME)
|
|
18
|
+
return join(process.env.XDG_CONFIG_HOME, "opencode");
|
|
19
|
+
return join(homedir(), ".config", "opencode");
|
|
20
|
+
}
|
|
21
|
+
export async function nearestPackageVersion() {
|
|
22
|
+
try {
|
|
23
|
+
const file = join(resolve(fileURLToPath(import.meta.url), "..", "..", ".."), "package.json");
|
|
24
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
25
|
+
if (typeof parsed.version !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(parsed.version))
|
|
26
|
+
throw new Error("invalid");
|
|
27
|
+
return parsed.version;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new Error("reflection_version_unavailable");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type ReflectionLayer } from "./config.js";
|
|
2
|
+
export type Evidence = "user-correction" | "repeated-process-failure" | "review-artifact-defect" | "retry-policy-violation";
|
|
3
|
+
export type EntryStatus = "active" | "promotable" | "promoted";
|
|
4
|
+
export interface ReflectionEntry {
|
|
5
|
+
id: string;
|
|
6
|
+
scope: string;
|
|
7
|
+
trigger: string;
|
|
8
|
+
cause: string;
|
|
9
|
+
prevention: string;
|
|
10
|
+
evidence: Evidence;
|
|
11
|
+
evidenceRef: string;
|
|
12
|
+
hits: number;
|
|
13
|
+
firstSeen: string;
|
|
14
|
+
lastSeen: string;
|
|
15
|
+
status: EntryStatus;
|
|
16
|
+
promotedRef?: string;
|
|
17
|
+
promotedAtVersion?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ReflectionBucket {
|
|
20
|
+
v: 1;
|
|
21
|
+
updatedAt: string;
|
|
22
|
+
entries: ReflectionEntry[];
|
|
23
|
+
}
|
|
24
|
+
export interface ReflectionStoreOptions {
|
|
25
|
+
now?: () => number;
|
|
26
|
+
sleep?: (ms: number) => Promise<void>;
|
|
27
|
+
warn?: (code: string) => void;
|
|
28
|
+
processAlive?: (pid: number) => boolean;
|
|
29
|
+
}
|
|
30
|
+
export declare class ReflectionError extends Error {
|
|
31
|
+
readonly code: string;
|
|
32
|
+
constructor(code: string);
|
|
33
|
+
}
|
|
34
|
+
export declare function normalizeField(value: unknown, max: number, scope?: boolean): string;
|
|
35
|
+
export declare function estimateInjectionTokens(text: string): number;
|
|
36
|
+
export declare class ReflectionStore {
|
|
37
|
+
private readonly root;
|
|
38
|
+
private readonly projectRoot;
|
|
39
|
+
private readonly warned;
|
|
40
|
+
private readonly now;
|
|
41
|
+
private readonly sleep;
|
|
42
|
+
private readonly warn;
|
|
43
|
+
private readonly processAlive;
|
|
44
|
+
constructor(root: string, projectRoot: string, options?: ReflectionStoreOptions);
|
|
45
|
+
private file;
|
|
46
|
+
private warning;
|
|
47
|
+
private artifacts;
|
|
48
|
+
private temps;
|
|
49
|
+
private load;
|
|
50
|
+
private validEntry;
|
|
51
|
+
private owner;
|
|
52
|
+
private live;
|
|
53
|
+
private heartbeat;
|
|
54
|
+
private acquire;
|
|
55
|
+
private stale;
|
|
56
|
+
private recoveryClaims;
|
|
57
|
+
private recoverGuard;
|
|
58
|
+
private recoverStale;
|
|
59
|
+
private lock;
|
|
60
|
+
private pathGuard;
|
|
61
|
+
private releaseGuard;
|
|
62
|
+
private withOwnership;
|
|
63
|
+
private release;
|
|
64
|
+
private save;
|
|
65
|
+
read(layer: ReflectionLayer, run?: string, currentVersion?: string): Promise<ReflectionBucket>;
|
|
66
|
+
private transaction;
|
|
67
|
+
record(layer: ReflectionLayer, run: string | undefined, input: Record<string, unknown>, currentVersion: string): Promise<ReflectionEntry>;
|
|
68
|
+
promote(layer: ReflectionLayer, run: string | undefined, id: string, ref: string, currentVersion: string): Promise<string>;
|
|
69
|
+
clear(layer: ReflectionLayer, run: string | undefined, confirmation: string, currentVersion: string): Promise<string>;
|
|
70
|
+
deleteRun(run: string): Promise<void>;
|
|
71
|
+
inject(layer: ReflectionLayer, run: string | undefined, max: number, tokenBudget: number, currentVersion: string): Promise<string>;
|
|
72
|
+
injectBuckets(buckets: readonly {
|
|
73
|
+
layer: ReflectionLayer;
|
|
74
|
+
run?: string;
|
|
75
|
+
}[], max: number, tokenBudget: number, currentVersion?: string): Promise<string>;
|
|
76
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmod, copyFile, mkdir, open, readFile, readdir, rename, rm, stat, unlink } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, basename } from "node:path";
|
|
4
|
+
import { projectKey } from "./config.js";
|
|
5
|
+
export class ReflectionError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code) {
|
|
8
|
+
super(code);
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const EMPTY = () => ({ v: 1, updatedAt: new Date(0).toISOString(), entries: [] });
|
|
13
|
+
const evidence = new Set(["user-correction", "repeated-process-failure", "review-artifact-defect", "retry-policy-violation"]);
|
|
14
|
+
const caps = { run: 12, project: 24, global: 16 };
|
|
15
|
+
const ages = { run: 6 * 3600000, project: 30 * 86400000, global: 90 * 86400000 };
|
|
16
|
+
const MAX_LEASE_AGE = 60_000;
|
|
17
|
+
const activeTokens = new Set();
|
|
18
|
+
const prohibited = /[\n\r\t\u0000-\u001f\u007f]|```|(?:api[_ -]?key|password|secret|token)|private\s+key|https?:\/\/|(?:^|\s)[+\-]{3}(?:\s|$)|\b[0-9a-f]{32,}\b|\b[A-Za-z0-9+/]{40,}={0,2}\b/iu;
|
|
19
|
+
const jsonBytes = (value) => Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
20
|
+
function version(value) { const match = /^(?:v)?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(value); if (!match)
|
|
21
|
+
return { core: [0, 0, 0] }; return { core: [Number(match[1]), Number(match[2]), Number(match[3])], ...(match[4] === undefined ? {} : { pre: match[4].split(".") }) }; }
|
|
22
|
+
function comparePre(left, right) { for (let index = 0; index < Math.max(left.length, right.length); index++) {
|
|
23
|
+
const a = left[index], b = right[index];
|
|
24
|
+
if (a === undefined)
|
|
25
|
+
return -1;
|
|
26
|
+
if (b === undefined)
|
|
27
|
+
return 1;
|
|
28
|
+
if (a === b)
|
|
29
|
+
continue;
|
|
30
|
+
const aNumber = /^\d+$/u.test(a), bNumber = /^\d+$/u.test(b);
|
|
31
|
+
if (aNumber && bNumber) {
|
|
32
|
+
const normalizedA = a.replace(/^0+(?=\d)/u, ""), normalizedB = b.replace(/^0+(?=\d)/u, "");
|
|
33
|
+
return normalizedA.length === normalizedB.length ? (normalizedA > normalizedB ? 1 : -1) : (normalizedA.length > normalizedB.length ? 1 : -1);
|
|
34
|
+
}
|
|
35
|
+
if (aNumber !== bNumber)
|
|
36
|
+
return aNumber ? -1 : 1;
|
|
37
|
+
return a > b ? 1 : -1;
|
|
38
|
+
} return 0; }
|
|
39
|
+
function greaterVersion(left, right) { const a = version(left), b = version(right); for (let index = 0; index < 3; index++) {
|
|
40
|
+
if (a.core[index] !== b.core[index])
|
|
41
|
+
return a.core[index] > b.core[index];
|
|
42
|
+
} if (a.pre === undefined || b.pre === undefined)
|
|
43
|
+
return a.pre === undefined && b.pre !== undefined; return comparePre(a.pre, b.pre) > 0; }
|
|
44
|
+
function redact(value) { if (!/[\\/]/u.test(value) || /:\/\//u.test(value))
|
|
45
|
+
return value; let result = value.replace(/[A-Za-z]:[\\/](?:[^\s\\/]+[\\/])*([^\s\\/]+)/gu, "$1"); return result.replace(/(^|\s)\/(?:[^\s/]+\/)*([^\s/]+)/gu, (_match, lead, leaf) => `${lead}${leaf}`).replace(/(^|\s)(?:[^\s\\/]+[\\/])+([^\s\\/]+)/gu, (_match, lead, leaf) => `${lead}${leaf}`); }
|
|
46
|
+
export function normalizeField(value, max, scope = false) { if (typeof value !== "string" || value.length === 0)
|
|
47
|
+
throw new ReflectionError("reflection_invalid_input"); const clean = scope ? value.toLowerCase() : redact(value); if (clean.length > max || prohibited.test(clean) || (scope && !/^[a-z0-9-]+$/u.test(clean)))
|
|
48
|
+
throw new ReflectionError("reflection_invalid_input"); return clean; }
|
|
49
|
+
function shortRef(value) { if (typeof value !== "string" || value.length === 0 || value.length > 64 || /[\\/]|\.log\b|(?:log|line|stack\s+trace|diff)\s*[:#]?/iu.test(value) || prohibited.test(value))
|
|
50
|
+
throw new ReflectionError("reflection_invalid_input"); return value; }
|
|
51
|
+
export function estimateInjectionTokens(text) { return Buffer.byteLength(text, "utf8"); }
|
|
52
|
+
function ulid() { const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; let timestamp = Date.now(), time = ""; for (let i = 0; i < 10; i++) {
|
|
53
|
+
time = alphabet[timestamp % 32] + time;
|
|
54
|
+
timestamp = Math.floor(timestamp / 32);
|
|
55
|
+
} let random = ""; for (const byte of randomBytes(16))
|
|
56
|
+
random += alphabet[byte & 31]; return time + random; }
|
|
57
|
+
function pathFor(root, layer, projectRoot, run) { if (layer === "run" && (typeof run !== "string" || !/^[A-Za-z0-9_-]{1,128}$/u.test(run)))
|
|
58
|
+
throw new ReflectionError("reflection_invalid_run"); return layer === "global" ? join(root, "global.json") : layer === "project" ? join(root, "projects", `${projectKey(projectRoot)}.json`) : join(root, "runs", `${run}.json`); }
|
|
59
|
+
export class ReflectionStore {
|
|
60
|
+
root;
|
|
61
|
+
projectRoot;
|
|
62
|
+
warned = new Set();
|
|
63
|
+
now;
|
|
64
|
+
sleep;
|
|
65
|
+
warn;
|
|
66
|
+
processAlive;
|
|
67
|
+
constructor(root, projectRoot, options = {}) {
|
|
68
|
+
this.root = root;
|
|
69
|
+
this.projectRoot = projectRoot;
|
|
70
|
+
this.now = options.now ?? Date.now;
|
|
71
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
72
|
+
this.warn = options.warn ?? (() => undefined);
|
|
73
|
+
this.processAlive = options.processAlive ?? ((pid) => { try {
|
|
74
|
+
process.kill(pid, 0);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
} });
|
|
80
|
+
}
|
|
81
|
+
file(layer, run) { return pathFor(this.root, layer, this.projectRoot, run); }
|
|
82
|
+
warning(file, code) { const key = `${file}:${code}`; if (!this.warned.has(key)) {
|
|
83
|
+
this.warned.add(key);
|
|
84
|
+
this.warn(code);
|
|
85
|
+
} }
|
|
86
|
+
async artifacts(file, olderThan) { const dir = dirname(file), prefix = `${basename(file)}.`; const names = await readdir(dir).catch(() => []); for (const name of names) {
|
|
87
|
+
if (!name.startsWith(prefix) || name.startsWith(`${basename(file)}.lock`))
|
|
88
|
+
continue;
|
|
89
|
+
const path = join(dir, name), info = await stat(path).catch(() => undefined);
|
|
90
|
+
if (olderThan !== undefined && (info === undefined || info.mtimeMs >= olderThan))
|
|
91
|
+
continue;
|
|
92
|
+
await rm(path, { force: true }).catch(() => undefined);
|
|
93
|
+
} }
|
|
94
|
+
async temps(file) { const dir = dirname(file); for (const name of await readdir(dir).catch(() => []))
|
|
95
|
+
if (name.startsWith(`${basename(file)}.`) && name.endsWith(".tmp"))
|
|
96
|
+
await rm(join(dir, name), { force: true }).catch(() => undefined); }
|
|
97
|
+
async load(file, layer, currentVersion, lock) {
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
100
|
+
if (parsed.v !== 0 && parsed.v !== 1 && (parsed.v ?? 0) > 1) {
|
|
101
|
+
this.warning(file, "reflection_future_schema");
|
|
102
|
+
return { bucket: EMPTY(), future: true, legacy: false, changed: false };
|
|
103
|
+
}
|
|
104
|
+
if (parsed.v !== 0 && parsed.v !== 1 || typeof parsed.updatedAt !== "string" || Number.isNaN(Date.parse(parsed.updatedAt)) || !Array.isArray(parsed.entries))
|
|
105
|
+
throw new Error("invalid");
|
|
106
|
+
const bucket = { v: 1, updatedAt: parsed.updatedAt, entries: parsed.entries };
|
|
107
|
+
if (!bucket.entries.every((entry) => this.validEntry(entry)))
|
|
108
|
+
throw new Error("invalid");
|
|
109
|
+
const cutoff = this.now() - ages[layer];
|
|
110
|
+
const before = bucket.entries.length;
|
|
111
|
+
bucket.entries = bucket.entries.filter((entry) => Date.parse(entry.lastSeen) >= cutoff && !(currentVersion && entry.status === "promoted" && entry.promotedAtVersion && greaterVersion(currentVersion, entry.promotedAtVersion)));
|
|
112
|
+
return { bucket, future: false, legacy: parsed.v === 0, changed: parsed.v === 0 || before !== bucket.entries.length };
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error?.code === "ENOENT")
|
|
116
|
+
return { bucket: EMPTY(), future: false, legacy: false, changed: false };
|
|
117
|
+
await this.withOwnership(lock, async () => { await rename(file, `${file}.corrupt.${this.now()}`); });
|
|
118
|
+
this.warning(file, "reflection_corrupt_json");
|
|
119
|
+
return { bucket: EMPTY(), future: false, legacy: false, changed: false };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
validEntry(entry) { if (!entry || typeof entry !== "object" || jsonBytes(entry) > 512)
|
|
123
|
+
return false; const item = entry; try {
|
|
124
|
+
normalizeField(item.scope, 80, true);
|
|
125
|
+
normalizeField(item.trigger, 160);
|
|
126
|
+
normalizeField(item.cause, 360);
|
|
127
|
+
normalizeField(item.prevention, 500);
|
|
128
|
+
shortRef(item.evidenceRef);
|
|
129
|
+
if (item.status === "promoted") {
|
|
130
|
+
shortRef(item.promotedRef);
|
|
131
|
+
if (typeof item.promotedAtVersion !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(item.promotedAtVersion))
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return /^[0-9A-HJKMNP-TV-Z]{26}$/u.test(item.id) && evidence.has(item.evidence) && ["active", "promotable", "promoted"].includes(item.status) && Number.isInteger(item.hits) && item.hits > 0 && !Number.isNaN(Date.parse(item.lastSeen)) && !Number.isNaN(Date.parse(item.firstSeen));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return false;
|
|
138
|
+
} }
|
|
139
|
+
owner(token) { return JSON.stringify({ pid: process.pid, token }); }
|
|
140
|
+
live(raw) { try {
|
|
141
|
+
const owner = JSON.parse(raw ?? "");
|
|
142
|
+
if (typeof owner.token !== "string" || typeof owner.pid !== "number" || !Number.isInteger(owner.pid))
|
|
143
|
+
return false;
|
|
144
|
+
return owner.pid === process.pid ? activeTokens.has(owner.token) : this.processAlive(owner.pid);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return false;
|
|
148
|
+
} }
|
|
149
|
+
async heartbeat(handle, owner) { await handle.truncate(0); await handle.write(owner, 0, "utf8"); await handle.sync(); }
|
|
150
|
+
async acquire(path) { const token = randomBytes(24).toString("hex"), owner = this.owner(token); const handle = await open(path, "wx", 0o600); activeTokens.add(token); try {
|
|
151
|
+
await this.heartbeat(handle, owner);
|
|
152
|
+
const timer = setInterval(() => { void this.heartbeat(handle, owner).catch(() => undefined); }, 1000);
|
|
153
|
+
timer.unref();
|
|
154
|
+
return { handle, path, token, timer };
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
const opened = await handle.stat().catch(() => undefined), removedOpen = await unlink(path).then(() => true).catch(() => false);
|
|
158
|
+
await handle.close().catch(() => undefined);
|
|
159
|
+
if (!removedOpen && opened !== undefined) {
|
|
160
|
+
const current = await stat(path).catch(() => undefined);
|
|
161
|
+
if (current !== undefined && current.dev === opened.dev && current.ino === opened.ino && Date.now() - current.mtimeMs <= 5000)
|
|
162
|
+
await unlink(path).catch(() => undefined);
|
|
163
|
+
}
|
|
164
|
+
activeTokens.delete(token);
|
|
165
|
+
throw error;
|
|
166
|
+
} }
|
|
167
|
+
async stale(path, finiteLease) { const info = await stat(path).catch(() => undefined); if (!info)
|
|
168
|
+
return false; const age = this.now() - info.mtimeMs; return age > 5000 && ((finiteLease && age > MAX_LEASE_AGE) || !this.live(await readFile(path, "utf8").catch(() => undefined))); }
|
|
169
|
+
async recoveryClaims(path) { const prefix = `${basename(path)}.recover.`; const claims = (await readdir(dirname(path)).catch(() => [])).filter((name) => name.startsWith(prefix)).map((name) => join(dirname(path), name)); for (const claim of claims)
|
|
170
|
+
if (await this.stale(claim, false))
|
|
171
|
+
await unlink(claim).catch(() => undefined); return (await readdir(dirname(path)).catch(() => [])).filter((name) => name.startsWith(prefix)).map((name) => join(dirname(path), name)).sort(); }
|
|
172
|
+
async recoverGuard(path) { if (!await this.stale(path, false))
|
|
173
|
+
return false; const claim = await this.acquire(`${path}.recover.${randomBytes(12).toString("hex")}`); try {
|
|
174
|
+
const claims = await this.recoveryClaims(path);
|
|
175
|
+
if (claims[0] !== claim.path || !await this.stale(path, false))
|
|
176
|
+
return false;
|
|
177
|
+
await unlink(path).catch(() => undefined);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
await this.releaseGuard(claim);
|
|
182
|
+
} }
|
|
183
|
+
async recoverStale(path) { if (!await this.stale(path, true))
|
|
184
|
+
return false; const guard = await this.pathGuard(path); if (!guard)
|
|
185
|
+
return false; try {
|
|
186
|
+
if (!await this.stale(path, true))
|
|
187
|
+
return false;
|
|
188
|
+
await unlink(path).catch(() => undefined);
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
await this.releaseGuard(guard);
|
|
193
|
+
} }
|
|
194
|
+
async lock(file) { const path = `${file}.lock`; for (let attempt = 0; attempt < 3; attempt++) {
|
|
195
|
+
try {
|
|
196
|
+
return await this.acquire(path);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (error?.code !== "EEXIST")
|
|
200
|
+
throw new ReflectionError("reflection_lock_timeout");
|
|
201
|
+
await this.recoverStale(path);
|
|
202
|
+
await this.sleep(10 * 2 ** attempt);
|
|
203
|
+
}
|
|
204
|
+
} throw new ReflectionError("reflection_lock_timeout"); }
|
|
205
|
+
async pathGuard(path) { const guard = `${path}.guard`; for (let attempt = 0; attempt < 3; attempt++) {
|
|
206
|
+
try {
|
|
207
|
+
if ((await this.recoveryClaims(guard)).length > 0) {
|
|
208
|
+
await this.sleep(2 ** attempt);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const candidate = await this.acquire(guard);
|
|
212
|
+
if ((await this.recoveryClaims(guard)).length === 0)
|
|
213
|
+
return candidate;
|
|
214
|
+
await this.releaseGuard(candidate);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
if (error?.code !== "EEXIST")
|
|
218
|
+
return undefined;
|
|
219
|
+
await this.recoverGuard(guard);
|
|
220
|
+
}
|
|
221
|
+
await this.sleep(2 ** attempt);
|
|
222
|
+
} return undefined; }
|
|
223
|
+
async releaseGuard(guard) { clearInterval(guard.timer); await guard.handle.close().catch(() => undefined); try {
|
|
224
|
+
if (await readFile(guard.path, "utf8").catch(() => undefined) === this.owner(guard.token))
|
|
225
|
+
await unlink(guard.path).catch(() => undefined);
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
activeTokens.delete(guard.token);
|
|
229
|
+
} }
|
|
230
|
+
async withOwnership(lock, action) { const guard = await this.pathGuard(lock.path); if (!guard)
|
|
231
|
+
throw new ReflectionError("reflection_lock_lost"); try {
|
|
232
|
+
if (await readFile(lock.path, "utf8").catch(() => undefined) !== this.owner(lock.token))
|
|
233
|
+
throw new ReflectionError("reflection_lock_lost");
|
|
234
|
+
return await action();
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
await this.releaseGuard(guard);
|
|
238
|
+
} }
|
|
239
|
+
async release(lock) { clearInterval(lock.timer); await lock.handle.close().catch(() => undefined); try {
|
|
240
|
+
await this.withOwnership(lock, async () => { await unlink(lock.path); });
|
|
241
|
+
}
|
|
242
|
+
catch { /* a newer owner or stale recovery owns the path */ }
|
|
243
|
+
finally {
|
|
244
|
+
activeTokens.delete(lock.token);
|
|
245
|
+
} }
|
|
246
|
+
async save(file, bucket, lock) { if (bucket.entries.some((entry) => jsonBytes(entry) > 512) || jsonBytes(bucket) > 32768)
|
|
247
|
+
throw new ReflectionError("reflection_size_limit"); const temp = `${file}.${ulid()}.tmp`; const handle = await open(temp, "w", 0o600); try {
|
|
248
|
+
await handle.writeFile(JSON.stringify(bucket));
|
|
249
|
+
await handle.sync();
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
await handle.close();
|
|
253
|
+
} await chmod(temp, 0o600).catch(() => undefined); try {
|
|
254
|
+
await this.withOwnership(lock, async () => { await rename(temp, file); });
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
await rm(temp, { force: true });
|
|
258
|
+
throw error;
|
|
259
|
+
} const dir = await open(dirname(file), "r").catch(() => undefined); await dir?.sync().catch(() => undefined); await dir?.close().catch(() => undefined); }
|
|
260
|
+
async read(layer, run, currentVersion) { const file = this.file(layer, run); if (!await stat(dirname(file)).catch(() => undefined))
|
|
261
|
+
return EMPTY(); const lock = await this.lock(file); try {
|
|
262
|
+
await this.withOwnership(lock, async () => { await this.temps(file); await this.artifacts(file, this.now() - ages[layer]); });
|
|
263
|
+
const loaded = await this.load(file, layer, currentVersion, lock);
|
|
264
|
+
if (loaded.future)
|
|
265
|
+
return loaded.bucket;
|
|
266
|
+
if (loaded.changed) {
|
|
267
|
+
if (loaded.legacy)
|
|
268
|
+
await this.withOwnership(lock, async () => { await copyFile(file, `${file}.v0.bak`); });
|
|
269
|
+
await this.save(file, loaded.bucket, lock);
|
|
270
|
+
}
|
|
271
|
+
return loaded.bucket;
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
await this.release(lock);
|
|
275
|
+
} }
|
|
276
|
+
async transaction(layer, run, currentVersion, fn) { const file = this.file(layer, run); await mkdir(dirname(file), { recursive: true, mode: 0o700 }); await chmod(dirname(file), 0o700).catch(() => undefined); const lock = await this.lock(file); try {
|
|
277
|
+
const loaded = await this.load(file, layer, currentVersion, lock);
|
|
278
|
+
if (loaded.future)
|
|
279
|
+
throw new ReflectionError("reflection_future_schema");
|
|
280
|
+
if (loaded.legacy)
|
|
281
|
+
await this.withOwnership(lock, async () => { await copyFile(file, `${file}.v0.bak`); });
|
|
282
|
+
const result = await fn(loaded.bucket, file, lock);
|
|
283
|
+
await this.save(file, loaded.bucket, lock);
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
await this.release(lock);
|
|
288
|
+
} }
|
|
289
|
+
async record(layer, run, input, currentVersion) { if (!evidence.has(input.evidence))
|
|
290
|
+
throw new ReflectionError("reflection_invalid_evidence"); const now = new Date(this.now()).toISOString(); const entry = { id: ulid(), scope: normalizeField(input.scope, 80, true), trigger: normalizeField(input.trigger, 160), cause: normalizeField(input.cause, 360), prevention: normalizeField(input.prevention, 500), evidence: input.evidence, evidenceRef: shortRef(input.evidenceRef), hits: 1, firstSeen: now, lastSeen: now, status: input.evidence === "user-correction" ? "promotable" : "active" }; if (jsonBytes(entry) > 512)
|
|
291
|
+
throw new ReflectionError("reflection_size_limit"); return this.transaction(layer, run, currentVersion, async (bucket) => { const same = bucket.entries.find((item) => item.scope === entry.scope); if (same) {
|
|
292
|
+
same.hits++;
|
|
293
|
+
same.lastSeen = now;
|
|
294
|
+
same.status = same.hits >= 2 || same.evidence === "user-correction" ? "promotable" : same.status;
|
|
295
|
+
bucket.updatedAt = now;
|
|
296
|
+
return same;
|
|
297
|
+
} bucket.entries.push(entry); while (bucket.entries.length > caps[layer])
|
|
298
|
+
bucket.entries.sort((a, b) => a.hits - b.hits || a.lastSeen.localeCompare(b.lastSeen) || a.id.localeCompare(b.id)).shift(); bucket.updatedAt = now; return entry; }); }
|
|
299
|
+
async promote(layer, run, id, ref, currentVersion) { return this.transaction(layer, run, currentVersion, async (bucket) => { const entry = bucket.entries.find((item) => item.id === id); if (!entry)
|
|
300
|
+
throw new ReflectionError("reflection_not_found"); if (entry.status !== "promotable")
|
|
301
|
+
throw new ReflectionError("reflection_not_promotable"); entry.status = "promoted"; entry.promotedRef = shortRef(ref); entry.promotedAtVersion = currentVersion; bucket.updatedAt = new Date(this.now()).toISOString(); return "promoted"; }); }
|
|
302
|
+
async clear(layer, run, confirmation, currentVersion) { if ((layer !== "run") && confirmation !== "CLEAR_REFLECTIONS")
|
|
303
|
+
throw new ReflectionError("reflection_confirmation_required"); return this.transaction(layer, run, currentVersion, async (bucket, file, lock) => { bucket.entries = []; bucket.updatedAt = new Date(this.now()).toISOString(); await this.withOwnership(lock, async () => { await this.artifacts(file); }); return "cleared"; }); }
|
|
304
|
+
async deleteRun(run) { const file = this.file("run", run); if (!await stat(dirname(file)).catch(() => undefined))
|
|
305
|
+
return; const lock = await this.lock(file); try {
|
|
306
|
+
await this.withOwnership(lock, async () => { await this.artifacts(file); await rm(file, { force: true }); });
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
await this.release(lock);
|
|
310
|
+
} }
|
|
311
|
+
async inject(layer, run, max, tokenBudget, currentVersion) { return this.injectBuckets([{ layer, run }], max, tokenBudget, currentVersion); }
|
|
312
|
+
async injectBuckets(buckets, max, tokenBudget, currentVersion) { const byScope = new Map(); for (const { layer, run } of buckets)
|
|
313
|
+
for (const entry of (await this.read(layer, run, currentVersion)).entries)
|
|
314
|
+
if (!byScope.has(entry.scope))
|
|
315
|
+
byScope.set(entry.scope, entry); const entries = [...byScope.values()].sort((a, b) => b.hits - a.hits || a.lastSeen.localeCompare(b.lastSeen) || a.id.localeCompare(b.id)); const lines = []; for (const entry of entries) {
|
|
316
|
+
const line = `- ${entry.scope}: ${entry.prevention}`;
|
|
317
|
+
if (lines.length >= max || estimateInjectionTokens([...lines, line].join("\n")) > tokenBudget)
|
|
318
|
+
break;
|
|
319
|
+
lines.push(line);
|
|
320
|
+
} return lines.join("\n"); }
|
|
321
|
+
}
|