27 Commits

Author SHA1 Message Date
70b0ccb7d2 feat(kb): wire kb-block fade into before_prompt_build
The previous commit shipped renderFaded + tick + seed on Block but
left them dead code because openclaw's `before_prompt_build` hook
ctx doesn't carry a currentTurn signal — the hook called plain
render() and turnFor() returned 0, so add() always stamped
last_refresh_at_turn=0 and fade distance never moved.

This adds a small per-session turn tracker (tools/turn-tracker.ts)
that the hook bumps each fire. On the first bump after plugin start
the counter is seeded from max(entry.last_refresh_at_turn) on the
loaded block, so a restart doesn't regress accumulated fade for
surviving entries. dynamic-kb-cache now reads currentTurnForSession
via the same counter so a fact cached on turn N reads back at d=0
on turn N+1.

The hook now: bumps turn, ticks (drop entries past the m% threshold,
log + save when anything drops), and renderFaded() into
appendSystemContext. Same algorithm as the Plexum-side
RenderDynamicSubblock path.

KBDeps.turnFor signature changed from (agentId) → (sessionId) since
turn is session-scoped — fixed the one cache-tool call site.

Verified end-to-end with a standalone smoke replaying the full
hook+tool flow against a temp profile: stale entry (d=201) drops on
turn 1, fresh entry (d=1) survives untouched, restart re-seeds the
counter from the surviving entry's last_refresh.
2026-06-06 01:06:30 +01:00
db323a2118 fix(kb): inject AGENT_VERIFY + AGENT_WORKSPACE for secret-mgr spawn
The original tokenFor() only overrode AGENT_ID, relying on the
openclaw parent process's environment to carry AGENT_VERIFY and
AGENT_WORKSPACE. In practice the openclaw daemon process is started
without those env vars, so the child secret-mgr call fails with
"must be invoked via pcexec" and the plugin silently falls back to
the plugin-level apiKey.

Synthesize all three env vars explicitly: the verify sentinel is
constant, AGENT_ID gates which agent's store secret-mgr looks at,
and AGENT_WORKSPACE only needs to be a syntactically valid path
because get-secret indexes by AGENT_ID alone. Verified in dind-t2:
with a clean parent env, spawnSync now successfully retrieves the
per-agent hf-token from /root/.openclaw/pc-pass-store/<agent>/.
2026-06-06 00:55:52 +01:00
cb331d3340 feat(kb): per-agent hf-token via secret-mgr (openclaw mirror)
Phase 4c openclaw side. dynamic-kb-* tools now resolve a per-agent
hf-token via the existing secret-mgr binary (same path ClawSkills
`tc-ctrl get-token` workflow uses), instead of relying solely on the
plugin-level apiKey config.

  plugin/index.ts kbDeps.tokenFor: spawns secret-mgr with
    AGENT_ID env set, parses stdout, 5s timeout. Returns null on any
    failure so the caller cleanly falls back to the plugin-level
    KBClient (which uses Config.APIKey via Bearer).

  plugin/index.ts kbDeps.makeClient: builds a fresh KBClient bound
    to the resolved per-agent token. Plugin-level Client stays as
    the fallback when no agent token is set.

Cross-runtime parity: Plexum side uses HostAPI.GetAgentSecret RPC,
openclaw side shells out to secret-mgr. Same KBDeps interface; same
behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 00:32:52 +01:00
2553d102f3 feat(kbblock,fade): TS fade port + scaffold renderFaded/tick (not wired in v1)
Phase 4b openclaw side. Provides fade primitives matching Plexum-sdk-
go/fade so renderFaded / tick are API-parity available, but stops
short of activating fade in the live before_prompt_build hook since
openclaw plugin SDK ctx doesn't currently expose currentTurn to
plugins. Plexum side fully wires fade per turn.

  plugin/tools/fade.ts (new): TS port of the fade algorithm. Maskable
    rune set matches Plexum source. PRNG is Mulberry32 instead of Go's
    math/rand — documented in the file: cross-runtime mask patterns
    DIFFER, but each runtime fades its own session-local kb-block
    independently so no comparison is expected.

  plugin/tools/kbblock.ts: Entry gains seed (random u31 at add());
    render() unchanged behaviour, plus new renderFaded(currentTurn,
    params) + tick(currentTurn, params) for future wiring when
    openclaw exposes turn signal to plugin hooks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 00:26:16 +01:00
c9792d1290 feat(kb): dynamic-kb-* tool family + <kb-block> subblock injection
Openclaw mirror of HarborForge.PlexumPlugin's Phase 2 work. Same tool
names + input schemas + return shapes + storage path layout so a
single ClawSkills workflow text works on both runtimes.

plugin/openclaw.plugin.json:
  - 5 new dynamic-kb-* contracts.tools entries

plugin/tools/kbblock.ts (new):
  - TS class mirror of Plexum internal/kbblock package
  - Storage at <OPENCLAW_PATH>/agents/<agentId>/sessions/<sessionId>/
    plugins/harbor-forge/kb-block.json
  - Entry shape + Render emit <kb-fact id=N kb=<code>
    source=topic:<slug>>...</kb-fact> identical to Plexum side
  - Insert-order rendering (§9 #4), no fade in v1 (§9 #3 placeholder)

plugin/tools/kbclient.ts (new):
  - TS HTTP client for HF backend KB routes:
    GET /knowledge-bases[?project=<code>], /knowledge-bases/{id}/topics,
    /knowledge-bases/{id}/tree, /knowledge-facts/{id}
  - ListFacts flattens the tree client-side filtered by topic ids
  - Bearer auth via plugin-level apiKey (per-agent hf-token resolution
    deferred — TODO matching Plexum side)

plugin/tools/dynamic-kb.ts (new):
  - 5 tool factories (createListKBs/Topics/Facts/Cache/Evict)
  - Each accepts ctx in execute so cache/evict resolve sessionID
  - KBDeps bundle wires client + token-for + makeClient + turn-for

plugin/index.ts:
  - Register 5 KB tools via the existing wrapped api.registerTool
    (already handles MCP content shape + PaddedCell tools-cache gate)
  - before_prompt_build hook: when block non-empty, returns
    appendSystemContext = <dynamic-block><kb-block>...</kb-block>
    </dynamic-block>. Empty block → no append. Limitation: append
    only (can't replace openclaw's baked-in static sys-msg content);
    the dynamic-block lands at the end rather than slotted into
    System[1] like on Plexum

No tests yet — sim verification on dind-t2 next (requires HF backend
container rebuild to expose /knowledge-* routes; current image
predates the KB module).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 23:32:34 +01:00
07e91ea858 feat(plugin): gate registerTool via __padded.allowTool
Wires HF into the PaddedCell tools-cache filter (decision #37,
openclaw side). Hooks into the existing api.registerTool wrap
(originally added for ensureMcpContentShape) so each tool:

  1. Registers name+description into PaddedCell's catalog
  2. Returns null when the per-session cache doesn't include the
     name (the model doesn't see it that turn)
  3. Has its execute() return coerced to MCP content shape
     (preserved from earlier)

Fail-open stub installed if PaddedCell hasn't loaded yet. All 9 HF
tools (status, telemetry, monitor_telemetry, calendar_*, restart_status)
are gate-able by default — agents `dynamic-cache-tools` them before use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 15:39:14 +01:00
h z
06ccd3564e Merge pull request 'refactor(install): clone HarborForge.Cli to /tmp instead of fixed path' (#12) from refactor/install-cli-clone-from-repo into main 2026-05-29 07:52:40 +00:00
2977ab369e refactor(install): clone HarborForge.Cli to /tmp instead of fixed path
`installCli()` used to look for the CLI source at a fixed path relative
to the plugin checkout: either `./HarborForge.Cli` or `../HarborForge.Cli`.
That breaks any install layout where the plugin lives on its own — the
script just logs "Skipping CLI installation" and returns. Same anti-
pattern installManagedMonitor had already fixed for the monitor binary.

Mirror the monitor flow:

  1. git clone --depth 1 --branch <cliBranch> CLI_REPO_URL → /tmp/<dir>
  2. go build -ldflags Version=<date>+<branch>-<sha> -o $openclaw/bin/hf
  3. chmod 755 + delete tmp dir on success or failure

Adds `--cli-branch <name>` (default: main) for parity with --monitor-branch.

Also stamps the binary with a real version string (was 'dev' before this
patch) so `hf version` is informative for debugging.
2026-05-29 08:52:14 +01:00
zhi
c8998c6b0d Merge pull request 'perf(meta-push): use cached api.config instead of deprecated loadConfig() — kills ~25% chronic baseline CPU' (#11) from fix/meta-push-use-cached-api-config into main 2026-05-27 08:25:34 +00:00
686f2c7cb0 perf(meta-push): use cached api.config instead of deprecated loadConfig()
`pushMetaToMonitor` and `resolveAgentId` were both calling
`api.runtime?.config?.loadConfig?.()` to read the agent list. That
deprecated path (openclaw warns at gateway start:
"plugin runtime config.loadConfig() is deprecated; use config.current()")
synchronously rebuilds the full plugin-metadata snapshot — realpathSync
walks every plugin's package.json + manifest + source up the directory
tree, hashWatchedFiles fingerprints every watched plugin file, and
discoverInDirectory re-scans every `dist/extensions/<plugin>` (~100 of
them on prod t2). Each rebuild costs ~6-7s of gateway CPU.

`pushMetaToMonitor` fires every `reportIntervalSec` (default 30s)
from `hooks/gateway-start.js`. With 100 plugins that put the gateway
into a chronic ~22-30% CPU baseline even with zero agent activity. V8
profile 2026-05-27 08:14:00 60s window (0 turns, 2 metadata pushes
during): lstat 44.2%, statSync(buildInstalledManifestRegistryIndexKey)
6.9%, hashWatchedFiles via memo key 1.7%, all routed through
`readPersistedInstalledPluginIndexInstallRecordsSync` -> per-plugin
`discoverInDirectory`.

Switching to `(api as any).config ?? api.runtime?.config?.loadConfig?.()`
reads from the snapshot cache the gateway already maintains — the same
pattern already used elsewhere in this file (e.g. the calendar wakeAgent
dispatcher at line 284). Same change applied to `resolveAgentId` (only
runs once at start, but same anti-pattern).

This is a plugin-side perf workaround. The underlying openclaw bug is
that `loadConfig()` rebuilds the snapshot rather than returning the
cached one — a chronic 'all sync cache validity checks pay the full
discovery cost' design issue worth pushing upstream separately (the
walks per-call cost we measured here is unrelated to and amplifies any
agent-turn-triggered walk path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:17:39 +01:00
h z
81d40ae63d fix(wakeup): drop WAKEUP_OK ack-token theatre (#10) 2026-05-26 08:13:42 +00:00
65a3fb8d2d fix(wakeup): drop WAKEUP_OK ack-token theatre from wakeup message
The wakeup dispatcher's `deliver` callback only does
`logger.info(reply.slice(0,100))` — no token detection, no scheduler
state change. The "first line of your reply MUST be exactly WAKEUP_OK
so the plugin records the ack" instruction was prompt theatre that
nothing in this plugin (or in openclaw) acted on. Confirmed by
reading openclaw/dist/plugin-sdk/src/auto-reply/tokens.d.ts which
declares HEARTBEAT_OK and SILENT_REPLY tokens but nothing for wakeup.

Symptom in the wild: agents would replay WAKEUP_OK every turn for no
gain — costing model budget on a no-op token — and the workflow doc
(`ClawSkills/workflows/hf-wakeup/flow.md`) carried a wandering
appendix explaining the ack "doesn't actually do anything anyway".

Rewrite the wakeup message to tell the agent the truth: drive the
hf-wakeup workflow to completion; the scheduler keeps re-waking
every 30s until the slot transitions out of `not_started` via
harborforge_calendar_complete or _abort. No ack token expected.

ClawSkills companion change (lyn/ClawSkills d0109f3) removes
WAKEUP_OK from skills/hf-hangman-lab/SKILL.md and
workflows/hf-wakeup/flow.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:10:47 +01:00
c2d00c18a7 feat(hf-plugin): __hfAgentStatus.hasOnCallCovering(agentId, from, to)
Cross-plugin accessor for "does agent have on_call slot covering this
window?" — first consumer is Dialectic.OpenclawPlugin signup pre-check
(its hf-precheck.ts has been degrading to "skipped" since Phase 3
ship pending this).

v1 honest scope: same-day windows only (scheduleCache is today-only
from /calendar/sync). Cross-day or empty-cache windows return undefined
which the caller treats as "skipped" (Dialectic backend stores
pre_validated:false as audit signal — same as before, just now we
actually validate when we can).

Logic: for each cached slot where slot_type=on_call AND status not in
{aborted,cancelled}, parse scheduled_at (HH:MM:SS or full ISO) and
estimated_duration to compute end; return true iff start<=from AND
end>=to. Returns false (not undefined) when cache has slots for the
agent on this date but none covers — that means "actually no coverage"
vs "I dont know".

Pairs with Dialectic.OpenclawPlugin/src/hf-precheck.ts which already
calls hf.hasOnCallCovering and handles all 3 return shapes.

No backend change required.
2026-05-23 14:58:37 +01:00
709f7e09ab feat(hf-plugin): expose globalThis.__hfAgentStatus.get(agentId)
Cross-plugin agent-status accessor for use by Fabric.OpenclawPlugin's
presence-sync loop (and any future plugin needing 'is agent X busy
right now'). Backed by CalendarBridgeClient.getAgentStatus() with a
30s in-memory TTL cache to avoid hammering the HF backend.

Returns one of 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline'
or undefined when the agent isn't known to HF. Cache miss + bridge
failure returns the last cached value (stale-data better than no
data for delivery-decision use cases).

Part of DIALECTIC-V2 Phase 1 (Fabric announce channel + busy-discard).
See /home/hzhang/arch/DIALECTIC-V2-DESIGN.md sections 7+8.
2026-05-23 11:31:27 +01:00
1c4cf773e5 fix(hf-plugin): wrap tool returns in MCP {content:[...]} shape
OpenClaw's Codex tool dispatcher (thread-lifecycle:255) expects every
tool execute() to return { content: [...] } and calls result.content.reduce()
to compute total text length. All 9 harborforge_* tools returned bare
objects ({ running, processing, currentSlot, ... }) which has no
.content field — so .reduce of undefined threw, and the agent saw the
cryptic 'Cannot read properties of undefined (reading reduce)' on
every call. This silently blocked every calendar slot transition on
prod for hours: agents could call harborforge_calendar_complete but
it always errored, so slots never moved out of not_started.

Fix is at the registerTool boundary: api.registerTool is wrapped once
to coerce every tool's execute return through ensureMcpContentShape.
Tools that already return the correct shape are unchanged. No per-tool
edits needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:48:05 +01:00
h z
ba420e858a Merge pull request 'fix: wakeup message says 'continue in same session', not 'only reply WAKEUP_OK'' (#9) from fix/wakeup-message-no-ack-only into main 2026-05-21 10:05:34 +00:00
e42b6b04ee fix(plugin): wakeup message says 'continue in same session', not 'only reply WAKEUP_OK'
E2e showed the old wakeup text trapped agents in an ack-only loop:

> "You have due slots. Follow the `hf-wakeup` workflow of skill
>  `hf-hangman-lab` to proceed. Only reply `WAKEUP_OK` in this session."

The two clauses contradicted each other — "follow the workflow" vs
"only reply WAKEUP_OK". MiniMax-M2.5 prioritised the literal "only"
and never proceeded past the ack; the scheduler then re-woke every 30s
because the slot stayed `not_started`, and the agent kept re-acking
forever (verified: 3 consecutive WAKEUP_OK-only replies across slot 7).

Rewrites the wakeup message to be explicit:
  - first line MUST be `WAKEUP_OK` (the ack token the plugin looks for)
  - then continue IN THE SAME session: drive calendar_status → task
    fetch → sub-workflow → calendar_complete/abort
  - flags the loop trap so the agent knows what to avoid

Bumps version 0.3.3 → 0.3.4.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:05:28 +01:00
h z
610f5961d1 Merge pull request 'fix: lift calendarScheduler to module scope (multi-register singleton)' (#8) from fix/scheduler-module-singleton into main 2026-05-21 09:54:55 +00:00
428fc254ee fix(plugin): lift calendarScheduler to module scope (multi-register singleton)
Trying the prior multi-agent-handle fix in dind-t2 surfaced a second bug
that PR #7 didn't reach: `harborforge_calendar_status` still returned
`Calendar scheduler not running` even though the gateway log showed the
scheduler had started 30+ seconds before the agent's call.

## Root cause

`register()` is invoked once per agent — `grep -c "HarborForge plugin
registered" /tmp/gw-stdout.log` reports 5 for a 5-agent claw. Every
invocation creates its own `let calendarScheduler` closure binding. But
`gateway_start` fires once and we only call `startCalendarScheduler()`
through that single hook, so exactly one of the five closures sees the
handle and the other four keep their bindings at `null`.

The host's tool router picks one of the five duplicate
`harborforge_calendar_status` registrations to dispatch to — most of the
time it's one of the four "null" closures, which is why every wakeup the
agent saw `Calendar scheduler not running`.

## Fix

Lift `let calendarScheduler` out of `register()` and into module scope.
All five register-call closures now reference the same binding; once the
single `gateway_start` initialises it, every tool sees it.

`startCalendarScheduler()` now early-returns when `calendarScheduler` is
already set, so duplicate `gateway_start` firings (if the host ever does
that) don't double-install intervals.

Bumps version 0.3.2 → 0.3.3.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:54:36 +01:00
h z
ff9929ad31 Merge pull request 'fix: real per-agent slot handle for multi-agent calendar tools' (#7) from fix/multi-agent-scheduler-handle into main 2026-05-21 09:39:51 +00:00
07a07b6876 fix(plugin): real per-agent slot handle for multi-agent calendar tools
In multi-agent sync mode every harborforge_calendar_* tool was returning
`calendarScheduler.<method> is not a function`. The cause: index.ts replaced
`calendarScheduler` (typed `CalendarScheduler | null`) with a `{ stop() }`
stub right after wiring the runSync/runCheck intervals, so `isRunning()`,
`getCurrentSlot()`, `completeCurrentSlot()`, `abortCurrentSlot()`,
`pauseCurrentSlot()`, `resumeCurrentSlot()`, `getState()`,
`isRestartPending()` and `getStateFilePath()` all blew up at call time.

Replaces the stub with a `MultiAgentSchedulerHandle` that:
  - tracks the last slot dispatched per agent (recorded by `wakeAgent`)
  - exposes status/complete/abort/pause/resume taking the calling agentId
  - resolves the implicit "current slot" via woken-cursor first then a
    cache scan over not_started/deferred/ongoing slots
  - PATCHes via `bridge.updateSlotAs(agentId, …)` so audit headers reflect
    the real caller (bridge constructor agentId is 'unused' in multi-agent)
  - mirrors the legacy `isRunning/isProcessing/getState/...` surface so
    the single-agent fallback (`CalendarScheduler`) keeps working unchanged

Each calendar tool factory now takes `OpenClawPluginToolContext`, reads
`ctx.agentId`, and dispatches through the handle. Single-agent path
(when `calendarScheduler` is a real `CalendarScheduler`) is preserved
behind `instanceof` checks.

Drops the dead `trackSessionCompletion` poll loop (only definition, no
caller) which referenced the removed `completeCurrentSlot`. Bumps
plugin version 0.2.0 → 0.3.2.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:38:57 +01:00
h z
a003416e56 Merge pull request 'fix: wake dedupe + inline slot context + complete contracts.tools' (#6) from fix/wake-dedupe-and-contracts into main 2026-05-20 14:48:06 +00:00
9ba591795b fix: wake dedupe + inline slot context + complete contracts.tools
Three issues making HF→agent wakeup unusable in practice, surfaced by
DinD sim end-to-end test (recruiter agent + slot for 招募 manager task):

1. **Plugin re-woke the same slot every 30s.** The inline runCheck only
   destructured agentId from scheduleCache.getAgentsWithDueSlots() and
   dropped the slots array, then called wakeAgent without recording the
   wake. The simplified inline scheduler also never PATCHes slot status
   server-side from not_started→ongoing, so the next 30s check sees the
   slot still due and wakes again. After 4 wakes the agent's wakeup
   session was full of WAKEUP_OK noise.

   Fix: keep slots in runCheck, add an in-memory wakedSlotKeys set
   keyed by (agentId, slotId|virtual_id|scheduled_at). Dedupe on this
   set; clear it inside the sync interval (fresh wake budget per sync).
   Server-side slot transition still TODO (requires re-introducing the
   CalendarScheduler class path or PATCH /calendar/slots/.../agent-update
   here); the dedupe at least stops the wake spam.

2. **Wakeup message had no slot context.** The wakeup body just said
   'follow hf-wakeup workflow' with no slot id/event_data/task_code.
   The agent then had to call harborforge_calendar_status to learn
   anything — which itself is broken in the simplified scheduler (it
   queries a CalendarScheduler instance that never gets created).

   Fix: pass dueSlots into wakeAgent and inline the highest-priority
   slot's {slot_id, scheduled_at, priority, slot_type, event_data} as
   a JSON block in the wakeup message. The agent reads event_data.
   task_code directly and routes via workflow_lookup without any
   round-trip. Per PLG-CAL-001 docs in hf-hangman-lab SKILL.md, this
   is the documented contract; we are bringing the message in line.

3. **contracts.tools listed 5 of the 9 registered tools.** Manifest had
   harborforge_status/telemetry/monitor_telemetry/calendar_status/
   calendar_complete. Code also registers calendar_abort, calendar_pause,
   calendar_resume, harborforge_restart_status. With the new OpenClaw
   plugin host enforcement (same gotcha that bit Meridian — see
   zhi/Meridian#2), undeclared tools are silently dropped from the
   agent's tool list, so abort/pause/resume cannot be called by the
   agent. plugin doctor was emitting:
   'plugin tool is undeclared (harbor-forge): harborforge_calendar_abort'
   for each missing tool.

   Fix: add the 4 missing tool names to contracts.tools.

Also use api.config as the primary config source in wakeAgent (current
public API), falling back to runtime.config.loadConfig() for older
hosts — same pattern as the Meridian fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:02:25 +01:00
c9f61419cb Merge docs/readme-refresh into main
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:55:57 +01:00
cc807484fc docs: refresh README — accuracy pass + HarborForge platform context
Verified against current code; fixed stale/inaccurate sections and
documented previously-undocumented features/flags/endpoints. Added a
"Part of the HarborForge platform" reference and role/port.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:50:01 +01:00
operator
917cb344cf chore: track ambient openclaw-sdk.d.ts (was caught by *.d.ts ignore)
The .gitignore was set up to skip tsc's compiled .d.ts output but it also
matched the hand-written ambient declarations file added in the previous
commit. Add a negation rule so that one file is tracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:29:35 +00:00
operator
64a9c431bf chore: convert plugin to ESM and migrate to current openclaw plugin SDK
ESM conversion:
- plugin/package.json: add "type": "module".
- plugin/tsconfig.json: switch module/moduleResolution to "nodenext"; bump
  target to ES2022.
- All relative imports across plugin/ now carry .js extensions as required
  by Node ESM (nodenext module resolution).
- plugin/index.ts: replace `require('./calendar/schedule-cache')` with a
  proper top-level import; switch `from 'os'` to `from 'node:os'`.

Plugin SDK convention update:
- Wrap default export with definePluginEntry({ id, name, description,
  register }) per the current openclaw plugin authoring contract.
- Modernize plugin/openclaw.plugin.json: drop entry/version, add
  activation.onStartup so gateway_start fires for this plugin at boot,
  declare contracts.tools listing the five harborforge_* tools.
- Add a local plugin/openclaw-sdk.d.ts with ambient declarations for the
  focused subpaths (openclaw/plugin-sdk/plugin-entry,
  openclaw/plugin-sdk/core). We deliberately do NOT add openclaw as an
  npm devDependency: the installer's `npm install --omit=dev` step trips
  over openclaw's own (deeply nested) dependency graph when listed via
  file:.../openclaw, and the runtime contract is provided by the gateway
  loader anyway.
- The local PluginAPI interface is preserved (broader than the standard
  OpenClawPluginApi — it surfaces `version`/`runtime`/`spawn`); the
  register function is cast at the definePluginEntry boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:28:58 +00:00
20 changed files with 1893 additions and 210 deletions

2
.gitignore vendored
View File

@@ -4,3 +4,5 @@ plugin/**/*.js
plugin/**/*.js.map plugin/**/*.js.map
plugin/**/*.d.ts plugin/**/*.d.ts
plugin/**/*.d.ts.map plugin/**/*.d.ts.map
# Hand-written ambient declarations are tracked; only compiled .d.ts above is ignored.
!plugin/openclaw-sdk.d.ts

192
README.md
View File

@@ -1,84 +1,107 @@
# HarborForge OpenClaw Plugin # HarborForge OpenClaw Plugin
OpenClaw 插件:向 HarborForge Monitor 暴露 OpenClaw 侧元数据,并提供可选的本地桥接能力;安装时也可顺带安装 `hf` CLI OpenClaw plugin that exposes OpenClaw-side metadata to the HarborForge Monitor, provides an optional local Monitor bridge, drives the HarborForge Calendar scheduler, and can optionally install the `hf` CLI.
## 当前状态 Part of the [HarborForge](../README.md) platform.
- 插件注册名:`harbor-forge` - Role: OpenClaw integration layer for HarborForge (registered plugin id: `harbor-forge`).
- 旧 sidecar `server/` 架构已移除 - Talks to the HarborForge backend (`backendUrl`, default `https://monitor.hangman-lab.top`) for Calendar APIs.
- 监控桥接走本地 `monitor_port` - Talks to a local HarborForge.Monitor bridge over `127.0.0.1:<monitor_port>` (no fixed default; commonly `9100`).
- 安装脚本支持 `--install-cli`
- `skills/hf/` 仅在 `--install-cli` 时一并安装
## 项目结构 ## Current State
- Plugin registration id: `harbor-forge` (was `harborforge-monitor`)
- Plugin version: `0.2.0` (package manifests); telemetry reports `pluginVersion` `0.3.1`
- Legacy sidecar `server/` architecture removed — telemetry is served directly by the plugin
- Monitor bridge runs over the local `monitor_port`
- Calendar scheduler integration (PLG-CAL-001 / 002 / 004)
- Installer supports `--install-cli` and an optional managed Monitor (`--install-monitor`)
- `skills/hf/` is installed only with `--install-cli`
## Project Structure
```text ```text
HarborForge.OpenclawPlugin/ HarborForge.OpenclawPlugin/
├── package.json ├── package.json
├── README.md ├── README.md
├── docs/ # design notes (calendar, monitor connector)
├── plugin/ ├── plugin/
│ ├── openclaw.plugin.json │ ├── openclaw.plugin.json # plugin manifest + config schema
│ ├── index.ts │ ├── index.ts # plugin entry, tool registration
│ ├── tsconfig.json
│ ├── core/ │ ├── core/
│ │ ├── config.ts │ │ ├── config.ts # config defaults / resolution
│ │ ├── managed-monitor.ts │ │ ├── managed-monitor.ts # optionally spawn HarborForge.Monitor
│ │ ── monitor-bridge.ts │ │ ── monitor-bridge.ts # client for the Monitor bridge
│ │ └── openclaw-agents.ts # enumerate OpenClaw agents
│ ├── calendar/ # Calendar scheduler + bridge
│ │ ├── index.ts
│ │ ├── scheduler.ts
│ │ ├── calendar-bridge.ts
│ │ └── types.ts
│ ├── hooks/ │ ├── hooks/
│ │ ├── gateway-start.ts │ │ ├── gateway-start.ts
│ │ └── gateway-stop.ts │ │ └── gateway-stop.ts
│ └── package.json │ └── package.json
├── skills/ ├── skills/
│ └── hf/ │ └── hf/
│ └── SKILL.md │ └── SKILL.md # installed only with --install-cli
└── scripts/ └── scripts/
└── install.mjs └── install.mjs
``` ```
## 安装 ## Installation
### 普通安装 ### Standard Install
```bash ```bash
node scripts/install.mjs node scripts/install.mjs
``` ```
这会: This will:
- 构建并安装 OpenClaw 插件 - Build and install the OpenClaw plugin
- 复制常规 skills - Copy regular skills
- **不会**安装 `hf` 二进制 - **Not** install the `hf` binary
- **不会**复制 `skills/hf/` - **Not** copy `skills/hf/`
### 安装插件 + `hf` CLI ### Plugin + `hf` CLI
```bash ```bash
node scripts/install.mjs --install-cli node scripts/install.mjs --install-cli
``` ```
这会额外: This additionally:
- 构建 `HarborForge.Cli` - Builds `HarborForge.Cli` (`go build ./cmd/hf`)
- 安装 `hf` `~/.openclaw/bin/hf` - Installs `hf` to `<openclaw>/bin/hf` (default `~/.openclaw/bin/hf`) and `chmod +x`
- `chmod +x ~/.openclaw/bin/hf` - Copies `skills/hf/` into the OpenClaw skills directory
- 复制 `skills/hf/` 到 OpenClaw profile skills 目录
### 常用选项 ### Common Options
```bash ```bash
# 仅构建 # Build only (no install / config)
node scripts/install.mjs --build-only node scripts/install.mjs --build-only
# 指定 OpenClaw profile # Install only, skip dependency checks
node scripts/install.mjs --skip-check
# Specify OpenClaw profile path (also honors OPENCLAW_PATH env)
node scripts/install.mjs --openclaw-profile-path /custom/path/.openclaw node scripts/install.mjs --openclaw-profile-path /custom/path/.openclaw
# 详细日志 # Build and install a managed HarborForge.Monitor binary alongside the plugin
node scripts/install.mjs --install-monitor yes --monitor-branch main
# Verbose logs
node scripts/install.mjs --verbose node scripts/install.mjs --verbose
# 卸载 # Uninstall (plugin, config entries, hf binary, managed monitor)
node scripts/install.mjs --uninstall node scripts/install.mjs --uninstall
``` ```
## 配置 The installer also updates OpenClaw config (`plugins.load.paths`, `plugins.allow`, `plugins.entries.harbor-forge.enabled`) via `openclaw config`.
编辑 `~/.openclaw/openclaw.json` ## Configuration
Edit `~/.openclaw/openclaw.json`:
```json ```json
{ {
@@ -94,7 +117,9 @@ node scripts/install.mjs --uninstall
"monitor_port": 9100, "monitor_port": 9100,
"reportIntervalSec": 30, "reportIntervalSec": 30,
"httpFallbackIntervalSec": 60, "httpFallbackIntervalSec": 60,
"logLevel": "info" "logLevel": "info",
"calendarEnabled": true,
"calendarHeartbeatIntervalSec": 60
} }
} }
} }
@@ -102,51 +127,72 @@ node scripts/install.mjs --uninstall
} }
``` ```
然后重启: Then restart:
```bash ```bash
openclaw gateway restart openclaw gateway restart
``` ```
## 配置项 ## Config Options
| 选项 | 类型 | 默认值 | 说明 | | Option | Type | Default | Description |
|------|------|--------|------| |--------|------|---------|-------------|
| `enabled` | boolean | `true` | 是否启用插件 | | `enabled` | boolean | `true` | Enable the plugin |
| `backendUrl` | string | `https://monitor.hangman-lab.top` | HarborForge Monitor 后端地址 | | `backendUrl` | string | `https://monitor.hangman-lab.top` | HarborForge backend base URL (Monitor + Calendar APIs) |
| `identifier` | string | 主机名 | 服务器标识符 | | `identifier` | string | hostname | Server / claw identifier |
| `apiKey` | string | 无 | HarborForge Monitor 生成的服务器 API Key | | `apiKey` | string | (none) | Server API key from the HarborForge Monitor admin panel |
| `monitor_port` | number | 无 | 本地桥接端口;插件通过 `127.0.0.1:<monitor_port>` 与 HarborForge.Monitor 通信 | | `monitor_port` | number | (none) | Local bridge port; plugin talks to HarborForge.Monitor via `127.0.0.1:<monitor_port>` |
| `reportIntervalSec` | number | `30` | 报告间隔(秒) | | `reportIntervalSec` | number | `30` | Metadata push interval (seconds) |
| `httpFallbackIntervalSec` | number | `60` | HTTP 回退间隔(秒) | | `httpFallbackIntervalSec` | number | `60` | HTTP heartbeat interval when WS unavailable |
| `logLevel` | string | `info` | 日志级别:`debug` / `info` / `warn` / `error` | | `logLevel` | string | `info` | Log level: `debug` / `info` / `warn` / `error` |
| `calendarEnabled` | boolean | `true` | Enable Calendar scheduler integration (PLG-CAL-001) |
| `calendarHeartbeatIntervalSec` | number | `60` | Calendar heartbeat interval (seconds) |
| `calendarApiKey` | string | (none) | API key for Calendar API auth; falls back to `apiKey` / `X-Agent-ID` |
| `managedMonitor` | string | (none) | Absolute path to a HarborForge.Monitor binary; if set, gateway start/stop hooks spawn/stop it |
## 本地桥接说明 ## Local Monitor Bridge
当插件配置了 `monitor_port`,并且 HarborForge.Monitor 也使用相同的 `MONITOR_PORT` 时: When the plugin has `monitor_port` configured and HarborForge.Monitor uses the same `MONITOR_PORT`:
- Monitor `127.0.0.1:<MONITOR_PORT>` 提供本地桥接服务 - Monitor serves a local bridge on `127.0.0.1:<MONITOR_PORT>`
- 插件可探测 `GET /health` - The plugin probes `GET /health`
- 插件工具 `harborforge_monitor_telemetry` 可读取 `GET /telemetry` - The plugin tool `harborforge_monitor_telemetry` reads `GET /telemetry`
- 如果桥接端口未配置或不可达,插件仍可正常运行 - The plugin pushes OpenClaw metadata (version, plugin version, agents) via `POST /openclaw` on the `reportIntervalSec` cadence, enriching Monitor heartbeats
- If the bridge port is unconfigured or unreachable, the plugin still works normally
也就是说,这条链路是**可选增强**,不是插件启动或 Monitor 心跳的前置条件。 This link is an **optional enhancement**, not a precondition for the plugin to start or for Monitor heartbeats.
## 插件提供的信息 ## Managed Monitor
### OpenClaw 元数据 If `managedMonitor` points to an installed HarborForge.Monitor binary, the `gateway_start` hook spawns it (passing `--backend-url`, `--identifier`, `--api-key`, `--monitor-port`, `--report-interval`, `--log-level` from the plugin config) and `gateway_stop` terminates it. Use `install.mjs --install-monitor yes` to build and wire this automatically.
- OpenClaw version
- plugin version
- 标识符 / 主机名
- 时间戳
### 系统快照 ## Calendar Scheduler
- uptime
- memory total/free/used/usagePercent
- load avg1/avg5/avg15
- platform
## 开发 When `calendarEnabled` is true, on gateway start the plugin starts a Calendar scheduler that heartbeats the backend (`/calendar/agent/heartbeat`, `/calendar/agent/status`, `/calendar/agent/notify`) to receive and run scheduled TimeSlots, waking/spawning agents via the OpenClaw `spawn` API (with a notification fallback). Scheduler state is persisted to a state file; gateway restarts can be requested by the backend (PLG-CAL-004).
## Tools Provided
| Tool | Description |
|------|-------------|
| `harborforge_status` | Plugin status, resolved config, Monitor bridge health, calendar status, telemetry snapshot |
| `harborforge_telemetry` | Current system telemetry snapshot from this host |
| `harborforge_monitor_telemetry` | Query the Monitor bridge for host hardware telemetry |
| `harborforge_calendar_status` | Calendar scheduler status and current slot |
| `harborforge_calendar_complete` | Complete the current calendar slot with actual duration |
| `harborforge_calendar_abort` | Abort the current calendar slot |
| `harborforge_calendar_pause` | Pause the current calendar slot |
| `harborforge_calendar_resume` | Resume the paused calendar slot |
| `harborforge_restart_status` | Check whether a gateway restart is pending |
### Telemetry Snapshot Fields
- `identifier`, `hostname`, `platform`, `timestamp`
- `uptime`
- `memory`: `total` / `free` / `used` / `usagePercent`
- `load`: `avg1` / `avg5` / `avg15`
- `openclaw`: `version` / `pluginVersion`
## Development
```bash ```bash
cd plugin cd plugin
@@ -154,14 +200,16 @@ npm install
npm run build npm run build
``` ```
## 依赖 The build runs `tsc` and emits `dist/` (`dist/index.js` is the plugin entry).
## Dependencies
- Node.js 18+ - Node.js 18+
- OpenClaw Gateway - OpenClaw Gateway
- Go 1.20+(仅 `--install-cli` 需要) - Go 1.20+ (only for `--install-cli` / `--install-monitor`)
## 相关提示 ## Tips
- 安装 `hf` 后,建议把 `~/.openclaw/bin` 加到 `PATH` - After installing `hf`, add `~/.openclaw/bin` to your `PATH`
- Agent 使用 `hf` 时,优先试 `hf --help-brief` - When an agent uses `hf`, try `hf --help-brief` first
- 完整命令树看 `hf --help` - For the full command tree, see `hf --help`

View File

@@ -29,7 +29,7 @@ import {
CalendarSlotResponse, CalendarSlotResponse,
SlotAgentUpdate, SlotAgentUpdate,
SlotStatus, SlotStatus,
} from './types'; } from './types.js';
export interface CalendarBridgeConfig { export interface CalendarBridgeConfig {
/** HarborForge backend base URL (e.g. "https://monitor.hangman-lab.top") */ /** HarborForge backend base URL (e.g. "https://monitor.hangman-lab.top") */
@@ -110,6 +110,23 @@ export class CalendarBridgeClient {
return this.sendBoolean('PATCH', url, update); return this.sendBoolean('PATCH', url, update);
} }
/**
* Same as {@link updateSlot} but overrides the `X-Agent-ID` header for a
* single call. Used by the multi-agent scheduler handle where the bridge
* client is shared across agents and the constructor agentId is `'unused'`.
*
* Backend identifies the slot purely by `slotId`; the header is informational
* for audit. Passing the calling agent's id keeps audit/log lines correct.
*/
async updateSlotAs(
agentId: string,
slotId: number,
update: SlotAgentUpdate
): Promise<boolean> {
const url = `${this.baseUrl}/calendar/slots/${slotId}/agent-update`;
return this.sendBooleanAs(agentId, 'PATCH', url, update);
}
/** /**
* Update a virtual (plan-generated) slot's status after agent execution. * Update a virtual (plan-generated) slot's status after agent execution.
* *
@@ -264,6 +281,15 @@ export class CalendarBridgeClient {
} }
private async sendBoolean(method: 'POST' | 'PATCH', url: string, body: unknown): Promise<boolean> { private async sendBoolean(method: 'POST' | 'PATCH', url: string, body: unknown): Promise<boolean> {
return this.sendBooleanAs(this.config.agentId, method, url, body);
}
private async sendBooleanAs(
agentId: string,
method: 'POST' | 'PATCH',
url: string,
body: unknown
): Promise<boolean> {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs); const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
@@ -272,7 +298,7 @@ export class CalendarBridgeClient {
method, method,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Agent-ID': this.config.agentId, 'X-Agent-ID': agentId,
'X-Claw-Identifier': this.config.clawIdentifier, 'X-Claw-Identifier': this.config.clawIdentifier,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -292,7 +318,7 @@ export class CalendarBridgeClient {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import { hostname } from 'os'; import { hostname } from 'os';
import { getPluginConfig } from '../core/config'; import { getPluginConfig } from '../core/config.js';
export interface CalendarPluginConfig { export interface CalendarPluginConfig {
/** Backend URL for calendar API (overrides monitor backendUrl) */ /** Backend URL for calendar API (overrides monitor backendUrl) */

View File

@@ -13,7 +13,7 @@
* • AgentWakeContext — context passed to agent when waking * • AgentWakeContext — context passed to agent when waking
* *
* Usage in plugin/index.ts: * Usage in plugin/index.ts:
* import { createCalendarBridgeClient, createCalendarScheduler } from './calendar'; * import { createCalendarBridgeClient, createCalendarScheduler } from './calendar.js';
* *
* const agentId = process.env.AGENT_ID || 'unknown'; * const agentId = process.env.AGENT_ID || 'unknown';
* const calendar = createCalendarBridgeClient(api, 'https://monitor.hangman-lab.top', agentId); * const calendar = createCalendarBridgeClient(api, 'https://monitor.hangman-lab.top', agentId);
@@ -28,7 +28,7 @@
* scheduler.start(); * scheduler.start();
*/ */
export * from './types'; export * from './types.js';
export * from './calendar-bridge'; export * from './calendar-bridge.js';
export * from './scheduler'; export * from './scheduler.js';
export * from './schedule-cache'; export * from './schedule-cache.js';

View File

@@ -0,0 +1,240 @@
/**
* MultiAgentSchedulerHandle — runtime façade that backs the public
* `harborforge_calendar_*` tools when the plugin runs in multi-agent sync
* mode.
*
* Background
* ----------
* The old single-agent path used `CalendarScheduler` which kept a "current
* slot" cursor and exposed `isRunning() / completeCurrentSlot() / abortCurrentSlot() / …`.
* In multi-agent mode the plugin doesn't own a single cursor — one plugin
* instance services every agent on the claw — so the previous code stubbed the
* `calendarScheduler` variable to `{ stop() }`. That made every tool fail
* with `calendarScheduler.<method> is not a function`.
*
* This handle restores the same surface area (`isRunning / getCurrentSlot /
* completeCurrentSlot / …`) but resolves the "current slot" per caller via
* the agentId/sessionKey supplied by the OpenClaw tool-factory context. The
* scheduler records the slot it just dispatched to each agent in
* {@link recordWoken}; the tool resolves the caller, looks up the last woken
* slot, and PATCHes the backend via the shared bridge.
*
* Tools must pass the calling agentId (from `OpenClawPluginToolContext.agentId`)
* into every method. The handle does not consult `process.env.AGENT_ID` — the
* gateway sets that to the host's primary agent which is meaningless in
* multi-agent mode.
*/
import type { CalendarBridgeClient } from './calendar-bridge.js';
import type { MultiAgentScheduleCache, CachedSlot } from './schedule-cache.js';
import { SlotStatus } from './types.js';
export interface MultiAgentSchedulerHandleParams {
bridge: CalendarBridgeClient;
cache: MultiAgentScheduleCache;
/** setInterval handles cleared on stop() */
syncHandle: ReturnType<typeof setInterval>;
checkHandle: ReturnType<typeof setInterval>;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
}
/** Last slot we dispatched to an agent. Used as the implicit "current slot". */
export interface WokenSlot {
agentId: string;
slotId: number | null;
virtualId: string | null;
scheduledAt: string | null;
slotType: string | null;
estimatedDuration: number | null;
wokenAt: string;
}
/** Public surface — mirrors the relevant subset of the old single-agent scheduler. */
export class MultiAgentSchedulerHandle {
private readonly bridge: CalendarBridgeClient;
private readonly cache: MultiAgentScheduleCache;
private readonly syncHandle: ReturnType<typeof setInterval>;
private readonly checkHandle: ReturnType<typeof setInterval>;
private readonly logger: MultiAgentSchedulerHandleParams['logger'];
private readonly woken: Map<string, WokenSlot> = new Map();
private stopped = false;
constructor(params: MultiAgentSchedulerHandleParams) {
this.bridge = params.bridge;
this.cache = params.cache;
this.syncHandle = params.syncHandle;
this.checkHandle = params.checkHandle;
this.logger = params.logger;
}
// ---------- lifecycle ----------
/** True while the sync/check intervals are still ticking. */
isRunning(): boolean {
return !this.stopped;
}
/** Always false in multi-agent mode — there is no per-instance work queue. */
isProcessing(): boolean {
return false;
}
stop(): void {
if (this.stopped) return;
this.stopped = true;
clearInterval(this.syncHandle);
clearInterval(this.checkHandle);
this.logger.info('Calendar scheduler stopped (multi-agent mode)');
}
// ---------- wake bookkeeping ----------
/** Record that we just dispatched a slot to `agentId`. */
recordWoken(agentId: string, slot: CachedSlot): void {
this.woken.set(agentId, {
agentId,
slotId: typeof slot.id === 'number' ? slot.id : null,
virtualId: typeof slot.virtual_id === 'string' ? slot.virtual_id : null,
scheduledAt: typeof slot.scheduled_at === 'string' ? slot.scheduled_at : null,
slotType: typeof slot.slot_type === 'string' ? slot.slot_type : null,
estimatedDuration:
typeof slot.estimated_duration === 'number' ? slot.estimated_duration : null,
wokenAt: new Date().toISOString(),
});
}
// ---------- per-agent reads ----------
/**
* The slot most recently dispatched to `agentId`, or null if we never woke
* them (e.g. tool called outside a wakeup). Callers can fall back to
* scanning {@link cachedSlotsFor} for `not_started`/`deferred` slots if they
* want a heuristic "current".
*/
getWokenSlot(agentId: string | null | undefined): WokenSlot | null {
if (!agentId) return null;
return this.woken.get(agentId) ?? null;
}
/** Today's cached slots for an agent (whatever runSync last pulled). */
cachedSlotsFor(agentId: string | null | undefined): CachedSlot[] {
if (!agentId) return [];
return this.cache.getAgentSlots(agentId);
}
/**
* Implicit "current" slot: the last woken slot if we know about it,
* otherwise the highest-priority `not_started`/`deferred`/`ongoing` cached
* slot for the agent (so a tool called between sync windows still finds
* something sensible).
*/
resolveCurrentSlot(agentId: string | null | undefined): WokenSlot | null {
const woken = this.getWokenSlot(agentId);
if (woken) return woken;
if (!agentId) return null;
const slots = this.cache.getAgentSlots(agentId)
.filter((s) => s.status === 'not_started' || s.status === 'deferred' || s.status === 'ongoing')
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
const top = slots[0];
if (!top) return null;
return {
agentId,
slotId: typeof top.id === 'number' ? top.id : null,
virtualId: typeof top.virtual_id === 'string' ? top.virtual_id : null,
scheduledAt: typeof top.scheduled_at === 'string' ? top.scheduled_at : null,
slotType: typeof top.slot_type === 'string' ? top.slot_type : null,
estimatedDuration:
typeof top.estimated_duration === 'number' ? top.estimated_duration : null,
wokenAt: 'inferred-from-cache',
};
}
// ---------- per-agent writes ----------
async completeSlot(agentId: string, actualDurationMinutes: number): Promise<SlotMutationResult> {
return this.transition(agentId, SlotStatus.FINISHED, { actual_duration: actualDurationMinutes });
}
async abortSlot(agentId: string, reason?: string): Promise<SlotMutationResult> {
if (reason) this.logger.info(`Aborting slot for ${agentId}: ${reason}`);
return this.transition(agentId, SlotStatus.ABORTED);
}
async pauseSlot(agentId: string): Promise<SlotMutationResult> {
return this.transition(agentId, SlotStatus.PAUSED);
}
/** Resume puts the slot back into `ongoing` so it isn't picked up as `not_started`. */
async resumeSlot(agentId: string): Promise<SlotMutationResult> {
return this.transition(agentId, SlotStatus.ONGOING);
}
private async transition(
agentId: string,
status: SlotStatus,
extra: { actual_duration?: number } = {}
): Promise<SlotMutationResult> {
const slot = this.resolveCurrentSlot(agentId);
if (!slot) {
return { ok: false, error: `No tracked slot for agent '${agentId}'` };
}
if (slot.slotId == null && slot.virtualId == null) {
return { ok: false, error: 'Resolved slot has neither id nor virtual_id' };
}
const update = { status, ...extra };
try {
if (slot.slotId != null) {
const ok = await this.bridge.updateSlotAs(agentId, slot.slotId, update);
if (!ok) return { ok: false, error: `Backend rejected slot ${slot.slotId}${status}` };
// Clear the wake cursor only on terminal transitions so re-wakes don't
// re-trigger the same slot mutation.
if (status === SlotStatus.FINISHED || status === SlotStatus.ABORTED) {
this.woken.delete(agentId);
}
return { ok: true, slot, status };
}
// virtual slot path: bridge has updateVirtualSlot
const materialised = await this.bridge.updateVirtualSlot(slot.virtualId!, update);
if (!materialised) {
return { ok: false, error: `Backend rejected virtual slot ${slot.virtualId}` };
}
if (status === SlotStatus.FINISHED || status === SlotStatus.ABORTED) {
this.woken.delete(agentId);
}
return { ok: true, slot, status, materialised };
} catch (err: unknown) {
const msg = (err as { message?: string } | undefined)?.message ?? String(err);
return { ok: false, error: msg };
}
}
// ---------- gateway-restart parity (no-op in multi-agent mode) ----------
/** Multi-agent scheduler does not persist a `ScheduledGatewayRestart` flag. */
isRestartPending(): boolean {
return false;
}
getStateFilePath(): string | null {
return null;
}
getState(): { mode: 'multi-agent'; agents: number; lastWoken: WokenSlot[] } {
return {
mode: 'multi-agent',
agents: this.cache.getStatus().agentCount,
lastWoken: Array.from(this.woken.values()),
};
}
}
export interface SlotMutationResult {
ok: boolean;
error?: string;
status?: SlotStatus;
slot?: WokenSlot;
/** Present only when a virtual slot was materialised. */
materialised?: unknown;
}

View File

@@ -18,7 +18,7 @@
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'; import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path'; import { join, dirname } from 'path';
import { CalendarBridgeClient } from './calendar-bridge'; import { CalendarBridgeClient } from './calendar-bridge.js';
import { import {
CalendarSlotResponse, CalendarSlotResponse,
SlotStatus, SlotStatus,
@@ -26,7 +26,7 @@ import {
SlotAgentUpdate, SlotAgentUpdate,
CalendarEventDataJob, CalendarEventDataJob,
CalendarEventDataSystemEvent, CalendarEventDataSystemEvent,
} from './types'; } from './types.js';
export interface CalendarSchedulerConfig { export interface CalendarSchedulerConfig {
/** Calendar bridge client for backend communication */ /** Calendar bridge client for backend communication */

View File

@@ -1,6 +1,6 @@
import { hostname } from 'os'; import { hostname } from 'os';
import { getPluginConfig } from '../core/config'; import { getPluginConfig } from '../core/config.js';
import { startManagedMonitor } from '../core/managed-monitor'; import { startManagedMonitor } from '../core/managed-monitor.js';
export function registerGatewayStartHook(api: any, deps: { export function registerGatewayStartHook(api: any, deps: {
logger: any; logger: any;

View File

@@ -1,4 +1,4 @@
import { stopManagedMonitor } from '../core/managed-monitor'; import { stopManagedMonitor } from '../core/managed-monitor.js';
export function registerGatewayStopHook(api: any, deps: { export function registerGatewayStopHook(api: any, deps: {
logger: any; logger: any;

View File

@@ -11,18 +11,50 @@
* served directly by the plugin when Monitor queries via the * served directly by the plugin when Monitor queries via the
* local monitor_port communication path. * local monitor_port communication path.
*/ */
import { hostname, freemem, totalmem, uptime, loadavg, platform } from 'os'; import { hostname, freemem, totalmem, uptime, loadavg, platform } from 'node:os';
import { getPluginConfig } from './core/config'; import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
import { MonitorBridgeClient, type OpenClawMeta } from './core/monitor-bridge'; import { MultiAgentScheduleCache } from './calendar/schedule-cache.js';
import type { OpenClawAgentInfo } from './core/openclaw-agents'; import { MultiAgentSchedulerHandle } from './calendar/multi-agent-handle.js';
import { registerGatewayStartHook } from './hooks/gateway-start'; import { getPluginConfig } from './core/config.js';
import { registerGatewayStopHook } from './hooks/gateway-stop'; import { MonitorBridgeClient, type OpenClawMeta } from './core/monitor-bridge.js';
import type { OpenClawAgentInfo } from './core/openclaw-agents.js';
import { registerGatewayStartHook } from './hooks/gateway-start.js';
import { registerGatewayStopHook } from './hooks/gateway-stop.js';
import { Block as KBBlock } from './tools/kbblock.js';
import { defaultFadeParams } from './tools/fade.js';
import { bumpTurnForSession, currentTurnForSession } from './tools/turn-tracker.js';
import { KBClient } from './tools/kbclient.js';
import {
type KBDeps,
createListKBsTool,
createListTopicsTool,
createListFactsTool,
createCacheTool,
createEvictTool,
} from './tools/dynamic-kb.js';
import { import {
createCalendarBridgeClient, createCalendarBridgeClient,
createCalendarScheduler,
CalendarScheduler, CalendarScheduler,
AgentWakeContext, } from './calendar/index.js';
} from './calendar';
// ---------------------------------------------------------------------------
// Module-scope calendar scheduler singleton.
//
// `register()` is called multiple times per gateway boot — once per agent
// (we see 5 `HarborForge plugin registered` lines for 5 agents on dind-t2).
// `gateway_start` only fires once, so before this lift the
// `startCalendarScheduler()` setup ran inside ONE closure while four other
// closures kept their own `calendarScheduler = null`. Whichever of the five
// tool registrations the gateway picked at call time was effectively a coin
// flip, and four times out of five `harborforge_calendar_status` returned
// `Calendar scheduler not running` even though the scheduler was active.
//
// Keeping the singleton at module scope removes the per-`register()` shadow:
// the scheduler is started once, every closure reads the same binding, and
// `startCalendarScheduler()` is idempotent so duplicate `gateway_start`
// firings are harmless.
// ---------------------------------------------------------------------------
let calendarScheduler: MultiAgentSchedulerHandle | CalendarScheduler | null = null;
interface PluginAPI { interface PluginAPI {
logger: { logger: {
@@ -53,10 +85,58 @@ interface PluginAPI {
getAgentStatus?: () => Promise<{ status: string } | null>; getAgentStatus?: () => Promise<{ status: string } | null>;
} }
export default { /**
id: 'harbor-forge', * Coerce a tool execute() return value into the MCP `{ content: [...] }`
name: 'HarborForge', * shape that the openclaw Codex tool dispatcher requires.
register(api: PluginAPI) { *
* Background: openclaw's `convertToolContents()` does `result.content.reduce(...)`
* to compute total text length before flattening. Every HF tool here returned a
* bare object (`{ running, processing, currentSlot, ... }`) which has no
* `.content` field, so `undefined.reduce` threw and every call to
* `harborforge_*` from a Codex-harness agent surfaced as the cryptic
* `Cannot read properties of undefined (reading 'reduce')`. The fix is to
* wrap every tool's execute return; doing it at the `registerTool` boundary
* keeps each tool body unchanged.
*/
/**
* Install a fail-open globalThis.__padded stub if PaddedCell hasn't loaded
* yet (load order isn't guaranteed). PaddedCell's installGlobalApi drains
* `_pendingCatalog` and replaces `allowTool` with the real check when it
* starts. This means HF tools registered before PaddedCell are visible
* to the agent until PaddedCell takes over, after which they fall under
* the per-session cache gate (decision #37, openclaw side).
*/
function ensurePaddedStub(): void {
const g = globalThis as unknown as {
__padded?: {
_pendingCatalog?: Array<{ name: string; description: string }>;
registerCatalogEntry?: (n: string, d: string) => void;
allowTool?: (n: string, c: unknown) => boolean;
};
};
if (g.__padded) return;
const buf: Array<{ name: string; description: string }> = [];
g.__padded = {
_pendingCatalog: buf,
registerCatalogEntry(name: string, description: string): void {
buf.push({ name, description });
},
allowTool: () => true,
};
}
function ensureMcpContentShape(result: unknown): { content: Array<{ type: 'text'; text: string }> } {
if (
result && typeof result === 'object' &&
Array.isArray((result as { content?: unknown }).content)
) {
return result as { content: Array<{ type: 'text'; text: string }> };
}
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
return { content: [{ type: 'text', text }] };
}
function register(api: PluginAPI): void {
const logger = api.logger || { const logger = api.logger || {
info: (...args: any[]) => console.log('[HarborForge]', ...args), info: (...args: any[]) => console.log('[HarborForge]', ...args),
error: (...args: any[]) => console.error('[HarborForge]', ...args), error: (...args: any[]) => console.error('[HarborForge]', ...args),
@@ -64,6 +144,43 @@ export default {
warn: (...args: any[]) => console.warn('[HarborForge]', ...args), warn: (...args: any[]) => console.warn('[HarborForge]', ...args),
}; };
// PaddedCell tools-cache integration (decision #37, openclaw side).
// Stub the global API early so the gate is consistent regardless of
// plugin load order; PaddedCell will replace stub with the real impl
// when it loads. fail-open until then.
ensurePaddedStub();
const seenForCatalog = new Set<string>();
// Wrap api.registerTool so every tool:
// (a) registers its name+description into PaddedCell's catalog so
// dynamic-list-tools / dynamic-search-tools surface it (#37)
// (b) returns null when the per-session cache doesn't include the
// name → the tool is hidden from the model that turn
// (c) has its execute() return coerced into the MCP `{ content: [...] }`
// shape openclaw expects (preserved from earlier).
const _origRegisterTool = api.registerTool.bind(api);
api.registerTool = (factory: (ctx: any) => any) => {
_origRegisterTool((ctx: any) => {
const def = factory(ctx);
if (!def || typeof def.execute !== 'function') return def;
const padded = (globalThis as any).__padded as
| { allowTool?: (n: string, c: any) => boolean; registerCatalogEntry?: (n: string, d: string) => void }
| undefined;
if (def.name && padded?.registerCatalogEntry && !seenForCatalog.has(def.name)) {
padded.registerCatalogEntry(def.name, def.description ?? '');
seenForCatalog.add(def.name);
}
if (def.name && padded?.allowTool && !padded.allowTool(def.name, ctx)) {
return null;
}
const origExecute = def.execute;
return {
...def,
execute: async (...args: any[]) => ensureMcpContentShape(await origExecute(...args)),
};
});
};
function resolveConfig() { function resolveConfig() {
return getPluginConfig(api); return getPluginConfig(api);
} }
@@ -71,7 +188,9 @@ export default {
/** Resolve agent ID from env, config, or fallback. */ /** Resolve agent ID from env, config, or fallback. */
function resolveAgentId(): string { function resolveAgentId(): string {
if (process.env.AGENT_ID) return process.env.AGENT_ID; if (process.env.AGENT_ID) return process.env.AGENT_ID;
const cfg = api.runtime?.config?.loadConfig?.(); // Read from cached `api.config` first — see pushMetaToMonitor for why
// the deprecated `api.runtime?.config?.loadConfig?.()` path is heavy.
const cfg = (api as any).config ?? api.runtime?.config?.loadConfig?.();
return cfg?.agents?.list?.[0]?.id ?? cfg?.agents?.defaults?.id ?? 'unknown'; return cfg?.agents?.list?.[0]?.id ?? cfg?.agents?.defaults?.id ?? 'unknown';
} }
@@ -110,7 +229,7 @@ export default {
}, },
openclaw: { openclaw: {
version: api.runtime?.version || api.version || 'unknown', version: api.runtime?.version || api.version || 'unknown',
pluginVersion: '0.3.1', // Bumped for PLG-CAL-004 pluginVersion: '0.3.4', // Bumped for PLG-CAL-004
}, },
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
@@ -119,13 +238,33 @@ export default {
// Periodic metadata push interval handle // Periodic metadata push interval handle
let metaPushInterval: ReturnType<typeof setInterval> | null = null; let metaPushInterval: ReturnType<typeof setInterval> | null = null;
// Calendar scheduler instance // (calendarScheduler is module-scope — see top of file for the why.
let calendarScheduler: CalendarScheduler | null = null; // Tools and lifecycle hooks all reference the same binding so the
// multi-register/single-start mismatch can't shadow them again.)
/** /**
* Push OpenClaw metadata to the Monitor bridge. * Push OpenClaw metadata to the Monitor bridge.
* This enriches Monitor heartbeats with OpenClaw version/plugin/agent info. * This enriches Monitor heartbeats with OpenClaw version/plugin/agent info.
* Failures are non-fatal — Monitor continues to work without this data. * Failures are non-fatal — Monitor continues to work without this data.
*
* IMPORTANT — read config from the cached `api.config` surface, NOT from
* the deprecated `api.runtime?.config?.loadConfig?.()` path. The
* deprecated path triggers a full plugin-metadata-snapshot rebuild on
* every call: realpathSync walks every plugin's package.json + manifest
* + source paths (lstats up the directory tree), `hashWatchedFiles`
* fingerprints all watched plugin files, and `discoverInDirectory`
* re-scans every `dist/extensions/<plugin>` dir. On t2 with ~100 plugins
* each rebuild costs ~6-7s of CPU; with this push firing every 30s
* (default reportIntervalSec) the chronic baseline was ~22-25% gateway
* CPU even with zero agent activity (V8 profile 2026-05-27 08:14:00 60s:
* lstat 44.2%, statSync 6.9%, hashWatchedFiles via memo key 1.7%, all
* routed through readPersistedInstalledPluginIndexInstallRecordsSync ->
* discoverInDirectory). Switching to `api.config` reads from the
* already-loaded snapshot cache; the elsewhere-in-this-file pattern was
* already `api.config ?? api.runtime?.config?.loadConfig?.()`.
*
* Same fix is applied to `resolveAgentId` below — that's read once at
* gateway start so the impact is smaller, but it's the same anti-pattern.
*/ */
async function pushMetaToMonitor() { async function pushMetaToMonitor() {
const bridgeClient = getBridgeClient(); const bridgeClient = getBridgeClient();
@@ -133,7 +272,7 @@ export default {
let agentNames: string[] = []; let agentNames: string[] = [];
try { try {
const cfg = api.runtime?.config?.loadConfig?.(); const cfg = (api as any).config ?? api.runtime?.config?.loadConfig?.();
const agentsList = cfg?.agents?.list; const agentsList = cfg?.agents?.list;
if (Array.isArray(agentsList)) { if (Array.isArray(agentsList)) {
agentNames = agentsList agentNames = agentsList
@@ -144,7 +283,7 @@ export default {
const meta: OpenClawMeta = { const meta: OpenClawMeta = {
version: api.runtime?.version || api.version || 'unknown', version: api.runtime?.version || api.version || 'unknown',
plugin_version: '0.3.1', plugin_version: '0.3.4',
agents: agentNames.map(name => ({ name })), agents: agentNames.map(name => ({ name })),
}; };
@@ -198,7 +337,18 @@ export default {
* Wake agent via dispatchInboundMessage — same mechanism used by Discord plugin. * Wake agent via dispatchInboundMessage — same mechanism used by Discord plugin.
* Direct in-process call, no WebSocket or CLI needed. * Direct in-process call, no WebSocket or CLI needed.
*/ */
async function wakeAgent(agentId: string): Promise<boolean> { async function wakeAgent(
agentId: string,
dueSlots?: Array<{
id?: number | null;
virtual_id?: string | null;
event_data?: any;
scheduled_at?: string;
priority?: number;
slot_type?: string;
[k: string]: unknown;
}>
): Promise<boolean> {
logger.info(`Waking agent ${agentId}: has due slots`); logger.info(`Waking agent ${agentId}: has due slots`);
const sessionKey = `agent:${agentId}:hf-wakeup`; const sessionKey = `agent:${agentId}:hf-wakeup`;
@@ -209,13 +359,51 @@ export default {
/* webpackIgnore: true */ sdkPath /* webpackIgnore: true */ sdkPath
); );
const cfg = api.runtime?.config?.loadConfig?.(); // api.config first (current public API). Fall back to deprecated
// runtime.config.loadConfig() for older host versions. Both should
// contain agents.list / channels for dispatch routing.
const cfg = (api as any).config ?? api.runtime?.config?.loadConfig?.();
if (!cfg) { if (!cfg) {
logger.error('Cannot load OpenClaw config for dispatch'); logger.error('Cannot load OpenClaw config for dispatch');
return false; return false;
} }
const wakeupMessage = `You have due slots. Follow the \`hf-wakeup\` workflow of skill \`hf-hangman-lab\` to proceed. Only reply \`WAKEUP_OK\` in this session.`; // Inline the highest-priority due slot's context so the agent does
// not need a second round-trip to harborforge_calendar_status. The
// agent can read event_data.task_code / task_title etc. directly.
let slotBlock = '';
const top = dueSlots && dueSlots.length ? dueSlots[0] : undefined;
if (top) {
slotBlock = `\n\nMatching slot:\n\`\`\`json\n${JSON.stringify(
{
slot_id: top.id ?? null,
virtual_id: top.virtual_id ?? null,
scheduled_at: top.scheduled_at ?? null,
priority: top.priority ?? null,
slot_type: top.slot_type ?? null,
event_data: top.event_data ?? null,
},
null,
2
)}\n\`\`\``;
}
// The wakeup dispatcher's `deliver` callback below only logs the
// reply text — it does NOT inspect any ack token. The earlier
// `WAKEUP_OK` first-line-ack convention was prompt-only theatre;
// nothing in this plugin or in openclaw acted on it. The only
// thing that ends a wake cycle is the slot transitioning out of
// `not_started`, which happens when the agent calls
// `harborforge_calendar_complete` or `harborforge_calendar_abort`.
// Tell the agent that plainly instead of asking for a fake ack.
const wakeupMessage =
`You have due slots. Drive the \`hf-wakeup\` workflow of skill ` +
`\`hf-hangman-lab\` to completion in this session — read slot ` +
`context, call the harborforge_calendar_* tools, route to the ` +
`right sub-workflow, and finish with harborforge_calendar_complete ` +
`or harborforge_calendar_abort. The scheduler keeps re-waking you ` +
`every 30s until the slot transitions out of \`not_started\`, so ` +
`partial work or silence just produces another wake.${slotBlock}`;
const result = await dispatchInboundMessageWithDispatcher({ const result = await dispatchInboundMessageWithDispatcher({
ctx: { ctx: {
@@ -244,42 +432,23 @@ export default {
} }
} }
/** // (trackSessionCompletion removed — legacy single-agent poll loop that
* Track session completion and update slot status accordingly. // called calendarScheduler.completeCurrentSlot. The multi-agent path
*/ // closes slots via the harborforge_calendar_complete tool, driven by
function trackSessionCompletion(sessionId: string, context: AgentWakeContext): void { // the agent itself, not by a timer.)
// Poll for session completion (simplified approach)
// In production, this would use webhooks or event streaming
const pollInterval = 30000; // 30 seconds
const maxDuration = context.slot.estimated_duration * 60 * 1000; // Convert to ms
const startTime = Date.now();
const poll = async () => {
if (!calendarScheduler) return;
const elapsed = Date.now() - startTime;
// Check if session is complete (would use actual API in production)
// For now, estimate completion based on duration
if (elapsed >= maxDuration) {
// Assume completion
const actualMinutes = Math.round(elapsed / 60000);
await calendarScheduler.completeCurrentSlot(actualMinutes);
return;
}
// Continue polling
setTimeout(poll, pollInterval);
};
// Start polling
setTimeout(poll, pollInterval);
}
/** /**
* Initialize and start the calendar scheduler. * Initialize and start the calendar scheduler.
*
* Idempotent — `gateway_start` may fire once per `register()` invocation
* (the host calls `register` per agent), and we only want one set of
* sync/check intervals across the whole process.
*/ */
function startCalendarScheduler(): void { function startCalendarScheduler(): void {
if (calendarScheduler) {
logger.info('Calendar scheduler already started, skipping duplicate gateway_start');
return;
}
const live = resolveConfig(); const live = resolveConfig();
// Create bridge client (claw-instance level, not per-agent) // Create bridge client (claw-instance level, not per-agent)
@@ -290,7 +459,6 @@ export default {
); );
// Multi-agent sync + check loop // Multi-agent sync + check loop
const { MultiAgentScheduleCache } = require('./calendar/schedule-cache') as typeof import('./calendar/schedule-cache');
const scheduleCache = new MultiAgentScheduleCache(); const scheduleCache = new MultiAgentScheduleCache();
const SYNC_INTERVAL_MS = 300_000; // 5 min const SYNC_INTERVAL_MS = 300_000; // 5 min
@@ -310,38 +478,164 @@ export default {
} }
} }
// Cross-plugin exposure: agent status lookup for other plugins
// (currently Fabric.OpenclawPlugin uses this to skip delivering
// `announce` channel messages to busy agents — see DIALECTIC-V2
// design doc, Phase 1). Backed by calendarBridge.getAgentStatus
// with a small TTL cache to avoid hammering the HF backend.
type HfStatus = 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline';
const HF_STATUS_CACHE_TTL_MS = 30_000;
const hfStatusCache = new Map<string, { status: HfStatus; at: number }>();
const _G = globalThis as Record<string, unknown>;
_G['__hfAgentStatus'] = {
async get(agentId: string): Promise<HfStatus | undefined> {
if (!agentId) return undefined;
const cached = hfStatusCache.get(agentId);
if (cached && Date.now() - cached.at < HF_STATUS_CACHE_TTL_MS) {
return cached.status;
}
try {
const status = await calendarBridge.getAgentStatus(agentId);
if (status) {
const typed = status as HfStatus;
hfStatusCache.set(agentId, { status: typed, at: Date.now() });
return typed;
}
} catch {
/* fall through to cached-or-undefined */
}
return cached?.status;
},
/**
* Approximate "does agent have an on_call slot covering [from, to]?"
* for cross-plugin pre-check use (currently:
* Dialectic.OpenclawPlugin's signup HF coverage).
*
* v1 honest scope: we only have today's slots in scheduleCache
* (synced from /calendar/sync which is today-only). Returns:
* - true iff window is same-day AND some cached on_call slot
* starts <= from AND ends >= to
* - false iff window is same-day AND no such slot
* - undefined for cross-day windows OR cache empty for this
* agent (caller treats undefined as "I don't know" — see
* Dialectic plugin's hf-precheck.ts which degrades to
* "skipped" gracefully)
*
* Phase TBD: when HF backend ships a `/calendar/slots?agent&from&to`
* endpoint, swap this to call it for arbitrary windows. Until then,
* same-day-only coverage gates ~all debates created by analyze-intel
* (which schedules <2h windows) without needing a backend change.
*/
async hasOnCallCovering(
agentId: string,
fromIso: string,
toIso: string,
): Promise<boolean | undefined> {
if (!agentId || !fromIso || !toIso) return undefined;
const from = new Date(fromIso);
const to = new Date(toIso);
if (isNaN(from.getTime()) || isNaN(to.getTime())) return undefined;
if (!(from < to)) return undefined;
// Cross-day → cache only has today; can't decide.
const fromDate = from.toISOString().slice(0, 10);
const toDate = to.toISOString().slice(0, 10);
if (fromDate !== toDate) return undefined;
// Cache's cachedDate must match our window's date.
const cacheStatus = scheduleCache.getStatus();
if (cacheStatus.cachedDate !== fromDate) return undefined;
const slots = scheduleCache.getAgentSlots(agentId);
if (slots.length === 0) return undefined; // cache empty for this agent — can't decide
for (const s of slots) {
if (s.slot_type !== 'on_call') continue;
// status: ignore aborted/cancelled, accept not_started / ongoing / finished
if (s.status === 'aborted' || s.status === 'cancelled') continue;
const startStr = s.scheduled_at;
if (typeof startStr !== 'string') continue;
// scheduled_at can be HH:MM:SS (cache-relative date) or full ISO
const start =
/^\d{2}:\d{2}(:\d{2})?$/.test(startStr)
? new Date(`${fromDate}T${startStr}Z`)
: new Date(startStr);
if (isNaN(start.getTime())) continue;
const dur = typeof s.estimated_duration === 'number' ? s.estimated_duration : 0;
const end = new Date(start.getTime() + dur * 60_000);
if (start <= from && end >= to) return true;
}
return false;
},
};
// Track wakes already dispatched for a slot in the current sync
// window — the simplified inline scheduler does not PATCH slot
// status server-side, so without dedupe the check loop re-wakes
// the same slot every 30s. Set is cleared by runSync (fresh wake
// budget per sync).
const wakedSlotKeys = new Set<string>();
// Check: find agents with due slots and wake them // Check: find agents with due slots and wake them
async function runCheck() { async function runCheck() {
const now = new Date(); const now = new Date();
const agentsWithDue = scheduleCache.getAgentsWithDueSlots(now); const agentsWithDue = scheduleCache.getAgentsWithDueSlots(now);
for (const { agentId } of agentsWithDue) { for (const { agentId, slots } of agentsWithDue) {
// Check if agent is busy // Filter out slots we've already woken this sync window
const status = await calendarBridge.getAgentStatus(agentId); const fresh = slots.filter((s) => {
const key = `${agentId}::${s.id ?? s.virtual_id ?? s.scheduled_at}`;
if (wakedSlotKeys.has(key)) return false;
return true;
});
if (fresh.length === 0) continue;
// Check if agent is busy (best effort; backend may 405 the GET
// — treat unknown as not-busy so wakeup still fires)
let status: string | null = null;
try {
status = await calendarBridge.getAgentStatus(agentId);
} catch {
status = null;
}
if (status === 'busy' || status === 'offline' || status === 'exhausted') { if (status === 'busy' || status === 'offline' || status === 'exhausted') {
continue; continue;
} }
// Wake the agent // Wake the agent with the slot context inlined
await wakeAgent(agentId); const ok = await wakeAgent(agentId, fresh);
if (ok) {
// Top slot is the one inlined in the wakeup message; record it as
// the agent's "current" so harborforge_calendar_complete/abort/…
// can resolve a slot without an explicit param.
const top = fresh[0];
if (top && calendarScheduler instanceof MultiAgentSchedulerHandle) {
calendarScheduler.recordWoken(agentId, top);
}
for (const s of fresh) {
const key = `${agentId}::${s.id ?? s.virtual_id ?? s.scheduled_at}`;
wakedSlotKeys.add(key);
}
}
} }
} }
// Initial sync // Initial sync (also resets the wake-dedupe window)
runSync(); const runSyncReset = async () => {
wakedSlotKeys.clear();
await runSync();
};
runSyncReset();
// Start intervals // Start intervals
const syncHandle = setInterval(runSync, SYNC_INTERVAL_MS); const syncHandle = setInterval(runSyncReset, SYNC_INTERVAL_MS);
const checkHandle = setInterval(runCheck, CHECK_INTERVAL_MS); const checkHandle = setInterval(runCheck, CHECK_INTERVAL_MS);
// Store handles for cleanup (reuse calendarScheduler variable) // Install the multi-agent handle so calendar tools resolve per-caller.
(calendarScheduler as any) = { calendarScheduler = new MultiAgentSchedulerHandle({
stop() { bridge: calendarBridge,
clearInterval(syncHandle); cache: scheduleCache,
clearInterval(checkHandle); syncHandle,
logger.info('Calendar scheduler stopped'); checkHandle,
}, logger,
}; });
logger.info('Calendar scheduler started (multi-agent sync mode)'); logger.info('Calendar scheduler started (multi-agent sync mode)');
} }
@@ -378,7 +672,7 @@ export default {
}); });
// Tool: plugin status // Tool: plugin status
api.registerTool(() => ({ api.registerTool((ctx) => ({
name: 'harborforge_status', name: 'harborforge_status',
description: 'Get HarborForge plugin status and current telemetry snapshot', description: 'Get HarborForge plugin status and current telemetry snapshot',
parameters: { parameters: {
@@ -397,13 +691,27 @@ export default {
: { connected: false, error: 'Monitor bridge unreachable' }; : { connected: false, error: 'Monitor bridge unreachable' };
} }
// Get calendar scheduler status // Get calendar scheduler status. In multi-agent mode `currentSlot`
const calendarStatus = calendarScheduler ? { // depends on the caller, so look it up via ctx.agentId.
running: calendarScheduler.isRunning(), const callerAgentId = ctx?.agentId ?? resolveAgentId();
processing: calendarScheduler.isProcessing(), const calendarStatus = calendarScheduler
currentSlot: calendarScheduler.getCurrentSlot(), ? calendarScheduler instanceof MultiAgentSchedulerHandle
isRestartPending: calendarScheduler.isRestartPending(), ? {
} : null; running: calendarScheduler.isRunning(),
processing: calendarScheduler.isProcessing(),
mode: 'multi-agent',
callerAgentId,
currentSlot: calendarScheduler.resolveCurrentSlot(callerAgentId),
isRestartPending: calendarScheduler.isRestartPending(),
}
: {
running: calendarScheduler.isRunning(),
processing: calendarScheduler.isProcessing(),
mode: 'single-agent',
currentSlot: calendarScheduler.getCurrentSlot(),
isRestartPending: calendarScheduler.isRestartPending(),
}
: null;
return { return {
enabled: live.enabled !== false, enabled: live.enabled !== false,
@@ -462,7 +770,7 @@ export default {
})); }));
// Tool: calendar slot management // Tool: calendar slot management
api.registerTool(() => ({ api.registerTool((ctx) => ({
name: 'harborforge_calendar_status', name: 'harborforge_calendar_status',
description: 'Get current calendar scheduler status and pending slots', description: 'Get current calendar scheduler status and pending slots',
parameters: { parameters: {
@@ -473,10 +781,24 @@ export default {
if (!calendarScheduler) { if (!calendarScheduler) {
return { error: 'Calendar scheduler not running' }; return { error: 'Calendar scheduler not running' };
} }
const callerAgentId = ctx?.agentId ?? resolveAgentId();
if (calendarScheduler instanceof MultiAgentSchedulerHandle) {
return {
running: calendarScheduler.isRunning(),
processing: calendarScheduler.isProcessing(),
mode: 'multi-agent',
callerAgentId,
currentSlot: calendarScheduler.resolveCurrentSlot(callerAgentId),
agentSlots: calendarScheduler.cachedSlotsFor(callerAgentId),
state: calendarScheduler.getState(),
isRestartPending: calendarScheduler.isRestartPending(),
stateFilePath: calendarScheduler.getStateFilePath(),
};
}
return { return {
running: calendarScheduler.isRunning(), running: calendarScheduler.isRunning(),
processing: calendarScheduler.isProcessing(), processing: calendarScheduler.isProcessing(),
mode: 'single-agent',
currentSlot: calendarScheduler.getCurrentSlot(), currentSlot: calendarScheduler.getCurrentSlot(),
state: calendarScheduler.getState(), state: calendarScheduler.getState(),
isRestartPending: calendarScheduler.isRestartPending(), isRestartPending: calendarScheduler.isRestartPending(),
@@ -486,7 +808,7 @@ export default {
})); }));
// Tool: complete current slot (for agent to report completion) // Tool: complete current slot (for agent to report completion)
api.registerTool(() => ({ api.registerTool((ctx) => ({
name: 'harborforge_calendar_complete', name: 'harborforge_calendar_complete',
description: 'Complete the current calendar slot with actual duration', description: 'Complete the current calendar slot with actual duration',
parameters: { parameters: {
@@ -503,14 +825,20 @@ export default {
if (!calendarScheduler) { if (!calendarScheduler) {
return { error: 'Calendar scheduler not running' }; return { error: 'Calendar scheduler not running' };
} }
if (calendarScheduler instanceof MultiAgentSchedulerHandle) {
const agentId = ctx?.agentId ?? resolveAgentId();
const res = await calendarScheduler.completeSlot(agentId, params.actualDurationMinutes);
return res.ok
? { success: true, message: 'Slot completed', slot: res.slot }
: { error: res.error };
}
await calendarScheduler.completeCurrentSlot(params.actualDurationMinutes); await calendarScheduler.completeCurrentSlot(params.actualDurationMinutes);
return { success: true, message: 'Slot completed' }; return { success: true, message: 'Slot completed' };
}, },
})); }));
// Tool: abort current slot (for agent to report failure) // Tool: abort current slot (for agent to report failure)
api.registerTool(() => ({ api.registerTool((ctx) => ({
name: 'harborforge_calendar_abort', name: 'harborforge_calendar_abort',
description: 'Abort the current calendar slot', description: 'Abort the current calendar slot',
parameters: { parameters: {
@@ -526,14 +854,20 @@ export default {
if (!calendarScheduler) { if (!calendarScheduler) {
return { error: 'Calendar scheduler not running' }; return { error: 'Calendar scheduler not running' };
} }
if (calendarScheduler instanceof MultiAgentSchedulerHandle) {
const agentId = ctx?.agentId ?? resolveAgentId();
const res = await calendarScheduler.abortSlot(agentId, params.reason);
return res.ok
? { success: true, message: 'Slot aborted', slot: res.slot }
: { error: res.error };
}
await calendarScheduler.abortCurrentSlot(params.reason); await calendarScheduler.abortCurrentSlot(params.reason);
return { success: true, message: 'Slot aborted' }; return { success: true, message: 'Slot aborted' };
}, },
})); }));
// Tool: pause current slot // Tool: pause current slot
api.registerTool(() => ({ api.registerTool((ctx) => ({
name: 'harborforge_calendar_pause', name: 'harborforge_calendar_pause',
description: 'Pause the current calendar slot', description: 'Pause the current calendar slot',
parameters: { parameters: {
@@ -544,14 +878,20 @@ export default {
if (!calendarScheduler) { if (!calendarScheduler) {
return { error: 'Calendar scheduler not running' }; return { error: 'Calendar scheduler not running' };
} }
if (calendarScheduler instanceof MultiAgentSchedulerHandle) {
const agentId = ctx?.agentId ?? resolveAgentId();
const res = await calendarScheduler.pauseSlot(agentId);
return res.ok
? { success: true, message: 'Slot paused', slot: res.slot }
: { error: res.error };
}
await calendarScheduler.pauseCurrentSlot(); await calendarScheduler.pauseCurrentSlot();
return { success: true, message: 'Slot paused' }; return { success: true, message: 'Slot paused' };
}, },
})); }));
// Tool: resume current slot // Tool: resume current slot
api.registerTool(() => ({ api.registerTool((ctx) => ({
name: 'harborforge_calendar_resume', name: 'harborforge_calendar_resume',
description: 'Resume the paused calendar slot', description: 'Resume the paused calendar slot',
parameters: { parameters: {
@@ -562,7 +902,13 @@ export default {
if (!calendarScheduler) { if (!calendarScheduler) {
return { error: 'Calendar scheduler not running' }; return { error: 'Calendar scheduler not running' };
} }
if (calendarScheduler instanceof MultiAgentSchedulerHandle) {
const agentId = ctx?.agentId ?? resolveAgentId();
const res = await calendarScheduler.resumeSlot(agentId);
return res.ok
? { success: true, message: 'Slot resumed', slot: res.slot }
: { error: res.error };
}
await calendarScheduler.resumeCurrentSlot(); await calendarScheduler.resumeCurrentSlot();
return { success: true, message: 'Slot resumed' }; return { success: true, message: 'Slot resumed' };
}, },
@@ -594,6 +940,123 @@ export default {
}, },
})); }));
// ---- dynamic-kb-* family (DESIGN-DYNAMIC-BLOCK.md §3.3 / §4.4)
// Cross-runtime mirror of HarborForge.PlexumPlugin/internal/tools/kb.go +
// /internal/kbblock + /internal/kbclient. v1 auth: per-agent hf-token
// resolved via secret-mgr (matches the rest of the Hangman-Lab
// openclaw plugins). Falls back to plugin-level apiKey when no
// per-agent token is configured.
const kbCfg = resolveConfig();
const kbBackendUrl =
typeof kbCfg?.backendUrl === 'string' && kbCfg.backendUrl
? (kbCfg.backendUrl as string)
: 'https://monitor.hangman-lab.top';
const kbApiKey = typeof kbCfg?.apiKey === 'string' ? (kbCfg.apiKey as string) : '';
const kbDeps: KBDeps = {
client: kbApiKey ? new KBClient(kbBackendUrl, kbApiKey) : null,
tokenFor: async (agentId: string): Promise<string | null> => {
// Per-agent hf-token via secret-mgr (decision #20 mirror).
// secret-mgr enforces AGENT_VERIFY + AGENT_WORKSPACE + AGENT_ID
// (pcexec contract); for get-secret it indexes the store by
// AGENT_ID alone, so AGENT_WORKSPACE only needs to be a syntactic
// placeholder. openclaw's parent process doesn't carry these
// env vars, so we synthesize them per-call.
try {
const { spawnSync } = await import('node:child_process');
const res = spawnSync('secret-mgr', ['get-secret', '--key', 'hf-token'], {
env: {
...process.env,
AGENT_VERIFY: 'IF YOU ARE AN AGENT/MODEL, YOU SHOULD NEVER TOUCH THIS ENV VARIABLE',
AGENT_ID: agentId,
AGENT_WORKSPACE: `/root/.openclaw/workspace/workspace-${agentId}`,
},
encoding: 'utf8',
timeout: 5000,
});
if (res.status === 0 && res.stdout.trim()) return res.stdout.trim();
return null;
} catch {
return null;
}
},
makeClient: (token: string) => new KBClient(kbBackendUrl, token),
turnFor: (sessionId: string) => currentTurnForSession(sessionId),
};
// Wrap each KB tool factory: pass ctx into execute so cache/evict can
// resolve the per-session kb-block.json path.
const kbFactories = [
createListKBsTool,
createListTopicsTool,
createListFactsTool,
createCacheTool,
createEvictTool,
];
for (const make of kbFactories) {
api.registerTool((ctx: any) => {
const tool = make(kbDeps);
const inner = tool.execute;
tool.execute = async (callId: string, params: any) =>
inner(callId, params, { agentId: ctx?.agentId, sessionId: ctx?.sessionId });
return tool;
});
}
// <kb-block> subblock injection via before_prompt_build hook
// (DESIGN-DYNAMIC-BLOCK.md §2 + §7: openclaw side uses
// appendSystemContext since the hook can't replace baked-in
// <available_skills> precisely).
//
// Per-turn fade: bump the session turn counter, then tick
// (dropping entries past the m% threshold) + renderFaded so the
// rendered text shows accumulated underscore masking. The Plexum
// mirror runs the same algorithm inside RenderDynamicSubblock.
const apiOn = (api as any).on;
if (typeof apiOn === 'function') {
apiOn.call(
api,
'before_prompt_build',
async (_event: any, ctx: any) => {
const agentId = ctx?.agentId;
const sessionId = ctx?.sessionId;
if (!agentId || !sessionId) return undefined;
let body = '';
try {
const block = KBBlock.open(agentId, sessionId);
const turn = bumpTurnForSession(sessionId, block);
const fadeParams = defaultFadeParams();
const dropped = block.tick(turn, fadeParams);
if (dropped.length > 0) {
logger.info(
`kb-block fade tick agent=${agentId} session=${sessionId} dropped=[${dropped.join(',')}]`,
);
block.save();
}
body = block.renderFaded(turn, fadeParams);
} catch (err) {
logger.warn(`kb-block render failed: ${err}`);
return undefined;
}
if (!body) return undefined;
return {
appendSystemContext: `<dynamic-block>\n<kb-block>\n${body}</kb-block>\n</dynamic-block>\n`,
};
},
);
logger.info('HarborForge kb-block subblock hook registered');
}
logger.info('HarborForge plugin registered (id: harbor-forge)'); logger.info('HarborForge plugin registered (id: harbor-forge)');
}, }
};
// HarborForge's local PluginAPI is broader than the standard OpenClawPluginApi
// (it surfaces optional `version`/`runtime`/`spawn` accessors that older
// OpenClaw builds exposed). The cast at the definePluginEntry boundary
// acknowledges that gap — the runtime api object is whatever the gateway
// passes us, and each access is guarded with optional chaining / fallbacks.
export default definePluginEntry({
id: 'harbor-forge',
name: 'HarborForge',
description: 'HarborForge plugin for OpenClaw - project management, monitoring, and CLI integration',
register: register as (api: any) => void,
});

25
plugin/openclaw-sdk.d.ts vendored Normal file
View File

@@ -0,0 +1,25 @@
// Ambient declarations for the focused subpaths of the openclaw plugin SDK
// that this plugin needs at compile time.
//
// We intentionally do NOT take a `dependencies` (or `devDependencies`) entry
// on the openclaw npm package itself: openclaw is provided by the host
// gateway at runtime, and listing it as a file:/.../openclaw devDep breaks
// the installer's `npm install --omit=dev` step because npm/arborist trips
// over openclaw's own (deeply nested) dependency graph.
//
// These declarations cover only what we use here. They are deliberately
// permissive — the runtime contract is whatever the gateway hands us, and
// we guard each api access with optional chaining or a fallback at call site.
declare module 'openclaw/plugin-sdk/plugin-entry' {
export function definePluginEntry<T extends {
id: string;
name: string;
description?: string;
register: (api: any) => void | Promise<void>;
}>(opts: T): T;
}
declare module 'openclaw/plugin-sdk/core' {
export type OpenClawPluginApi = unknown;
}

View File

@@ -1,9 +1,28 @@
{ {
"id": "harbor-forge", "id": "harbor-forge",
"name": "HarborForge", "name": "HarborForge",
"version": "0.2.0",
"description": "HarborForge plugin for OpenClaw - project management, monitoring, and CLI integration", "description": "HarborForge plugin for OpenClaw - project management, monitoring, and CLI integration",
"entry": "./dist/index.js", "activation": {
"onStartup": true
},
"contracts": {
"tools": [
"harborforge_status",
"harborforge_telemetry",
"harborforge_monitor_telemetry",
"harborforge_calendar_status",
"harborforge_calendar_complete",
"harborforge_calendar_abort",
"harborforge_calendar_pause",
"harborforge_calendar_resume",
"harborforge_restart_status",
"dynamic-kb-list-kbs",
"dynamic-kb-list-topics",
"dynamic-kb-list-facts",
"dynamic-kb-cache",
"dynamic-kb-evict"
]
},
"configSchema": { "configSchema": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,

View File

@@ -9,14 +9,14 @@
"version": "0.2.0", "version": "0.2.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.19.41",
"typescript": "^5.0.0" "typescript": "^5.0.0"
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.37", "version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -1,7 +1,8 @@
{ {
"name": "harbor-forge-plugin", "name": "harbor-forge-plugin",
"version": "0.2.0", "version": "0.3.4",
"description": "OpenClaw plugin for HarborForge monitor bridge and CLI integration", "description": "OpenClaw plugin for HarborForge monitor bridge and CLI integration",
"type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"clean": "rm -rf dist", "clean": "rm -rf dist",
@@ -9,7 +10,7 @@
"watch": "tsc --watch" "watch": "tsc --watch"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.19.41",
"typescript": "^5.0.0" "typescript": "^5.0.0"
}, },
"license": "MIT" "license": "MIT"

283
plugin/tools/dynamic-kb.ts Normal file
View File

@@ -0,0 +1,283 @@
// dynamic-kb.ts — 5 host tools (dynamic-kb-list-kbs / list-topics /
// list-facts / cache / evict) implementing the agent-side knowledge-
// base browse + cache flow per DESIGN-DYNAMIC-BLOCK.md §4.4.
//
// Cross-runtime aligned with Plexum's dynmem/kb_tools.go — same tool
// names, same input shapes, same return shapes (so ClawSkills
// workflow text reads identically on either runtime).
//
// Returns ToolResult.content[].text for both ok and error cases. The
// `isError: true` flag surfaces errors to the model rather than RPC-
// failing the tool call.
import { Block as KBBlock } from './kbblock.js';
import { KBClient } from './kbclient.js';
interface ToolCtx {
agentId?: string;
sessionId?: string;
}
interface ToolResult {
content: Array<{ type: 'text'; text: string }>;
isError?: boolean;
}
function ok(text: string): ToolResult {
return { content: [{ type: 'text', text }] };
}
function err(text: string): ToolResult {
return { isError: true, content: [{ type: 'text', text }] };
}
/** Dependencies the 5 KB tools share. Built once in index.ts. */
export interface KBDeps {
/** HF backend KB HTTP client. */
client: KBClient | null;
/** Resolve hf-token for the agent at call time (via secret-mgr). null → no auth */
tokenFor: ((agentId: string) => Promise<string | null>) | null;
/** Build new client given a token (so each call gets fresh per-agent client). */
makeClient: ((token: string) => KBClient) | null;
/**
* Best-effort current turn number for a session; 0 is acceptable.
* Wired against the same per-session counter that the
* before_prompt_build hook bumps, so a fact added during turn N
* reads back at d=0 on turn N+1.
*/
turnFor: (sessionId: string) => number;
}
/**
* Resolve a per-agent KBClient. Returns null with reason if no client
* (no agent context / no token / no backend URL).
*/
async function clientFor(deps: KBDeps, ctx: ToolCtx): Promise<{ c: KBClient | null; reason: string }> {
if (!ctx.agentId) return { c: null, reason: 'no agent context' };
if (deps.makeClient && deps.tokenFor) {
const tok = await deps.tokenFor(ctx.agentId);
if (!tok) return { c: null, reason: 'agent has no hf-token in secret-mgr' };
return { c: deps.makeClient(tok), reason: '' };
}
if (deps.client) return { c: deps.client, reason: '' };
return { c: null, reason: 'HF KB backend unavailable' };
}
// ----- 5 tool factories -----
export function createListKBsTool(deps: KBDeps) {
return {
name: 'dynamic-kb-list-kbs',
description: 'List HarborForge Knowledge Bases the agent can access. Optional project-code filter.',
parameters: {
type: 'object',
properties: { 'project-code': { type: 'string' } },
},
async execute(_id: string, params: any, ctxArg?: ToolCtx): Promise<ToolResult> {
const ctx = ctxArg ?? {};
const r = await clientFor(deps, ctx);
if (!r.c) return err('dynamic-kb-list-kbs: ' + r.reason);
const proj = params?.['project-code'];
try {
const kbs = await r.c.listKBs(typeof proj === 'string' ? proj : undefined);
const lines: string[] = [];
if (proj) lines.push(`project: ${proj}`);
lines.push(`kbs: ${kbs.length}`, '');
for (const k of kbs) {
lines.push(`[${k.knowledge_base_code}] ${k.title}`);
lines.push(` ${k.description || ''}`, '');
}
lines.push(`Call dynamic-kb-list-topics({kb-code: "<code>"}) to drill into a KB.`);
return ok(lines.join('\n'));
} catch (e: any) {
return err('dynamic-kb-list-kbs: ' + (e?.message || String(e)));
}
},
};
}
export function createListTopicsTool(deps: KBDeps) {
return {
name: 'dynamic-kb-list-topics',
description: 'List topics in one HF KB by code.',
parameters: {
type: 'object',
properties: { 'kb-code': { type: 'string' } },
required: ['kb-code'],
},
async execute(_id: string, params: any, ctxArg?: ToolCtx): Promise<ToolResult> {
const ctx = ctxArg ?? {};
const r = await clientFor(deps, ctx);
if (!r.c) return err('dynamic-kb-list-topics: ' + r.reason);
const kbCode = String(params?.['kb-code'] ?? '');
if (!kbCode) return err('dynamic-kb-list-topics: kb-code required');
try {
const ts = await r.c.listTopics(kbCode);
const lines = [
`kb: ${kbCode}`,
`topics: ${ts.length}`,
'',
...ts.flatMap((t) => [`[${t.id}] ${t.topic}`, ` ${t.description || ''}`, '']),
`Call dynamic-kb-list-facts({kb-code: "${kbCode}", topic-ids: [<id>, ...]}) to drill into facts.`,
];
return ok(lines.join('\n'));
} catch (e: any) {
return err('dynamic-kb-list-topics: ' + (e?.message || String(e)));
}
},
};
}
export function createListFactsTool(deps: KBDeps) {
return {
name: 'dynamic-kb-list-facts',
description: 'List fact previews (id/topic/title/snippet) in given topics. Feed into dynamic-kb-cache.',
parameters: {
type: 'object',
properties: {
'kb-code': { type: 'string' },
'topic-ids': { type: 'array', items: { type: 'integer' } },
},
required: ['kb-code', 'topic-ids'],
},
async execute(_id: string, params: any, ctxArg?: ToolCtx): Promise<ToolResult> {
const ctx = ctxArg ?? {};
const r = await clientFor(deps, ctx);
if (!r.c) return err('dynamic-kb-list-facts: ' + r.reason);
const kbCode = String(params?.['kb-code'] ?? '');
const topicIds = Array.isArray(params?.['topic-ids']) ? params['topic-ids'].map((x: any) => Number(x)) : [];
if (!kbCode || topicIds.length === 0) {
return err('dynamic-kb-list-facts: kb-code + topic-ids required');
}
try {
const facts = await r.c.listFacts(kbCode, topicIds);
const lines = [
`kb: ${kbCode}`,
`topics: [${topicIds.join(', ')}]`,
`facts: ${facts.length}`,
'',
...facts.flatMap((f) => [
`[${f.id}] (topic ${f.topic_id}/${f.topic_slug}) ${f.title}`,
` ${f.snippet}`,
'',
]),
`Call dynamic-kb-cache({kb-code: "${kbCode}", fact-ids: [<id>, ...]}) to commit selected facts to your kb-block.`,
];
return ok(lines.join('\n'));
} catch (e: any) {
return err('dynamic-kb-list-facts: ' + (e?.message || String(e)));
}
},
};
}
export function createCacheTool(deps: KBDeps) {
return {
name: 'dynamic-kb-cache',
description: 'Cache KB facts into your per-session kb-block (visible next turn).',
parameters: {
type: 'object',
properties: {
'kb-code': { type: 'string' },
'fact-ids': { type: 'array', items: { type: 'integer' } },
'previous-dynamic-id': { type: 'string' },
},
required: ['kb-code', 'fact-ids'],
},
async execute(_id: string, params: any, ctxArg?: ToolCtx): Promise<ToolResult> {
const ctx = ctxArg ?? {};
if (!ctx.agentId || !ctx.sessionId) {
return err('dynamic-kb-cache: no per-session context');
}
const r = await clientFor(deps, ctx);
if (!r.c) return err('dynamic-kb-cache: ' + r.reason);
const kbCode = String(params?.['kb-code'] ?? '');
const factIds = Array.isArray(params?.['fact-ids']) ? params['fact-ids'].map((x: any) => Number(x)) : [];
if (!kbCode || factIds.length === 0) {
return err('dynamic-kb-cache: kb-code + fact-ids required');
}
let block: KBBlock;
try {
block = KBBlock.open(ctx.agentId, ctx.sessionId);
} catch (e: any) {
return err('dynamic-kb-cache: open block: ' + (e?.message || String(e)));
}
const toFetch: number[] = [];
const alreadyCached: number[] = [];
for (const id of factIds) {
if (block.has(id)) alreadyCached.push(id);
else toFetch.push(id);
}
let fetched: Array<{ id: number; kb_code: string; topic_slug: string; content: string }> = [];
if (toFetch.length > 0) {
try {
fetched = await r.c.getFacts(kbCode, toFetch);
} catch (e: any) {
return err('dynamic-kb-cache: ' + (e?.message || String(e)));
}
}
const turn = deps.turnFor(ctx.sessionId);
const fetchedByID = new Map(fetched.map((f) => [f.id, f]));
const added: number[] = [];
for (const id of toFetch) {
const f = fetchedByID.get(id);
if (!f) continue;
if (block.add(f.id, kbCode, f.topic_slug, f.content, turn)) added.push(f.id);
}
try {
block.save();
} catch (e: any) {
return err('dynamic-kb-cache: save: ' + (e?.message || String(e)));
}
const missing = toFetch.filter((id) => !added.includes(id));
return ok(
JSON.stringify({
added: added.sort((a, b) => a - b),
already_cached: alreadyCached.sort((a, b) => a - b),
missing: missing.length ? missing.sort((a, b) => a - b) : undefined,
note: 'Newly cached facts are available starting your next turn.',
}),
);
},
};
}
export function createEvictTool(_deps: KBDeps) {
return {
name: 'dynamic-kb-evict',
description: "Remove cached facts from your kb-block. Takes effect next turn.",
parameters: {
type: 'object',
properties: { 'fact-ids': { type: 'array', items: { type: 'integer' } } },
required: ['fact-ids'],
},
async execute(_id: string, params: any, ctxArg?: ToolCtx): Promise<ToolResult> {
const ctx = ctxArg ?? {};
if (!ctx.agentId || !ctx.sessionId) {
return err('dynamic-kb-evict: no per-session context');
}
const factIds = Array.isArray(params?.['fact-ids']) ? params['fact-ids'].map((x: any) => Number(x)) : [];
if (factIds.length === 0) return err('dynamic-kb-evict: fact-ids required');
let block: KBBlock;
try {
block = KBBlock.open(ctx.agentId, ctx.sessionId);
} catch (e: any) {
return err('dynamic-kb-evict: open block: ' + (e?.message || String(e)));
}
const evicted = block.remove(factIds);
try {
block.save();
} catch (e: any) {
return err('dynamic-kb-evict: save: ' + (e?.message || String(e)));
}
const notCached = factIds.filter((id: number) => !evicted.includes(id));
return ok(
JSON.stringify({
evicted: evicted.sort((a, b) => a - b),
not_cached: notCached.length ? notCached.sort((a: number, b: number) => a - b) : undefined,
note: 'Evictions take effect starting your next turn.',
}),
);
},
};
}

144
plugin/tools/fade.ts Normal file
View File

@@ -0,0 +1,144 @@
// fade.ts — TypeScript port of Plexum-sdk-go/fade. Deterministic fade-
// out for kb-block / future subblock entries. Matches Plexum decision
// #30 (n=5/w=10/m=70 default) so cross-runtime behaviour stays
// identical when ports are compared.
//
// The algorithm:
// - For elapsed `d <= n` turns since last_refresh, content unchanged
// - For d > n: ticks = d - n; each tick masks ceil(w%) of the
// currently-unmasked maskable-rune positions with `_`. Mask set is
// cumulative across ticks. PRNG is seeded by (entrySeed ^ tick),
// so the masking pattern is reproducible across processes.
// - Tick drops the entry when underscore_ratio > m% of maskable.
//
// Maskable runes (matches Go source):
// - ASCII alphanumerics [0-9A-Za-z]
// - CJK ideographs (U+4E00U+9FFF)
// - Hiragana (U+3040U+309F) + Katakana (U+30A0U+30FF)
// - Hangul syllables (U+AC00U+D7A3)
// Everything else (whitespace, punctuation, XML structure characters)
// stays unmodified so the rendered XML keeps parseable shape.
export interface FadeParams {
n: number; // safe-period turns
w: number; // % maskable chars masked per tick
m: number; // % drop threshold on underscore_ratio
}
export interface FadeResult {
rendered: string;
maskedCount: number;
totalMaskable: number;
}
export function defaultFadeParams(): FadeParams {
return { n: 5, w: 10, m: 70 };
}
/** shouldDrop returns true iff the entry's underscore ratio crosses m%. */
export function shouldDrop(r: FadeResult, params: FadeParams): boolean {
if (r.totalMaskable === 0) return false;
return r.maskedCount * 100 > params.m * r.totalMaskable;
}
/**
* Fade computes the rendered form of `content` given the entry seed +
* elapsed turns since last refresh.
*/
export function fade(content: string, seed: number | bigint, d: number, params: FadeParams): FadeResult {
const runes = Array.from(content); // surrogate-pair safe
const maskable: number[] = [];
for (let i = 0; i < runes.length; i++) {
if (isMaskable(runes[i])) maskable.push(i);
}
if (d <= params.n) {
return { rendered: content, maskedCount: 0, totalMaskable: maskable.length };
}
const ticks = d - params.n;
const masked = positionsToMaskCumulative(maskable, BigInt(seed), ticks, params.w);
return {
rendered: applyUnderscores(runes, masked),
maskedCount: masked.length,
totalMaskable: maskable.length,
};
}
// ---- helpers ----
function isMaskable(s: string): boolean {
if (s.length === 0) return false;
// For surrogate pairs, take the first code point.
const cp = s.codePointAt(0);
if (cp === undefined) return false;
if (cp >= 0x30 && cp <= 0x39) return true; // 0-9
if ((cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A)) return true; // A-Z a-z
if (cp >= 0x4E00 && cp <= 0x9FFF) return true; // CJK Unified Ideographs
if (cp >= 0x3040 && cp <= 0x309F) return true; // Hiragana
if (cp >= 0x30A0 && cp <= 0x30FF) return true; // Katakana
if (cp >= 0xAC00 && cp <= 0xD7A3) return true; // Hangul syllables
return false;
}
function positionsToMaskCumulative(maskable: number[], seed: bigint, ticks: number, w: number): number[] {
if (maskable.length === 0 || ticks <= 0 || w <= 0) return [];
const used = new Set<number>();
const result: number[] = [];
for (let tick = 1; tick <= ticks; tick++) {
const remaining = maskable.length - used.size;
if (remaining === 0) break;
let target = Math.ceil((w * remaining) / 100);
if (target < 1) target = 1;
if (target > remaining) target = remaining;
const unmasked: number[] = [];
for (const p of maskable) {
if (!used.has(p)) unmasked.push(p);
}
// Deterministic shuffle using a tick-specific seed.
const tickSeed = seed ^ BigInt(tick);
shuffleDeterministic(unmasked, tickSeed);
for (let i = 0; i < target; i++) {
used.add(unmasked[i]);
result.push(unmasked[i]);
}
}
result.sort((a, b) => a - b);
return result;
}
function applyUnderscores(runes: string[], masked: number[]): string {
if (masked.length === 0) return runes.join('');
const maskSet = new Set(masked);
let out = '';
for (let i = 0; i < runes.length; i++) {
out += maskSet.has(i) ? '_' : runes[i];
}
return out;
}
/**
* Fisher-Yates shuffle using a Mulberry32-style xorshift PRNG seeded
* by `seed`. Deterministic for the same seed input.
*
* Note: the Go source uses math/rand.NewSource(seed).Shuffle, which
* uses a different PRNG algorithm. So the SAME (seed, tick) on Go vs
* TS will produce different mask positions. That's acceptable for v1
* because no cross-runtime comparison of rendered fade is expected;
* each runtime fades its own session-local kb-block independently.
* If future use cases need cross-runtime parity, port Go's rand
* algorithm to TS (~50 LOC).
*/
function shuffleDeterministic<T>(arr: T[], seed: bigint): void {
// Mulberry32 needs a u32 seed.
let s = Number(seed & 0xFFFFFFFFn) >>> 0;
const rng = () => {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 0x100000000;
};
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}

196
plugin/tools/kbblock.ts Normal file
View File

@@ -0,0 +1,196 @@
// kbblock.ts — TypeScript mirror of HarborForge.PlexumPlugin's
// internal/kbblock package. Per-session storage of HarborForge KB
// facts the agent has cached, rendered as <kb-fact id=N kb=<code>
// source=topic:<slug>>content</kb-fact> per DESIGN-DYNAMIC-BLOCK.md
// §3.3 / §4.4.
//
// Storage location:
//
// <OPENCLAW_PATH>/agents/<agentId>/sessions/<sessionId>/plugins/
// harbor-forge/kb-block.json
//
// Fade algorithm wired in v0.3 via turn-tracker.ts: the
// before_prompt_build hook bumps a per-session counter (seeded from
// max(entry.last_refresh_at_turn) on the first touch after plugin
// restart) and calls tick() + renderFaded() each turn. The Plexum
// mirror runs the same algorithm inside RenderDynamicSubblock.
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fade, shouldDrop, type FadeParams } from './fade.js';
export interface Entry {
id: number; // HF backend DB primary key
kb_code: string; // e.g. "KB-PAYROT"
source_topic: string; // human slug from HF, e.g. "debugging"
content: string;
insert_seq: number;
added_at_turn: number;
last_refresh_at_turn: number;
seed: number; // PRNG seed for fade — generated at add() time
}
interface BlockShape {
version: number;
next_seq: number;
entries: Entry[];
}
const FILE_NAME = 'kb-block.json';
const VERSION = 1;
function openclawRoot(): string {
const env = process.env.OPENCLAW_PATH;
if (env) return env;
const home = process.env.HOME || os.homedir();
return path.join(home, '.openclaw');
}
/** sessions/<sid>/plugins/harbor-forge path under openclaw profile. */
export function sessionDir(agentId: string, sessionId: string): string {
return path.join(openclawRoot(), 'agents', agentId, 'sessions', sessionId, 'plugins', 'harbor-forge');
}
export function blockPath(agentId: string, sessionId: string): string {
return path.join(sessionDir(agentId, sessionId), FILE_NAME);
}
/** Block is the in-memory representation. Cheap to Open + Save per call. */
export class Block {
private readonly path: string;
next_seq: number = 1;
entries: Entry[] = [];
private constructor(blockPath: string) { this.path = blockPath; }
/** Open from disk (missing file → empty block). Throws on parse error. */
static open(agentId: string, sessionId: string): Block {
if (!agentId || !sessionId) {
throw new Error('kbblock.open: agentId + sessionId required');
}
const b = new Block(blockPath(agentId, sessionId));
try {
const raw = fs.readFileSync(b.path, 'utf8');
if (raw.trim().length === 0) return b;
const data = JSON.parse(raw) as Partial<BlockShape>;
if (data.next_seq && data.next_seq > 0) b.next_seq = data.next_seq;
if (Array.isArray(data.entries)) b.entries = data.entries;
} catch (err: any) {
if (err?.code === 'ENOENT') return b;
throw err;
}
return b;
}
len(): number { return this.entries.length; }
has(id: number): boolean {
return this.entries.some((e) => e.id === id);
}
/** Add new entry. Returns it; null on duplicate (silent no-op per §9 #4). */
add(id: number, kb_code: string, source_topic: string, content: string, at_turn: number): Entry | null {
if (this.has(id)) return null;
const e: Entry = {
id, kb_code, source_topic, content,
insert_seq: this.next_seq,
added_at_turn: at_turn,
last_refresh_at_turn: at_turn,
seed: Math.floor(Math.random() * 0x7FFFFFFF),
};
this.next_seq += 1;
this.entries.push(e);
return e;
}
/** Remove given IDs. Returns IDs actually removed. */
remove(ids: number[]): number[] {
const want = new Set(ids);
const removed: number[] = [];
this.entries = this.entries.filter((e) => {
if (want.has(e.id)) {
removed.push(e.id);
return false;
}
return true;
});
return removed;
}
lookup(id: number): Entry | undefined {
return this.entries.find((e) => e.id === id);
}
/** Atomic save (tmp+rename, 0600). Empty block with no prior file = no-op. */
save(): void {
if (this.entries.length === 0) {
if (!fs.existsSync(this.path)) return;
}
fs.mkdirSync(path.dirname(this.path), { recursive: true });
const payload: BlockShape = {
version: VERSION,
next_seq: this.next_seq,
entries: this.entries,
};
const tmp = this.path + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 });
fs.renameSync(tmp, this.path);
}
/**
* Render the <kb-block> inner body — flat sequence of
* <kb-fact id=N kb=<code> source=topic:<slug>>content</kb-fact>
* ordered by insert_seq (§9 #4). Empty block → "". No fade applied.
*/
render(): string {
return this.renderInner(null);
}
/**
* RenderFaded mirrors render() but applies fade() per entry given
* currentTurn + params. Used by the before_prompt_build hook (turn
* source: tools/turn-tracker.ts).
*/
renderFaded(currentTurn: number, params: FadeParams): string {
return this.renderInner((e) => fade(e.content, e.seed, currentTurn - e.last_refresh_at_turn, params).rendered);
}
private renderInner(transform: ((e: Entry) => string) | null): string {
if (this.entries.length === 0) return '';
const ordered = [...this.entries].sort((a, b) => a.insert_seq - b.insert_seq);
const lines: string[] = [];
ordered.forEach((e, i) => {
if (i > 0) lines.push('');
let head = `<kb-fact id=${e.id} kb=${e.kb_code}`;
if (e.source_topic) head += ` source=topic:${e.source_topic}`;
head += '>';
lines.push(head);
lines.push(transform ? transform(e) : e.content);
lines.push('</kb-fact>');
});
return lines.join('\n') + '\n';
}
/**
* Tick applies fade tick — drops entries whose underscore ratio
* crossed the m% threshold. Returns IDs dropped. Caller must
* save() after.
*/
tick(currentTurn: number, params: FadeParams): number[] {
if (this.entries.length === 0) return [];
const dropped: number[] = [];
const kept: Entry[] = [];
for (const e of this.entries) {
const d = currentTurn - e.last_refresh_at_turn;
const r = fade(e.content, e.seed, d, params);
if (shouldDrop(r, params)) {
dropped.push(e.id);
} else {
kept.push(e);
}
}
this.entries = kept;
return dropped;
}
}

166
plugin/tools/kbclient.ts Normal file
View File

@@ -0,0 +1,166 @@
// kbclient.ts — TypeScript HTTP client for HarborForge.Backend KB
// REST routes. Mirrors HarborForge.PlexumPlugin/internal/kbclient.
// Routes verified against app/api/routers/knowledge.py:
//
// GET /knowledge-bases[?project=<code>] list KBs
// GET /knowledge-bases/{id|code}/topics list topics in a KB
// GET /knowledge-bases/{id|code}/tree full hierarchy
// GET /knowledge-facts/{id} single fact
//
// Auth: per-agent HF token (looked up via secret-mgr by caller; this
// client takes a pre-resolved token string). Cross-runtime parity
// note: Plexum client uses plugin-level APIKey from config because
// the Plexum SDK can't reach secret-mgr; openclaw side has agent
// context so we go per-agent here.
export interface KBSummary {
id: number;
knowledge_base_code: string;
title: string;
description: string;
}
export interface TopicSummary {
id: number;
topic: string;
description: string;
}
export interface FactSummary {
id: number;
topic_id: number;
topic_slug: string;
title: string;
snippet: string;
}
export interface Fact {
id: number;
kb_code: string;
topic_slug: string;
content: string;
}
interface TreeNode {
id: number;
topic?: string;
category?: string;
title?: string;
content?: string;
categories?: TreeNode[];
facts?: TreeNode[];
}
interface KBTree {
knowledge_base_code: string;
topics: TreeNode[];
}
export class KBClient {
private readonly baseUrl: string;
private readonly token: string;
constructor(baseUrl: string, token: string) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.token = token;
}
async listKBs(projectCode?: string): Promise<KBSummary[]> {
let path = '/knowledge-bases';
if (projectCode) path += '?project=' + encodeURIComponent(projectCode);
return this.get<KBSummary[]>(path);
}
async listTopics(kbCode: string): Promise<TopicSummary[]> {
if (!kbCode) throw new Error('kbclient: kb-code required');
return this.get<TopicSummary[]>('/knowledge-bases/' + encodeURIComponent(kbCode) + '/topics');
}
/** ListFacts pulls the tree once + flattens facts under topic-ids client-side. */
async listFacts(kbCode: string, topicIds: number[]): Promise<FactSummary[]> {
if (!kbCode) throw new Error('kbclient: kb-code required');
if (!topicIds || topicIds.length === 0) throw new Error('kbclient: topic-ids required');
const tree = await this.get<KBTree>('/knowledge-bases/' + encodeURIComponent(kbCode) + '/tree');
const wantTopic = new Set(topicIds);
const out: FactSummary[] = [];
for (const topic of tree.topics || []) {
if (!wantTopic.has(topic.id)) continue;
this.walkFactsInto(out, topic.id, topic.topic || '', topic.categories || [], topic.facts || []);
}
return out;
}
private walkFactsInto(
out: FactSummary[],
topicId: number,
topicSlug: string,
cats: TreeNode[],
facts: TreeNode[],
): void {
for (const f of facts) {
out.push({
id: f.id,
topic_id: topicId,
topic_slug: topicSlug,
title: f.title || '',
snippet: snippet(f.content || '', 120),
});
}
for (const c of cats) {
this.walkFactsInto(out, topicId, topicSlug, c.categories || [], c.facts || []);
}
}
/** GetFacts pulls each fact's full body. Backend has no batch route. */
async getFacts(kbCode: string, factIds: number[]): Promise<Fact[]> {
if (!kbCode) throw new Error('kbclient: kb-code required');
const out: Fact[] = [];
for (const id of factIds) {
try {
const node = await this.get<{ id: number; title: string; content: string; topic: string }>(
'/knowledge-facts/' + id,
);
out.push({
id: node.id,
kb_code: kbCode,
topic_slug: node.topic || '',
content: node.content || '',
});
} catch (err: any) {
if (err?.code === 404) continue; // skip missing
throw err;
}
}
return out;
}
private async get<T>(p: string): Promise<T> {
const res = await fetch(this.baseUrl + p, {
method: 'GET',
headers: {
Accept: 'application/json',
...(this.token ? { Authorization: 'Bearer ' + this.token } : {}),
},
});
if (res.status === 404) {
const e: any = new Error(`GET ${p}: 404 not found`);
e.code = 404;
throw e;
}
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`GET ${p}: HTTP ${res.status}: ${truncate(body, 200)}`);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
}
function snippet(s: string, n: number): string {
s = s.trim();
return s.length <= n ? s : s.slice(0, n) + '...';
}
function truncate(s: string, n: number): string {
return s.length > n ? s.slice(0, n) + '...' : s;
}

View File

@@ -0,0 +1,62 @@
// turn-tracker.ts — per-session monotonic turn counter for KB fade.
//
// The openclaw `before_prompt_build` hook does not expose currentTurn
// to plugins (verified against the SDK hook-types: PluginHookAgentContext
// has agentId/sessionId/workspaceDir/... but no turn). To make fade
// effective, we maintain a small Map<sessionId, turn> that increments
// once per before_prompt_build fire. The `dynamic-kb-cache` tool reads
// the same counter so each new Entry's `last_refresh_at_turn` is
// consistent with the hook's view of the clock.
//
// Restart safety: in-memory counters reset to 0 on plugin reload, but
// `seedFromBlock` is called the first time we touch a given session
// during a hook; it scans the loaded block's entries and seeds the
// counter to max(last_refresh_at_turn). The next bump then yields a
// strictly-greater turn, so existing entries keep their accumulated
// fade distance instead of regressing to d=0.
//
// Process-local only. The Plexum side keeps the same semantics in Go
// (it has currentTurn from the host).
import type { Block } from './kbblock.js';
const counters = new Map<string, number>();
/**
* Return the latest turn assigned to this session, or 0 if untouched.
* `dynamic-kb-cache` uses this for `at_turn` on Block.add so a fact
* cached during turn N reads back at d=0 on turn N+1.
*/
export function currentTurnForSession(sessionId: string): number {
if (!sessionId) return 0;
return counters.get(sessionId) ?? 0;
}
/**
* Increment the counter for this session and return the new value.
* If the counter is unset (first hook after plugin start), seed it to
* max(entry.last_refresh_at_turn) so restart doesn't regress fade.
*
* Pass the loaded block so seeding doesn't need a second disk read.
*/
export function bumpTurnForSession(sessionId: string, block: Block): number {
if (!sessionId) return 0;
let cur = counters.get(sessionId);
if (cur === undefined) {
cur = 0;
for (const e of block.entries) {
if (e.last_refresh_at_turn > cur) cur = e.last_refresh_at_turn;
}
}
const next = cur + 1;
counters.set(sessionId, next);
return next;
}
/**
* Test hook. Forget all in-memory counters. Not exported through any
* stable contract — for unit tests only.
*/
export function _resetForTesting(): void {
counters.clear();
}

View File

@@ -1,8 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "CommonJS", "module": "nodenext",
"moduleResolution": "node", "moduleResolution": "nodenext",
"esModuleInterop": true, "esModuleInterop": true,
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,

View File

@@ -31,6 +31,7 @@ const OLD_PLUGIN_NAME = 'harborforge-monitor';
const PLUGIN_SRC_DIR = join(__dirname, 'plugin'); const PLUGIN_SRC_DIR = join(__dirname, 'plugin');
const SKILLS_SRC_DIR = join(__dirname, 'skills'); const SKILLS_SRC_DIR = join(__dirname, 'skills');
const MONITOR_REPO_URL = 'https://git.hangman-lab.top/zhi/HarborForge.Monitor.git'; const MONITOR_REPO_URL = 'https://git.hangman-lab.top/zhi/HarborForge.Monitor.git';
const CLI_REPO_URL = 'https://git.hangman-lab.top/zhi/HarborForge.Cli.git';
const args = process.argv.slice(2); const args = process.argv.slice(2);
const options = { const options = {
@@ -43,6 +44,7 @@ const options = {
installCli: args.includes('--install-cli'), installCli: args.includes('--install-cli'),
installMonitor: 'no', installMonitor: 'no',
monitorBranch: 'main', monitorBranch: 'main',
cliBranch: 'main',
}; };
const profileIdx = args.indexOf('--openclaw-profile-path'); const profileIdx = args.indexOf('--openclaw-profile-path');
@@ -60,6 +62,11 @@ if (monitorBranchIdx !== -1 && args[monitorBranchIdx + 1]) {
options.monitorBranch = String(args[monitorBranchIdx + 1]); options.monitorBranch = String(args[monitorBranchIdx + 1]);
} }
const cliBranchIdx = args.indexOf('--cli-branch');
if (cliBranchIdx !== -1 && args[cliBranchIdx + 1]) {
options.cliBranch = String(args[cliBranchIdx + 1]);
}
function resolveOpenclawPath() { function resolveOpenclawPath() {
if (options.openclawProfilePath) return options.openclawProfilePath; if (options.openclawProfilePath) return options.openclawProfilePath;
if (process.env.OPENCLAW_PATH) return resolve(process.env.OPENCLAW_PATH); if (process.env.OPENCLAW_PATH) return resolve(process.env.OPENCLAW_PATH);
@@ -316,39 +323,40 @@ async function installCli() {
if (!options.installCli) return; if (!options.installCli) return;
const totalSteps = 6; const totalSteps = 6;
logStep(5, totalSteps, 'Building and installing hf CLI...'); logStep(5, totalSteps, 'Building and installing hf CLI...');
const openclawPath = resolveOpenclawPath(); const openclawPath = resolveOpenclawPath();
const binDir = join(openclawPath, 'bin'); const binDir = join(openclawPath, 'bin');
mkdirSync(binDir, { recursive: true }); mkdirSync(binDir, { recursive: true });
// Find CLI source — look for HarborForge.Cli relative to project root // Clone CLI repo to /tmp, build there, copy artifact out. Mirrors
const projectRoot = resolve(__dirname, '..'); // installManagedMonitor so the install never depends on a checked-out
const cliDir = join(projectRoot, 'HarborForge.Cli'); // sibling repo at a fixed path.
const tmpDir = join('/tmp', `harborforge-cli-${Date.now()}`);
if (!existsSync(cliDir)) { const hfBinary = join(binDir, 'hf');
// Try parent directory (monorepo layout)
const monoCliDir = resolve(projectRoot, '..', 'HarborForge.Cli');
if (!existsSync(monoCliDir)) {
logErr(`Cannot find HarborForge.Cli at ${cliDir} or ${monoCliDir}`);
logWarn('Skipping CLI installation');
return;
}
}
const effectiveCliDir = existsSync(cliDir)
? cliDir
: resolve(projectRoot, '..', 'HarborForge.Cli');
log(` Building hf from ${effectiveCliDir}...`, 'blue');
try { try {
const hfBinary = join(binDir, 'hf'); log(` Cloning ${CLI_REPO_URL} (branch ${options.cliBranch}) → ${tmpDir}...`, 'blue');
exec(`go build -o ${hfBinary} ./cmd/hf`, { cwd: effectiveCliDir, silent: !options.verbose }); exec(`git clone --branch ${shellEscape(options.cliBranch)} --depth 1 ${shellEscape(CLI_REPO_URL)} ${shellEscape(tmpDir)}`, { silent: !options.verbose });
// Stamp the binary with the version string the prod CLI surfaces in
// `hf version`. Fall back to a date-only label if rev-parse fails for
// any reason (shallow clone shouldn't, but be defensive).
let versionLabel = `${new Date().toISOString().slice(0, 10)}+install`;
try {
const sha = exec(`git rev-parse --short HEAD`, { cwd: tmpDir, silent: true }).trim();
if (sha) versionLabel = `${new Date().toISOString().slice(0, 10)}+${options.cliBranch}-${sha}`;
} catch { /* keep fallback */ }
log(` Building hf (version=${versionLabel})...`, 'blue');
const ldflags = `-X git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands.Version=${versionLabel}`;
exec(`go build -ldflags ${shellEscape(ldflags)} -o ${shellEscape(hfBinary)} ./cmd/hf`, { cwd: tmpDir, silent: !options.verbose });
chmodSync(hfBinary, 0o755); chmodSync(hfBinary, 0o755);
logOk(`hf binary → ${hfBinary}`); logOk(`hf binary → ${hfBinary} (branch hint: ${options.cliBranch})`);
} catch (err) { } catch (err) {
logErr(`Failed to build hf CLI: ${err.message}`); logErr(`Failed to build hf CLI: ${err.message}`);
logWarn('CLI installation failed, plugin still installed'); logWarn('CLI installation failed, plugin still installed');
} finally {
rmSync(tmpDir, { recursive: true, force: true });
} }
} }