openclaw-lark-multi-agent 1.8.4 → 1.8.6
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/README.md +23 -17
- package/README.zh-CN.md +3 -1
- package/dist/cli.js +19 -20
- package/dist/feishu-bot.d.ts +10 -0
- package/dist/feishu-bot.js +611 -363
- package/dist/message-store.d.ts +63 -1
- package/dist/message-store.js +336 -8
- package/dist/openclaw-client.d.ts +50 -24
- package/dist/openclaw-client.js +401 -133
- package/dist/session-status.d.ts +2 -0
- package/dist/session-status.js +1 -0
- package/docs/releases/1.8.5.md +65 -0
- package/package.json +9 -3
- package/plugins/lma-core/README.md +83 -0
- package/plugins/lma-core/dist/compaction.js +82 -0
- package/plugins/lma-core/dist/go-policy.js +25 -0
- package/plugins/lma-core/dist/go-registry.js +37 -0
- package/plugins/lma-core/dist/go.js +42 -0
- package/plugins/lma-core/dist/index.js +29 -0
- package/plugins/lma-core/dist/steer.js +212 -0
- package/plugins/lma-core/dist/tooltrim.js +152 -0
- package/plugins/lma-core/openclaw.plugin.json +45 -0
- package/plugins/lma-core/package.json +15 -0
- package/plugins/lma-steer/README.md +7 -0
- package/scripts/audit-delivery-context.mjs +49 -0
package/dist/session-status.d.ts
CHANGED
package/dist/session-status.js
CHANGED
|
@@ -14,6 +14,7 @@ export function normalizeSessionRuntimeStatus(value) {
|
|
|
14
14
|
const contextUsedTokens = session?.totalTokensFresh !== false && typeof used === "number" && Number.isFinite(used) && used >= 0 ? used : undefined;
|
|
15
15
|
const contextLimitTokens = typeof limit === "number" && Number.isFinite(limit) && limit > 0 ? limit : undefined;
|
|
16
16
|
return { status: running ? "running" : status, running, checkedAt: Date.now(),
|
|
17
|
+
...(session?.hasActiveRun === false && !running ? { idleConfirmed: true } : {}),
|
|
17
18
|
...(contextUsedTokens !== undefined ? { contextUsedTokens } : {}),
|
|
18
19
|
...(contextLimitTokens !== undefined ? { contextLimitTokens } : {}),
|
|
19
20
|
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# 1.8.5 — reliability candidate (not published)
|
|
2
|
+
|
|
3
|
+
- Long silent tools/API calls have no LMA 24-hour failure horizon. Ten-minute
|
|
4
|
+
silence changes presentation only; exact-run custody survives disconnect and
|
|
5
|
+
process restart in LMA-owned SQLite. Queries have short independent deadlines.
|
|
6
|
+
- Superseded observers transfer exact-run delivery custody synchronously. Failed
|
|
7
|
+
replacement admission leaves a healthy old collector alive. Admission, owner,
|
|
8
|
+
actual batch and persistent stop generations fence late work and UI updates.
|
|
9
|
+
- Inbound requests start with Typing. Get requires actual accepted custody, never
|
|
10
|
+
local queueing, send initiation, a boolean eligibility value or `in_flight`.
|
|
11
|
+
DONE belongs to the exact settled task. UI API failure is independently retried
|
|
12
|
+
and cannot block admission or result delivery.
|
|
13
|
+
- Body, file/image/document and supplemental outputs share durable platform
|
|
14
|
+
operation receipts and atomic context/checkpoint registration. Unknown platform
|
|
15
|
+
outcomes fence retry/fallback. Exact platform IDs deduplicate echoes; independent
|
|
16
|
+
identical-text tasks remain distinct. Confirmed partial chunks are recoverable.
|
|
17
|
+
- Historical context preview/explicit registration (never resend):
|
|
18
|
+
`node scripts/audit-delivery-context.mjs --db <explicit-path> --bot <name>`.
|
|
19
|
+
`--register --outbox <id> --chat <id> --verified-message-id <actual-id>` requires
|
|
20
|
+
independently verified receipts. Upgrade does **not** auto-backfill live history.
|
|
21
|
+
|
|
22
|
+
## Persistent real-time insertion (coordinated bridge + lma-core 0.2.0)
|
|
23
|
+
|
|
24
|
+
- Busy-session inputs reserve durable message custody before ingress awaits.
|
|
25
|
+
Short submit/query RPCs and one paced bridge recovery scheduler retain the
|
|
26
|
+
original input beyond 3/8 seconds and hours of not-ready observations. There
|
|
27
|
+
is no insertion age deadline or automatic ordinary-send timeout fallback.
|
|
28
|
+
- The production plugin now actually invokes the **public**
|
|
29
|
+
`queueAgentHarnessMessage(..., { onQueueAccepted })`. Only the backend's
|
|
30
|
+
`onQueueAccepted(true)` is persisted as acceptance and drives Get. Immediate
|
|
31
|
+
SDK `true` means an attempt, not a receipt. SDK `false` or explicit callback
|
|
32
|
+
`false` is a no-effect refusal and permits paced retry of the original target.
|
|
33
|
+
- Stable input ID + payload/session-generation/run/stop identity and fsynced
|
|
34
|
+
plugin attempt journal make late callbacks queryable after RPC timeout or
|
|
35
|
+
disconnect. Restart preserves accepted/unknown. Missing callbacks, missing
|
|
36
|
+
journals after a possible attempt and crash ambiguity stay **unknown**, never
|
|
37
|
+
ordinary-replayed; `queueIdentity` is NOT assumed to deduplicate host effects.
|
|
38
|
+
- Canonical bot/session generation, archive/reset and persistent stop epochs
|
|
39
|
+
fence admission and reactions. Lifecycle + public exact-terminal observations
|
|
40
|
+
prevent a known ended original run from being SDK-retargeted to its successor.
|
|
41
|
+
Only an input proven never dispatched to the backend, with confirmed original
|
|
42
|
+
terminal and session idle, is explicitly handed back to the ordinary queue
|
|
43
|
+
once, with a status notice. Successor runs in the same session are legitimate
|
|
44
|
+
session work, not a different chat; they are not silently selected by retries.
|
|
45
|
+
|
|
46
|
+
### Honest public-runtime boundary (not a feature-disable gate)
|
|
47
|
+
|
|
48
|
+
`atomicRunBound` remains **false**: public SDK resolution plus queue invocation
|
|
49
|
+
is synchronous locally, but the host offers no portable final-effect original-
|
|
50
|
+
run CAS across awaited backend preparation. We do **not** claim that stronger
|
|
51
|
+
host guarantee or model consumption. It is not a prerequisite for the user's
|
|
52
|
+
current-session input semantics, so builtin insertion is enabled with truthful
|
|
53
|
+
callback receipts instead of being universally `capability_blocked`.
|
|
54
|
+
|
|
55
|
+
Both inspected builtin versions implement the acceptance callback. A legacy or
|
|
56
|
+
alternative backend that never calls it remains **acceptance unknown** with no
|
|
57
|
+
Get/no automatic resend. Real public-SDK typed synthetic-harness and application
|
|
58
|
+
integration tests execute queue methods; these are offline transport conformance,
|
|
59
|
+
not live model/Feishu acceptance or proof that every optional backend supports
|
|
60
|
+
callbacks. No private SDK imports, synthetic authority, host DB edits or system
|
|
61
|
+
installation changes are involved.
|
|
62
|
+
|
|
63
|
+
This coordinated protocol candidate requires the matching bridge and lma-core
|
|
64
|
+
plugin. This file is release preparation, **not permission to publish, deploy or
|
|
65
|
+
restart anything**.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-lark-multi-agent",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.6",
|
|
4
4
|
"description": "Multi-bot Lark/Feishu bridge for OpenClaw, with per-bot model routing and isolated sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"prepack": "npm run build",
|
|
14
14
|
"prepublishOnly": "npm test && npm pack --dry-run",
|
|
15
15
|
"build:go-plugin": "tsc -p plugins/lma-go/tsconfig.json",
|
|
16
|
-
"build:core-plugin": "tsc -p plugins/lma-core/tsconfig.json"
|
|
16
|
+
"build:core-plugin": "tsc -p plugins/lma-core/tsconfig.json",
|
|
17
|
+
"test:sdk-contract": "tsc --noEmit --strict --target ES2022 --module ESNext --moduleResolution bundler --skipLibCheck tests/reliability-public-sdk.test.ts"
|
|
17
18
|
},
|
|
18
19
|
"dependencies": {
|
|
19
20
|
"@larksuiteoapi/node-sdk": "^1.73.3",
|
|
@@ -67,7 +68,12 @@
|
|
|
67
68
|
"README.md",
|
|
68
69
|
"README.zh-CN.md",
|
|
69
70
|
"LICENSE",
|
|
70
|
-
"SECURITY.md"
|
|
71
|
+
"SECURITY.md",
|
|
72
|
+
"plugins/lma-core/dist",
|
|
73
|
+
"plugins/lma-core/openclaw.plugin.json",
|
|
74
|
+
"plugins/lma-core/package.json",
|
|
75
|
+
"plugins/lma-core/README.md",
|
|
76
|
+
"docs/releases/1.8.5.md"
|
|
71
77
|
],
|
|
72
78
|
"engines": {
|
|
73
79
|
"node": ">=22"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# lma-core
|
|
2
|
+
|
|
3
|
+
The single consolidated LMA gateway plugin. Replaces:
|
|
4
|
+
|
|
5
|
+
- `lma-steer` 0.1.1 → steer module (`lma.steer`)
|
|
6
|
+
- `lma-go` 0.1.0 → go module (`lma.go.bind/capabilities/snapshot`, `before_tool_call` guard)
|
|
7
|
+
|
|
8
|
+
plus the new compaction R&D module (`lma.core.capabilities`).
|
|
9
|
+
|
|
10
|
+
## Why one plugin
|
|
11
|
+
|
|
12
|
+
Each extra plugin is one more manifest + allowlist + upgrade path that can
|
|
13
|
+
silently fail to load (two production incidents in one week proved it).
|
|
14
|
+
lma-core keeps ALL gateway method names and the persisted go-bindings path
|
|
15
|
+
(`<stateDir>/lma-go/bindings.json`) unchanged, so the LMA bridge and existing
|
|
16
|
+
state migrate with zero changes.
|
|
17
|
+
|
|
18
|
+
## Gateway methods
|
|
19
|
+
|
|
20
|
+
| Method | Scope | Purpose |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `lma.steer` | (bridge) | Durable session-input submit/query; actual backend callback acceptance |
|
|
23
|
+
| `lma.steer.stop` | (bridge) | Persist session stop-epoch cutoff; late old attempts cannot revive |
|
|
24
|
+
| `lma.steer.capabilities` | (bridge) | Truthful acceptance/atomic-target capability report |
|
|
25
|
+
| `lma.go.bind` | operator.admin | Persist a checker tool-isolation binding |
|
|
26
|
+
| `lma.go.capabilities` | operator.read | Report Go module capability surface |
|
|
27
|
+
| `lma.go.snapshot` | operator.read | Session-scoped task snapshot |
|
|
28
|
+
| `lma.core.capabilities` | operator.read | Module inventory + compaction strategy report |
|
|
29
|
+
|
|
30
|
+
## Compaction R&D (Phase 1)
|
|
31
|
+
|
|
32
|
+
Custom compaction (tool-trim: drop tool payload, keep text) is being built as
|
|
33
|
+
a gateway-sanctioned projection — the transcript stays intact; only the model
|
|
34
|
+
context gets filtered. Phase 1 ships the capability report and an
|
|
35
|
+
env-gated api-surface probe (`LMA_CORE_LOG_API=1` logs the plugin api's
|
|
36
|
+
enumerable surface at registration) so Phase 2 targets real hook names
|
|
37
|
+
(candidates: `llmInputHook`, `Before/AfterCompactionHook`, ContextEngine
|
|
38
|
+
projections).
|
|
39
|
+
|
|
40
|
+
## Build
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cd plugins/lma-core && ../../node_modules/.bin/tsc -p tsconfig.json
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
(Repo root `npm run build:core-plugin` runs the same.)
|
|
47
|
+
|
|
48
|
+
## 0.2.0 persistent callback-confirmed insertion
|
|
49
|
+
|
|
50
|
+
`lma.steer.capabilities` returns protocol 2, `acceptanceCallback:true`,
|
|
51
|
+
`durableAttempts:true`, `sessionGenerationBound:true`, **`atomicRunBound:false`**.
|
|
52
|
+
The latter is a disclosed public-host limitation, not a gate disabling insertion.
|
|
53
|
+
|
|
54
|
+
`lma.steer` requires `sessionKey`, `text`, stable `insertionId`, `expectedRunId`,
|
|
55
|
+
`expectedSessionId`, and integer `stopEpoch`. The bridge pins these before its
|
|
56
|
+
attempt journal and drives paced recovery; the method returns promptly rather
|
|
57
|
+
than holding an RPC for readiness/acceptance. `queryOnly:true` never queues.
|
|
58
|
+
The plugin synchronously resolves/checks/dispatches using public SDK exports,
|
|
59
|
+
fsyncs an unknown-attempt fence before queue invocation, and records the actual
|
|
60
|
+
`onQueueAccepted(true/false)` asynchronously under that exact attempt identity.
|
|
61
|
+
No `deliveryTimeoutMs` or unverified cross-version abort behavior is assumed.
|
|
62
|
+
|
|
63
|
+
- SDK immediate false: no queue dispatch, pending/no-effect, safe paced retry.
|
|
64
|
+
- SDK immediate true: attempt/unknown; only backend callback true permits Get.
|
|
65
|
+
- Explicit callback false: no-effect refusal; retries remain original-target
|
|
66
|
+
scoped and paced, never a switch to whatever run happens to be current.
|
|
67
|
+
- No callback, lost journal after attempt, crash at/before dispatch: unknown,
|
|
68
|
+
never a retry or ordinary fallback. Stable queue ID is not host idempotency.
|
|
69
|
+
- Delayed callback true: durable accepted, readable on later query/restart.
|
|
70
|
+
- Stop/reset epoch or changed generation/archive: cancel/pause, no migration.
|
|
71
|
+
- Exact old-run terminal + proven never dispatched + positive session idle: the bridge
|
|
72
|
+
explicitly releases the original pending input to ordinary custody once.
|
|
73
|
+
|
|
74
|
+
The journal lives under `<OPENCLAW_STATE_DIR>/lma-core/steer-custody` (default
|
|
75
|
+
`~/.openclaw`). Only explicit never-attempted `capability_blocked` records from
|
|
76
|
+
an earlier local candidate are migrated; attempted uncertainty is not erased.
|
|
77
|
+
No OpenClaw internal DB, private hashed imports, handle replacement or invented
|
|
78
|
+
source authority is used. Backend acceptance is not model consumption or DONE.
|
|
79
|
+
|
|
80
|
+
Both inspected builtin SDK versions call the acceptance callback. Backends that
|
|
81
|
+
omit it stay explicitly unconfirmed and cannot get Get. The public SDK does not
|
|
82
|
+
provide final-effect original-run CAS across backend awaits: session generation
|
|
83
|
+
and observed lifecycle fencing must not be advertised as that stronger promise.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lma-core :: compaction module (Phase 1: capability surface)
|
|
3
|
+
* -----------------------------------------------------------
|
|
4
|
+
* Home of the custom compaction R&D (user directive 2026-10-10: tool-trim —
|
|
5
|
+
* drop tool payload, keep text — via the gateway's sanctioned internals
|
|
6
|
+
* instead of external storage writes).
|
|
7
|
+
*
|
|
8
|
+
* Phase 1 ships:
|
|
9
|
+
* - `lma.core.capabilities`: module inventory + compaction strategy report.
|
|
10
|
+
* - Registration-time api-surface probe (env LMA_CORE_LOG_API=1): logs the
|
|
11
|
+
* plugin api's enumerable surface so Phase 2 can target the real
|
|
12
|
+
* llm-input / compaction hook names instead of guessing.
|
|
13
|
+
*
|
|
14
|
+
* Phase 2 (next): tool-trim projection using the discovered hooks
|
|
15
|
+
* (candidates from the SDK: runAgentHarnessLlmInputHook,
|
|
16
|
+
* Before/AfterCompactionHook, ContextEngine projections).
|
|
17
|
+
*/
|
|
18
|
+
export function registerCompaction(api) {
|
|
19
|
+
if (process.env.LMA_CORE_LOG_API === "1") {
|
|
20
|
+
try {
|
|
21
|
+
const surface = Object.getOwnPropertyNames(Object.getPrototypeOf(api) ?? {})
|
|
22
|
+
.concat(Object.keys(api ?? {}));
|
|
23
|
+
const unique = Array.from(new Set(surface)).sort();
|
|
24
|
+
console.log(`[lma-core] compaction probe: plugin api surface (${unique.length}): ${unique.join(", ")}`);
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
console.warn(`[lma-core] compaction probe failed:`, err.message);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
api.registerGatewayMethod("lma.core.probe", ({ respond }) => {
|
|
31
|
+
// Phase-2 R&D: dump the live plugin api + runtime surface (names only,
|
|
32
|
+
// no values — nothing sensitive) so toolTrim targets real methods.
|
|
33
|
+
const surface = (obj, depth = 0) => {
|
|
34
|
+
if (!obj || typeof obj !== "object" || depth > 2)
|
|
35
|
+
return typeof obj;
|
|
36
|
+
const out = {};
|
|
37
|
+
let keys = [];
|
|
38
|
+
try {
|
|
39
|
+
keys = Object.keys(obj);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return "inaccessible";
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const proto = Object.getPrototypeOf(obj);
|
|
46
|
+
if (proto && proto !== Object.prototype) {
|
|
47
|
+
keys = Array.from(new Set([...Object.getOwnPropertyNames(proto), ...keys]));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch { /* ignore */ }
|
|
51
|
+
for (const k of keys.slice(0, 120)) {
|
|
52
|
+
let v;
|
|
53
|
+
try {
|
|
54
|
+
v = obj[k];
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
out[k] = "getter-threw";
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
out[k] = typeof v === "function" ? "fn" : surface(v, depth + 1);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
};
|
|
64
|
+
respond(true, { api: surface(api), version: "0.1.0" });
|
|
65
|
+
}, { scope: "operator.read" });
|
|
66
|
+
api.registerGatewayMethod("lma.core.capabilities", ({ respond }) => {
|
|
67
|
+
respond(true, {
|
|
68
|
+
plugin: "lma-core",
|
|
69
|
+
modules: ["steer", "go", "compaction"],
|
|
70
|
+
methods: ["lma.steer", "lma.go.bind", "lma.go.capabilities", "lma.go.snapshot", "lma.core.capabilities"],
|
|
71
|
+
compaction: {
|
|
72
|
+
rpcStrategies: ["semantic (LLM summarize)", "maxLines (transcript tail truncate)"],
|
|
73
|
+
toolTrim: {
|
|
74
|
+
status: "phase-1-probe",
|
|
75
|
+
target: "projection-style: keep transcript intact, filter tool payload from model context",
|
|
76
|
+
candidateHooks: ["llmInputHook", "beforeCompactionHook", "contextEngineProjection"],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
version: "0.1.0",
|
|
80
|
+
});
|
|
81
|
+
}, { scope: "operator.read" });
|
|
82
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function checkTool(bindings, event, ctx) {
|
|
2
|
+
for (const b of bindings) {
|
|
3
|
+
// Deny also on changed/missing generation: an old checker must not become unrestricted.
|
|
4
|
+
if (ctx.sessionKey === b.checkerSessionKey || ctx.sessionId === b.checkerSessionId)
|
|
5
|
+
return { block: true, blockReason: "LMA Go message-review Checker cannot invoke tools" };
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function parseBindings(value) {
|
|
9
|
+
if (!Array.isArray(value))
|
|
10
|
+
throw new Error("bindings must be an array");
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
return value.map((item) => {
|
|
13
|
+
if (!item || typeof item !== "object")
|
|
14
|
+
throw new Error("invalid binding");
|
|
15
|
+
for (const k of ["checkerSessionKey", "checkerSessionId", "executorSessionKey", "executorSessionId"])
|
|
16
|
+
if (typeof item[k] !== "string" || !item[k].trim())
|
|
17
|
+
throw new Error(`missing ${k}`);
|
|
18
|
+
if (!item.checkerSessionKey.startsWith("agent:") || !item.executorSessionKey.startsWith("agent:"))
|
|
19
|
+
throw new Error("canonical session keys required");
|
|
20
|
+
if (item.checkerSessionKey === item.executorSessionKey || item.checkerSessionId === item.executorSessionId || seen.has(item.checkerSessionKey))
|
|
21
|
+
throw new Error("ambiguous binding");
|
|
22
|
+
seen.add(item.checkerSessionKey);
|
|
23
|
+
return { ...item };
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { parseBindings } from "./go-policy.js";
|
|
4
|
+
/** Atomic persisted restriction registry. Never auto-unbind on restart/stop. */
|
|
5
|
+
export class GoPolicyRegistry {
|
|
6
|
+
path;
|
|
7
|
+
bindings;
|
|
8
|
+
constructor(path, initial = []) {
|
|
9
|
+
this.path = path;
|
|
10
|
+
let value;
|
|
11
|
+
try {
|
|
12
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
if (e.code !== "ENOENT")
|
|
16
|
+
throw e;
|
|
17
|
+
value = initial;
|
|
18
|
+
}
|
|
19
|
+
this.bindings = parseBindings(value);
|
|
20
|
+
}
|
|
21
|
+
list() { return this.bindings.map(b => ({ ...b })); }
|
|
22
|
+
bind(value) {
|
|
23
|
+
const [binding] = parseBindings([value]);
|
|
24
|
+
const previous = this.bindings.find(b => b.checkerSessionKey === binding.checkerSessionKey);
|
|
25
|
+
if (previous) {
|
|
26
|
+
if (JSON.stringify(previous) !== JSON.stringify(binding))
|
|
27
|
+
throw new Error("Binding identity conflict");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const next = parseBindings([...this.bindings, binding]);
|
|
31
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
32
|
+
const temp = `${this.path}.tmp`;
|
|
33
|
+
writeFileSync(temp, JSON.stringify(next), { mode: 0o600 });
|
|
34
|
+
renameSync(temp, this.path);
|
|
35
|
+
this.bindings = next;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { checkTool } from "./go-policy.js";
|
|
4
|
+
import { GoPolicyRegistry } from "./go-registry.js";
|
|
5
|
+
/**
|
|
6
|
+
* lma-core :: go module
|
|
7
|
+
* ---------------------
|
|
8
|
+
* Exact-identity persisted Go tool enforcement for the independent LMA Go
|
|
9
|
+
* Checker (message-review checkers may not invoke tools).
|
|
10
|
+
*
|
|
11
|
+
* Gateway methods (names kept identical to the former standalone lma-go
|
|
12
|
+
* plugin): `lma.go.bind`, `lma.go.capabilities`, `lma.go.snapshot`.
|
|
13
|
+
* The persisted registry path is also unchanged
|
|
14
|
+
* (`<stateDir>/lma-go/bindings.json`) so existing bindings carry over.
|
|
15
|
+
*/
|
|
16
|
+
export function registerGo(api) {
|
|
17
|
+
const registry = new GoPolicyRegistry(join(resolveStateDir(), "lma-go", "bindings.json"), api.pluginConfig?.bindings ?? []);
|
|
18
|
+
api.on("before_tool_call", (event, ctx) => checkTool(registry.list(), event, ctx), { priority: 10000 });
|
|
19
|
+
api.registerGatewayMethod("lma.go.bind", ({ params, respond }) => {
|
|
20
|
+
try {
|
|
21
|
+
registry.bind(params.binding);
|
|
22
|
+
respond(true, { persisted: true, binding: params.binding });
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
respond(false, undefined, { code: "INVALID_REQUEST", message: e.message });
|
|
26
|
+
}
|
|
27
|
+
}, { scope: "operator.admin" });
|
|
28
|
+
api.registerGatewayMethod("lma.go.capabilities", ({ respond }) => {
|
|
29
|
+
respond(true, { mode: "independent", checkerToolsIsolated: true, taskQuery: true,
|
|
30
|
+
atomicSnapshot: false, nativeGoalAuthority: false });
|
|
31
|
+
}, { scope: "operator.read" });
|
|
32
|
+
api.registerGatewayMethod("lma.go.snapshot", ({ params, respond }) => {
|
|
33
|
+
if (typeof params.sessionKey !== "string" || typeof params.sessionId !== "string") {
|
|
34
|
+
respond(false, undefined, { code: "INVALID_REQUEST", message: "exact session identity required" });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const tasks = api.runtime.tasks.runs.bindSession({ sessionKey: params.sessionKey }).list();
|
|
38
|
+
respond(true, { sessionKey: params.sessionKey, requestedSessionId: params.sessionId, tasks,
|
|
39
|
+
queryAvailable: true, atomicSnapshot: false,
|
|
40
|
+
boundaries: ["tasks scoped to canonical key; adapter pins generation and run ids", "LMA checks its own queue and delivery ledger"] });
|
|
41
|
+
}, { scope: "operator.read" });
|
|
42
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
|
+
import { registerSteer } from "./steer.js";
|
|
3
|
+
import { registerGo } from "./go.js";
|
|
4
|
+
import { registerCompaction } from "./compaction.js";
|
|
5
|
+
import { registerToolTrim } from "./tooltrim.js";
|
|
6
|
+
/**
|
|
7
|
+
* lma-core — the single consolidated LMA gateway plugin
|
|
8
|
+
* ------------------------------------------------------
|
|
9
|
+
* Replaces the former standalone plugins:
|
|
10
|
+
* - lma-steer (0.1.1) -> steer module (`lma.steer`)
|
|
11
|
+
* - lma-go (0.1.0) -> go module (`lma.go.*`, before_tool_call guard)
|
|
12
|
+
* plus the new compaction R&D module (`lma.core.capabilities`).
|
|
13
|
+
*
|
|
14
|
+
* One manifest, one allowlist entry, one upgrade path. All gateway method
|
|
15
|
+
* names and the persisted go-bindings path are unchanged, so the LMA bridge
|
|
16
|
+
* and existing state migrate with zero changes.
|
|
17
|
+
*/
|
|
18
|
+
export default definePluginEntry({
|
|
19
|
+
id: "lma-core",
|
|
20
|
+
name: "LMA Core",
|
|
21
|
+
description: "Consolidated LMA plugin: mid-run steer injection, Go checker tool enforcement, custom compaction R&D.",
|
|
22
|
+
register(api) {
|
|
23
|
+
registerSteer(api);
|
|
24
|
+
registerGo(api);
|
|
25
|
+
registerCompaction(api);
|
|
26
|
+
registerToolTrim(api);
|
|
27
|
+
console.log("[lma-core] registered: steer (lma.steer), go (lma.go.*), compaction (lma.core.*), tooltrim (lma.core.compact.tooltrim)");
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { resolveActiveEmbeddedRunSessionId, queueAgentHarnessMessage, onAgentEvent } from "openclaw/plugin-sdk/agent-harness-runtime";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync, openSync, fsyncSync, closeSync } from "node:fs";
|
|
4
|
+
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
const canonical = (key) => /^agent:/.test(key) ? key : `agent:main:${key}`;
|
|
7
|
+
const nonempty = (value) => typeof value === 'string' && !!value.trim();
|
|
8
|
+
/** Short submit/query RPCs. The bridge owns one paced recovery scheduler, so
|
|
9
|
+
* callback lifetime is independent of the RPC deadline. Resolve + queue are
|
|
10
|
+
* synchronous on one event-loop turn. This is session-generation custody and
|
|
11
|
+
* observed lifecycle fencing, NOT a host final-effect original-run CAS. */
|
|
12
|
+
export function registerSteer(api) {
|
|
13
|
+
const dir = join(resolveStateDir(), 'lma-core', 'steer-custody');
|
|
14
|
+
const active = new Map(), ended = new Set();
|
|
15
|
+
const unsubscribe = onAgentEvent(event => {
|
|
16
|
+
if (!event.sessionKey || !event.runId || event.stream !== 'lifecycle')
|
|
17
|
+
return;
|
|
18
|
+
const key = canonical(event.sessionKey), phase = String(event.data?.phase);
|
|
19
|
+
if (['end', 'error'].includes(phase)) {
|
|
20
|
+
ended.add(`${key}\0${event.runId}`);
|
|
21
|
+
if (active.get(key)?.runId === event.runId)
|
|
22
|
+
active.delete(key);
|
|
23
|
+
if (ended.size > 2048)
|
|
24
|
+
ended.delete(ended.values().next().value);
|
|
25
|
+
}
|
|
26
|
+
else if (phase === 'start') {
|
|
27
|
+
const startedAt = Number(event.data?.startedAt) || event.ts;
|
|
28
|
+
if ((active.get(key)?.startedAt ?? 0) > startedAt)
|
|
29
|
+
return;
|
|
30
|
+
active.set(key, { runId: event.runId, sessionId: event.sessionId, startedAt });
|
|
31
|
+
if (active.size > 512)
|
|
32
|
+
active.delete(active.keys().next().value);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
const pathFor = (id) => join(dir, createHash('sha256').update(id).digest('hex') + '.json');
|
|
36
|
+
const readJson = (path) => { try {
|
|
37
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
if (e.code === 'ENOENT')
|
|
41
|
+
return;
|
|
42
|
+
throw e;
|
|
43
|
+
} };
|
|
44
|
+
// Durable no-replay fence, including power/process loss around rename.
|
|
45
|
+
const writeJson = (path, value) => {
|
|
46
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
47
|
+
const temp = path + '.tmp';
|
|
48
|
+
writeFileSync(temp, JSON.stringify(value), { mode: 0o600 });
|
|
49
|
+
const fd = openSync(temp, 'r+');
|
|
50
|
+
try {
|
|
51
|
+
fsyncSync(fd);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
closeSync(fd);
|
|
55
|
+
}
|
|
56
|
+
renameSync(temp, path);
|
|
57
|
+
// Windows permits opening some directories but fsync then fails EPERM.
|
|
58
|
+
// The file is already durable+renamed: never report a false failed attempt.
|
|
59
|
+
if (process.platform !== 'win32') {
|
|
60
|
+
const parent = openSync(dir, 'r');
|
|
61
|
+
try {
|
|
62
|
+
fsyncSync(parent);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
closeSync(parent);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const read = (id) => readJson(pathFor(id));
|
|
70
|
+
const save = (record) => { record.ordinaryEligible = record.attempted === false && record.everAttempted !== true; writeJson(pathFor(record.insertionId), record); };
|
|
71
|
+
const stopPath = (key) => pathFor(`session-stop:${key}`);
|
|
72
|
+
const cutoff = (key) => readJson(stopPath(key))?.epoch ?? 0;
|
|
73
|
+
api.registerGatewayMethod('lma.steer.capabilities', ({ respond }) => respond(true, { protocol: 2, atomicRunBound: false,
|
|
74
|
+
acceptanceCallback: true, durableAttempts: true, sessionGenerationBound: true,
|
|
75
|
+
reason: 'Backend callback confirms acceptance, not consumption. Original-run final-effect CAS is unavailable.' }));
|
|
76
|
+
api.registerGatewayMethod('lma.steer.stop', ({ params, respond }) => {
|
|
77
|
+
try {
|
|
78
|
+
if (!nonempty(params.sessionKey) || !Number.isSafeInteger(params.stopEpoch))
|
|
79
|
+
throw new Error('sessionKey and stopEpoch required');
|
|
80
|
+
const key = canonical(params.sessionKey), epoch = Math.max(cutoff(key), params.stopEpoch);
|
|
81
|
+
writeJson(stopPath(key), { epoch });
|
|
82
|
+
respond(true, { sessionKey: key, stopEpoch: epoch });
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
respond(false, undefined, { code: 'INTERNAL', message: String(error) });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
api.registerGatewayMethod('lma.steer', ({ params, respond }) => {
|
|
89
|
+
if (!nonempty(params.sessionKey) || !nonempty(params.text) || !nonempty(params.insertionId) || !nonempty(params.expectedRunId) ||
|
|
90
|
+
!nonempty(params.expectedSessionId) || !Number.isSafeInteger(params.stopEpoch)) {
|
|
91
|
+
respond(false, undefined, { code: 'INVALID_REQUEST', message: 'Immutable input, session/run identity and stopEpoch required' });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const key = canonical(params.sessionKey);
|
|
95
|
+
try {
|
|
96
|
+
let old = read(params.insertionId);
|
|
97
|
+
const textHash = createHash('sha256').update(params.text).digest('hex');
|
|
98
|
+
// Upgrade the prior unpublished no-effect candidate's journal only.
|
|
99
|
+
// Never reinterpret an attempted/unknown row as safe to send.
|
|
100
|
+
if (old && old.status === 'capability_blocked' && old.attempted === false &&
|
|
101
|
+
old.textHash === textHash && old.sessionKey === key && old.expectedRunId === params.expectedRunId &&
|
|
102
|
+
(!old.sessionId || old.sessionId === params.expectedSessionId)) {
|
|
103
|
+
old = { ...old, sessionId: params.expectedSessionId, stopEpoch: params.stopEpoch, status: 'pending', attempt: 0 };
|
|
104
|
+
save(old);
|
|
105
|
+
}
|
|
106
|
+
if (old && (old.textHash !== textHash || old.sessionKey !== key || old.expectedRunId !== params.expectedRunId ||
|
|
107
|
+
old.sessionId !== params.expectedSessionId || old.stopEpoch !== params.stopEpoch))
|
|
108
|
+
throw new Error('Immutable insertion identity/payload mismatch');
|
|
109
|
+
if (params.queryOnly === true && !old) {
|
|
110
|
+
respond(true, { insertionId: params.insertionId, expectedRunId: params.expectedRunId, sessionId: params.expectedSessionId,
|
|
111
|
+
status: 'unknown', attempted: true, reason: 'journal unavailable; not replayed' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const record = old || { insertionId: params.insertionId, sessionKey: key, expectedRunId: params.expectedRunId, textHash,
|
|
115
|
+
sessionId: params.expectedSessionId, stopEpoch: params.stopEpoch, status: 'pending', attempted: false, attempt: 0 };
|
|
116
|
+
if (params.cancel === true || record.stopEpoch < cutoff(key)) {
|
|
117
|
+
record.status = 'stopped';
|
|
118
|
+
save(record);
|
|
119
|
+
respond(true, record);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (record.attempted || record.status !== 'pending' || params.queryOnly === true) {
|
|
123
|
+
respond(true, record);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (params.originalRunEnded === true || ended.has(`${key}\0${record.expectedRunId}`)) {
|
|
127
|
+
record.status = 'target_ended';
|
|
128
|
+
record.reason = 'original run ended without an insertion attempt';
|
|
129
|
+
save(record);
|
|
130
|
+
respond(true, record);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const observedRun = active.get(key);
|
|
134
|
+
if (observedRun && observedRun.runId !== record.expectedRunId && (!observedRun.sessionId || observedRun.sessionId === record.sessionId)) {
|
|
135
|
+
// A same-session successor is not a generation/authorization change.
|
|
136
|
+
// Wait for the original exact terminal; never SDK-retarget this retry.
|
|
137
|
+
record.reason = 'target_not_ready';
|
|
138
|
+
save(record);
|
|
139
|
+
respond(true, record);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const sessionId = resolveActiveEmbeddedRunSessionId(key);
|
|
143
|
+
if (!sessionId) {
|
|
144
|
+
record.reason = 'target_not_ready';
|
|
145
|
+
save(record);
|
|
146
|
+
respond(true, record);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (record.sessionId !== sessionId) {
|
|
150
|
+
record.status = 'target_changed';
|
|
151
|
+
record.reason = 'session generation changed; no automatic migration';
|
|
152
|
+
save(record);
|
|
153
|
+
respond(true, record);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// Crash even just BEFORE queue invocation remains unknown. queueIdentity
|
|
157
|
+
// is an item identity, NEVER a host dedupe/retry token.
|
|
158
|
+
const previouslyAttempted = record.everAttempted === true;
|
|
159
|
+
record.everAttempted = true;
|
|
160
|
+
record.attempted = true;
|
|
161
|
+
record.status = 'unknown';
|
|
162
|
+
record.attempt++;
|
|
163
|
+
record.reason = 'awaiting_backend_acceptance';
|
|
164
|
+
save(record);
|
|
165
|
+
const attempt = record.attempt;
|
|
166
|
+
const queued = queueAgentHarnessMessage(sessionId, params.text, { steeringMode: 'all', queueIdentity: `${record.insertionId}:${attempt}`,
|
|
167
|
+
onQueueAccepted: (accepted) => {
|
|
168
|
+
try {
|
|
169
|
+
const current = read(record.insertionId);
|
|
170
|
+
if (!current || current.attempt !== attempt || current.acceptedAt)
|
|
171
|
+
return;
|
|
172
|
+
if (current.status === 'stopped' || current.stopEpoch < cutoff(key)) {
|
|
173
|
+
current.status = 'stopped';
|
|
174
|
+
if (accepted)
|
|
175
|
+
current.acceptanceAfterStop = true;
|
|
176
|
+
}
|
|
177
|
+
else if (accepted) {
|
|
178
|
+
current.status = 'accepted';
|
|
179
|
+
current.acceptedAt = Date.now();
|
|
180
|
+
current.reason = 'backend_onQueueAccepted_true';
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
current.status = 'pending';
|
|
184
|
+
current.attempted = false;
|
|
185
|
+
current.reason = 'backend_rejected_no_effect';
|
|
186
|
+
}
|
|
187
|
+
save(current);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
// No durable receipt -> no Get/retry. Never throw into a backend
|
|
191
|
+
// after it accepted input, including an asynchronous callback.
|
|
192
|
+
api.logger?.warn?.(`lma.steer callback journal failure: ${String(error)}`);
|
|
193
|
+
}
|
|
194
|
+
} });
|
|
195
|
+
if (!queued) {
|
|
196
|
+
const current = read(record.insertionId);
|
|
197
|
+
if (current.attempt === attempt && !current.acceptedAt) {
|
|
198
|
+
current.everAttempted = previouslyAttempted;
|
|
199
|
+
current.attempted = false;
|
|
200
|
+
current.status = 'pending';
|
|
201
|
+
current.reason = 'target_not_ready';
|
|
202
|
+
save(current);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
respond(true, read(record.insertionId));
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
respond(false, undefined, { code: 'INTERNAL', message: `lma.steer: ${error instanceof Error ? error.message : String(error)}` });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
return unsubscribe;
|
|
212
|
+
}
|