@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 +22 -0
- package/README.md +152 -0
- package/config.ts +101 -0
- package/dream.ts +73 -0
- package/halo.ts +46 -0
- package/index.ts +62 -0
- package/openclaw.plugin.json +53 -0
- package/package.json +49 -0
- package/tools/publish.ts +190 -0
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
|
+
}
|
package/tools/publish.ts
ADDED
|
@@ -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
|
+
}
|