@aexol/spectral 0.9.20 → 0.9.24
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/agents.d.ts +17 -1
- package/dist/agent/agents.d.ts.map +1 -1
- package/dist/agent/agents.js +42 -2
- package/dist/agent/index.d.ts +3 -1
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +40 -12
- package/dist/cli.js +9 -3
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +14 -5
- package/dist/memory/index.d.ts.map +1 -1
- package/dist/memory/index.js +2 -0
- package/dist/memory/tool-output-compressor.d.ts +61 -0
- package/dist/memory/tool-output-compressor.d.ts.map +1 -0
- package/dist/memory/tool-output-compressor.js +569 -0
- package/dist/relay/dispatcher.d.ts +3 -1
- package/dist/relay/dispatcher.d.ts.map +1 -1
- package/dist/relay/dispatcher.js +23 -0
- package/dist/sdk/coding-agent/config.d.ts.map +1 -1
- package/dist/sdk/coding-agent/config.js +10 -1
- package/dist/server/agent-bridge.d.ts +8 -0
- package/dist/server/agent-bridge.d.ts.map +1 -1
- package/dist/server/agent-bridge.js +29 -1
- package/dist/server/handlers/agent-settings.d.ts +49 -0
- package/dist/server/handlers/agent-settings.d.ts.map +1 -0
- package/dist/server/handlers/agent-settings.js +109 -0
- package/dist/server/handlers/settings-lock.d.ts +36 -0
- package/dist/server/handlers/settings-lock.d.ts.map +1 -0
- package/dist/server/handlers/settings-lock.js +89 -0
- package/dist/server/handlers/settings.d.ts +0 -11
- package/dist/server/handlers/settings.d.ts.map +1 -1
- package/dist/server/handlers/settings.js +5 -85
- package/package.json +1 -1
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
* instance is reused across `prompt()` calls).
|
|
50
50
|
*/
|
|
51
51
|
import { type AgentSessionEvent } from "../sdk/coding-agent/index.js";
|
|
52
|
+
import { type AgentSettings } from "./handlers/agent-settings.js";
|
|
52
53
|
import { fetchAllowedModels as defaultFetchAllowedModels } from "../relay/models-fetch.js";
|
|
53
54
|
import type { SessionMemorySnapshot } from "./storage.js";
|
|
54
55
|
import type { ImageAttachment, ServerEvent, WireMessage } from "./wire.js";
|
|
@@ -116,6 +117,13 @@ export interface AgentBridgeOptions {
|
|
|
116
117
|
* can run without a live backend.
|
|
117
118
|
*/
|
|
118
119
|
fetchAllowedModels?: FetchAllowedModelsFn;
|
|
120
|
+
/**
|
|
121
|
+
* Agent-behavior toggles (primary-agent injection, subagent tool).
|
|
122
|
+
* When omitted, settings are loaded from `settings.json` at `start()`
|
|
123
|
+
* time. Changes take effect on the next session/bridge — a running
|
|
124
|
+
* session is not hot-reloaded.
|
|
125
|
+
*/
|
|
126
|
+
agentSettings?: AgentSettings;
|
|
119
127
|
}
|
|
120
128
|
export declare class AgentBridge {
|
|
121
129
|
private session?;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-bridge.d.ts","sourceRoot":"","sources":["../../src/server/agent-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAcH,OAAO,EAQL,KAAK,iBAAiB,EAEvB,MAAM,8BAA8B,CAAC;
|
|
1
|
+
{"version":3,"file":"agent-bridge.d.ts","sourceRoot":"","sources":["../../src/server/agent-bridge.ts"],"names":[],"mappings":"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"}
|
|
@@ -55,6 +55,8 @@ import { existsSync, statSync } from "node:fs";
|
|
|
55
55
|
import { dirname, join, resolve } from "node:path";
|
|
56
56
|
import { fileURLToPath } from "node:url";
|
|
57
57
|
import aexolMcpExtension from "../extensions/aexol-mcp.js";
|
|
58
|
+
import { discoverPrimaryAgent, injectPrimaryAgentPrompt } from "../agent/agents.js";
|
|
59
|
+
import { loadAgentSettings } from "./handlers/agent-settings.js";
|
|
58
60
|
import kanbanBridgeExtension from "../extensions/kanban-bridge.js";
|
|
59
61
|
import spectralVisionExtension from "../extensions/spectral-vision-fallback.js";
|
|
60
62
|
import subagentExt from "../agent/index.js";
|
|
@@ -417,7 +419,30 @@ export class AgentBridge {
|
|
|
417
419
|
async start() {
|
|
418
420
|
if (this.disposed)
|
|
419
421
|
throw new Error("AgentBridge already disposed");
|
|
420
|
-
const extensionFactories = [
|
|
422
|
+
const extensionFactories = [
|
|
423
|
+
// Primary-agent prompt is injected per-turn via before_agent_start so the
|
|
424
|
+
// primaryAgentEnabled toggle takes effect mid-session (next user message).
|
|
425
|
+
async (ext) => {
|
|
426
|
+
ext.on("before_agent_start", (event) => {
|
|
427
|
+
const settings = loadAgentSettings(this.opts.cwd, this.opts.agentDir);
|
|
428
|
+
if (!settings.primaryAgentEnabled)
|
|
429
|
+
return;
|
|
430
|
+
const primaryAgent = discoverPrimaryAgent(this.opts.cwd, "both", this.opts.agentDir);
|
|
431
|
+
const injected = injectPrimaryAgentPrompt(event.systemPrompt, primaryAgent);
|
|
432
|
+
if (injected === event.systemPrompt)
|
|
433
|
+
return;
|
|
434
|
+
return { systemPrompt: injected };
|
|
435
|
+
});
|
|
436
|
+
},
|
|
437
|
+
aexolMcpExtension, kanbanBridgeExtension, async (ext) => { spectralVisionExtension(ext); }, async (ext) => { designerExtension(ext); }, async (ext) => { observationalMemory(ext); }
|
|
438
|
+
];
|
|
439
|
+
// Agent-behavior toggles. Loaded once at bridge construction so changes
|
|
440
|
+
// take effect on the next session (not mid-turn).
|
|
441
|
+
const agentSettings = this.opts.agentSettings ?? loadAgentSettings(this.opts.cwd, this.opts.agentDir);
|
|
442
|
+
// Only register the subagent tool when the toggle is enabled.
|
|
443
|
+
if (agentSettings.subagentsEnabled) {
|
|
444
|
+
extensionFactories.splice(4, 0, async (ext) => { subagentExt(ext, { disabledAgents: agentSettings.disabledSubagents }); });
|
|
445
|
+
}
|
|
421
446
|
// Load spectral-mcp-adapter via jiti so tsc never crawls its .ts files in
|
|
422
447
|
// node_modules. The static `import` was causing tsc to type-check
|
|
423
448
|
// spectral-mcp-adapter's source and fail the build on its type errors.
|
|
@@ -448,6 +473,9 @@ export class AgentBridge {
|
|
|
448
473
|
// .opencode/skills and .aexol/skills so OpenCode/Codex/Aexol skills
|
|
449
474
|
// work out of the box.
|
|
450
475
|
const extraSkillPaths = collectAncestorSkillDirs(this.opts.cwd, [".opencode/skills", ".aexol/skills"]);
|
|
476
|
+
// Primary-agent prompt injection happens per-turn via the before_agent_start
|
|
477
|
+
// extension registered above, so the primaryAgentEnabled toggle takes effect
|
|
478
|
+
// mid-session (on the next user message) rather than only at session start.
|
|
451
479
|
const resourceLoader = new DefaultResourceLoader({
|
|
452
480
|
cwd: this.opts.cwd,
|
|
453
481
|
agentDir: this.opts.agentDir ?? `${process.env.HOME ?? ""}/.spectral/agent`,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-behavior toggles stored under the `agent` namespace in
|
|
3
|
+
* `settings.json`. These control whether the primary-agent system prompt
|
|
4
|
+
* is injected and whether the `subagent` tool is registered.
|
|
5
|
+
*
|
|
6
|
+
* Both default to `true` so existing behaviour is preserved when the
|
|
7
|
+
* namespace is absent.
|
|
8
|
+
*/
|
|
9
|
+
export interface AgentSettings {
|
|
10
|
+
primaryAgentEnabled: boolean;
|
|
11
|
+
subagentsEnabled: boolean;
|
|
12
|
+
/** Names of individual subagents that have been explicitly disabled. */
|
|
13
|
+
disabledSubagents: string[];
|
|
14
|
+
}
|
|
15
|
+
export declare const AGENT_SETTINGS_DEFAULTS: AgentSettings;
|
|
16
|
+
export interface PutAgentSettingsInput {
|
|
17
|
+
primaryAgentEnabled?: unknown;
|
|
18
|
+
subagentsEnabled?: unknown;
|
|
19
|
+
disabledSubagents?: unknown;
|
|
20
|
+
}
|
|
21
|
+
export interface PutAgentSettingsOutput extends AgentSettings {
|
|
22
|
+
ok: true;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Load agent settings by merging defaults ← global ← project overrides.
|
|
26
|
+
* Mirrors the precedence pattern used by `loadConfig` for observational
|
|
27
|
+
* memory config.
|
|
28
|
+
*/
|
|
29
|
+
export declare function loadAgentSettings(cwd: string, agentDir?: string): AgentSettings;
|
|
30
|
+
export declare function handleGetAgentSettings(cwd: string, agentDir?: string): AgentSettings;
|
|
31
|
+
/**
|
|
32
|
+
* Persist a partial update to the `agent` namespace in the **global**
|
|
33
|
+
* `settings.json` (per-machine, matching the existing memory-settings
|
|
34
|
+
* behaviour). Project-level overrides are read-only from the REST API.
|
|
35
|
+
*
|
|
36
|
+
* Only boolean values are accepted for the two known keys; anything else
|
|
37
|
+
* is a `400 BadRequestError`.
|
|
38
|
+
*/
|
|
39
|
+
export declare function handlePutAgentSettings(cwd: string, body: PutAgentSettingsInput, agentDir?: string): PutAgentSettingsOutput;
|
|
40
|
+
export interface AgentInfo {
|
|
41
|
+
name: string;
|
|
42
|
+
description: string;
|
|
43
|
+
mode: string;
|
|
44
|
+
source: string;
|
|
45
|
+
}
|
|
46
|
+
export declare function handleListAgents(cwd: string, agentDir?: string): {
|
|
47
|
+
agents: AgentInfo[];
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=agent-settings.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-settings.d.ts","sourceRoot":"","sources":["../../../src/server/handlers/agent-settings.ts"],"names":[],"mappings":"AAOA;;;;;;;GAOG;AACH,MAAM,WAAW,aAAa;IAC5B,mBAAmB,EAAE,OAAO,CAAC;IAC7B,gBAAgB,EAAE,OAAO,CAAC;IAC1B,wEAAwE;IACxE,iBAAiB,EAAE,MAAM,EAAE,CAAC;CAC7B;AAED,eAAO,MAAM,uBAAuB,EAAE,aAIrC,CAAC;AAKF,MAAM,WAAW,qBAAqB;IACpC,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,MAAM,WAAW,sBAAuB,SAAQ,aAAa;IAC3D,EAAE,EAAE,IAAI,CAAC;CACV;AA+BD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,EACX,QAAQ,CAAC,EAAE,MAAM,GAChB,aAAa,CAQf;AAED,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,EACX,QAAQ,CAAC,EAAE,MAAM,GAChB,aAAa,CAEf;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,qBAAqB,EAC3B,QAAQ,CAAC,EAAE,MAAM,GAChB,sBAAsB,CAuCxB;AAMD,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,MAAM,EACX,QAAQ,CAAC,EAAE,MAAM,GAChB;IAAE,MAAM,EAAE,SAAS,EAAE,CAAA;CAAE,CAUzB"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "../../sdk/coding-agent/index.js";
|
|
4
|
+
import { discoverAgents } from "../../agent/agents.js";
|
|
5
|
+
import { BadRequestError } from "./errors.js";
|
|
6
|
+
import { withSettingsLock } from "./settings-lock.js";
|
|
7
|
+
export const AGENT_SETTINGS_DEFAULTS = {
|
|
8
|
+
primaryAgentEnabled: true,
|
|
9
|
+
subagentsEnabled: true,
|
|
10
|
+
disabledSubagents: [],
|
|
11
|
+
};
|
|
12
|
+
const AGENT_NAMESPACE = "agent";
|
|
13
|
+
const ALLOWED_KEYS = new Set(["primaryAgentEnabled", "subagentsEnabled", "disabledSubagents"]);
|
|
14
|
+
/**
|
|
15
|
+
* Read the `agent` namespace from a single `settings.json` file.
|
|
16
|
+
* Returns `{}` when the file or namespace is absent or unparseable.
|
|
17
|
+
*/
|
|
18
|
+
function readAgentNamespace(path) {
|
|
19
|
+
if (!existsSync(path))
|
|
20
|
+
return {};
|
|
21
|
+
try {
|
|
22
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
23
|
+
const ns = raw[AGENT_NAMESPACE];
|
|
24
|
+
if (!ns || typeof ns !== "object")
|
|
25
|
+
return {};
|
|
26
|
+
const obj = ns;
|
|
27
|
+
const result = {};
|
|
28
|
+
if (typeof obj.primaryAgentEnabled === "boolean") {
|
|
29
|
+
result.primaryAgentEnabled = obj.primaryAgentEnabled;
|
|
30
|
+
}
|
|
31
|
+
if (typeof obj.subagentsEnabled === "boolean") {
|
|
32
|
+
result.subagentsEnabled = obj.subagentsEnabled;
|
|
33
|
+
}
|
|
34
|
+
if (Array.isArray(obj.disabledSubagents)) {
|
|
35
|
+
result.disabledSubagents = obj.disabledSubagents.filter((s) => typeof s === "string");
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Load agent settings by merging defaults ← global ← project overrides.
|
|
45
|
+
* Mirrors the precedence pattern used by `loadConfig` for observational
|
|
46
|
+
* memory config.
|
|
47
|
+
*/
|
|
48
|
+
export function loadAgentSettings(cwd, agentDir) {
|
|
49
|
+
const globalPath = join(agentDir ?? getAgentDir(), "settings.json");
|
|
50
|
+
const projectPath = join(cwd, ".spectral", "settings.json");
|
|
51
|
+
return {
|
|
52
|
+
...AGENT_SETTINGS_DEFAULTS,
|
|
53
|
+
...readAgentNamespace(globalPath),
|
|
54
|
+
...readAgentNamespace(projectPath),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function handleGetAgentSettings(cwd, agentDir) {
|
|
58
|
+
return loadAgentSettings(cwd, agentDir);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Persist a partial update to the `agent` namespace in the **global**
|
|
62
|
+
* `settings.json` (per-machine, matching the existing memory-settings
|
|
63
|
+
* behaviour). Project-level overrides are read-only from the REST API.
|
|
64
|
+
*
|
|
65
|
+
* Only boolean values are accepted for the two known keys; anything else
|
|
66
|
+
* is a `400 BadRequestError`.
|
|
67
|
+
*/
|
|
68
|
+
export function handlePutAgentSettings(cwd, body, agentDir) {
|
|
69
|
+
const patch = {};
|
|
70
|
+
for (const key of ["primaryAgentEnabled", "subagentsEnabled"]) {
|
|
71
|
+
if (body[key] !== undefined) {
|
|
72
|
+
if (typeof body[key] !== "boolean") {
|
|
73
|
+
throw new BadRequestError(`agent setting "${key}" must be a boolean, got ${typeof body[key]}`);
|
|
74
|
+
}
|
|
75
|
+
patch[key] = body[key];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// disabledSubagents is a full-replacement string array
|
|
79
|
+
if (body.disabledSubagents !== undefined) {
|
|
80
|
+
if (!Array.isArray(body.disabledSubagents) ||
|
|
81
|
+
!body.disabledSubagents.every((s) => typeof s === "string")) {
|
|
82
|
+
throw new BadRequestError(`agent setting "disabledSubagents" must be a string array`);
|
|
83
|
+
}
|
|
84
|
+
patch.disabledSubagents = body.disabledSubagents;
|
|
85
|
+
}
|
|
86
|
+
if (Object.keys(patch).length === 0) {
|
|
87
|
+
throw new BadRequestError(`agent settings body must include at least one of: ${[...ALLOWED_KEYS].join(", ")}`);
|
|
88
|
+
}
|
|
89
|
+
// Persist to the global file only. The lock is shared with the
|
|
90
|
+
// observational-memory handler so concurrent writes cannot corrupt
|
|
91
|
+
// each other's namespace.
|
|
92
|
+
withSettingsLock({ agentDir, namespace: AGENT_NAMESPACE }, (current) => {
|
|
93
|
+
return { ...current, ...patch };
|
|
94
|
+
});
|
|
95
|
+
// Return the fully resolved settings (global + project merge) so the
|
|
96
|
+
// caller sees the effective state, not just what we wrote.
|
|
97
|
+
return { ok: true, ...loadAgentSettings(cwd, agentDir) };
|
|
98
|
+
}
|
|
99
|
+
export function handleListAgents(cwd, agentDir) {
|
|
100
|
+
const { agents } = discoverAgents(cwd, "both", agentDir);
|
|
101
|
+
return {
|
|
102
|
+
agents: agents.map((a) => ({
|
|
103
|
+
name: a.name,
|
|
104
|
+
description: a.description,
|
|
105
|
+
mode: a.mode,
|
|
106
|
+
source: a.source,
|
|
107
|
+
})),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { BadRequestError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Acquire a synchronous file lock with bounded retries on `ELOCKED`.
|
|
4
|
+
*
|
|
5
|
+
* Shared by all settings handlers that persist to `settings.json` so
|
|
6
|
+
* concurrent writes from different namespaces (observational-memory,
|
|
7
|
+
* agent) cannot interleave and corrupt the file.
|
|
8
|
+
*/
|
|
9
|
+
export declare function acquireLockSyncWithRetry(path: string): () => void;
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the `settings.json` path for a given agent directory.
|
|
12
|
+
* Falls back to the default agent dir (`~/.spectral/agent`) when
|
|
13
|
+
* `agentDir` is omitted.
|
|
14
|
+
*/
|
|
15
|
+
export declare function settingsFilePath(agentDir?: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Read-persist cycle for a single namespace inside `settings.json`,
|
|
18
|
+
* guarded by a file-level lock so concurrent writes across namespaces
|
|
19
|
+
* are serialized.
|
|
20
|
+
*
|
|
21
|
+
* - Reads the **entire** file (preserving all top-level keys).
|
|
22
|
+
* - Extracts `parsed[namespace]` (or `{}` if absent).
|
|
23
|
+
* - Passes the current namespace object to `fn`. If `fn` returns
|
|
24
|
+
* `undefined`, no write occurs. Otherwise the returned object
|
|
25
|
+
* replaces the namespace and the full file is written back.
|
|
26
|
+
*
|
|
27
|
+
* The lock is acquired **before** writing (and also before reading if
|
|
28
|
+
* the file already exists) so we never observe a half-written file.
|
|
29
|
+
*/
|
|
30
|
+
export declare function withSettingsLock(opts: {
|
|
31
|
+
agentDir?: string;
|
|
32
|
+
namespace: string;
|
|
33
|
+
}, fn: (current: Record<string, unknown>) => Record<string, unknown> | undefined): void;
|
|
34
|
+
/** Re-exported for handler convenience. */
|
|
35
|
+
export { BadRequestError };
|
|
36
|
+
//# sourceMappingURL=settings-lock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"settings-lock.d.ts","sourceRoot":"","sources":["../../../src/server/handlers/settings-lock.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9C;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,IAAI,CAsBjE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,EAC9C,EAAE,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAC5E,IAAI,CA4BN;AAED,2CAA2C;AAC3C,OAAO,EAAE,eAAe,EAAE,CAAC"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import lockfile from "proper-lockfile";
|
|
4
|
+
import { getAgentDir } from "../../sdk/coding-agent/index.js";
|
|
5
|
+
import { BadRequestError } from "./errors.js";
|
|
6
|
+
/**
|
|
7
|
+
* Acquire a synchronous file lock with bounded retries on `ELOCKED`.
|
|
8
|
+
*
|
|
9
|
+
* Shared by all settings handlers that persist to `settings.json` so
|
|
10
|
+
* concurrent writes from different namespaces (observational-memory,
|
|
11
|
+
* agent) cannot interleave and corrupt the file.
|
|
12
|
+
*/
|
|
13
|
+
export function acquireLockSyncWithRetry(path) {
|
|
14
|
+
const maxAttempts = 10;
|
|
15
|
+
const delayMs = 20;
|
|
16
|
+
let lastError;
|
|
17
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
18
|
+
try {
|
|
19
|
+
return lockfile.lockSync(path, { realpath: false });
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
const code = typeof error === "object" && error !== null && "code" in error
|
|
23
|
+
? String(error.code)
|
|
24
|
+
: undefined;
|
|
25
|
+
if (code !== "ELOCKED" || attempt === maxAttempts) {
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
lastError = error;
|
|
29
|
+
const start = Date.now();
|
|
30
|
+
while (Date.now() - start < delayMs) {
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw lastError ?? new Error("Failed to acquire settings lock");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the `settings.json` path for a given agent directory.
|
|
38
|
+
* Falls back to the default agent dir (`~/.spectral/agent`) when
|
|
39
|
+
* `agentDir` is omitted.
|
|
40
|
+
*/
|
|
41
|
+
export function settingsFilePath(agentDir) {
|
|
42
|
+
return join(agentDir ?? getAgentDir(), "settings.json");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read-persist cycle for a single namespace inside `settings.json`,
|
|
46
|
+
* guarded by a file-level lock so concurrent writes across namespaces
|
|
47
|
+
* are serialized.
|
|
48
|
+
*
|
|
49
|
+
* - Reads the **entire** file (preserving all top-level keys).
|
|
50
|
+
* - Extracts `parsed[namespace]` (or `{}` if absent).
|
|
51
|
+
* - Passes the current namespace object to `fn`. If `fn` returns
|
|
52
|
+
* `undefined`, no write occurs. Otherwise the returned object
|
|
53
|
+
* replaces the namespace and the full file is written back.
|
|
54
|
+
*
|
|
55
|
+
* The lock is acquired **before** writing (and also before reading if
|
|
56
|
+
* the file already exists) so we never observe a half-written file.
|
|
57
|
+
*/
|
|
58
|
+
export function withSettingsLock(opts, fn) {
|
|
59
|
+
const path = settingsFilePath(opts.agentDir);
|
|
60
|
+
const dir = dirname(path);
|
|
61
|
+
let release;
|
|
62
|
+
try {
|
|
63
|
+
const fileExists = existsSync(path);
|
|
64
|
+
if (fileExists) {
|
|
65
|
+
release = acquireLockSyncWithRetry(path);
|
|
66
|
+
}
|
|
67
|
+
const raw = fileExists ? readFileSync(path, "utf-8") : "{}";
|
|
68
|
+
const parsed = JSON.parse(raw);
|
|
69
|
+
const ns = (parsed[opts.namespace] ?? {});
|
|
70
|
+
const next = fn(ns);
|
|
71
|
+
if (next !== undefined) {
|
|
72
|
+
parsed[opts.namespace] = { ...ns, ...next };
|
|
73
|
+
if (!existsSync(dir)) {
|
|
74
|
+
mkdirSync(dir, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
if (!release) {
|
|
77
|
+
release = acquireLockSyncWithRetry(path);
|
|
78
|
+
}
|
|
79
|
+
writeFileSync(path, JSON.stringify(parsed, null, 2), "utf-8");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
if (release) {
|
|
84
|
+
release();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Re-exported for handler convenience. */
|
|
89
|
+
export { BadRequestError };
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure REST handler for `GET /api/settings` and `PUT /api/settings`.
|
|
3
|
-
*
|
|
4
|
-
* Settings are stored in the machine's global `settings.json` under the
|
|
5
|
-
* `"observational-memory"` namespace (see `src/memory/config.ts`).
|
|
6
|
-
*
|
|
7
|
-
* GET /api/settings → returns full observational-memory config
|
|
8
|
-
* PUT /api/settings → updates a single key via `{ key, value }` body
|
|
9
|
-
*
|
|
10
|
-
* All paths are machine-level — no session context needed.
|
|
11
|
-
*/
|
|
12
1
|
export interface PutSettingsInput {
|
|
13
2
|
key?: unknown;
|
|
14
3
|
value?: unknown;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../../../src/server/handlers/settings.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../../../src/server/handlers/settings.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,gBAAgB;IAC/B,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AACD,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,IAAI,CAAC;IACT,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;CACf;AACD,MAAM,WAAW,iBAAiB;IAChC,0BAA0B,EAAE,MAAM,CAAC;IACnC,yBAAyB,EAAE,MAAM,CAAC;IAClC,yBAAyB,EAAE,MAAM,CAAC;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAgBD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,CAShE;AACD,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,gBAAgB,GACrB,iBAAiB,CAoBnB"}
|
|
@@ -1,93 +1,19 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure REST handler for `GET /api/settings` and `PUT /api/settings`.
|
|
3
|
-
*
|
|
4
|
-
* Settings are stored in the machine's global `settings.json` under the
|
|
5
|
-
* `"observational-memory"` namespace (see `src/memory/config.ts`).
|
|
6
|
-
*
|
|
7
|
-
* GET /api/settings → returns full observational-memory config
|
|
8
|
-
* PUT /api/settings → updates a single key via `{ key, value }` body
|
|
9
|
-
*
|
|
10
|
-
* All paths are machine-level — no session context needed.
|
|
11
|
-
*/
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
-
import { dirname, join } from "node:path";
|
|
14
|
-
import lockfile from "proper-lockfile";
|
|
15
|
-
import { getAgentDir } from "../../sdk/coding-agent/index.js";
|
|
16
1
|
import { loadConfig } from "../../memory/config.js";
|
|
17
2
|
import { BadRequestError } from "./errors.js";
|
|
18
|
-
|
|
3
|
+
import { withSettingsLock } from "./settings-lock.js";
|
|
19
4
|
const ALLOWED_KEYS = new Set([
|
|
20
5
|
"observationThresholdTokens",
|
|
21
6
|
"compactionThresholdTokens",
|
|
22
7
|
"reflectionThresholdTokens",
|
|
23
8
|
]);
|
|
24
|
-
/** Sane bounds for token thresholds. */
|
|
25
9
|
const MIN_TOKENS = 1_000;
|
|
26
10
|
const MAX_TOKENS = 1_000_000;
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// File helpers (inlined to avoid pulling in the full SettingsManager)
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
function acquireLockSyncWithRetry(path) {
|
|
31
|
-
const maxAttempts = 10;
|
|
32
|
-
const delayMs = 20;
|
|
33
|
-
let lastError;
|
|
34
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
35
|
-
try {
|
|
36
|
-
return lockfile.lockSync(path, { realpath: false });
|
|
37
|
-
}
|
|
38
|
-
catch (error) {
|
|
39
|
-
const code = typeof error === "object" && error !== null && "code" in error
|
|
40
|
-
? String(error.code)
|
|
41
|
-
: undefined;
|
|
42
|
-
if (code !== "ELOCKED" || attempt === maxAttempts) {
|
|
43
|
-
throw error;
|
|
44
|
-
}
|
|
45
|
-
lastError = error;
|
|
46
|
-
const start = Date.now();
|
|
47
|
-
while (Date.now() - start < delayMs) {
|
|
48
|
-
// busy-wait; synchronous by design
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
throw lastError ?? new Error("Failed to acquire settings lock");
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Atomically read-modify-write the global `settings.json` under the
|
|
56
|
-
* `"observational-memory"` namespace.
|
|
57
|
-
*/
|
|
58
11
|
function withObservationalMemoryLock(fn) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const fileExists = existsSync(path);
|
|
64
|
-
if (fileExists) {
|
|
65
|
-
release = acquireLockSyncWithRetry(path);
|
|
66
|
-
}
|
|
67
|
-
const raw = fileExists ? readFileSync(path, "utf-8") : "{}";
|
|
68
|
-
const parsed = JSON.parse(raw);
|
|
69
|
-
const ns = (parsed["observational-memory"] ?? {});
|
|
70
|
-
const next = fn(ns);
|
|
71
|
-
if (next !== undefined) {
|
|
72
|
-
parsed["observational-memory"] = { ...ns, ...next };
|
|
73
|
-
if (!existsSync(dir)) {
|
|
74
|
-
mkdirSync(dir, { recursive: true });
|
|
75
|
-
}
|
|
76
|
-
if (!release) {
|
|
77
|
-
release = acquireLockSyncWithRetry(path);
|
|
78
|
-
}
|
|
79
|
-
writeFileSync(path, JSON.stringify(parsed, null, 2), "utf-8");
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
finally {
|
|
83
|
-
if (release) {
|
|
84
|
-
release();
|
|
85
|
-
}
|
|
86
|
-
}
|
|
12
|
+
withSettingsLock({ namespace: "observational-memory" }, (current) => {
|
|
13
|
+
const next = fn(current);
|
|
14
|
+
return next;
|
|
15
|
+
});
|
|
87
16
|
}
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
// GET handler
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
17
|
export function handleGetSettings(cwd) {
|
|
92
18
|
const config = loadConfig(cwd);
|
|
93
19
|
return {
|
|
@@ -98,16 +24,11 @@ export function handleGetSettings(cwd) {
|
|
|
98
24
|
debugLog: config.debugLog,
|
|
99
25
|
};
|
|
100
26
|
}
|
|
101
|
-
// ---------------------------------------------------------------------------
|
|
102
|
-
// PUT handler
|
|
103
|
-
// ---------------------------------------------------------------------------
|
|
104
27
|
export function handlePutSettings(cwd, body) {
|
|
105
|
-
// Validate key
|
|
106
28
|
if (typeof body.key !== "string" || !ALLOWED_KEYS.has(body.key)) {
|
|
107
29
|
throw new BadRequestError(`settings key must be one of: ${[...ALLOWED_KEYS].join(", ")}`);
|
|
108
30
|
}
|
|
109
31
|
const key = body.key;
|
|
110
|
-
// Validate value
|
|
111
32
|
if (typeof body.value !== "number" || !Number.isInteger(body.value)) {
|
|
112
33
|
throw new BadRequestError("settings value must be an integer");
|
|
113
34
|
}
|
|
@@ -115,7 +36,6 @@ export function handlePutSettings(cwd, body) {
|
|
|
115
36
|
throw new BadRequestError(`token threshold must be between ${MIN_TOKENS.toLocaleString()} and ${MAX_TOKENS.toLocaleString()}`);
|
|
116
37
|
}
|
|
117
38
|
const value = body.value;
|
|
118
|
-
// Persist
|
|
119
39
|
withObservationalMemoryLock((current) => {
|
|
120
40
|
return { ...current, [key]: value };
|
|
121
41
|
});
|