@lazyingart/agintiflow 0.20.198 → 0.20.199
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/bin/aginti-cli.js +18 -3
- package/docs/agent-runtime-pipe.md +24 -0
- package/docs/labcanvas-chatops-fallback.md +46 -0
- package/package.json +6 -3
- package/scripts/smoke-run-stdin.js +64 -44
- package/scripts/smoke-runtime-core.js +98 -0
- package/src/agent-runner.js +95 -35
- package/src/cli.js +6 -1
- package/src/context-budget-controller.js +31 -0
- package/src/model-client.js +40 -4
- package/src/scs-evidence.js +28 -9
- package/src/session-index.js +119 -74
- package/src/session-store.js +58 -29
package/bin/aginti-cli.js
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
2
|
+
import fs from "node:fs";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
const argv = process.argv.slice(2);
|
|
5
|
+
|
|
6
|
+
function fail(error) {
|
|
5
7
|
console.error(error);
|
|
6
8
|
process.exit(1);
|
|
7
|
-
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
if (["--version", "version", "-v"].includes(argv[0])) {
|
|
13
|
+
const packageJson = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
14
|
+
console.log(packageJson.version);
|
|
15
|
+
} else {
|
|
16
|
+
import("../src/cli.js")
|
|
17
|
+
.then(({ main }) => main(argv))
|
|
18
|
+
.catch(fail);
|
|
19
|
+
}
|
|
20
|
+
} catch (error) {
|
|
21
|
+
fail(error);
|
|
22
|
+
}
|
|
@@ -8,6 +8,30 @@ AgInTiFlow keeps CLI and web runs equivalent by using a project-local session in
|
|
|
8
8
|
- Canonical session store: `~/.agintiflow/sessions/<session-id>/`.
|
|
9
9
|
- Runtime inbox: `~/.agintiflow/sessions/<session-id>/inbox.jsonl`.
|
|
10
10
|
|
|
11
|
+
## Persistence Guarantees
|
|
12
|
+
|
|
13
|
+
Long-lived processes reuse one SQLite session-index connection and prepared
|
|
14
|
+
statement set per resolved `AGINTIFLOW_HOME`. The index enables WAL mode and a
|
|
15
|
+
bounded busy timeout so CLI, web, and bridge processes can update the
|
|
16
|
+
rebuildable index without repeatedly reopening and migrating the database.
|
|
17
|
+
Call `closeSessionIndexConnections()` in tests or embedding hosts that switch
|
|
18
|
+
runtime homes inside one process.
|
|
19
|
+
|
|
20
|
+
Each `SessionStore` memoizes directory/pointer initialization and serializes
|
|
21
|
+
its event appends. Concurrent callers therefore retain call order without
|
|
22
|
+
recreating the session pointer for every event. `state.json` remains an atomic,
|
|
23
|
+
fsynced save boundary. A missing state file is resumably absent; malformed JSON
|
|
24
|
+
raises `SESSION_STATE_CORRUPT` instead of silently looking like a new session.
|
|
25
|
+
|
|
26
|
+
## Machine Run Input
|
|
27
|
+
|
|
28
|
+
`aginti run` uses deterministic input precedence: explicit `--stdin` reads
|
|
29
|
+
standard input, otherwise a positional prompt wins, and piped standard input is
|
|
30
|
+
used only when no positional prompt exists. This keeps positional automation
|
|
31
|
+
working in subprocesses whose stdin is non-interactive while preserving both
|
|
32
|
+
explicit and implicit pipe workflows. `aginti --version` is handled by the
|
|
33
|
+
lightweight launcher without loading the full agent and web runtime.
|
|
34
|
+
|
|
11
35
|
When a run is active, the web chat and `aginti queue <session-id> "..."` append messages to the inbox instead of trying to mutate the running process directly. The web API exposes `GET /api/sessions/:id/inbox`, `POST /api/sessions/:id/inbox`, `PATCH /api/sessions/:id/inbox/:itemId`, and `DELETE /api/sessions/:id/inbox/:itemId` so browser users can inspect, edit, or remove pending pipe messages before the runner consumes them. The runner drains the inbox at safe boundaries: before each model step and after tool execution. This mirrors the event-queue style used by mature agent UIs while keeping the backend decoupled from any specific frontend.
|
|
12
36
|
|
|
13
37
|
The interactive CLI keeps the input panel visible while a run is working. Enter sends the current draft as an ASAP pipe message and displays it as `→`; the runner drains those messages before normal inbox items and before after-finish queued prompts. Tab stores the draft as an after-finish queue item and displays it as `↳`; those prompts run only after the current run completes. Alt+Up moves the last pending `→` message back into the editor, and Shift+Left moves the last pending `↳` message back into the editor. Idle Esc is ignored so it does not redraw the prompt into the transcript. During a run, Esc waits when `→` pipe messages are still pending and stops the run only when no ASAP pipe message is pending; Ctrl+C always stops. The current command cwd is rendered below the input panel in both idle and running states.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# LabCanvas ChatOps Fallback
|
|
2
|
+
|
|
3
|
+
AgInTiFlow is a reasoning and tool-supervision fallback for LabCanvas. LabCanvas remains the owner of chat transport, schedules, exact-source media resolution, durable task state, routine selection, artifact validation, and delivery.
|
|
4
|
+
|
|
5
|
+
## Contract
|
|
6
|
+
|
|
7
|
+
LabCanvas should pass one bounded task packet containing:
|
|
8
|
+
|
|
9
|
+
- exact current request and source-chat identity;
|
|
10
|
+
- latest same-chat interruptions and a small amount of attributed context;
|
|
11
|
+
- one selected routine and its contract paths;
|
|
12
|
+
- current deterministic preflight and stage state;
|
|
13
|
+
- exact artifact directory and irreversible-action gates.
|
|
14
|
+
|
|
15
|
+
AgInTi should read `AGENTS.md` and the selected routine contract, then call established commands. It should not invent a second scheduler, publication pipeline, media downloader, CAD generator, or delivery mechanism.
|
|
16
|
+
|
|
17
|
+
The default provider chain is `deepseek,localllm`. Switching providers is safe only when the first provider failed before inference or tool execution. Never replay an unknown task failure or timeout on another provider because the first attempt may already have caused a side effect.
|
|
18
|
+
|
|
19
|
+
## Evidence Scope
|
|
20
|
+
|
|
21
|
+
ChatOps prompts may include a trusted single-line marker:
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
AGINTI_EVIDENCE_SCOPE_JSON: {"mode":"chat-response","request":"Produce only the requested chat response."}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
or:
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
AGINTI_EVIDENCE_SCOPE_JSON: {"mode":"task","request":"Create the requested PDF from the supplied evidence."}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
For `chat-response`, ordinary conversation and routing do not require file or command evidence. For `task`, evidence requirements are inferred only from the exact request, not from surrounding wrapper prose. Artifact requests still require real artifacts.
|
|
34
|
+
|
|
35
|
+
## Local Context Recovery
|
|
36
|
+
|
|
37
|
+
LocalLLM planning compacts oversized goals to a bounded head-and-tail representation. Runtime compaction retains the first request and latest interruptions. A `LocalContextBudgetError` triggers one compact-and-retry cycle at the same step and records private recovery events. It does not authorize replaying task side effects.
|
|
38
|
+
|
|
39
|
+
Validate with:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm run check
|
|
43
|
+
npm run smoke:context-budget-recovery
|
|
44
|
+
npm run smoke:truthful-completion
|
|
45
|
+
```
|
|
46
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lazyingart/agintiflow",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.199",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-aware intelligence, software automation, and industrial workflows.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
"scripts/local-first-agent-eval.mjs",
|
|
87
87
|
"scripts/fixtures/local-first-agent-eval-fixtures.mjs",
|
|
88
88
|
"scripts/smoke-run-stdin.js",
|
|
89
|
+
"scripts/smoke-runtime-core.js",
|
|
89
90
|
"scripts/smoke-mcp.js",
|
|
90
91
|
"scripts/fixtures/mcp-stdio-smoke-server.mjs",
|
|
91
92
|
"scripts/smoke-model-roles.js",
|
|
@@ -121,7 +122,7 @@
|
|
|
121
122
|
"scripts": {
|
|
122
123
|
"start": "node run.js",
|
|
123
124
|
"web": "node web.js",
|
|
124
|
-
"check": "node --check run.js && node --check web.js && node --check bin/aginti-cli.js && node --check bin/aginti-public-research.js && node --check bin/aginti-safe-chat.js && node --check src/*.js && node --check src/mcp/*.js && node --check public/*.js && node --check scripts/postinstall-webapp.js && node --check scripts/seed-supervised-homework.js && node --check scripts/smoke-agentlink.js && node --check scripts/smoke-execution-policy.js && node --check scripts/smoke-math-rendering.js && node --check scripts/smoke-mcp.js && node --check scripts/smoke-public-research-wrapper.js && node --check scripts/smoke-safe-chat.js && node --check scripts/smoke-web-ui.js && node --check scripts/smoke-scs-evidence-visibility.js && node --check scripts/fixtures/mcp-stdio-smoke-server.mjs",
|
|
125
|
+
"check": "node --check run.js && node --check web.js && node --check bin/aginti-cli.js && node --check bin/aginti-public-research.js && node --check bin/aginti-safe-chat.js && node --check src/*.js && node --check src/mcp/*.js && node --check public/*.js && node --check scripts/postinstall-webapp.js && node --check scripts/seed-supervised-homework.js && node --check scripts/smoke-agentlink.js && node --check scripts/smoke-execution-policy.js && node --check scripts/smoke-math-rendering.js && node --check scripts/smoke-mcp.js && node --check scripts/smoke-public-research-wrapper.js && node --check scripts/smoke-runtime-core.js && node --check scripts/smoke-safe-chat.js && node --check scripts/smoke-web-ui.js && node --check scripts/smoke-scs-evidence-visibility.js && node --check scripts/fixtures/mcp-stdio-smoke-server.mjs",
|
|
125
126
|
"setup:toolchain-docker": "scripts/setup-agent-toolchain-docker.sh",
|
|
126
127
|
"smoke:coding-tools": "node scripts/smoke-coding-tools.js",
|
|
127
128
|
"smoke:dynamic-step-budget": "node scripts/smoke-dynamic-step-budget.js",
|
|
@@ -140,8 +141,10 @@
|
|
|
140
141
|
"smoke:inbox": "node scripts/smoke-inbox.js",
|
|
141
142
|
"smoke:long-jobs": "node scripts/smoke-long-jobs.js",
|
|
142
143
|
"smoke:run-stdin": "node scripts/smoke-run-stdin.js",
|
|
144
|
+
"smoke:runtime-core": "node scripts/smoke-runtime-core.js",
|
|
143
145
|
"smoke:localllm-auto-max": "node scripts/smoke-localllm-auto-max.js",
|
|
144
146
|
"smoke:local-resource-policy": "node scripts/smoke-local-resource-policy.js",
|
|
147
|
+
"smoke:context-budget-recovery": "node scripts/smoke-context-budget-recovery.js",
|
|
145
148
|
"smoke:localllm-model-tiers": "node scripts/smoke-localllm-model-tiers.js",
|
|
146
149
|
"smoke:localllm-provider": "node scripts/smoke-localllm-provider.js",
|
|
147
150
|
"smoke:progressive-tools": "node scripts/smoke-progressive-tool-selection.js",
|
|
@@ -171,7 +174,7 @@
|
|
|
171
174
|
"storage:migrate": "node bin/aginti-cli.js storage migrate",
|
|
172
175
|
"publish:env": "node scripts/npm-publish-from-env.js publish --access public",
|
|
173
176
|
"publish:env:whoami": "node scripts/npm-publish-from-env.js whoami",
|
|
174
|
-
"test": "npm run check && npm run smoke:localllm-provider && npm run smoke:localllm-model-tiers && npm run smoke:localllm-auto-max && npm run smoke:local-resource-policy && npm run smoke:session-runtime && npm run smoke:progressive-tools && npm run smoke:truthful-completion && npm run smoke:writing-specialist-routing && npm run eval:local-first-agent && npm run smoke:runtime-compat && npm run smoke:autoupdate && npm run smoke:web-api && npm run smoke:math-rendering && npm run smoke:web-ui && npm run smoke:web-autostart && npm run smoke:webapp-command && npm run smoke:web-port-fallback && npm run smoke:docker-command && npm run smoke:coding-tools && npm run smoke:dynamic-step-budget && npm run smoke:execution-policy && npm run smoke:aaps-adapter && npm run smoke:auxiliary-tools && npm run smoke:perception-research && npm run smoke:public-research && npm run smoke:safe-chat && npm run smoke:auth && npm run smoke:agentlink && npm run smoke:canvas-artifacts && npm run smoke:capabilities && npm run smoke:mcp && npm run smoke:model-roles && npm run smoke:platform && npm run smoke:permission-modes && npm run smoke:skills && npm run smoke:skillmesh && npm run smoke:tmux-tools && npm run smoke:long-jobs && npm run smoke:run-stdin && npm run smoke:cli-chat && npm run smoke:inbox",
|
|
177
|
+
"test": "npm run check && npm run smoke:localllm-provider && npm run smoke:localllm-model-tiers && npm run smoke:localllm-auto-max && npm run smoke:local-resource-policy && npm run smoke:context-budget-recovery && npm run smoke:session-runtime && npm run smoke:runtime-core && npm run smoke:progressive-tools && npm run smoke:truthful-completion && npm run smoke:writing-specialist-routing && npm run eval:local-first-agent && npm run smoke:runtime-compat && npm run smoke:autoupdate && npm run smoke:web-api && npm run smoke:math-rendering && npm run smoke:web-ui && npm run smoke:web-autostart && npm run smoke:webapp-command && npm run smoke:web-port-fallback && npm run smoke:docker-command && npm run smoke:coding-tools && npm run smoke:dynamic-step-budget && npm run smoke:execution-policy && npm run smoke:aaps-adapter && npm run smoke:auxiliary-tools && npm run smoke:perception-research && npm run smoke:public-research && npm run smoke:safe-chat && npm run smoke:auth && npm run smoke:agentlink && npm run smoke:canvas-artifacts && npm run smoke:capabilities && npm run smoke:mcp && npm run smoke:model-roles && npm run smoke:platform && npm run smoke:permission-modes && npm run smoke:skills && npm run smoke:skillmesh && npm run smoke:tmux-tools && npm run smoke:long-jobs && npm run smoke:run-stdin && npm run smoke:cli-chat && npm run smoke:inbox",
|
|
175
178
|
"pack:dry-run": "npm pack --dry-run",
|
|
176
179
|
"smoke:capabilities": "node scripts/smoke-capabilities.js"
|
|
177
180
|
},
|
|
@@ -7,10 +7,8 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
|
|
8
8
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
9
|
const home = await fs.mkdtemp(path.join(os.tmpdir(), "aginti-run-stdin-"));
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
"run",
|
|
13
|
-
"--stdin",
|
|
10
|
+
const cliPath = path.join(root, "bin", "aginti-cli.js");
|
|
11
|
+
const machineOptions = [
|
|
14
12
|
"--json",
|
|
15
13
|
"--provider",
|
|
16
14
|
"mock",
|
|
@@ -27,48 +25,70 @@ const command = [
|
|
|
27
25
|
"host",
|
|
28
26
|
];
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
28
|
+
async function runMachine(label, runArgs, stdin = "") {
|
|
29
|
+
const command = [cliPath, "run", ...runArgs];
|
|
30
|
+
const output = await new Promise((resolve, reject) => {
|
|
31
|
+
const child = spawn(process.execPath, command, {
|
|
32
|
+
cwd: home,
|
|
33
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
34
|
+
env: {
|
|
35
|
+
...process.env,
|
|
36
|
+
AGINTIFLOW_HOME: path.join(home, ".agintiflow"),
|
|
37
|
+
AGINTIFLOW_RUNTIME_DIR: "",
|
|
38
|
+
AGINTIFLOW_NO_WEB_AUTO_START: "1",
|
|
39
|
+
AGINTI_LANGUAGE: "en",
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
let stdout = "";
|
|
43
|
+
let stderr = "";
|
|
44
|
+
const timer = setTimeout(() => {
|
|
45
|
+
child.kill("SIGKILL");
|
|
46
|
+
reject(new Error(`${label} timed out\nstdout=${stdout}\nstderr=${stderr}`));
|
|
47
|
+
}, process.env.CI ? 90000 : 45000);
|
|
48
|
+
child.stdout.on("data", (chunk) => {
|
|
49
|
+
stdout += String(chunk);
|
|
50
|
+
});
|
|
51
|
+
child.stderr.on("data", (chunk) => {
|
|
52
|
+
stderr += String(chunk);
|
|
53
|
+
});
|
|
54
|
+
child.on("error", reject);
|
|
55
|
+
child.on("close", (code) => {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
resolve({ code, stdout, stderr });
|
|
58
|
+
});
|
|
59
|
+
child.stdin.end(stdin);
|
|
41
60
|
});
|
|
42
|
-
let stdout = "";
|
|
43
|
-
let stderr = "";
|
|
44
|
-
const timer = setTimeout(() => {
|
|
45
|
-
child.kill("SIGKILL");
|
|
46
|
-
reject(new Error(`machine run timed out\nstdout=${stdout}\nstderr=${stderr}`));
|
|
47
|
-
}, process.env.CI ? 90000 : 45000);
|
|
48
|
-
child.stdout.on("data", (chunk) => {
|
|
49
|
-
stdout += String(chunk);
|
|
50
|
-
});
|
|
51
|
-
child.stderr.on("data", (chunk) => {
|
|
52
|
-
stderr += String(chunk);
|
|
53
|
-
});
|
|
54
|
-
child.on("error", reject);
|
|
55
|
-
child.on("close", (code) => {
|
|
56
|
-
clearTimeout(timer);
|
|
57
|
-
resolve({ code, stdout, stderr });
|
|
58
|
-
});
|
|
59
|
-
child.stdin.end("Reply briefly that the machine transport smoke completed.");
|
|
60
|
-
});
|
|
61
61
|
|
|
62
|
-
const lines = output.stdout.trim().split(/\r?\n/).filter(Boolean);
|
|
63
|
-
if (output.code !== 0) throw new Error(
|
|
64
|
-
if (lines.length !== 1) throw new Error(
|
|
65
|
-
const payload = JSON.parse(lines[0]);
|
|
66
|
-
if (payload.ok !== true || !String(payload.result || "").trim()) {
|
|
67
|
-
|
|
62
|
+
const lines = output.stdout.trim().split(/\r?\n/).filter(Boolean);
|
|
63
|
+
if (output.code !== 0) throw new Error(`${label} exited ${output.code}\n${output.stdout}\n${output.stderr}`);
|
|
64
|
+
if (lines.length !== 1) throw new Error(`${label} emitted ${lines.length} stdout lines\n${output.stdout}`);
|
|
65
|
+
const payload = JSON.parse(lines[0]);
|
|
66
|
+
if (payload.ok !== true || !String(payload.result || "").trim()) {
|
|
67
|
+
throw new Error(`${label} returned an unusable payload\n${output.stdout}`);
|
|
68
|
+
}
|
|
69
|
+
if (/(?:^|\n)(?:Session|Provider|Model|Routing|Workspace|Plan):/i.test(output.stdout)) {
|
|
70
|
+
throw new Error(`${label} leaked interactive metadata\n${output.stdout}`);
|
|
71
|
+
}
|
|
72
|
+
if (output.stderr.trim()) throw new Error(`${label} leaked stderr\n${output.stderr}`);
|
|
68
73
|
}
|
|
69
|
-
|
|
70
|
-
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
await runMachine(
|
|
77
|
+
"explicit stdin machine run",
|
|
78
|
+
["--stdin", ...machineOptions],
|
|
79
|
+
"Reply briefly that the explicit stdin transport smoke completed."
|
|
80
|
+
);
|
|
81
|
+
await runMachine(
|
|
82
|
+
"positional prompt machine run",
|
|
83
|
+
[...machineOptions, "Reply briefly that the positional prompt smoke completed."]
|
|
84
|
+
);
|
|
85
|
+
await runMachine(
|
|
86
|
+
"implicit piped stdin machine run",
|
|
87
|
+
machineOptions,
|
|
88
|
+
"Reply briefly that the implicit piped stdin smoke completed."
|
|
89
|
+
);
|
|
90
|
+
} finally {
|
|
91
|
+
await fs.rm(home, { recursive: true, force: true });
|
|
71
92
|
}
|
|
72
|
-
if (output.stderr.trim()) throw new Error(`machine run leaked stderr\n${output.stderr}`);
|
|
73
93
|
|
|
74
|
-
console.log("run
|
|
94
|
+
console.log("run input precedence smoke passed");
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "aginti-runtime-core-"));
|
|
8
|
+
const originalHome = process.env.AGINTIFLOW_HOME;
|
|
9
|
+
const originalHousekeeping = process.env.AGINTIFLOW_HOUSEKEEPING;
|
|
10
|
+
process.env.AGINTIFLOW_HOUSEKEEPING = "0";
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
closeSessionIndexConnections,
|
|
14
|
+
listSessionIndex,
|
|
15
|
+
sessionIndexConnectionCount,
|
|
16
|
+
upsertSessionIndex,
|
|
17
|
+
} = await import("../src/session-index.js");
|
|
18
|
+
const { SessionStore } = await import("../src/session-store.js");
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const firstHome = path.join(tempRoot, "home-one");
|
|
22
|
+
const firstProject = path.join(tempRoot, "project-one");
|
|
23
|
+
process.env.AGINTIFLOW_HOME = firstHome;
|
|
24
|
+
|
|
25
|
+
const indexStartedAt = performance.now();
|
|
26
|
+
for (let index = 0; index < 200; index += 1) {
|
|
27
|
+
assert.equal(
|
|
28
|
+
upsertSessionIndex({
|
|
29
|
+
sessionId: `runtime-${index}`,
|
|
30
|
+
projectRoot: firstProject,
|
|
31
|
+
commandCwd: firstProject,
|
|
32
|
+
status: "running",
|
|
33
|
+
goal: `runtime smoke ${index}`,
|
|
34
|
+
}),
|
|
35
|
+
true
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const indexElapsedMs = performance.now() - indexStartedAt;
|
|
39
|
+
assert.equal(sessionIndexConnectionCount(), 1, "one runtime home should reuse one SQLite connection");
|
|
40
|
+
const firstRows = listSessionIndex({ projectRoot: firstProject, commandCwd: firstProject, limit: 500 });
|
|
41
|
+
assert.equal(firstRows.length, 200, "cached prepared queries lost indexed sessions");
|
|
42
|
+
|
|
43
|
+
const secondHome = path.join(tempRoot, "home-two");
|
|
44
|
+
const secondProject = path.join(tempRoot, "project-two");
|
|
45
|
+
process.env.AGINTIFLOW_HOME = secondHome;
|
|
46
|
+
assert.equal(
|
|
47
|
+
upsertSessionIndex({
|
|
48
|
+
sessionId: "other-home",
|
|
49
|
+
projectRoot: secondProject,
|
|
50
|
+
commandCwd: secondProject,
|
|
51
|
+
status: "saved",
|
|
52
|
+
}),
|
|
53
|
+
true
|
|
54
|
+
);
|
|
55
|
+
assert.equal(sessionIndexConnectionCount(), 2, "separate runtime homes must not share a SQLite connection");
|
|
56
|
+
assert.equal(listSessionIndex({ projectRoot: secondProject }).length, 1);
|
|
57
|
+
assert.equal(listSessionIndex({ projectRoot: firstProject }).length, 0, "runtime homes leaked index records");
|
|
58
|
+
|
|
59
|
+
process.env.AGINTIFLOW_HOME = firstHome;
|
|
60
|
+
const store = new SessionStore(path.join(firstHome, "sessions"), "event-order");
|
|
61
|
+
const eventStartedAt = performance.now();
|
|
62
|
+
await Promise.all(
|
|
63
|
+
Array.from({ length: 100 }, (_, index) => store.appendEvent("runtime.smoke", { index }))
|
|
64
|
+
);
|
|
65
|
+
const eventElapsedMs = performance.now() - eventStartedAt;
|
|
66
|
+
const events = await store.loadEvents();
|
|
67
|
+
assert.equal(events.length, 100, "concurrent appends lost or duplicated session events");
|
|
68
|
+
assert.deepEqual(
|
|
69
|
+
events.map((event) => event.data.index),
|
|
70
|
+
Array.from({ length: 100 }, (_, index) => index),
|
|
71
|
+
"concurrent session events were persisted out of order"
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const missingStore = new SessionStore(path.join(firstHome, "sessions"), "missing-state");
|
|
75
|
+
assert.equal(await missingStore.loadState(), null, "an absent session state should remain resumably absent");
|
|
76
|
+
|
|
77
|
+
const corruptStore = new SessionStore(path.join(firstHome, "sessions"), "corrupt-state");
|
|
78
|
+
await fs.mkdir(corruptStore.sessionDir, { recursive: true });
|
|
79
|
+
await fs.writeFile(corruptStore.statePath, "{not-json\n", "utf8");
|
|
80
|
+
await assert.rejects(
|
|
81
|
+
corruptStore.loadState(),
|
|
82
|
+
(error) => error?.code === "SESSION_STATE_CORRUPT" && error?.name === "SessionStateCorruptionError",
|
|
83
|
+
"malformed session state must fail visibly instead of appearing missing"
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
closeSessionIndexConnections();
|
|
87
|
+
assert.equal(sessionIndexConnectionCount(), 0, "session index connections did not close cleanly");
|
|
88
|
+
console.log(
|
|
89
|
+
`runtime core smoke passed (200 index writes ${indexElapsedMs.toFixed(1)}ms; 100 ordered events ${eventElapsedMs.toFixed(1)}ms)`
|
|
90
|
+
);
|
|
91
|
+
} finally {
|
|
92
|
+
closeSessionIndexConnections();
|
|
93
|
+
if (originalHome === undefined) delete process.env.AGINTIFLOW_HOME;
|
|
94
|
+
else process.env.AGINTIFLOW_HOME = originalHome;
|
|
95
|
+
if (originalHousekeeping === undefined) delete process.env.AGINTIFLOW_HOUSEKEEPING;
|
|
96
|
+
else process.env.AGINTIFLOW_HOUSEKEEPING = originalHousekeeping;
|
|
97
|
+
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
98
|
+
}
|
package/src/agent-runner.js
CHANGED
|
@@ -326,7 +326,11 @@ function compactJson(value, limit = 1200) {
|
|
|
326
326
|
function compactMultiline(value = "", limit = 3600) {
|
|
327
327
|
const text = redactSensitiveText(String(value || ""));
|
|
328
328
|
if (!limit || text.length <= limit) return text;
|
|
329
|
-
|
|
329
|
+
const marker = `\n... [${text.length - limit} chars omitted] ...\n`;
|
|
330
|
+
const available = Math.max(0, limit - marker.length);
|
|
331
|
+
const head = Math.floor(available * 0.35);
|
|
332
|
+
const tail = Math.max(0, available - head);
|
|
333
|
+
return `${text.slice(0, head).trimEnd()}${marker}${text.slice(-tail).trimStart()}`;
|
|
330
334
|
}
|
|
331
335
|
|
|
332
336
|
function safeParseToolContent(content) {
|
|
@@ -379,7 +383,9 @@ function summarizeOriginalRequests(messages = [], limit = 6) {
|
|
|
379
383
|
if (/^Previous assistant response retained as compacted history/i.test(content)) continue;
|
|
380
384
|
requests.push(compactSingleLine(content, 1200));
|
|
381
385
|
}
|
|
382
|
-
|
|
386
|
+
const unique = [...new Set(requests)];
|
|
387
|
+
if (unique.length <= limit) return unique;
|
|
388
|
+
return [unique[0], ...unique.slice(-(limit - 1))];
|
|
383
389
|
}
|
|
384
390
|
|
|
385
391
|
function countMessageChars(messages = []) {
|
|
@@ -393,7 +399,13 @@ function modelTimeoutMsForConfig(config = {}) {
|
|
|
393
399
|
|
|
394
400
|
function buildCompactedRuntimeMessages(state, config, snapshot, step, options = {}) {
|
|
395
401
|
const messages = Array.isArray(state?.messages) ? state.messages : [];
|
|
396
|
-
const systemMessages = messages
|
|
402
|
+
const systemMessages = messages
|
|
403
|
+
.filter((message) => message?.role === "system")
|
|
404
|
+
.slice(0, 3)
|
|
405
|
+
.map((message) => ({
|
|
406
|
+
...message,
|
|
407
|
+
content: compactMultiline(message.content, 12000),
|
|
408
|
+
}));
|
|
397
409
|
const requests = summarizeOriginalRequests(messages);
|
|
398
410
|
const toolHistory = summarizeToolHistory(messages);
|
|
399
411
|
const snapshotSummary = {
|
|
@@ -472,6 +484,10 @@ function isModelTimeoutError(error) {
|
|
|
472
484
|
return error?.name === "ModelTimeoutError" || /timed out after \d+ms/i.test(String(error?.message || ""));
|
|
473
485
|
}
|
|
474
486
|
|
|
487
|
+
function isLocalContextBudgetError(error) {
|
|
488
|
+
return error?.name === "LocalContextBudgetError" || error?.code === "LOCALLLM_CONTEXT_BUDGET_EXCEEDED";
|
|
489
|
+
}
|
|
490
|
+
|
|
475
491
|
export function repairModelMessageHistory(state, config = {}) {
|
|
476
492
|
if (!Array.isArray(state?.messages)) {
|
|
477
493
|
return {
|
|
@@ -3438,39 +3454,83 @@ export async function runAgent(config) {
|
|
|
3438
3454
|
response = await requestNextStep(client, config, state.messages);
|
|
3439
3455
|
} catch (error) {
|
|
3440
3456
|
const retryKey = `step-${step}`;
|
|
3441
|
-
const
|
|
3442
|
-
if (
|
|
3457
|
+
const contextRetriedSteps = state.meta.localContextBudgetRetries || {};
|
|
3458
|
+
if (isLocalContextBudgetError(error) && !contextRetriedSteps[retryKey]) {
|
|
3459
|
+
const compactMessages = buildContextBudgetCompactionMessages(
|
|
3460
|
+
state,
|
|
3461
|
+
config,
|
|
3462
|
+
snapshot,
|
|
3463
|
+
step,
|
|
3464
|
+
{
|
|
3465
|
+
reason: redactSensitiveText(
|
|
3466
|
+
error instanceof Error ? error.message : String(error)
|
|
3467
|
+
),
|
|
3468
|
+
}
|
|
3469
|
+
);
|
|
3470
|
+
const detail = {
|
|
3471
|
+
step,
|
|
3472
|
+
provider: config.provider,
|
|
3473
|
+
model: config.model,
|
|
3474
|
+
messageCharsBefore: countMessageChars(state.messages),
|
|
3475
|
+
messageCharsAfter: countMessageChars(compactMessages),
|
|
3476
|
+
messageTokensBefore: estimateMessageTokens(state.messages),
|
|
3477
|
+
messageTokensAfter: estimateMessageTokens(compactMessages),
|
|
3478
|
+
error: redactSensitiveText(
|
|
3479
|
+
error instanceof Error ? error.message : String(error)
|
|
3480
|
+
),
|
|
3481
|
+
};
|
|
3482
|
+
state.messages = compactMessages;
|
|
3483
|
+
state.meta.localContextBudgetRetries = {
|
|
3484
|
+
...contextRetriedSteps,
|
|
3485
|
+
[retryKey]: true,
|
|
3486
|
+
};
|
|
3487
|
+
state.meta.lastLocalContextBudgetRecovery = detail;
|
|
3488
|
+
await store.appendEvent("model.local_context_budget_exceeded", detail);
|
|
3489
|
+
await store.appendEvent("history.compacted_for_local_context_retry", detail);
|
|
3490
|
+
observers.event("model.local_context_budget_exceeded", detail);
|
|
3491
|
+
observers.event("history.compacted_for_local_context_retry", detail);
|
|
3492
|
+
emitConsole(
|
|
3493
|
+
config,
|
|
3494
|
+
`Local provider context exceeded its configured window at step ${step}; compacted authoritative context and retrying once.`,
|
|
3495
|
+
{ kind: "meta" }
|
|
3496
|
+
);
|
|
3497
|
+
await store.saveState(state);
|
|
3498
|
+
response = await requestNextStep(client, config, state.messages);
|
|
3499
|
+
} else {
|
|
3500
|
+
const retriedSteps = state.meta.modelTimeoutRetries || {};
|
|
3501
|
+
if (!isModelTimeoutError(error) || retriedSteps[retryKey]) throw error;
|
|
3443
3502
|
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3503
|
+
const timeoutMs = modelTimeoutMsForConfig(config);
|
|
3504
|
+
const retryTimeoutMs = Math.max(timeoutMs * 2, 180000);
|
|
3505
|
+
const compactMessages = buildModelTimeoutRetryMessages(state, config, snapshot, step, error);
|
|
3506
|
+
const detail = {
|
|
3507
|
+
step,
|
|
3508
|
+
provider: config.provider,
|
|
3509
|
+
model: config.model,
|
|
3510
|
+
timeoutMs,
|
|
3511
|
+
retryTimeoutMs,
|
|
3512
|
+
messageCharsBefore: countMessageChars(state.messages),
|
|
3513
|
+
messageCharsAfter: countMessageChars(compactMessages),
|
|
3514
|
+
error: redactSensitiveText(error instanceof Error ? error.message : String(error)),
|
|
3515
|
+
};
|
|
3516
|
+
state.messages = compactMessages;
|
|
3517
|
+
state.meta.modelTimeoutRetries = {
|
|
3518
|
+
...retriedSteps,
|
|
3519
|
+
[retryKey]: true,
|
|
3520
|
+
};
|
|
3521
|
+
state.meta.lastModelTimeout = detail;
|
|
3522
|
+
await store.appendEvent("model.timeout", detail);
|
|
3523
|
+
await store.appendEvent("history.compacted_for_model_retry", detail);
|
|
3524
|
+
observers.event("model.timeout", detail);
|
|
3525
|
+
observers.event("history.compacted_for_model_retry", detail);
|
|
3526
|
+
emitConsole(
|
|
3527
|
+
config,
|
|
3528
|
+
`Model request timed out after ${timeoutMs}ms; compacted history and retrying once with ${retryTimeoutMs}ms.`,
|
|
3529
|
+
{ kind: "meta" }
|
|
3530
|
+
);
|
|
3531
|
+
await store.saveState(state);
|
|
3532
|
+
response = await requestNextStep(client, { ...config, modelTimeoutMs: retryTimeoutMs }, state.messages);
|
|
3533
|
+
}
|
|
3474
3534
|
}
|
|
3475
3535
|
const assistantMessage = response.choices[0]?.message;
|
|
3476
3536
|
if (!assistantMessage) {
|
package/src/cli.js
CHANGED
|
@@ -2398,7 +2398,12 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
2398
2398
|
const optionArgv = runArgv.filter((arg) => !["--stdin", "--json"].includes(arg));
|
|
2399
2399
|
const parsedRunArgs = parseArgs(optionArgv);
|
|
2400
2400
|
exitOnUnknownOptions(parsedRunArgs);
|
|
2401
|
-
|
|
2401
|
+
let prompt = parsedRunArgs.goal;
|
|
2402
|
+
if (stdinFlag) {
|
|
2403
|
+
prompt = await readStdin();
|
|
2404
|
+
} else if (!prompt && !process.stdin.isTTY) {
|
|
2405
|
+
prompt = await readStdin();
|
|
2406
|
+
}
|
|
2402
2407
|
if (!prompt) {
|
|
2403
2408
|
if (jsonFlag) {
|
|
2404
2409
|
console.log(JSON.stringify({ ok: false, result: "", reason: "empty_prompt" }));
|
|
@@ -49,6 +49,37 @@ export function estimateTextTokens(value = "") {
|
|
|
49
49
|
return Math.ceil(ascii / 3) + Math.ceil(nonAscii * 1.5);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export function compactTextForTokenBudget(value = "", maxTokens = 0, options = {}) {
|
|
53
|
+
const text = String(value ?? "");
|
|
54
|
+
const budget = positiveInteger(maxTokens, 0);
|
|
55
|
+
if (!budget || estimateTextTokens(text) <= budget) return text;
|
|
56
|
+
|
|
57
|
+
const headFraction = Math.min(0.8, Math.max(0.2, Number(options.headFraction ?? 0.35)));
|
|
58
|
+
const buildCandidate = (keptChars) => {
|
|
59
|
+
const kept = Math.max(0, Math.min(text.length, Math.floor(keptChars)));
|
|
60
|
+
const headChars = Math.floor(kept * headFraction);
|
|
61
|
+
const tailChars = Math.max(0, kept - headChars);
|
|
62
|
+
const omitted = Math.max(0, text.length - headChars - tailChars);
|
|
63
|
+
const marker = `\n\n[... ${omitted} chars omitted to fit the provider context ...]\n\n`;
|
|
64
|
+
return `${text.slice(0, headChars).trimEnd()}${marker}${text.slice(text.length - tailChars).trimStart()}`;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
let low = 0;
|
|
68
|
+
let high = text.length;
|
|
69
|
+
let best = "[... content omitted to fit the provider context ...]";
|
|
70
|
+
while (low <= high) {
|
|
71
|
+
const middle = Math.floor((low + high) / 2);
|
|
72
|
+
const candidate = buildCandidate(middle);
|
|
73
|
+
if (estimateTextTokens(candidate) <= budget) {
|
|
74
|
+
best = candidate;
|
|
75
|
+
low = middle + 1;
|
|
76
|
+
} else {
|
|
77
|
+
high = middle - 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return best;
|
|
81
|
+
}
|
|
82
|
+
|
|
52
83
|
export function estimateMessageTokens(messages = []) {
|
|
53
84
|
return (Array.isArray(messages) ? messages : []).reduce((total, message) => {
|
|
54
85
|
let count = 6 + estimateTextTokens(message?.role || "") + estimateTextTokens(message?.content || "");
|
package/src/model-client.js
CHANGED
|
@@ -19,7 +19,11 @@ import {
|
|
|
19
19
|
providerSupportsReasoningEffort,
|
|
20
20
|
} from "./provider-contract.js";
|
|
21
21
|
import { selectProgressiveTools } from "./progressive-tool-selection.js";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
compactTextForTokenBudget,
|
|
24
|
+
estimateMessageTokens,
|
|
25
|
+
estimateToolSchemaTokens,
|
|
26
|
+
} from "./context-budget-controller.js";
|
|
23
27
|
import { attachToolContract } from "./tool-contract.js";
|
|
24
28
|
|
|
25
29
|
export function createClient(config) {
|
|
@@ -834,6 +838,14 @@ export async function createPlan(client, config, state) {
|
|
|
834
838
|
const planMaxTokens = normalizeProviderId(config.provider, "") === "localllm"
|
|
835
839
|
? Math.min(2048, Number(config.maxOutputTokens || 2048))
|
|
836
840
|
: 0;
|
|
841
|
+
const localContextWindow = Number(config.contextWindowTokens || 32768);
|
|
842
|
+
const planGoal = planMaxTokens > 0
|
|
843
|
+
? compactTextForTokenBudget(
|
|
844
|
+
state.goal,
|
|
845
|
+
Math.max(2048, Math.min(8192, Math.floor(localContextWindow * 0.3))),
|
|
846
|
+
{ headFraction: 0.3 }
|
|
847
|
+
)
|
|
848
|
+
: state.goal;
|
|
837
849
|
const planPayload = {
|
|
838
850
|
model: config.model,
|
|
839
851
|
temperature: 0,
|
|
@@ -846,7 +858,7 @@ export async function createPlan(client, config, state) {
|
|
|
846
858
|
{
|
|
847
859
|
role: "user",
|
|
848
860
|
content: [
|
|
849
|
-
`Goal: ${
|
|
861
|
+
`Goal: ${planGoal}`,
|
|
850
862
|
state.startUrl ? `Suggested start URL: ${state.startUrl}` : "",
|
|
851
863
|
config.allowedDomains.length > 0 ? `Allowed domains: ${config.allowedDomains.join(", ")}` : "",
|
|
852
864
|
config.allowShellTool
|
|
@@ -912,9 +924,33 @@ export async function createPlan(client, config, state) {
|
|
|
912
924
|
},
|
|
913
925
|
],
|
|
914
926
|
...(planMaxTokens > 0 ? { max_tokens: planMaxTokens } : {}),
|
|
915
|
-
|
|
927
|
+
};
|
|
916
928
|
const planConfig = planMaxTokens > 0 ? { ...config, maxOutputTokens: planMaxTokens } : config;
|
|
917
|
-
|
|
929
|
+
try {
|
|
930
|
+
assertLocalRequestWithinContext(planPayload, planConfig, "plan request");
|
|
931
|
+
} catch (error) {
|
|
932
|
+
if (error?.name !== "LocalContextBudgetError") throw error;
|
|
933
|
+
const minimalGoal = compactTextForTokenBudget(state.goal, 4096, { headFraction: 0.3 });
|
|
934
|
+
planPayload.messages = [
|
|
935
|
+
{
|
|
936
|
+
role: "system",
|
|
937
|
+
content:
|
|
938
|
+
"Plan one exact agent task in 3 to 6 concise steps. Use the established project routines and tools; do not redesign a mature workflow. Preserve current intent, later interruptions, safety gates, and requested artifacts.",
|
|
939
|
+
},
|
|
940
|
+
{
|
|
941
|
+
role: "user",
|
|
942
|
+
content: [
|
|
943
|
+
`Goal: ${minimalGoal}`,
|
|
944
|
+
`Task profile: ${taskProfile.label}. ${taskProfile.prompt}`,
|
|
945
|
+
projectInstructions?.exists
|
|
946
|
+
? `Project instructions are available at ${projectInstructions.path}; read them when executing.`
|
|
947
|
+
: "Read AGENTS.md/README and the exact routine contract when present.",
|
|
948
|
+
"Return a numbered plan only.",
|
|
949
|
+
].join("\n"),
|
|
950
|
+
},
|
|
951
|
+
];
|
|
952
|
+
assertLocalRequestWithinContext(planPayload, planConfig, "compacted plan request");
|
|
953
|
+
}
|
|
918
954
|
const response = await createChatCompletion(client, planPayload, planConfig, "plan request");
|
|
919
955
|
|
|
920
956
|
return redactSensitiveText(response.choices[0]?.message?.content?.trim() || "1. Inspect the page.\n2. Use the smallest safe action.\n3. Finish with a concise answer.");
|
package/src/scs-evidence.js
CHANGED
|
@@ -495,29 +495,48 @@ function stripForbiddenLanguage(goal = "") {
|
|
|
495
495
|
.replace(/禁止([^。\n;]+)/g, "");
|
|
496
496
|
}
|
|
497
497
|
|
|
498
|
+
function scopedChatopsEvidenceGoal(goal = "", taskProfile = "") {
|
|
499
|
+
if (String(taskProfile || "").trim().toLowerCase() !== "chatops") return String(goal || "");
|
|
500
|
+
const match = String(goal || "").match(/^AGINTI_EVIDENCE_SCOPE_JSON:\s*(\{[^\n]+\})\s*$/m);
|
|
501
|
+
if (!match) return String(goal || "");
|
|
502
|
+
try {
|
|
503
|
+
const payload = JSON.parse(match[1]);
|
|
504
|
+
if (!payload || typeof payload !== "object") return String(goal || "");
|
|
505
|
+
const mode = String(payload.mode || "").trim().toLowerCase();
|
|
506
|
+
if (mode === "chat-response") {
|
|
507
|
+
return "Answer the current chat turn directly without external execution.";
|
|
508
|
+
}
|
|
509
|
+
const request = String(payload.request || "").trim();
|
|
510
|
+
return request || String(goal || "");
|
|
511
|
+
} catch {
|
|
512
|
+
return String(goal || "");
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
498
516
|
export function deriveScsTaskContract({ goal = "", taskProfile = "", acceptanceCriteria = [] } = {}) {
|
|
499
|
-
const
|
|
500
|
-
const
|
|
501
|
-
const
|
|
517
|
+
const evidenceGoal = scopedChatopsEvidenceGoal(goal, taskProfile);
|
|
518
|
+
const requirementCategories = inferRequirementCategories(evidenceGoal, taskProfile, acceptanceCriteria);
|
|
519
|
+
const requiredToolCalls = inferRequiredToolCalls(evidenceGoal);
|
|
520
|
+
const requiresExternalEvidence = requirementCategories.length > 0 || requiredToolCalls.length > 0 || goalRequiresEvidence(evidenceGoal, taskProfile);
|
|
502
521
|
const requiredEvidence = requirementCategories.map((category) => ({
|
|
503
522
|
id: category,
|
|
504
523
|
category,
|
|
505
524
|
description: CATEGORY_LABELS[category] || category,
|
|
506
525
|
}));
|
|
507
|
-
const exactOutputPaths = inferExactOutputPaths(
|
|
508
|
-
const exactInputPaths = inferExactInputPaths(
|
|
526
|
+
const exactOutputPaths = inferExactOutputPaths(evidenceGoal);
|
|
527
|
+
const exactInputPaths = inferExactInputPaths(evidenceGoal).filter((item) => !exactOutputPaths.includes(item));
|
|
509
528
|
return {
|
|
510
529
|
version: 1,
|
|
511
|
-
outcome: compact(
|
|
530
|
+
outcome: compact(evidenceGoal || "Complete the requested task.", 500),
|
|
512
531
|
taskProfile: String(taskProfile || "auto"),
|
|
513
532
|
requiresExternalEvidence,
|
|
514
533
|
requiredEvidence,
|
|
515
|
-
forbiddenActions: inferForbiddenActions(
|
|
534
|
+
forbiddenActions: inferForbiddenActions(evidenceGoal),
|
|
516
535
|
exactOutputPaths,
|
|
517
536
|
exactInputPaths,
|
|
518
537
|
requiredToolCalls,
|
|
519
|
-
requiredTextTerms: inferRequiredTextTerms(
|
|
520
|
-
forbiddenTextTerms: inferForbiddenTextTerms(
|
|
538
|
+
requiredTextTerms: inferRequiredTextTerms(evidenceGoal),
|
|
539
|
+
forbiddenTextTerms: inferForbiddenTextTerms(evidenceGoal),
|
|
521
540
|
successCriteria: unique(acceptanceCriteria).slice(0, 10),
|
|
522
541
|
};
|
|
523
542
|
}
|
package/src/session-index.js
CHANGED
|
@@ -6,6 +6,11 @@ import { loadDatabaseSync } from "./sqlite.js";
|
|
|
6
6
|
export const PROJECT_SESSIONS_DIR_NAME = ".aginti-sessions";
|
|
7
7
|
export const LEGACY_PROJECT_SESSIONS_DIR_NAME = ".sessions";
|
|
8
8
|
|
|
9
|
+
const SESSION_COLUMNS = `session_id AS sessionId, project_root AS projectRoot, command_cwd AS commandCwd, project_sessions_dir AS projectSessionsDir,
|
|
10
|
+
session_dir AS sessionDir, provider, model, goal, title, status,
|
|
11
|
+
created_at AS createdAt, updated_at AS updatedAt, ended_at AS endedAt, result, error`;
|
|
12
|
+
const indexDbConnections = new Map();
|
|
13
|
+
|
|
9
14
|
export function agintiflowHome() {
|
|
10
15
|
return path.resolve(process.env.AGINTIFLOW_HOME || path.join(os.homedir(), ".agintiflow"));
|
|
11
16
|
}
|
|
@@ -29,34 +34,106 @@ export function isSafeSessionId(sessionId) {
|
|
|
29
34
|
|
|
30
35
|
function ensureIndexDb() {
|
|
31
36
|
const paths = globalSessionPaths();
|
|
37
|
+
if (indexDbConnections.has(paths.indexDbPath)) return indexDbConnections.get(paths.indexDbPath);
|
|
32
38
|
fs.mkdirSync(paths.sessionsDir, { recursive: true });
|
|
33
39
|
const DatabaseSync = loadDatabaseSync({ optional: true });
|
|
34
|
-
if (!DatabaseSync)
|
|
40
|
+
if (!DatabaseSync) {
|
|
41
|
+
indexDbConnections.set(paths.indexDbPath, null);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
35
44
|
const db = new DatabaseSync(paths.indexDbPath);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
45
|
+
try {
|
|
46
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
47
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
48
|
+
db.exec(`
|
|
49
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
50
|
+
session_id TEXT PRIMARY KEY,
|
|
51
|
+
project_root TEXT NOT NULL DEFAULT '',
|
|
52
|
+
command_cwd TEXT NOT NULL DEFAULT '',
|
|
53
|
+
project_sessions_dir TEXT NOT NULL DEFAULT '',
|
|
54
|
+
session_dir TEXT NOT NULL DEFAULT '',
|
|
55
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
56
|
+
model TEXT NOT NULL DEFAULT '',
|
|
57
|
+
goal TEXT NOT NULL DEFAULT '',
|
|
58
|
+
title TEXT NOT NULL DEFAULT '',
|
|
59
|
+
status TEXT NOT NULL DEFAULT '',
|
|
60
|
+
created_at TEXT NOT NULL DEFAULT '',
|
|
61
|
+
updated_at TEXT NOT NULL DEFAULT '',
|
|
62
|
+
ended_at TEXT,
|
|
63
|
+
result TEXT NOT NULL DEFAULT '',
|
|
64
|
+
error TEXT NOT NULL DEFAULT ''
|
|
65
|
+
);
|
|
66
|
+
`);
|
|
67
|
+
const existingColumns = db.prepare("PRAGMA table_info(sessions)").all();
|
|
68
|
+
if (!existingColumns.some((column) => column.name === "command_cwd")) {
|
|
69
|
+
try {
|
|
70
|
+
db.exec("ALTER TABLE sessions ADD COLUMN command_cwd TEXT NOT NULL DEFAULT ''");
|
|
71
|
+
} catch (error) {
|
|
72
|
+
const migratedColumns = db.prepare("PRAGMA table_info(sessions)").all();
|
|
73
|
+
if (!migratedColumns.some((column) => column.name === "command_cwd")) throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const connection = {
|
|
78
|
+
db,
|
|
79
|
+
upsert: db.prepare(
|
|
80
|
+
`INSERT INTO sessions (
|
|
81
|
+
session_id, project_root, command_cwd, project_sessions_dir, session_dir,
|
|
82
|
+
provider, model, goal, title, status, created_at, updated_at, ended_at, result, error
|
|
83
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
84
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
85
|
+
project_root = CASE WHEN excluded.project_root != '' THEN excluded.project_root ELSE sessions.project_root END,
|
|
86
|
+
command_cwd = CASE WHEN excluded.command_cwd != '' THEN excluded.command_cwd ELSE sessions.command_cwd END,
|
|
87
|
+
project_sessions_dir = CASE WHEN excluded.project_sessions_dir != '' THEN excluded.project_sessions_dir ELSE sessions.project_sessions_dir END,
|
|
88
|
+
session_dir = CASE WHEN excluded.session_dir != '' THEN excluded.session_dir ELSE sessions.session_dir END,
|
|
89
|
+
provider = CASE WHEN excluded.provider != '' THEN excluded.provider ELSE sessions.provider END,
|
|
90
|
+
model = CASE WHEN excluded.model != '' THEN excluded.model ELSE sessions.model END,
|
|
91
|
+
goal = CASE WHEN excluded.goal != '' THEN excluded.goal ELSE sessions.goal END,
|
|
92
|
+
title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
|
|
93
|
+
status = CASE WHEN excluded.status != '' THEN excluded.status ELSE sessions.status END,
|
|
94
|
+
updated_at = excluded.updated_at,
|
|
95
|
+
ended_at = excluded.ended_at,
|
|
96
|
+
result = CASE WHEN excluded.result != '' THEN excluded.result ELSE sessions.result END,
|
|
97
|
+
error = CASE WHEN excluded.error != '' THEN excluded.error ELSE sessions.error END`
|
|
98
|
+
),
|
|
99
|
+
rename: db.prepare("UPDATE sessions SET title = ?, updated_at = ? WHERE session_id = ?"),
|
|
100
|
+
remove: db.prepare("DELETE FROM sessions WHERE session_id = ?"),
|
|
101
|
+
listAll: db.prepare(`SELECT ${SESSION_COLUMNS} FROM sessions ORDER BY updated_at DESC LIMIT ?`),
|
|
102
|
+
listByProjectRoot: db.prepare(
|
|
103
|
+
`SELECT ${SESSION_COLUMNS} FROM sessions WHERE project_root = ? ORDER BY updated_at DESC LIMIT ?`
|
|
104
|
+
),
|
|
105
|
+
listByCommandCwd: db.prepare(
|
|
106
|
+
`SELECT ${SESSION_COLUMNS} FROM sessions WHERE command_cwd = ? ORDER BY updated_at DESC LIMIT ?`
|
|
107
|
+
),
|
|
108
|
+
listByProjectAndCwd: db.prepare(
|
|
109
|
+
`SELECT ${SESSION_COLUMNS} FROM sessions WHERE project_root = ? AND command_cwd = ? ORDER BY updated_at DESC LIMIT ?`
|
|
110
|
+
),
|
|
111
|
+
};
|
|
112
|
+
indexDbConnections.set(paths.indexDbPath, connection);
|
|
113
|
+
return connection;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
try {
|
|
116
|
+
db.close();
|
|
117
|
+
} catch {
|
|
118
|
+
// Preserve the initialization error.
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function sessionIndexConnectionCount() {
|
|
125
|
+
return [...indexDbConnections.values()].filter(Boolean).length;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function closeSessionIndexConnections() {
|
|
129
|
+
for (const connection of indexDbConnections.values()) {
|
|
130
|
+
try {
|
|
131
|
+
connection?.db.close();
|
|
132
|
+
} catch {
|
|
133
|
+
// Closing is best effort during tests and process shutdown.
|
|
134
|
+
}
|
|
58
135
|
}
|
|
59
|
-
|
|
136
|
+
indexDbConnections.clear();
|
|
60
137
|
}
|
|
61
138
|
|
|
62
139
|
function emptyIndexState() {
|
|
@@ -115,9 +192,9 @@ function normalizeIndexRecord(record = {}, sessionId = "") {
|
|
|
115
192
|
export function upsertSessionIndex(record = {}) {
|
|
116
193
|
const sessionId = String(record.sessionId || record.session_id || "").trim();
|
|
117
194
|
if (!isSafeSessionId(sessionId)) return false;
|
|
118
|
-
const
|
|
195
|
+
const index = ensureIndexDb();
|
|
119
196
|
const normalized = normalizeIndexRecord(record, sessionId);
|
|
120
|
-
if (!
|
|
197
|
+
if (!index) {
|
|
121
198
|
const state = readIndexJson();
|
|
122
199
|
const previous = state.sessions[sessionId] || {};
|
|
123
200
|
state.sessions[sessionId] = {
|
|
@@ -139,26 +216,7 @@ export function upsertSessionIndex(record = {}) {
|
|
|
139
216
|
return true;
|
|
140
217
|
}
|
|
141
218
|
const paths = globalSessionPaths(sessionId);
|
|
142
|
-
|
|
143
|
-
`INSERT INTO sessions (
|
|
144
|
-
session_id, project_root, command_cwd, project_sessions_dir, session_dir,
|
|
145
|
-
provider, model, goal, title, status, created_at, updated_at, ended_at, result, error
|
|
146
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
147
|
-
ON CONFLICT(session_id) DO UPDATE SET
|
|
148
|
-
project_root = CASE WHEN excluded.project_root != '' THEN excluded.project_root ELSE sessions.project_root END,
|
|
149
|
-
command_cwd = CASE WHEN excluded.command_cwd != '' THEN excluded.command_cwd ELSE sessions.command_cwd END,
|
|
150
|
-
project_sessions_dir = CASE WHEN excluded.project_sessions_dir != '' THEN excluded.project_sessions_dir ELSE sessions.project_sessions_dir END,
|
|
151
|
-
session_dir = CASE WHEN excluded.session_dir != '' THEN excluded.session_dir ELSE sessions.session_dir END,
|
|
152
|
-
provider = CASE WHEN excluded.provider != '' THEN excluded.provider ELSE sessions.provider END,
|
|
153
|
-
model = CASE WHEN excluded.model != '' THEN excluded.model ELSE sessions.model END,
|
|
154
|
-
goal = CASE WHEN excluded.goal != '' THEN excluded.goal ELSE sessions.goal END,
|
|
155
|
-
title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
|
|
156
|
-
status = CASE WHEN excluded.status != '' THEN excluded.status ELSE sessions.status END,
|
|
157
|
-
updated_at = excluded.updated_at,
|
|
158
|
-
ended_at = excluded.ended_at,
|
|
159
|
-
result = CASE WHEN excluded.result != '' THEN excluded.result ELSE sessions.result END,
|
|
160
|
-
error = CASE WHEN excluded.error != '' THEN excluded.error ELSE sessions.error END`
|
|
161
|
-
).run(
|
|
219
|
+
index.upsert.run(
|
|
162
220
|
sessionId,
|
|
163
221
|
normalized.projectRoot,
|
|
164
222
|
normalized.commandCwd,
|
|
@@ -180,8 +238,8 @@ export function upsertSessionIndex(record = {}) {
|
|
|
180
238
|
|
|
181
239
|
export function renameSessionIndex(sessionId, title) {
|
|
182
240
|
if (!isSafeSessionId(sessionId)) return false;
|
|
183
|
-
const
|
|
184
|
-
if (!
|
|
241
|
+
const index = ensureIndexDb();
|
|
242
|
+
if (!index) {
|
|
185
243
|
const state = readIndexJson();
|
|
186
244
|
if (!state.sessions[sessionId]) return false;
|
|
187
245
|
state.sessions[sessionId].title = String(title || "").trim();
|
|
@@ -189,30 +247,28 @@ export function renameSessionIndex(sessionId, title) {
|
|
|
189
247
|
writeIndexJson(state);
|
|
190
248
|
return true;
|
|
191
249
|
}
|
|
192
|
-
const result =
|
|
193
|
-
.prepare("UPDATE sessions SET title = ?, updated_at = ? WHERE session_id = ?")
|
|
194
|
-
.run(String(title || "").trim(), new Date().toISOString(), sessionId);
|
|
250
|
+
const result = index.rename.run(String(title || "").trim(), new Date().toISOString(), sessionId);
|
|
195
251
|
return result.changes > 0;
|
|
196
252
|
}
|
|
197
253
|
|
|
198
254
|
export function deleteSessionIndex(sessionId) {
|
|
199
255
|
if (!isSafeSessionId(sessionId)) return false;
|
|
200
|
-
const
|
|
201
|
-
if (!
|
|
256
|
+
const index = ensureIndexDb();
|
|
257
|
+
if (!index) {
|
|
202
258
|
const state = readIndexJson();
|
|
203
259
|
if (!state.sessions[sessionId]) return false;
|
|
204
260
|
delete state.sessions[sessionId];
|
|
205
261
|
writeIndexJson(state);
|
|
206
262
|
return true;
|
|
207
263
|
}
|
|
208
|
-
const result =
|
|
264
|
+
const result = index.remove.run(sessionId);
|
|
209
265
|
return result.changes > 0;
|
|
210
266
|
}
|
|
211
267
|
|
|
212
268
|
export function listSessionIndex({ projectRoot = "", commandCwd = "", limit = 100 } = {}) {
|
|
213
|
-
const
|
|
269
|
+
const index = ensureIndexDb();
|
|
214
270
|
const maxRows = Math.min(Math.max(Number(limit) || 100, 1), 1000);
|
|
215
|
-
if (!
|
|
271
|
+
if (!index) {
|
|
216
272
|
const resolvedProjectRoot = projectRoot ? path.resolve(projectRoot) : "";
|
|
217
273
|
const resolvedCommandCwd = commandCwd ? path.resolve(commandCwd) : "";
|
|
218
274
|
return Object.values(readIndexJson().sessions)
|
|
@@ -224,23 +280,12 @@ export function listSessionIndex({ projectRoot = "", commandCwd = "", limit = 10
|
|
|
224
280
|
.sort((left, right) => String(right.updatedAt || "").localeCompare(String(left.updatedAt || "")))
|
|
225
281
|
.slice(0, maxRows);
|
|
226
282
|
}
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const params = [];
|
|
232
|
-
if (projectRoot) {
|
|
233
|
-
clauses.push("project_root = ?");
|
|
234
|
-
params.push(path.resolve(projectRoot));
|
|
235
|
-
}
|
|
236
|
-
if (commandCwd) {
|
|
237
|
-
clauses.push("command_cwd = ?");
|
|
238
|
-
params.push(path.resolve(commandCwd));
|
|
239
|
-
}
|
|
240
|
-
if (clauses.length > 0) {
|
|
241
|
-
return db
|
|
242
|
-
.prepare(`SELECT ${columns} FROM sessions WHERE ${clauses.join(" AND ")} ORDER BY updated_at DESC LIMIT ?`)
|
|
243
|
-
.all(...params, maxRows);
|
|
283
|
+
const resolvedProjectRoot = projectRoot ? path.resolve(projectRoot) : "";
|
|
284
|
+
const resolvedCommandCwd = commandCwd ? path.resolve(commandCwd) : "";
|
|
285
|
+
if (resolvedProjectRoot && resolvedCommandCwd) {
|
|
286
|
+
return index.listByProjectAndCwd.all(resolvedProjectRoot, resolvedCommandCwd, maxRows);
|
|
244
287
|
}
|
|
245
|
-
|
|
288
|
+
if (resolvedProjectRoot) return index.listByProjectRoot.all(resolvedProjectRoot, maxRows);
|
|
289
|
+
if (resolvedCommandCwd) return index.listByCommandCwd.all(resolvedCommandCwd, maxRows);
|
|
290
|
+
return index.listAll.all(maxRows);
|
|
246
291
|
}
|
package/src/session-store.js
CHANGED
|
@@ -75,6 +75,26 @@ async function appendDurably(filePath, content) {
|
|
|
75
75
|
if (!existed) await syncDirectory(directoryPath);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
async function loadStateFile(filePath) {
|
|
79
|
+
let raw;
|
|
80
|
+
try {
|
|
81
|
+
raw = await fs.readFile(filePath, "utf8");
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error?.code === "ENOENT") return null;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(raw);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const corruptionError = new Error(`Session state is not valid JSON: ${filePath}`);
|
|
91
|
+
corruptionError.name = "SessionStateCorruptionError";
|
|
92
|
+
corruptionError.code = "SESSION_STATE_CORRUPT";
|
|
93
|
+
corruptionError.cause = error;
|
|
94
|
+
throw corruptionError;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
78
98
|
function inboxItemKey(item = {}) {
|
|
79
99
|
const id = String(item.id || "").trim();
|
|
80
100
|
if (id) return `id:${id}`;
|
|
@@ -167,11 +187,23 @@ export class SessionStore {
|
|
|
167
187
|
DEFAULT_INBOX_COMPACTION_BYTE_THRESHOLD
|
|
168
188
|
);
|
|
169
189
|
this.inboxLockTimeoutMs = positiveInteger(options.inboxLockTimeoutMs, DEFAULT_INBOX_LOCK_TIMEOUT_MS);
|
|
190
|
+
this.ensurePromise = null;
|
|
191
|
+
this.eventAppendTail = Promise.resolve();
|
|
170
192
|
}
|
|
171
193
|
|
|
172
194
|
async ensure() {
|
|
173
|
-
|
|
174
|
-
|
|
195
|
+
if (!this.ensurePromise) {
|
|
196
|
+
this.ensurePromise = (async () => {
|
|
197
|
+
await fs.mkdir(this.artifactsDir, { recursive: true });
|
|
198
|
+
await this.writePointer().catch(() => {});
|
|
199
|
+
})();
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
await this.ensurePromise;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
this.ensurePromise = null;
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
175
207
|
}
|
|
176
208
|
|
|
177
209
|
async writePointer(state = {}) {
|
|
@@ -196,20 +228,10 @@ export class SessionStore {
|
|
|
196
228
|
}
|
|
197
229
|
|
|
198
230
|
async loadState() {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (this.legacySessionDir) {
|
|
204
|
-
try {
|
|
205
|
-
const raw = await fs.readFile(path.join(this.legacySessionDir, "state.json"), "utf8");
|
|
206
|
-
return JSON.parse(raw);
|
|
207
|
-
} catch {
|
|
208
|
-
return null;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
return null;
|
|
212
|
-
}
|
|
231
|
+
const currentState = await loadStateFile(this.statePath);
|
|
232
|
+
if (currentState !== null) return currentState;
|
|
233
|
+
if (!this.legacySessionDir) return null;
|
|
234
|
+
return loadStateFile(path.join(this.legacySessionDir, "state.json"));
|
|
213
235
|
}
|
|
214
236
|
|
|
215
237
|
async saveState(state) {
|
|
@@ -248,20 +270,24 @@ export class SessionStore {
|
|
|
248
270
|
}
|
|
249
271
|
|
|
250
272
|
async appendEvent(type, data = {}) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
273
|
+
const operation = this.eventAppendTail.then(async () => {
|
|
274
|
+
await this.ensure();
|
|
275
|
+
const event = {
|
|
276
|
+
timestamp: new Date().toISOString(),
|
|
277
|
+
type,
|
|
278
|
+
data,
|
|
279
|
+
};
|
|
280
|
+
const line = JSON.stringify(event);
|
|
281
|
+
await fs.appendFile(this.eventsPath, `${line}\n`, "utf8");
|
|
282
|
+
enqueueHousekeepingEvent({
|
|
283
|
+
sessionId: this.sessionId,
|
|
284
|
+
projectRoot: this.projectRoot,
|
|
285
|
+
commandCwd: this.commandCwd,
|
|
286
|
+
event,
|
|
287
|
+
});
|
|
264
288
|
});
|
|
289
|
+
this.eventAppendTail = operation.catch(() => {});
|
|
290
|
+
return operation;
|
|
265
291
|
}
|
|
266
292
|
|
|
267
293
|
async loadEvents() {
|
|
@@ -681,8 +707,11 @@ export class SessionStore {
|
|
|
681
707
|
}
|
|
682
708
|
|
|
683
709
|
async remove() {
|
|
710
|
+
await this.eventAppendTail.catch(() => {});
|
|
684
711
|
await fs.rm(this.sessionDir, { recursive: true, force: true });
|
|
685
712
|
if (this.pointerDir) await fs.rm(this.pointerDir, { recursive: true, force: true }).catch(() => {});
|
|
686
713
|
deleteSessionIndex(this.sessionId);
|
|
714
|
+
this.ensurePromise = null;
|
|
715
|
+
this.eventAppendTail = Promise.resolve();
|
|
687
716
|
}
|
|
688
717
|
}
|