@shayne/openclaw-temporal-halo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shayne
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # OpenClaw Temporal Halo
2
+
3
+ Temporal Halo is an OpenClaw plugin that maintains a living markdown document (`HALO.md`) describing the user’s recent past, present, and future. The plugin injects `HALO.md` into every agent turn and provides a scheduled “dream” flow to keep it up to date.
4
+
5
+ This plugin is intentionally tool/skill agnostic. The dream step tells the agent to look at categories like email, calendar, and prior sessions, and lets the agent decide which installed skills/tools to use.
6
+
7
+ ## What You Get
8
+
9
+ - Always-on context injection via `before_agent_start`
10
+ - `HALO.md` is prepended verbatim to every agent turn (high convenience, higher privacy risk).
11
+ - Dream mode (cron-triggered)
12
+ - Run every 30 minutes in an isolated session
13
+ - Updates `~/.openclaw/temporal-halo/HALO.md`
14
+ - Size guardrails
15
+ - `HALO.md` budget: 25,000 chars
16
+ - If a dream draft is >25k on first publish attempt, the agent is instructed to compact to <=20k and retry.
17
+ - If it’s still >25k on the second attempt, the plugin warns the user (system event) and does not write an oversized file.
18
+
19
+ ## Install (Dev / Local)
20
+
21
+ Link-install the plugin (no copy) so edits take effect immediately:
22
+
23
+ ```bash
24
+ openclaw plugins install -l .
25
+ openclaw plugins enable openclaw-temporal-halo
26
+ ```
27
+
28
+ Verify it loaded:
29
+
30
+ ```bash
31
+ openclaw plugins info openclaw-temporal-halo
32
+ ```
33
+
34
+ ## Install (Remote / Server)
35
+
36
+ Recommended (after this package is published to npm):
37
+
38
+ ```bash
39
+ openclaw plugins install @shayne/openclaw-temporal-halo
40
+ openclaw plugins enable openclaw-temporal-halo
41
+ ```
42
+
43
+ Alternative (if you want to run from a git clone on the remote machine):
44
+
45
+ ```bash
46
+ git clone https://github.com/shayne/openclaw-temporal-halo.git
47
+ cd openclaw-temporal-halo
48
+ openclaw plugins install -l .
49
+ openclaw plugins enable openclaw-temporal-halo
50
+ ```
51
+
52
+ ## Configure
53
+
54
+ Plugin config lives under `plugins.entries.openclaw-temporal-halo.config` in your OpenClaw config (typically `~/.openclaw/openclaw.json`).
55
+
56
+ Example:
57
+
58
+ ```json5
59
+ {
60
+ plugins: {
61
+ entries: {
62
+ "openclaw-temporal-halo": {
63
+ enabled: true,
64
+ config: {
65
+ // default: ~/.openclaw/temporal-halo/HALO.md
66
+ haloPath: "~/.openclaw/temporal-halo/HALO.md",
67
+ dreamMarker: "[temporal-halo:dream]",
68
+ maxChars: 25000,
69
+ compactTargetChars: 20000,
70
+ debug: false,
71
+ },
72
+ },
73
+ },
74
+ },
75
+ }
76
+ ```
77
+
78
+ ## Set Up Dreaming (OpenClaw Cron)
79
+
80
+ Temporal Halo uses OpenClaw’s built-in cron scheduler (no OS cron).
81
+
82
+ Create a repeating isolated job (every 30 minutes) that is silent by default:
83
+
84
+ ```bash
85
+ openclaw cron add \
86
+ --name "Temporal Halo: Dream" \
87
+ --every "30m" \
88
+ --session isolated \
89
+ --no-deliver \
90
+ --message "[temporal-halo:dream] Update HALO.md by reviewing email, calendar, recent chats, and prior OpenClaw sessions. Publish via temporal_halo_publish."
91
+ ```
92
+
93
+ Run it once to test:
94
+
95
+ ```bash
96
+ openclaw cron list
97
+ openclaw cron run <job-id>
98
+ ```
99
+
100
+ ## Tool Allowlists (If You Use Them)
101
+
102
+ If you have a strict tool allowlist configured (global `tools.allow` or per-agent `agents.list[].tools.allow`), make sure you allow either:
103
+
104
+ - `temporal_halo_publish` (tool name), or
105
+ - `openclaw-temporal-halo` (plugin id), or
106
+ - `group:plugins` (all plugin tools)
107
+
108
+ ## HALO.md Structure (Guideline)
109
+
110
+ The dream step maintains a stable schema with:
111
+
112
+ - Present (Now to 24h)
113
+ - Near Future (Next 14d)
114
+ - Medium Future (15–60d)
115
+ - Long Horizon (60d+ important)
116
+ - Recent Past (Last 14d)
117
+ - Retrieval Recipes (tool/skill agnostic pointers)
118
+ - Key Identifiers (full values allowed: confirmations, locators, etc.)
119
+
120
+ ## Security Notes
121
+
122
+ - `HALO.md` may contain sensitive personal identifiers and is injected into every agent turn.
123
+ - Treat this as equivalent to pasting `HALO.md` into every prompt.
124
+ - If you don’t want that, don’t enable this plugin.
125
+
126
+ ## Development
127
+
128
+ This repo uses `mise` to manage tooling and tasks.
129
+
130
+ ```bash
131
+ mise install
132
+ mise run lint
133
+ mise run check-types
134
+ mise run test
135
+ ```
136
+
137
+ ## Publishing (Maintainers)
138
+
139
+ Preferred: GitHub Actions Trusted Publishing (OIDC) in `.github/workflows/release.yml`.
140
+
141
+ - Push a tag `vX.Y.Z` to publish `latest` (the workflow verifies the tag matches `package.json`).
142
+ - Push to `main` to publish a `dev` dist-tag (with an auto-generated dev version).
143
+
144
+ Manual fallback (publishes from your machine; requires `npm login` and publish rights):
145
+
146
+ ```bash
147
+ mise run publish-npm
148
+ ```
149
+
150
+ ## License
151
+
152
+ MIT. See `LICENSE`.
package/config.ts ADDED
@@ -0,0 +1,101 @@
1
+ import os from "node:os"
2
+ import path from "node:path"
3
+
4
+ export type TemporalHaloConfig = {
5
+ enabled: boolean
6
+ haloPath: string
7
+ dreamMarker: string
8
+ maxChars: number
9
+ compactTargetChars: number
10
+ debug: boolean
11
+ }
12
+
13
+ const ALLOWED_KEYS = [
14
+ "enabled",
15
+ "haloPath",
16
+ "dreamMarker",
17
+ "maxChars",
18
+ "compactTargetChars",
19
+ "debug",
20
+ ]
21
+
22
+ function assertAllowedKeys(
23
+ value: Record<string, unknown>,
24
+ allowed: string[],
25
+ label: string,
26
+ ): void {
27
+ const unknown = Object.keys(value).filter((k) => !allowed.includes(k))
28
+ if (unknown.length > 0) {
29
+ throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`)
30
+ }
31
+ }
32
+
33
+ function resolveEnvVars(value: string): string {
34
+ return value.replace(/\$\{([^}]+)\}/g, (_, envVar: string) => {
35
+ const envValue = process.env[envVar]
36
+ if (!envValue) {
37
+ throw new Error(`Environment variable ${envVar} is not set`)
38
+ }
39
+ return envValue
40
+ })
41
+ }
42
+
43
+ function expandTilde(p: string): string {
44
+ const trimmed = p.trim()
45
+ if (trimmed === "~") return os.homedir()
46
+ if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2))
47
+ return trimmed
48
+ }
49
+
50
+ function defaultHaloPath(): string {
51
+ return path.join(os.homedir(), ".openclaw", "temporal-halo", "HALO.md")
52
+ }
53
+
54
+ export function parseConfig(raw: unknown): TemporalHaloConfig {
55
+ const cfg =
56
+ raw && typeof raw === "object" && !Array.isArray(raw)
57
+ ? (raw as Record<string, unknown>)
58
+ : {}
59
+
60
+ if (Object.keys(cfg).length > 0) {
61
+ assertAllowedKeys(cfg, ALLOWED_KEYS, "temporal-halo config")
62
+ }
63
+
64
+ const enabled = typeof cfg.enabled === "boolean" ? cfg.enabled : true
65
+ const haloPathRaw =
66
+ typeof cfg.haloPath === "string" && cfg.haloPath.trim()
67
+ ? resolveEnvVars(cfg.haloPath)
68
+ : defaultHaloPath()
69
+
70
+ const maxChars =
71
+ typeof cfg.maxChars === "number" && Number.isFinite(cfg.maxChars)
72
+ ? Math.trunc(cfg.maxChars)
73
+ : 25_000
74
+ const compactTargetChars =
75
+ typeof cfg.compactTargetChars === "number" &&
76
+ Number.isFinite(cfg.compactTargetChars)
77
+ ? Math.trunc(cfg.compactTargetChars)
78
+ : 20_000
79
+
80
+ if (compactTargetChars > maxChars) {
81
+ throw new Error(
82
+ `temporal-halo: compactTargetChars (${compactTargetChars}) must be <= maxChars (${maxChars})`,
83
+ )
84
+ }
85
+
86
+ return {
87
+ enabled,
88
+ haloPath: path.resolve(expandTilde(haloPathRaw)),
89
+ dreamMarker:
90
+ typeof cfg.dreamMarker === "string" && cfg.dreamMarker.trim()
91
+ ? cfg.dreamMarker.trim()
92
+ : "[temporal-halo:dream]",
93
+ maxChars,
94
+ compactTargetChars,
95
+ debug: typeof cfg.debug === "boolean" ? cfg.debug : false,
96
+ }
97
+ }
98
+
99
+ export const temporalHaloConfigSchema = {
100
+ parse: parseConfig,
101
+ }
package/dream.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { TemporalHaloConfig } from "./config.ts"
2
+
3
+ export function isDreamPrompt(prompt: string, marker: string): boolean {
4
+ return prompt.includes(marker)
5
+ }
6
+
7
+ export function buildHaloUsageInstructions(cfg: TemporalHaloConfig): string {
8
+ return [
9
+ "<temporal-halo:usage>",
10
+ "Use the injected HALO.md below as high-signal temporal context.",
11
+ "- Prefer HALO.md for disambiguation (recent past / now / upcoming).",
12
+ "- Use Key Identifiers first for concrete values (confirmations, locators, etc.).",
13
+ "- If a needed detail is missing, follow Retrieval Recipes to look it up using available tools/skills.",
14
+ "- Do not hallucinate missing identifiers; retrieve or ask the user.",
15
+ `- HALO path: ${cfg.haloPath}`,
16
+ "</temporal-halo:usage>",
17
+ ].join("\n")
18
+ }
19
+
20
+ export function buildDreamInstructions(cfg: TemporalHaloConfig): string {
21
+ return [
22
+ "<temporal-halo:dream>",
23
+ "You are in Temporal Halo Dream mode.",
24
+ "Your job: refresh HALO.md by surveying the user’s digital life using whatever tools/skills are available.",
25
+ "",
26
+ "Sources to consider (tool/skill agnostic):",
27
+ "- Email: confirmations, threads needing replies, travel receipts, shipments, key plans.",
28
+ "- Calendar: today/tomorrow, next 14d, next 60d, and major long-horizon items.",
29
+ "- Recent chats/messages: commitments, asks, decisions, open loops.",
30
+ "- Prior OpenClaw sessions: what the user was doing, promised follow-ups, recent context.",
31
+ "",
32
+ "HALO horizons:",
33
+ "- Past: last 14 days",
34
+ "- Future: next 60 days",
35
+ "- Keep long-horizon exceptions if they are significant (e.g. big trip).",
36
+ "",
37
+ "HALO requirements:",
38
+ "- Use the stable schema: Present / Near Future / Medium Future / Long Horizon / Recent Past.",
39
+ "- Include Retrieval Recipes: how to locate original sources when details are missing.",
40
+ "- Include Key Identifiers with full values when important (sensitive allowed).",
41
+ "- Keep the document compact and scannable.",
42
+ `- Hard max size: ${cfg.maxChars} chars. Aim for <=${cfg.compactTargetChars} chars.`,
43
+ "",
44
+ "Publishing:",
45
+ "- When you have the updated markdown for the entire file, call the tool `temporal_halo_publish` with the full markdown.",
46
+ `- If the tool reports the content is oversize, compact to <=${cfg.compactTargetChars} chars and call the tool again exactly once.`,
47
+ "- If the second publish attempt is still oversize, stop; a warning will be sent to the user and HALO.md will remain unchanged.",
48
+ "</temporal-halo:dream>",
49
+ ].join("\n")
50
+ }
51
+
52
+ export function buildHaloBlock(params: {
53
+ haloPath: string
54
+ haloText: string | null
55
+ }): string {
56
+ if (!params.haloText?.trim()) {
57
+ return [
58
+ "<temporal-halo:file>",
59
+ `HALO.md not found (yet): ${params.haloPath}`,
60
+ "To create/update it, run the Temporal Halo dream cron job.",
61
+ "</temporal-halo:file>",
62
+ ].join("\n")
63
+ }
64
+
65
+ return [
66
+ "<temporal-halo:file>",
67
+ `Path: ${params.haloPath}`,
68
+ "----- HALO.md BEGIN -----",
69
+ params.haloText.trimEnd(),
70
+ "----- HALO.md END -----",
71
+ "</temporal-halo:file>",
72
+ ].join("\n")
73
+ }
package/halo.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import fs from "node:fs/promises"
3
+ import path from "node:path"
4
+
5
+ export async function readHaloFile(filePath: string): Promise<string | null> {
6
+ try {
7
+ return await fs.readFile(filePath, "utf-8")
8
+ } catch (err) {
9
+ const code = (err as { code?: unknown } | null)?.code
10
+ if (code === "ENOENT" || code === "ENOTDIR") {
11
+ return null
12
+ }
13
+ throw err
14
+ }
15
+ }
16
+
17
+ async function renameOverwriting(
18
+ tmpPath: string,
19
+ destPath: string,
20
+ ): Promise<void> {
21
+ try {
22
+ await fs.rename(tmpPath, destPath)
23
+ return
24
+ } catch (err) {
25
+ // On some platforms, rename won't overwrite an existing destination.
26
+ const code = (err as { code?: unknown } | null)?.code
27
+ if (code === "EEXIST" || code === "EPERM" || code === "EACCES") {
28
+ await fs.rm(destPath, { force: true })
29
+ await fs.rename(tmpPath, destPath)
30
+ return
31
+ }
32
+ throw err
33
+ }
34
+ }
35
+
36
+ export async function writeHaloAtomic(
37
+ filePath: string,
38
+ contents: string,
39
+ ): Promise<void> {
40
+ const dir = path.dirname(filePath)
41
+ await fs.mkdir(dir, { recursive: true })
42
+
43
+ const tmpPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`
44
+ await fs.writeFile(tmpPath, contents, "utf-8")
45
+ await renameOverwriting(tmpPath, filePath)
46
+ }
package/index.ts ADDED
@@ -0,0 +1,62 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
2
+ import { parseConfig, temporalHaloConfigSchema } from "./config.ts"
3
+ import {
4
+ buildDreamInstructions,
5
+ buildHaloBlock,
6
+ buildHaloUsageInstructions,
7
+ isDreamPrompt,
8
+ } from "./dream.ts"
9
+ import { readHaloFile } from "./halo.ts"
10
+ import { registerTemporalHaloPublishTool } from "./tools/publish.ts"
11
+
12
+ export default {
13
+ id: "openclaw-temporal-halo",
14
+ name: "Temporal Halo",
15
+ description: "Always-on temporal HALO.md context + cron-driven dreaming",
16
+ configSchema: temporalHaloConfigSchema,
17
+
18
+ register(api: OpenClawPluginApi) {
19
+ const cfg = parseConfig(api.pluginConfig)
20
+
21
+ if (!cfg.enabled) {
22
+ api.logger.info("temporal-halo: disabled via plugin config")
23
+ return
24
+ }
25
+
26
+ registerTemporalHaloPublishTool(api, cfg)
27
+
28
+ api.on(
29
+ "before_agent_start",
30
+ async (event: Record<string, unknown>, ctx: Record<string, unknown>) => {
31
+ const prompt = typeof event.prompt === "string" ? event.prompt : ""
32
+ const isDream = isDreamPrompt(prompt, cfg.dreamMarker)
33
+
34
+ let haloText: string | null = null
35
+ try {
36
+ haloText = await readHaloFile(cfg.haloPath)
37
+ } catch (err) {
38
+ api.logger.warn(
39
+ `temporal-halo: failed reading HALO.md: ${String(err)}`,
40
+ )
41
+ }
42
+
43
+ const parts: string[] = []
44
+ parts.push(buildHaloUsageInstructions(cfg))
45
+ if (isDream) {
46
+ parts.push(buildDreamInstructions(cfg))
47
+ }
48
+ parts.push(buildHaloBlock({ haloPath: cfg.haloPath, haloText }))
49
+
50
+ if (cfg.debug) {
51
+ const sessionKey =
52
+ typeof ctx.sessionKey === "string" ? ctx.sessionKey : ""
53
+ api.logger.info(
54
+ `temporal-halo: injecting HALO context (${haloText?.length ?? 0} chars) sessionKey=${sessionKey}`,
55
+ )
56
+ }
57
+
58
+ return { prependContext: parts.join("\n\n") }
59
+ },
60
+ )
61
+ },
62
+ }
@@ -0,0 +1,53 @@
1
+ {
2
+ "id": "openclaw-temporal-halo",
3
+ "name": "Temporal Halo",
4
+ "description": "Always-on temporal HALO.md context + cron-driven dreaming",
5
+ "version": "0.1.0",
6
+ "uiHints": {
7
+ "enabled": {
8
+ "label": "Enabled",
9
+ "help": "If false, the plugin will not inject HALO or enable dream helpers."
10
+ },
11
+ "haloPath": {
12
+ "label": "HALO.md Path",
13
+ "placeholder": "~/.openclaw/temporal-halo/HALO.md",
14
+ "help": "Global HALO document path. Default: ~/.openclaw/temporal-halo/HALO.md"
15
+ },
16
+ "dreamMarker": {
17
+ "label": "Dream Marker",
18
+ "placeholder": "[temporal-halo:dream]",
19
+ "help": "If the agent prompt contains this marker, the plugin injects dream instructions."
20
+ },
21
+ "maxChars": {
22
+ "label": "Max HALO chars",
23
+ "placeholder": "25000",
24
+ "help": "Hard cap for HALO.md on disk. Publish attempts above this are rejected."
25
+ },
26
+ "compactTargetChars": {
27
+ "label": "Compaction target chars",
28
+ "placeholder": "20000",
29
+ "help": "If a publish attempt is oversize, the agent is instructed to compact to this target."
30
+ },
31
+ "debug": {
32
+ "label": "Debug Logging",
33
+ "help": "Enable verbose logging"
34
+ }
35
+ },
36
+ "configSchema": {
37
+ "type": "object",
38
+ "additionalProperties": false,
39
+ "properties": {
40
+ "enabled": { "type": "boolean" },
41
+ "haloPath": { "type": "string" },
42
+ "dreamMarker": { "type": "string" },
43
+ "maxChars": { "type": "number", "minimum": 1000, "maximum": 200000 },
44
+ "compactTargetChars": {
45
+ "type": "number",
46
+ "minimum": 1000,
47
+ "maximum": 200000
48
+ },
49
+ "debug": { "type": "boolean" }
50
+ },
51
+ "required": []
52
+ }
53
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@shayne/openclaw-temporal-halo",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "OpenClaw temporal HALO.md plugin (always-on context + scheduled dreaming)",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/shayne/openclaw-temporal-halo.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/shayne/openclaw-temporal-halo/issues"
13
+ },
14
+ "homepage": "https://github.com/shayne/openclaw-temporal-halo#readme",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "files": [
19
+ "*.ts",
20
+ "tools/**/*.ts",
21
+ "openclaw.plugin.json",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "dependencies": {
26
+ "@sinclair/typebox": "0.34.47"
27
+ },
28
+ "scripts": {
29
+ "check-types": "bunx tsc --noEmit",
30
+ "lint": "bunx @biomejs/biome ci .",
31
+ "lint:fix": "bunx @biomejs/biome check --write .",
32
+ "test": "bunx vitest run"
33
+ },
34
+ "peerDependencies": {
35
+ "openclaw": ">=2026.2.14"
36
+ },
37
+ "openclaw": {
38
+ "extensions": [
39
+ "./index.ts"
40
+ ]
41
+ },
42
+ "devDependencies": {
43
+ "@biomejs/biome": "^2.3.8",
44
+ "@types/node": "^22.13.4",
45
+ "openclaw": "2026.2.14",
46
+ "typescript": "^5.9.3",
47
+ "vitest": "^3.0.6"
48
+ }
49
+ }
@@ -0,0 +1,190 @@
1
+ import { Type } from "@sinclair/typebox"
2
+ import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk"
3
+ import type { TemporalHaloConfig } from "../config.ts"
4
+ import { writeHaloAtomic } from "../halo.ts"
5
+
6
+ type RegisteredTool = Parameters<OpenClawPluginApi["registerTool"]>[0]
7
+ type ExtractFn<T> = T extends (...args: infer Args) => infer Result
8
+ ? (...args: Args) => Result
9
+ : never
10
+ type OpenClawPluginToolFactory = ExtractFn<RegisteredTool>
11
+
12
+ export type TemporalHaloToolContext = Parameters<OpenClawPluginToolFactory>[0]
13
+
14
+ function normalizeToken(value: string | undefined | null): string {
15
+ return (value ?? "").trim().toLowerCase()
16
+ }
17
+
18
+ function normalizeMainKey(value: string | undefined | null): string {
19
+ const trimmed = (value ?? "").trim()
20
+ return trimmed ? trimmed.toLowerCase() : "main"
21
+ }
22
+
23
+ function normalizeAgentId(value: string | undefined | null): string {
24
+ const raw = (value ?? "").trim()
25
+ if (!raw) return "main"
26
+ const lowered = raw.toLowerCase()
27
+ const normalized = lowered
28
+ .replace(/[^a-z0-9_-]+/g, "-")
29
+ .replace(/^-+|-+$/g, "")
30
+ return normalized.slice(0, 64) || "main"
31
+ }
32
+
33
+ function resolveAgentIdFromSessionKey(
34
+ sessionKey: string | undefined | null,
35
+ ): string | null {
36
+ const raw = (sessionKey ?? "").trim()
37
+ if (!raw) return null
38
+ const lowered = raw.toLowerCase()
39
+ if (!lowered.startsWith("agent:")) return null
40
+ const parts = lowered.split(":")
41
+ return parts.length >= 2 ? (parts[1] ?? null) : null
42
+ }
43
+
44
+ export function resolveWarningSessionKey(
45
+ toolCtx: TemporalHaloToolContext,
46
+ ): string | null {
47
+ const cfg = toolCtx.config as unknown as {
48
+ session?: { scope?: unknown; mainKey?: unknown }
49
+ } | null
50
+
51
+ if (cfg?.session?.scope === "global") {
52
+ return "global"
53
+ }
54
+
55
+ const agentId =
56
+ normalizeToken(toolCtx.agentId) ||
57
+ normalizeToken(resolveAgentIdFromSessionKey(toolCtx.sessionKey)) ||
58
+ "main"
59
+ const mainKey = normalizeMainKey(
60
+ typeof cfg?.session?.mainKey === "string" ? cfg.session.mainKey : null,
61
+ )
62
+
63
+ return `agent:${normalizeAgentId(agentId)}:${mainKey}`
64
+ }
65
+
66
+ export function createTemporalHaloPublishTool(params: {
67
+ api: OpenClawPluginApi
68
+ cfg: TemporalHaloConfig
69
+ toolCtx: TemporalHaloToolContext
70
+ }): AnyAgentTool {
71
+ let oversizeAttempts = 0
72
+
73
+ return {
74
+ name: "temporal_halo_publish",
75
+ label: "Temporal Halo Publish",
76
+ description: "Atomically write HALO.md with strict size enforcement.",
77
+ parameters: Type.Object({
78
+ markdown: Type.String({
79
+ description: "Full markdown content for HALO.md",
80
+ }),
81
+ }),
82
+ async execute(_toolCallId: string, args: { markdown: string }) {
83
+ const markdown = typeof args.markdown === "string" ? args.markdown : ""
84
+ const trimmed = markdown.trimEnd()
85
+ if (!trimmed.trim()) {
86
+ return {
87
+ content: [
88
+ {
89
+ type: "text" as const,
90
+ text: "temporal_halo_publish: markdown required",
91
+ },
92
+ ],
93
+ details: { ok: false, error: "markdown_required" },
94
+ }
95
+ }
96
+
97
+ const chars = trimmed.length
98
+ if (chars > params.cfg.maxChars) {
99
+ oversizeAttempts += 1
100
+ if (oversizeAttempts === 1) {
101
+ return {
102
+ content: [
103
+ {
104
+ type: "text" as const,
105
+ text:
106
+ `HALO draft is ${chars} chars (max ${params.cfg.maxChars}). ` +
107
+ `Compact it to <=${params.cfg.compactTargetChars} chars and call temporal_halo_publish again.`,
108
+ },
109
+ ],
110
+ details: {
111
+ ok: false,
112
+ published: false,
113
+ error: "oversize",
114
+ chars,
115
+ maxChars: params.cfg.maxChars,
116
+ next: "compact_and_retry",
117
+ targetChars: params.cfg.compactTargetChars,
118
+ attempt: oversizeAttempts,
119
+ },
120
+ }
121
+ }
122
+
123
+ const warnKey =
124
+ resolveWarningSessionKey(params.toolCtx) ??
125
+ (params.toolCtx.sessionKey ? params.toolCtx.sessionKey : null)
126
+ if (warnKey) {
127
+ try {
128
+ params.api.runtime.system.enqueueSystemEvent(
129
+ `Temporal Halo warning: HALO publish still oversize (${chars} chars) after compaction attempt. HALO.md was not updated.`,
130
+ { sessionKey: warnKey, contextKey: "temporal-halo" },
131
+ )
132
+ } catch (err) {
133
+ params.api.logger.warn(
134
+ `temporal-halo: failed to enqueue warning system event: ${String(err)}`,
135
+ )
136
+ }
137
+ }
138
+
139
+ return {
140
+ content: [
141
+ {
142
+ type: "text" as const,
143
+ text:
144
+ `HALO draft is still ${chars} chars (max ${params.cfg.maxChars}). ` +
145
+ "Warning sent. HALO.md was not updated.",
146
+ },
147
+ ],
148
+ details: {
149
+ ok: false,
150
+ published: false,
151
+ error: "oversize_after_retry",
152
+ chars,
153
+ maxChars: params.cfg.maxChars,
154
+ attempt: oversizeAttempts,
155
+ warned: Boolean(warnKey),
156
+ },
157
+ }
158
+ }
159
+
160
+ await writeHaloAtomic(params.cfg.haloPath, trimmed)
161
+ oversizeAttempts = 0
162
+
163
+ return {
164
+ content: [
165
+ {
166
+ type: "text" as const,
167
+ text: `Published HALO.md (${chars} chars) to ${params.cfg.haloPath}`,
168
+ },
169
+ ],
170
+ details: {
171
+ ok: true,
172
+ published: true,
173
+ path: params.cfg.haloPath,
174
+ chars,
175
+ },
176
+ }
177
+ },
178
+ }
179
+ }
180
+
181
+ export function registerTemporalHaloPublishTool(
182
+ api: OpenClawPluginApi,
183
+ cfg: TemporalHaloConfig,
184
+ ) {
185
+ api.registerTool(
186
+ (toolCtx: TemporalHaloToolContext) =>
187
+ createTemporalHaloPublishTool({ api, cfg, toolCtx }),
188
+ { name: "temporal_halo_publish" },
189
+ )
190
+ }