@byte5ai/palaia 2.1.0 → 2.2.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.
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Session-isolated turn state, plugin state persistence, and session key helpers.
3
+ *
4
+ * Extracted from hooks.ts during Phase 1.5 decomposition.
5
+ * No logic changes — pure structural refactoring.
6
+ */
7
+
8
+ import fs from "node:fs/promises";
9
+ import path from "node:path";
10
+ import type { PalaiaPluginConfig } from "../config.js";
11
+
12
+ // ============================================================================
13
+ // Plugin State Persistence (Issue #87: Recall counter for nudges)
14
+ // ============================================================================
15
+
16
+ export interface PluginState {
17
+ successfulRecalls: number;
18
+ satisfactionNudged: boolean;
19
+ transparencyNudged: boolean;
20
+ firstRecallTimestamp: string | null;
21
+ }
22
+
23
+ export const DEFAULT_PLUGIN_STATE: PluginState = {
24
+ successfulRecalls: 0,
25
+ satisfactionNudged: false,
26
+ transparencyNudged: false,
27
+ firstRecallTimestamp: null,
28
+ };
29
+
30
+ /**
31
+ * Load plugin state from disk.
32
+ *
33
+ * Note: No file locking is applied here. The plugin-state.json file stores
34
+ * non-critical counters (recall count, nudge flags). In the worst case of a
35
+ * race condition between multiple agents, a nudge fires one recall too early
36
+ * or too late. This is acceptable given the low-stakes nature of the data
37
+ * and the complexity cost of adding advisory locks in Node.js.
38
+ */
39
+ export async function loadPluginState(workspace?: string): Promise<PluginState> {
40
+ const dir = workspace || process.cwd();
41
+ const statePath = path.join(dir, ".palaia", "plugin-state.json");
42
+ try {
43
+ const raw = await fs.readFile(statePath, "utf-8");
44
+ return { ...DEFAULT_PLUGIN_STATE, ...JSON.parse(raw) };
45
+ } catch {
46
+ return { ...DEFAULT_PLUGIN_STATE };
47
+ }
48
+ }
49
+
50
+ export async function savePluginState(state: PluginState, workspace?: string): Promise<void> {
51
+ const dir = workspace || process.cwd();
52
+ const statePath = path.join(dir, ".palaia", "plugin-state.json");
53
+ try {
54
+ await fs.writeFile(statePath, JSON.stringify(state, null, 2));
55
+ } catch {
56
+ // Non-fatal
57
+ }
58
+ }
59
+
60
+ // ============================================================================
61
+ // Session-isolated Turn State (Issue #87: Emoji Reactions)
62
+ // ============================================================================
63
+
64
+ /** Per-session turn state for tracking recall/capture across hooks. */
65
+ export interface TurnState {
66
+ recallOccurred: boolean;
67
+ lastInboundMessageId: string | null;
68
+ lastInboundChannelId: string | null;
69
+ channelProvider: string | null;
70
+ capturedInThisTurn: boolean;
71
+ /** Timestamp when this entry was created (for TTL-based pruning). */
72
+ createdAt: number;
73
+ }
74
+
75
+ function createDefaultTurnState(): TurnState {
76
+ return {
77
+ recallOccurred: false,
78
+ lastInboundMessageId: null,
79
+ lastInboundChannelId: null,
80
+ channelProvider: null,
81
+ capturedInThisTurn: false,
82
+ createdAt: Date.now(),
83
+ };
84
+ }
85
+
86
+ /** Maximum age for turn state entries before they are pruned (5 minutes). */
87
+ const TURN_STATE_TTL_MS = 5 * 60 * 1000;
88
+ /** Maximum age for inbound message entries before they are pruned (5 minutes). */
89
+ const INBOUND_MESSAGE_TTL_MS = 5 * 60 * 1000;
90
+
91
+ /**
92
+ * Session-isolated turn state map. Keyed by sessionKey.
93
+ * Set in before_prompt_build / message_received, consumed + deleted in agent_end.
94
+ * NEVER use global variables for turn data — race condition with multi-agent.
95
+ */
96
+ export const turnStateBySession = new Map<string, TurnState>();
97
+
98
+ // ============================================================================
99
+ // Inbound Message ID Store (for emoji reactions)
100
+ // ============================================================================
101
+
102
+ /**
103
+ * Stores the most recent inbound message ID per channel.
104
+ * Keyed by channelId (e.g. "C0AKE2G15HV"), value is the message ts.
105
+ * Written by message_received, consumed by agent_end.
106
+ * Entries are short-lived and cleaned up after agent_end.
107
+ */
108
+ export const lastInboundMessageByChannel = new Map<string, { messageId: string; provider: string; timestamp: number }>();
109
+
110
+ /** Channels that support emoji reactions. */
111
+ export const REACTION_SUPPORTED_PROVIDERS = new Set(["slack", "discord"]);
112
+
113
+ /**
114
+ * Remove stale entries from turnStateBySession and lastInboundMessageByChannel.
115
+ * Called at the start of before_prompt_build to prevent memory leaks from
116
+ * sessions that were killed/crashed without firing agent_end.
117
+ */
118
+ export function pruneStaleEntries(): void {
119
+ const now = Date.now();
120
+ for (const [key, state] of turnStateBySession) {
121
+ if (now - state.createdAt > TURN_STATE_TTL_MS) {
122
+ turnStateBySession.delete(key);
123
+ }
124
+ }
125
+ for (const [key, entry] of lastInboundMessageByChannel) {
126
+ if (now - entry.timestamp > INBOUND_MESSAGE_TTL_MS) {
127
+ lastInboundMessageByChannel.delete(key);
128
+ }
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Get or create turn state for a session.
134
+ */
135
+ export function getOrCreateTurnState(sessionKey: string): TurnState {
136
+ let state = turnStateBySession.get(sessionKey);
137
+ if (!state) {
138
+ state = createDefaultTurnState();
139
+ turnStateBySession.set(sessionKey, state);
140
+ }
141
+ return state;
142
+ }
143
+
144
+ /**
145
+ * Delete turn state for a session (cleanup after agent_end).
146
+ */
147
+ export function deleteTurnState(sessionKey: string): void {
148
+ turnStateBySession.delete(sessionKey);
149
+ }
150
+
151
+ /** Reset all turn state, inbound message store (for testing and cleanup). */
152
+ export function resetTurnState(): void {
153
+ turnStateBySession.clear();
154
+ lastInboundMessageByChannel.clear();
155
+ }
156
+
157
+ // ============================================================================
158
+ // Session Key Helpers
159
+ // ============================================================================
160
+
161
+ /**
162
+ * Extract channel target from a session key.
163
+ * e.g. "agent:main:slack:channel:c0ake2g15hv" -> "channel:C0AKE2G15HV"
164
+ */
165
+ export function extractTargetFromSessionKey(sessionKey: string): string | undefined {
166
+ const parts = sessionKey.split(":");
167
+ for (let i = 0; i < parts.length - 1; i++) {
168
+ if (parts[i] === "channel" || parts[i] === "dm" || parts[i] === "group") {
169
+ return `${parts[i]}:${parts[i + 1].toUpperCase()}`;
170
+ }
171
+ }
172
+ return undefined;
173
+ }
174
+
175
+ /**
176
+ * Extract channel provider from a session key.
177
+ * e.g. "agent:main:slack:channel:c0ake2g15hv" -> "slack"
178
+ */
179
+ export function extractChannelFromSessionKey(sessionKey: string): string | undefined {
180
+ const parts = sessionKey.split(":");
181
+ if (parts.length >= 5 && parts[0] === "agent") {
182
+ return parts[2];
183
+ }
184
+ return undefined;
185
+ }
186
+
187
+ /**
188
+ * Extract the Slack channel ID from a session key.
189
+ * e.g. "agent:main:slack:channel:c0ake2g15hv" -> "C0AKE2G15HV"
190
+ */
191
+ export function extractSlackChannelIdFromSessionKey(sessionKey: string): string | undefined {
192
+ const parts = sessionKey.split(":");
193
+ for (let i = 0; i < parts.length - 1; i++) {
194
+ if (parts[i] === "channel" || parts[i] === "dm") {
195
+ return parts[i + 1].toUpperCase();
196
+ }
197
+ }
198
+ return undefined;
199
+ }
200
+
201
+ /**
202
+ * Extract the real Slack channel ID from event metadata or ctx.
203
+ * OpenClaw stores the channel in "channel:C0AKE2G15HV" format in:
204
+ * - event.metadata.to
205
+ * - event.metadata.originatingTo
206
+ * - ctx.conversationId
207
+ *
208
+ * ctx.channelId is the PROVIDER NAME ("slack"), not the channel ID.
209
+ * ctx.sessionKey is null during message_received.
210
+ */
211
+ export function extractChannelIdFromEvent(event: any, ctx: any): string | undefined {
212
+ const rawTo = event?.metadata?.to
213
+ ?? event?.metadata?.originatingTo
214
+ ?? ctx?.conversationId
215
+ ?? "";
216
+ const match = String(rawTo).match(/^(?:channel|dm|group):([A-Z0-9]+)$/i);
217
+ return match ? match[1].toUpperCase() : undefined;
218
+ }
219
+
220
+ /**
221
+ * Resolve the session key for the current turn from available ctx.
222
+ * Tries ctx.sessionKey first, then falls back to sessionId.
223
+ */
224
+ export function resolveSessionKeyFromCtx(ctx: any): string | undefined {
225
+ const sk = ctx?.sessionKey?.trim?.();
226
+ if (sk) return sk;
227
+ const sid = ctx?.sessionId?.trim?.();
228
+ return sid || undefined;
229
+ }
230
+
231
+ // ============================================================================
232
+ // Scope Validation (Issue #90)
233
+ // ============================================================================
234
+
235
+ const VALID_SCOPES = ["private", "team", "public"];
236
+
237
+ /**
238
+ * Check if a scope string is valid for palaia write.
239
+ * Valid: "private", "team", "public", or any "shared:*" prefix.
240
+ */
241
+ export function isValidScope(s: string): boolean {
242
+ return VALID_SCOPES.includes(s) || s.startsWith("shared:");
243
+ }
244
+
245
+ /**
246
+ * Sanitize a scope value -- returns the value if valid, otherwise fallback.
247
+ * Enforces: LLM may suggest private or team, but NEVER public (unless explicitly configured).
248
+ */
249
+ export function sanitizeScope(rawScope: string | null | undefined, fallback = "team", allowPublic = false): string {
250
+ if (!rawScope || !isValidScope(rawScope)) return fallback;
251
+ // Block public scope unless explicitly allowed (config-level override)
252
+ if (rawScope === "public" && !allowPublic) return fallback;
253
+ return rawScope;
254
+ }
255
+
256
+ // ============================================================================
257
+ // Hook helpers
258
+ // ============================================================================
259
+
260
+ /**
261
+ * Resolve per-agent workspace and agentId from hook context.
262
+ * Fallback chain: ctx.workspaceDir -> config.workspace -> cwd
263
+ * Agent chain: ctx.agentId -> PALAIA_AGENT env var -> undefined
264
+ */
265
+ export function resolvePerAgentContext(ctx: any, config: PalaiaPluginConfig) {
266
+ return {
267
+ workspace: ctx?.workspaceDir || config.workspace,
268
+ agentId: ctx?.agentId || process.env.PALAIA_AGENT || undefined,
269
+ };
270
+ }
271
+
272
+ // ============================================================================
273
+ // /palaia status command -- Format helpers
274
+ // ============================================================================
275
+
276
+ /**
277
+ * Format an ISO date string as a short date: "Mar 16", "Feb 10".
278
+ */
279
+ export function formatShortDate(isoDate: string): string {
280
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
281
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
282
+ try {
283
+ const d = new Date(isoDate);
284
+ if (isNaN(d.getTime())) return "";
285
+ return `${months[d.getMonth()]} ${d.getDate()}`;
286
+ } catch {
287
+ return "";
288
+ }
289
+ }
290
+
291
+ export function formatStatusResponse(
292
+ state: PluginState,
293
+ stats: Record<string, unknown>,
294
+ config: PalaiaPluginConfig,
295
+ ): string {
296
+ const lines: string[] = ["Palaia Memory Status", ""];
297
+
298
+ // Recall count
299
+ const sinceDate = state.firstRecallTimestamp
300
+ ? formatShortDate(state.firstRecallTimestamp)
301
+ : "n/a";
302
+ lines.push(`Recalls: ${state.successfulRecalls} successful (since ${sinceDate})`);
303
+
304
+ // Store stats from palaia status --json
305
+ const totalEntries = stats.total_entries ?? stats.totalEntries ?? "?";
306
+ const hotEntries = stats.hot ?? stats.hotEntries ?? "?";
307
+ const warmEntries = stats.warm ?? stats.warmEntries ?? "?";
308
+ lines.push(`Store: ${totalEntries} entries (${hotEntries} hot, ${warmEntries} warm)`);
309
+
310
+ // Recall indicator
311
+ lines.push(`Recall indicator: ${config.showMemorySources ? "ON" : "OFF"}`);
312
+
313
+ // Config summary
314
+ lines.push(`Config: autoCapture=${config.autoCapture}, captureScope=${config.captureScope || "team"}`);
315
+
316
+ return lines.join("\n");
317
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Injection priority management for multi-agent setups (Issue #121).
3
+ *
4
+ * TypeScript-side integration: loads `.palaia/priorities.json`,
5
+ * resolves layered config (global -> agent -> project), and provides
6
+ * blocking/filtering utilities for recall injection.
7
+ *
8
+ * Mirrors the Python implementation in palaia/priorities.py.
9
+ */
10
+
11
+ import { readFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+
14
+ // ============================================================================
15
+ // Interfaces
16
+ // ============================================================================
17
+
18
+ export interface PrioritiesConfig {
19
+ version: number;
20
+ blocked: string[];
21
+ recallTypeWeight?: Record<string, number>;
22
+ recallMinScore?: number;
23
+ maxInjectedChars?: number;
24
+ tier?: string;
25
+ agents?: Record<string, AgentPriorityOverride>;
26
+ projects?: Record<string, ProjectPriorityOverride>;
27
+ }
28
+
29
+ export interface AgentPriorityOverride {
30
+ blocked?: string[];
31
+ recallTypeWeight?: Record<string, number>;
32
+ recallMinScore?: number;
33
+ maxInjectedChars?: number;
34
+ tier?: string;
35
+ }
36
+
37
+ export interface ProjectPriorityOverride {
38
+ blocked?: string[];
39
+ recallTypeWeight?: Record<string, number>;
40
+ }
41
+
42
+ export interface ResolvedPriorities {
43
+ blocked: Set<string>;
44
+ recallTypeWeight: Record<string, number>;
45
+ recallMinScore: number;
46
+ maxInjectedChars: number;
47
+ tier: string;
48
+ }
49
+
50
+ // ============================================================================
51
+ // Defaults (match Python side)
52
+ // ============================================================================
53
+
54
+ const DEFAULT_TYPE_WEIGHTS: Record<string, number> = { process: 1.5, task: 1.2, memory: 1.0 };
55
+ const DEFAULT_MIN_SCORE = 0.0;
56
+ const DEFAULT_MAX_CHARS = 4000;
57
+ const DEFAULT_TIER = "hot";
58
+
59
+ // ============================================================================
60
+ // TTL Cache
61
+ // ============================================================================
62
+
63
+ const CACHE_TTL_MS = 30_000;
64
+
65
+ let _cache: {
66
+ workspace: string;
67
+ data: PrioritiesConfig | null;
68
+ loadedAt: number;
69
+ } | null = null;
70
+
71
+ /** Reset cache (for testing). */
72
+ export function resetPrioritiesCache(): void {
73
+ _cache = null;
74
+ }
75
+
76
+ // ============================================================================
77
+ // Load
78
+ // ============================================================================
79
+
80
+ /**
81
+ * Load `.palaia/priorities.json` from the given workspace directory.
82
+ * Returns null if the file does not exist or is invalid.
83
+ * Results are cached with a 30-second TTL.
84
+ */
85
+ export async function loadPriorities(workspace: string): Promise<PrioritiesConfig | null> {
86
+ const now = Date.now();
87
+ if (_cache && _cache.workspace === workspace && (now - _cache.loadedAt) < CACHE_TTL_MS) {
88
+ return _cache.data;
89
+ }
90
+
91
+ const path = join(workspace, ".palaia", "priorities.json");
92
+ let data: PrioritiesConfig | null = null;
93
+
94
+ try {
95
+ const raw = await readFile(path, "utf-8");
96
+ const parsed = JSON.parse(raw);
97
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
98
+ data = parsed as PrioritiesConfig;
99
+ }
100
+ } catch {
101
+ // File doesn't exist or is invalid — return null
102
+ }
103
+
104
+ _cache = { workspace, data, loadedAt: now };
105
+ return data;
106
+ }
107
+
108
+ // ============================================================================
109
+ // Resolve
110
+ // ============================================================================
111
+
112
+ export interface PriorityDefaults {
113
+ recallTypeWeight: Record<string, number>;
114
+ recallMinScore: number;
115
+ maxInjectedChars: number;
116
+ tier: string;
117
+ }
118
+
119
+ /**
120
+ * Merge priority layers into a flat resolved config.
121
+ *
122
+ * Resolution order: plugin defaults -> global overrides -> agent overrides -> project overrides.
123
+ * `blocked` is a union across all layers.
124
+ * Type weights are merged (partial overrides fill from previous layer).
125
+ * Scalar values are overridden (last layer wins).
126
+ */
127
+ export function resolvePriorities(
128
+ prio: PrioritiesConfig | null,
129
+ defaults: PriorityDefaults,
130
+ agentId?: string,
131
+ project?: string,
132
+ ): ResolvedPriorities {
133
+ const resolved: ResolvedPriorities = {
134
+ blocked: new Set<string>(),
135
+ recallTypeWeight: { ...DEFAULT_TYPE_WEIGHTS, ...defaults.recallTypeWeight },
136
+ recallMinScore: defaults.recallMinScore ?? DEFAULT_MIN_SCORE,
137
+ maxInjectedChars: defaults.maxInjectedChars ?? DEFAULT_MAX_CHARS,
138
+ tier: defaults.tier ?? DEFAULT_TIER,
139
+ };
140
+
141
+ if (!prio) return resolved;
142
+
143
+ // Layer 1: Global
144
+ if (prio.blocked) {
145
+ for (const b of prio.blocked) resolved.blocked.add(b);
146
+ }
147
+ if (prio.recallTypeWeight) {
148
+ resolved.recallTypeWeight = { ...resolved.recallTypeWeight, ...prio.recallTypeWeight };
149
+ }
150
+ if (prio.recallMinScore !== undefined) {
151
+ resolved.recallMinScore = Number(prio.recallMinScore);
152
+ }
153
+ if (prio.maxInjectedChars !== undefined) {
154
+ resolved.maxInjectedChars = Number(prio.maxInjectedChars);
155
+ }
156
+ if (prio.tier !== undefined) {
157
+ resolved.tier = prio.tier;
158
+ }
159
+
160
+ // Layer 2: Agent override
161
+ if (agentId) {
162
+ const agentCfg = prio.agents?.[agentId];
163
+ if (agentCfg) {
164
+ if (agentCfg.blocked) {
165
+ for (const b of agentCfg.blocked) resolved.blocked.add(b);
166
+ }
167
+ if (agentCfg.recallTypeWeight) {
168
+ resolved.recallTypeWeight = { ...resolved.recallTypeWeight, ...agentCfg.recallTypeWeight };
169
+ }
170
+ if (agentCfg.recallMinScore !== undefined) {
171
+ resolved.recallMinScore = Number(agentCfg.recallMinScore);
172
+ }
173
+ if (agentCfg.maxInjectedChars !== undefined) {
174
+ resolved.maxInjectedChars = Number(agentCfg.maxInjectedChars);
175
+ }
176
+ if (agentCfg.tier !== undefined) {
177
+ resolved.tier = agentCfg.tier;
178
+ }
179
+ }
180
+ }
181
+
182
+ // Layer 3: Project override (only blocked + recallTypeWeight)
183
+ if (project) {
184
+ const projCfg = prio.projects?.[project];
185
+ if (projCfg) {
186
+ if (projCfg.blocked) {
187
+ for (const b of projCfg.blocked) resolved.blocked.add(b);
188
+ }
189
+ if (projCfg.recallTypeWeight) {
190
+ resolved.recallTypeWeight = { ...resolved.recallTypeWeight, ...projCfg.recallTypeWeight };
191
+ }
192
+ }
193
+ }
194
+
195
+ return resolved;
196
+ }
197
+
198
+ // ============================================================================
199
+ // Blocking
200
+ // ============================================================================
201
+
202
+ /**
203
+ * Check if an entry ID is blocked (exact or prefix match).
204
+ * Prefix match requires the blocked prefix to be at least 8 chars.
205
+ */
206
+ export function isBlocked(entryId: string, blocked: Set<string>): boolean {
207
+ if (blocked.has(entryId)) return true;
208
+ for (const b of blocked) {
209
+ if (b.length >= 8 && entryId.startsWith(b)) return true;
210
+ }
211
+ return false;
212
+ }
213
+
214
+ /**
215
+ * Filter out blocked entries from a list.
216
+ * Each entry must have an `id` property.
217
+ */
218
+ export function filterBlocked<T extends { id: string }>(entries: T[], blocked: Set<string>): T[] {
219
+ if (blocked.size === 0) return entries;
220
+ return entries.filter((e) => !isBlocked(e.id, blocked));
221
+ }
package/src/tools.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  import { Type } from "@sinclair/typebox";
10
10
  import { run, runJson, type RunnerOpts } from "./runner.js";
11
11
  import type { PalaiaPluginConfig } from "./config.js";
12
- import { sanitizeScope, isValidScope } from "./hooks.js";
12
+ import { sanitizeScope, isValidScope } from "./hooks/index.js";
13
+ import type { OpenClawPluginApi } from "./types.js";
13
14
 
14
15
  /** Shape returned by `palaia query --json` */
15
16
  interface QueryResult {
@@ -62,7 +63,7 @@ function buildRunnerOpts(config: PalaiaPluginConfig): RunnerOpts {
62
63
  /**
63
64
  * Register all Palaia agent tools on the given plugin API.
64
65
  */
65
- export function registerTools(api: any, config: PalaiaPluginConfig): void {
66
+ export function registerTools(api: OpenClawPluginApi, config: PalaiaPluginConfig): void {
66
67
  const opts = buildRunnerOpts(config);
67
68
 
68
69
  // ── memory_search ──────────────────────────────────────────────
package/src/types.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * OpenClaw Plugin SDK types.
3
+ *
4
+ * These interfaces define the contract with OpenClaw's plugin system.
5
+ * They are maintained locally to avoid a build-time dependency on the
6
+ * openclaw package (which is a peerDependency loaded at runtime).
7
+ *
8
+ * Based on OpenClaw v2026.3.22 plugin-sdk.
9
+ */
10
+
11
+ import type { TObject } from "@sinclair/typebox";
12
+
13
+ // ── Tool Types ──────────────────────────────────────────────────────────
14
+
15
+ export interface ToolDefinition {
16
+ name: string;
17
+ description: string;
18
+ parameters: TObject;
19
+ execute(id: string, params: Record<string, unknown>): Promise<ToolResult>;
20
+ }
21
+
22
+ export interface ToolResult {
23
+ content: Array<{ type: "text"; text: string }>;
24
+ }
25
+
26
+ export interface ToolOptions {
27
+ /** Mark this tool as optional (not registered by default). */
28
+ optional?: boolean;
29
+ }
30
+
31
+ export type ToolFactory = ToolDefinition;
32
+
33
+ // ── Hook Types ──────────────────────────────────────────────────────────
34
+
35
+ export type HookName =
36
+ | "before_prompt_build"
37
+ | "agent_end"
38
+ | "message_received"
39
+ | "message_sending";
40
+
41
+ export type HookHandler = (event: unknown, ctx: unknown) => void | Promise<unknown>;
42
+
43
+ export interface HookOptions {
44
+ priority?: number;
45
+ }
46
+
47
+ // ── Command Types ───────────────────────────────────────────────────────
48
+
49
+ export interface CommandDefinition {
50
+ name: string;
51
+ description: string;
52
+ handler(args: string): Promise<{ text: string }> | { text: string };
53
+ }
54
+
55
+ // ── Service Types ───────────────────────────────────────────────────────
56
+
57
+ export interface ServiceDefinition {
58
+ id: string;
59
+ start(): Promise<void>;
60
+ stop?(): Promise<void>;
61
+ }
62
+
63
+ // ── Context Engine Types ────────────────────────────────────────────────
64
+
65
+ export interface ContextEngine {
66
+ bootstrap?(): Promise<void>;
67
+ ingest?(messages: unknown[]): Promise<void>;
68
+ assemble(budget: { maxTokens: number }): Promise<{ content: string; tokenEstimate: number }>;
69
+ compact?(): Promise<void>;
70
+ afterTurn?(turn: unknown): Promise<void>;
71
+ prepareSubagentSpawn?(parentContext: unknown): Promise<unknown>;
72
+ onSubagentEnded?(result: unknown): Promise<void>;
73
+ }
74
+
75
+ // ── Logger ──────────────────────────────────────────────────────────────
76
+
77
+ export interface PluginLogger {
78
+ info(...args: unknown[]): void;
79
+ warn(...args: unknown[]): void;
80
+ error?(...args: unknown[]): void;
81
+ debug?(...args: unknown[]): void;
82
+ }
83
+
84
+ // ── Runtime ─────────────────────────────────────────────────────────────
85
+
86
+ export interface PluginRuntime {
87
+ agent?: {
88
+ runEmbeddedPiAgent?(params: Record<string, unknown>): Promise<unknown>;
89
+ };
90
+ modelAuth?: {
91
+ resolveApiKeyForProvider(params: {
92
+ provider: string;
93
+ cfg: Record<string, unknown>;
94
+ }): Promise<string | null>;
95
+ };
96
+ }
97
+
98
+ // ── Main Plugin API ─────────────────────────────────────────────────────
99
+
100
+ export interface OpenClawPluginApi {
101
+ registerTool(definition: ToolDefinition, options?: ToolOptions): void;
102
+ registerCommand(command: CommandDefinition): void;
103
+ registerService(service: ServiceDefinition): void;
104
+ registerContextEngine?(id: string, engine: ContextEngine): void;
105
+ on(hook: HookName | string, handler: HookHandler): void;
106
+ getConfig(pluginId: string): Record<string, unknown> | undefined;
107
+ logger: PluginLogger;
108
+ runtime: PluginRuntime;
109
+ config: Record<string, unknown>;
110
+ workspace?: { dir: string; agentId?: string } | string;
111
+ }
112
+
113
+ // ── Plugin Entry ────────────────────────────────────────────────────────
114
+
115
+ export interface OpenClawPluginEntry {
116
+ id: string;
117
+ name: string;
118
+ register(api: OpenClawPluginApi): void | Promise<void>;
119
+ }