@eight-atulya/atulya-openclaw 0.8.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/README.md +116 -0
- package/dist/client.d.ts +34 -0
- package/dist/client.js +214 -0
- package/dist/embed-manager.d.ts +27 -0
- package/dist/embed-manager.js +210 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +1181 -0
- package/dist/moltbot-types.d.ts +27 -0
- package/dist/moltbot-types.js +3 -0
- package/dist/types.d.ts +115 -0
- package/dist/types.js +2 -0
- package/openclaw.plugin.json +316 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Atulya Memory Plugin for OpenClaw
|
|
2
|
+
|
|
3
|
+
Biomimetic long-term memory for [OpenClaw](https://openclaw.ai) using [Atulya](https://github.com/eight-atulya/atulya). Automatically captures conversations and intelligently recalls relevant context.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 1. Configure your LLM provider for memory extraction
|
|
9
|
+
# Option A: OpenAI
|
|
10
|
+
export OPENAI_API_KEY="sk-your-key"
|
|
11
|
+
|
|
12
|
+
# Option B: Claude Code (no API key needed)
|
|
13
|
+
export ATULYA_API_LLM_PROVIDER=claude-code
|
|
14
|
+
|
|
15
|
+
# Option C: OpenAI Codex (no API key needed)
|
|
16
|
+
export ATULYA_API_LLM_PROVIDER=openai-codex
|
|
17
|
+
|
|
18
|
+
# 2. Install and enable the plugin
|
|
19
|
+
openclaw plugins install @eight-atulya/atulya-openclaw
|
|
20
|
+
|
|
21
|
+
# 3. Start OpenClaw
|
|
22
|
+
openclaw gateway
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
That's it! The plugin will automatically start capturing and recalling memories.
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- **Auto-capture** and **auto-recall** of memories each turn
|
|
30
|
+
- **Memory isolation** — configurable per agent, channel, user, or provider via `dynamicBankGranularity`
|
|
31
|
+
- **Retention controls** — choose which message roles to retain and toggle auto-retain on/off
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
Optional settings in `~/.openclaw/openclaw.json` under `plugins.entries.atulya-openclaw.config`:
|
|
36
|
+
|
|
37
|
+
| Option | Default | Description |
|
|
38
|
+
|--------|---------|-------------|
|
|
39
|
+
| `apiPort` | `9077` | Port for the local Atulya daemon |
|
|
40
|
+
| `daemonIdleTimeout` | `0` | Seconds before daemon shuts down from inactivity (0 = never) |
|
|
41
|
+
| `embedPort` | `0` | Port for `atulya-embed` server (`0` = auto-assign) |
|
|
42
|
+
| `embedVersion` | `"latest"` | atulya-embed version |
|
|
43
|
+
| `embedPackagePath` | — | Local path to `atulya-embed` package for development |
|
|
44
|
+
| `bankMission` | — | Agent identity/purpose stored on the memory bank. Helps the engine understand context for better fact extraction. Set once per bank — not a recall prompt. |
|
|
45
|
+
| `llmProvider` | auto-detect | LLM provider override for memory extraction (`openai`, `anthropic`, `gemini`, `groq`, `ollama`, `openai-codex`, `claude-code`) |
|
|
46
|
+
| `llmModel` | provider default | LLM model override used with `llmProvider` |
|
|
47
|
+
| `llmApiKeyEnv` | provider standard env var | Custom env var name for the provider API key |
|
|
48
|
+
| `dynamicBankId` | `true` | Enable per-context memory banks |
|
|
49
|
+
| `bankIdPrefix` | — | Prefix for bank IDs (e.g. `"prod"`) |
|
|
50
|
+
| `dynamicBankGranularity` | `["agent", "channel", "user"]` | Fields used to derive bank ID. Options: `agent`, `channel`, `user`, `provider` |
|
|
51
|
+
| `excludeProviders` | `[]` | Message providers to skip for recall/retain (e.g. `slack`, `telegram`, `discord`) |
|
|
52
|
+
| `autoRecall` | `true` | Auto-inject memories before each turn. Set to `false` when the agent has its own recall tool. |
|
|
53
|
+
| `autoRetain` | `true` | Auto-retain conversations after each turn |
|
|
54
|
+
| `retainRoles` | `["user", "assistant"]` | Which message roles to retain. Options: `user`, `assistant`, `system`, `tool` |
|
|
55
|
+
| `retainEveryNTurns` | `1` | Retain every Nth turn. `1` = every turn (default). Values > 1 enable chunked retention with a sliding window. |
|
|
56
|
+
| `retainOverlapTurns` | `0` | Extra prior turns included when chunked retention fires. Window = `retainEveryNTurns + retainOverlapTurns`. Only applies when `retainEveryNTurns > 1`. |
|
|
57
|
+
| `recallBudget` | `"mid"` | Recall effort: `low`, `mid`, or `high`. Higher budgets use more retrieval strategies. |
|
|
58
|
+
| `recallMaxTokens` | `1024` | Max tokens for recall response. Controls how much memory context is injected per turn. |
|
|
59
|
+
| `recallTypes` | `["world", "experience"]` | Memory types to recall. Options: `world`, `experience`, `observation`. Excludes verbose `observation` entries by default. |
|
|
60
|
+
| `recallRoles` | `["user", "assistant"]` | Roles included when building prior context for recall query composition. Options: `user`, `assistant`, `system`, `tool`. |
|
|
61
|
+
| `recallTopK` | — | Max number of memories to inject per turn. Applied after API response as a hard cap. |
|
|
62
|
+
| `recallContextTurns` | `1` | Number of user turns to include when composing recall query context. `1` keeps latest-message-only behavior. |
|
|
63
|
+
| `recallMaxQueryChars` | `800` | Maximum character length for the composed recall query before calling recall. |
|
|
64
|
+
| `recallPromptPreamble` | built-in string | Prompt text placed above recalled memories in the injected `<atulya_memories>` block. |
|
|
65
|
+
| `atulyaApiUrl` | — | External Atulya API URL (skips local daemon) |
|
|
66
|
+
| `atulyaApiToken` | — | Auth token for external API |
|
|
67
|
+
|
|
68
|
+
## Documentation
|
|
69
|
+
|
|
70
|
+
For full documentation, configuration options, troubleshooting, and development guide, see:
|
|
71
|
+
|
|
72
|
+
**[OpenClaw Integration Documentation](https://github.com/eight-atulya/atulya/blob/main/atulya-docs/docs/sdks/integrations/openclaw.md)**
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
To test local changes to the Atulya package before publishing:
|
|
77
|
+
|
|
78
|
+
1. Add `embedPackagePath` to your plugin config in `~/.openclaw/openclaw.json`:
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"plugins": {
|
|
82
|
+
"entries": {
|
|
83
|
+
"atulya-openclaw": {
|
|
84
|
+
"enabled": true,
|
|
85
|
+
"config": {
|
|
86
|
+
"embedPackagePath": "/path/to/atulya-wt3/atulya-embed"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
2. The plugin will use `uv run --directory <path> atulya-embed` instead of `uvx atulya-embed@latest`
|
|
95
|
+
|
|
96
|
+
3. To use a specific profile for testing:
|
|
97
|
+
```bash
|
|
98
|
+
# Check daemon status
|
|
99
|
+
uvx atulya-embed@latest -p openclaw daemon status
|
|
100
|
+
|
|
101
|
+
# View logs
|
|
102
|
+
tail -f ~/.atulya/profiles/openclaw.log
|
|
103
|
+
|
|
104
|
+
# List profiles
|
|
105
|
+
uvx atulya-embed@latest profile list
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Links
|
|
109
|
+
|
|
110
|
+
- [Atulya Documentation](https://github.com/eight-atulya/atulya/tree/main/atulya-docs)
|
|
111
|
+
- [OpenClaw Documentation](https://openclaw.ai)
|
|
112
|
+
- [GitHub Repository](https://github.com/eight-atulya/atulya)
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { RetainRequest, RetainResponse, RecallRequest, RecallResponse } from './types.js';
|
|
2
|
+
export interface AtulyaClientOptions {
|
|
3
|
+
llmModel?: string;
|
|
4
|
+
embedVersion?: string;
|
|
5
|
+
embedPackagePath?: string;
|
|
6
|
+
apiUrl?: string;
|
|
7
|
+
apiToken?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare class AtulyaClient {
|
|
10
|
+
private bankId;
|
|
11
|
+
private llmModel?;
|
|
12
|
+
private embedVersion;
|
|
13
|
+
private embedPackagePath?;
|
|
14
|
+
private apiUrl?;
|
|
15
|
+
private apiToken?;
|
|
16
|
+
constructor(opts: AtulyaClientOptions);
|
|
17
|
+
private get httpMode();
|
|
18
|
+
/**
|
|
19
|
+
* Get the command and base args to run atulya-embed.
|
|
20
|
+
* Returns [command, ...baseArgs] for use with execFile/spawn (no shell).
|
|
21
|
+
*/
|
|
22
|
+
private getEmbedCommand;
|
|
23
|
+
private httpHeaders;
|
|
24
|
+
setBankId(bankId: string): void;
|
|
25
|
+
setBankMission(mission: string): Promise<void>;
|
|
26
|
+
private setBankMissionHttp;
|
|
27
|
+
private setBankMissionSubprocess;
|
|
28
|
+
retain(request: RetainRequest): Promise<RetainResponse>;
|
|
29
|
+
private retainHttp;
|
|
30
|
+
private retainSubprocess;
|
|
31
|
+
recall(request: RecallRequest, timeoutMs?: number): Promise<RecallResponse>;
|
|
32
|
+
private recallHttp;
|
|
33
|
+
private recallSubprocess;
|
|
34
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { writeFile, mkdir, rm } from 'fs/promises';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { randomBytes } from 'crypto';
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const MAX_BUFFER = 5 * 1024 * 1024; // 5 MB — large transcripts can exceed default 1 MB
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
10
|
+
/** Strip null bytes from strings — Node 22 rejects them in execFile() args */
|
|
11
|
+
const sanitize = (s) => s.replace(/\0/g, '');
|
|
12
|
+
/**
|
|
13
|
+
* Sanitize a string for use as a cross-platform filename.
|
|
14
|
+
* Replaces characters illegal on Windows or Unix with underscores.
|
|
15
|
+
*/
|
|
16
|
+
function sanitizeFilename(name) {
|
|
17
|
+
// Replace characters illegal on Windows (\/:*?"<>|) and control chars
|
|
18
|
+
return name.replace(/[\\/:*?"<>|\x00-\x1f]/g, '_').slice(0, 200) || 'content';
|
|
19
|
+
}
|
|
20
|
+
export class AtulyaClient {
|
|
21
|
+
bankId = 'default';
|
|
22
|
+
llmModel;
|
|
23
|
+
embedVersion;
|
|
24
|
+
embedPackagePath;
|
|
25
|
+
apiUrl;
|
|
26
|
+
apiToken;
|
|
27
|
+
constructor(opts) {
|
|
28
|
+
this.llmModel = opts.llmModel;
|
|
29
|
+
this.embedVersion = opts.embedVersion || 'latest';
|
|
30
|
+
this.embedPackagePath = opts.embedPackagePath;
|
|
31
|
+
this.apiUrl = opts.apiUrl?.replace(/\/$/, ''); // strip trailing slash
|
|
32
|
+
this.apiToken = opts.apiToken;
|
|
33
|
+
}
|
|
34
|
+
get httpMode() {
|
|
35
|
+
return !!this.apiUrl;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Get the command and base args to run atulya-embed.
|
|
39
|
+
* Returns [command, ...baseArgs] for use with execFile/spawn (no shell).
|
|
40
|
+
*/
|
|
41
|
+
getEmbedCommand() {
|
|
42
|
+
if (this.embedPackagePath) {
|
|
43
|
+
return ['uv', 'run', '--directory', this.embedPackagePath, 'atulya-embed'];
|
|
44
|
+
}
|
|
45
|
+
const embedPackage = this.embedVersion ? `atulya-embed@${this.embedVersion}` : 'atulya-embed@latest';
|
|
46
|
+
return ['uvx', embedPackage];
|
|
47
|
+
}
|
|
48
|
+
httpHeaders() {
|
|
49
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
50
|
+
if (this.apiToken) {
|
|
51
|
+
headers['Authorization'] = `Bearer ${this.apiToken}`;
|
|
52
|
+
}
|
|
53
|
+
return headers;
|
|
54
|
+
}
|
|
55
|
+
setBankId(bankId) {
|
|
56
|
+
this.bankId = bankId;
|
|
57
|
+
}
|
|
58
|
+
// --- setBankMission ---
|
|
59
|
+
async setBankMission(mission) {
|
|
60
|
+
if (!mission || mission.trim().length === 0) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (this.httpMode) {
|
|
64
|
+
return this.setBankMissionHttp(mission);
|
|
65
|
+
}
|
|
66
|
+
return this.setBankMissionSubprocess(mission);
|
|
67
|
+
}
|
|
68
|
+
async setBankMissionHttp(mission) {
|
|
69
|
+
try {
|
|
70
|
+
const url = `${this.apiUrl}/v1/default/banks/${encodeURIComponent(this.bankId)}`;
|
|
71
|
+
const res = await fetch(url, {
|
|
72
|
+
method: 'PUT',
|
|
73
|
+
headers: this.httpHeaders(),
|
|
74
|
+
body: JSON.stringify({ mission }),
|
|
75
|
+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
const body = await res.text().catch(() => '');
|
|
79
|
+
throw new Error(`HTTP ${res.status}: ${body}`);
|
|
80
|
+
}
|
|
81
|
+
console.log(`[Atulya] Bank mission set via HTTP`);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
console.warn(`[Atulya] Could not set bank mission (bank may not exist yet): ${error}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async setBankMissionSubprocess(mission) {
|
|
88
|
+
const [cmd, ...baseArgs] = this.getEmbedCommand();
|
|
89
|
+
const args = [...baseArgs, '--profile', 'openclaw', 'bank', 'mission', this.bankId, sanitize(mission)];
|
|
90
|
+
try {
|
|
91
|
+
const { stdout } = await execFileAsync(cmd, args, { maxBuffer: MAX_BUFFER });
|
|
92
|
+
console.log(`[Atulya] Bank mission set: ${stdout.trim()}`);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
// Don't fail if mission set fails - bank might not exist yet, will be created on first retain
|
|
96
|
+
console.warn(`[Atulya] Could not set bank mission (bank may not exist yet): ${error}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// --- retain ---
|
|
100
|
+
async retain(request) {
|
|
101
|
+
if (this.httpMode) {
|
|
102
|
+
return this.retainHttp(request);
|
|
103
|
+
}
|
|
104
|
+
return this.retainSubprocess(request);
|
|
105
|
+
}
|
|
106
|
+
async retainHttp(request) {
|
|
107
|
+
const url = `${this.apiUrl}/v1/default/banks/${encodeURIComponent(this.bankId)}/memories`;
|
|
108
|
+
const body = {
|
|
109
|
+
items: [{
|
|
110
|
+
content: request.content,
|
|
111
|
+
document_id: request.document_id || 'conversation',
|
|
112
|
+
metadata: request.metadata,
|
|
113
|
+
}],
|
|
114
|
+
async: true,
|
|
115
|
+
};
|
|
116
|
+
const res = await fetch(url, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: this.httpHeaders(),
|
|
119
|
+
body: JSON.stringify(body),
|
|
120
|
+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
const text = await res.text().catch(() => '');
|
|
124
|
+
throw new Error(`Failed to retain memory (HTTP ${res.status}): ${text}`);
|
|
125
|
+
}
|
|
126
|
+
const data = await res.json();
|
|
127
|
+
console.log(`[Atulya] Retained via HTTP (async): ${JSON.stringify(data).substring(0, 200)}`);
|
|
128
|
+
return {
|
|
129
|
+
message: 'Memory queued for background processing',
|
|
130
|
+
document_id: request.document_id || 'conversation',
|
|
131
|
+
memory_unit_ids: [],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async retainSubprocess(request) {
|
|
135
|
+
const docId = request.document_id || 'conversation';
|
|
136
|
+
// Write content to a temp file to avoid E2BIG (ARG_MAX) errors when passing
|
|
137
|
+
// large conversations as arguments.
|
|
138
|
+
const tempDir = join(tmpdir(), `atulya_${randomBytes(8).toString('hex')}`);
|
|
139
|
+
const safeFilename = sanitizeFilename(docId);
|
|
140
|
+
const tempFile = join(tempDir, `${safeFilename}.txt`);
|
|
141
|
+
try {
|
|
142
|
+
await mkdir(tempDir, { recursive: true });
|
|
143
|
+
await writeFile(tempFile, sanitize(request.content), 'utf8');
|
|
144
|
+
const [cmd, ...baseArgs] = this.getEmbedCommand();
|
|
145
|
+
const args = [...baseArgs, '--profile', 'openclaw', 'memory', 'retain-files', this.bankId, tempFile, '--async'];
|
|
146
|
+
const { stdout } = await execFileAsync(cmd, args, { maxBuffer: MAX_BUFFER });
|
|
147
|
+
console.log(`[Atulya] Retained (async): ${stdout.trim()}`);
|
|
148
|
+
return {
|
|
149
|
+
message: 'Memory queued for background processing',
|
|
150
|
+
document_id: docId,
|
|
151
|
+
memory_unit_ids: [],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
throw new Error(`Failed to retain memory: ${error}`, { cause: error });
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => { });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// --- recall ---
|
|
162
|
+
async recall(request, timeoutMs) {
|
|
163
|
+
if (this.httpMode) {
|
|
164
|
+
return this.recallHttp(request, timeoutMs);
|
|
165
|
+
}
|
|
166
|
+
return this.recallSubprocess(request, timeoutMs);
|
|
167
|
+
}
|
|
168
|
+
async recallHttp(request, timeoutMs) {
|
|
169
|
+
const url = `${this.apiUrl}/v1/default/banks/${encodeURIComponent(this.bankId)}/memories/recall`;
|
|
170
|
+
// Defense-in-depth: truncate query to stay under API's 500-token limit
|
|
171
|
+
const MAX_QUERY_CHARS = 800;
|
|
172
|
+
const query = request.query.length > MAX_QUERY_CHARS
|
|
173
|
+
? (console.warn(`[Atulya] Truncating recall query from ${request.query.length} to ${MAX_QUERY_CHARS} chars`),
|
|
174
|
+
request.query.substring(0, MAX_QUERY_CHARS))
|
|
175
|
+
: request.query;
|
|
176
|
+
const body = {
|
|
177
|
+
query,
|
|
178
|
+
max_tokens: request.max_tokens || 1024,
|
|
179
|
+
};
|
|
180
|
+
if (request.budget) {
|
|
181
|
+
body.budget = request.budget;
|
|
182
|
+
}
|
|
183
|
+
if (request.types) {
|
|
184
|
+
body.types = request.types;
|
|
185
|
+
}
|
|
186
|
+
const res = await fetch(url, {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
headers: this.httpHeaders(),
|
|
189
|
+
body: JSON.stringify(body),
|
|
190
|
+
signal: AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
191
|
+
});
|
|
192
|
+
if (!res.ok) {
|
|
193
|
+
const text = await res.text().catch(() => '');
|
|
194
|
+
throw new Error(`Failed to recall memories (HTTP ${res.status}): ${text}`);
|
|
195
|
+
}
|
|
196
|
+
return res.json();
|
|
197
|
+
}
|
|
198
|
+
async recallSubprocess(request, timeoutMs) {
|
|
199
|
+
const query = sanitize(request.query);
|
|
200
|
+
const maxTokens = request.max_tokens || 1024;
|
|
201
|
+
const [cmd, ...baseArgs] = this.getEmbedCommand();
|
|
202
|
+
const args = [...baseArgs, '--profile', 'openclaw', 'memory', 'recall', this.bankId, query, '--output', 'json', '--max-tokens', String(maxTokens)];
|
|
203
|
+
try {
|
|
204
|
+
const { stdout } = await execFileAsync(cmd, args, {
|
|
205
|
+
maxBuffer: MAX_BUFFER,
|
|
206
|
+
timeout: timeoutMs ?? 30_000, // subprocess gets a longer default
|
|
207
|
+
});
|
|
208
|
+
return JSON.parse(stdout);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
throw new Error(`Failed to recall memories: ${error}`, { cause: error });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare class AtulyaEmbedManager {
|
|
2
|
+
private process;
|
|
3
|
+
private port;
|
|
4
|
+
private baseUrl;
|
|
5
|
+
private embedDir;
|
|
6
|
+
private llmProvider;
|
|
7
|
+
private llmApiKey;
|
|
8
|
+
private llmModel?;
|
|
9
|
+
private llmBaseUrl?;
|
|
10
|
+
private daemonIdleTimeout;
|
|
11
|
+
private embedVersion;
|
|
12
|
+
private embedPackagePath?;
|
|
13
|
+
constructor(port: number, llmProvider: string, llmApiKey: string, llmModel?: string, llmBaseUrl?: string, daemonIdleTimeout?: number, // Default: never timeout
|
|
14
|
+
embedVersion?: string, // Default: latest
|
|
15
|
+
embedPackagePath?: string);
|
|
16
|
+
/**
|
|
17
|
+
* Get the command to run atulya-embed (either local or from PyPI)
|
|
18
|
+
*/
|
|
19
|
+
private getEmbedCommand;
|
|
20
|
+
start(): Promise<void>;
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
private waitForReady;
|
|
23
|
+
getBaseUrl(): string;
|
|
24
|
+
isRunning(): boolean;
|
|
25
|
+
checkHealth(): Promise<boolean>;
|
|
26
|
+
private configureProfile;
|
|
27
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
export class AtulyaEmbedManager {
|
|
5
|
+
process = null;
|
|
6
|
+
port;
|
|
7
|
+
baseUrl;
|
|
8
|
+
embedDir;
|
|
9
|
+
llmProvider;
|
|
10
|
+
llmApiKey;
|
|
11
|
+
llmModel;
|
|
12
|
+
llmBaseUrl;
|
|
13
|
+
daemonIdleTimeout;
|
|
14
|
+
embedVersion;
|
|
15
|
+
embedPackagePath;
|
|
16
|
+
constructor(port, llmProvider, llmApiKey, llmModel, llmBaseUrl, daemonIdleTimeout = 0, // Default: never timeout
|
|
17
|
+
embedVersion = 'latest', // Default: latest
|
|
18
|
+
embedPackagePath // Local path to atulya package
|
|
19
|
+
) {
|
|
20
|
+
// Use the configured port (default: 9077 from config)
|
|
21
|
+
this.port = port;
|
|
22
|
+
this.baseUrl = `http://127.0.0.1:${port}`;
|
|
23
|
+
this.embedDir = join(homedir(), '.openclaw', 'atulya-embed');
|
|
24
|
+
this.llmProvider = llmProvider;
|
|
25
|
+
this.llmApiKey = llmApiKey;
|
|
26
|
+
this.llmModel = llmModel;
|
|
27
|
+
this.llmBaseUrl = llmBaseUrl;
|
|
28
|
+
this.daemonIdleTimeout = daemonIdleTimeout;
|
|
29
|
+
this.embedVersion = embedVersion || 'latest';
|
|
30
|
+
this.embedPackagePath = embedPackagePath;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Get the command to run atulya-embed (either local or from PyPI)
|
|
34
|
+
*/
|
|
35
|
+
getEmbedCommand() {
|
|
36
|
+
if (this.embedPackagePath) {
|
|
37
|
+
// Local package: uv run --directory <path> atulya-embed
|
|
38
|
+
return ['uv', 'run', '--directory', this.embedPackagePath, 'atulya-embed'];
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// PyPI package: uvx atulya-embed@version
|
|
42
|
+
const embedPackage = this.embedVersion ? `atulya-embed@${this.embedVersion}` : 'atulya-embed@latest';
|
|
43
|
+
return ['uvx', embedPackage];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async start() {
|
|
47
|
+
console.log(`[Atulya] Starting atulya-embed daemon...`);
|
|
48
|
+
// Build environment variables using standard ATULYA_API_LLM_* variables
|
|
49
|
+
const env = {
|
|
50
|
+
...process.env,
|
|
51
|
+
ATULYA_API_LLM_PROVIDER: this.llmProvider,
|
|
52
|
+
ATULYA_API_LLM_API_KEY: this.llmApiKey,
|
|
53
|
+
ATULYA_EMBED_DAEMON_IDLE_TIMEOUT: this.daemonIdleTimeout.toString(),
|
|
54
|
+
};
|
|
55
|
+
if (this.llmModel) {
|
|
56
|
+
env['ATULYA_API_LLM_MODEL'] = this.llmModel;
|
|
57
|
+
}
|
|
58
|
+
// Pass through base URL for OpenAI-compatible providers (OpenRouter, etc.)
|
|
59
|
+
if (this.llmBaseUrl) {
|
|
60
|
+
env['ATULYA_API_LLM_BASE_URL'] = this.llmBaseUrl;
|
|
61
|
+
}
|
|
62
|
+
// On macOS, force CPU for embeddings/reranker to avoid MPS/Metal issues in daemon mode
|
|
63
|
+
if (process.platform === 'darwin') {
|
|
64
|
+
env['ATULYA_API_EMBEDDINGS_LOCAL_FORCE_CPU'] = '1';
|
|
65
|
+
env['ATULYA_API_RERANKER_LOCAL_FORCE_CPU'] = '1';
|
|
66
|
+
}
|
|
67
|
+
// Configure "openclaw" profile using atulya-embed configure (non-interactive)
|
|
68
|
+
console.log('[Atulya] Configuring "openclaw" profile...');
|
|
69
|
+
await this.configureProfile(env);
|
|
70
|
+
// Start atulya-embed daemon with openclaw profile
|
|
71
|
+
const embedCmd = this.getEmbedCommand();
|
|
72
|
+
const startDaemon = spawn(embedCmd[0], [...embedCmd.slice(1), 'daemon', '--profile', 'openclaw', 'start'], {
|
|
73
|
+
stdio: 'pipe',
|
|
74
|
+
});
|
|
75
|
+
// Collect output
|
|
76
|
+
let output = '';
|
|
77
|
+
startDaemon.stdout?.on('data', (data) => {
|
|
78
|
+
const text = data.toString();
|
|
79
|
+
output += text;
|
|
80
|
+
console.log(`[Atulya] ${text.trim()}`);
|
|
81
|
+
});
|
|
82
|
+
startDaemon.stderr?.on('data', (data) => {
|
|
83
|
+
const text = data.toString();
|
|
84
|
+
output += text;
|
|
85
|
+
console.error(`[Atulya] ${text.trim()}`);
|
|
86
|
+
});
|
|
87
|
+
// Wait for daemon start command to complete
|
|
88
|
+
await new Promise((resolve, reject) => {
|
|
89
|
+
startDaemon.on('exit', (code) => {
|
|
90
|
+
if (code === 0) {
|
|
91
|
+
console.log('[Atulya] Daemon start command completed');
|
|
92
|
+
resolve();
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
reject(new Error(`Daemon start failed with code ${code}: ${output}`));
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
startDaemon.on('error', (error) => {
|
|
99
|
+
reject(error);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
// Wait for server to be ready
|
|
103
|
+
await this.waitForReady();
|
|
104
|
+
console.log('[Atulya] Daemon is ready');
|
|
105
|
+
}
|
|
106
|
+
async stop() {
|
|
107
|
+
console.log('[Atulya] Stopping atulya-embed daemon...');
|
|
108
|
+
const embedCmd = this.getEmbedCommand();
|
|
109
|
+
const stopDaemon = spawn(embedCmd[0], [...embedCmd.slice(1), 'daemon', '--profile', 'openclaw', 'stop'], {
|
|
110
|
+
stdio: 'pipe',
|
|
111
|
+
});
|
|
112
|
+
await new Promise((resolve) => {
|
|
113
|
+
stopDaemon.on('exit', () => {
|
|
114
|
+
console.log('[Atulya] Daemon stopped');
|
|
115
|
+
resolve();
|
|
116
|
+
});
|
|
117
|
+
stopDaemon.on('error', (error) => {
|
|
118
|
+
console.error('[Atulya] Error stopping daemon:', error);
|
|
119
|
+
resolve(); // Resolve anyway
|
|
120
|
+
});
|
|
121
|
+
// Timeout after 5 seconds
|
|
122
|
+
setTimeout(() => {
|
|
123
|
+
console.log('[Atulya] Daemon stop timeout');
|
|
124
|
+
resolve();
|
|
125
|
+
}, 5000);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async waitForReady(maxAttempts = 30) {
|
|
129
|
+
console.log('[Atulya] Waiting for daemon to be ready...');
|
|
130
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
131
|
+
try {
|
|
132
|
+
const response = await fetch(`${this.baseUrl}/health`);
|
|
133
|
+
if (response.ok) {
|
|
134
|
+
console.log('[Atulya] Daemon health check passed');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Not ready yet
|
|
140
|
+
}
|
|
141
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
142
|
+
}
|
|
143
|
+
throw new Error('Atulya daemon failed to become ready within 30 seconds');
|
|
144
|
+
}
|
|
145
|
+
getBaseUrl() {
|
|
146
|
+
return this.baseUrl;
|
|
147
|
+
}
|
|
148
|
+
isRunning() {
|
|
149
|
+
return this.process !== null;
|
|
150
|
+
}
|
|
151
|
+
async checkHealth() {
|
|
152
|
+
try {
|
|
153
|
+
const response = await fetch(`${this.baseUrl}/health`, { signal: AbortSignal.timeout(2000) });
|
|
154
|
+
return response.ok;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async configureProfile(env) {
|
|
161
|
+
// Build profile create command args with --merge, --port and --env flags
|
|
162
|
+
// Use --merge to allow updating existing profile
|
|
163
|
+
const createArgs = ['profile', 'create', 'openclaw', '--merge', '--port', this.port.toString()];
|
|
164
|
+
// Add all environment variables as --env flags
|
|
165
|
+
const envVars = [
|
|
166
|
+
'ATULYA_API_LLM_PROVIDER',
|
|
167
|
+
'ATULYA_API_LLM_MODEL',
|
|
168
|
+
'ATULYA_API_LLM_API_KEY',
|
|
169
|
+
'ATULYA_API_LLM_BASE_URL',
|
|
170
|
+
'ATULYA_EMBED_DAEMON_IDLE_TIMEOUT',
|
|
171
|
+
'ATULYA_API_EMBEDDINGS_LOCAL_FORCE_CPU',
|
|
172
|
+
'ATULYA_API_RERANKER_LOCAL_FORCE_CPU',
|
|
173
|
+
];
|
|
174
|
+
for (const envVar of envVars) {
|
|
175
|
+
if (env[envVar]) {
|
|
176
|
+
createArgs.push('--env', `${envVar}=${env[envVar]}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Run profile create command (non-interactive, overwrites if exists)
|
|
180
|
+
const embedCmd = this.getEmbedCommand();
|
|
181
|
+
const create = spawn(embedCmd[0], [...embedCmd.slice(1), ...createArgs], {
|
|
182
|
+
stdio: 'pipe',
|
|
183
|
+
});
|
|
184
|
+
let output = '';
|
|
185
|
+
create.stdout?.on('data', (data) => {
|
|
186
|
+
const text = data.toString();
|
|
187
|
+
output += text;
|
|
188
|
+
console.log(`[Atulya] ${text.trim()}`);
|
|
189
|
+
});
|
|
190
|
+
create.stderr?.on('data', (data) => {
|
|
191
|
+
const text = data.toString();
|
|
192
|
+
output += text;
|
|
193
|
+
console.error(`[Atulya] ${text.trim()}`);
|
|
194
|
+
});
|
|
195
|
+
await new Promise((resolve, reject) => {
|
|
196
|
+
create.on('exit', (code) => {
|
|
197
|
+
if (code === 0) {
|
|
198
|
+
console.log('[Atulya] Profile "openclaw" configured successfully');
|
|
199
|
+
resolve();
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
reject(new Error(`Profile create failed with code ${code}: ${output}`));
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
create.on('error', (error) => {
|
|
206
|
+
reject(error);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { MoltbotPluginAPI, PluginConfig, PluginHookAgentContext, MemoryResult } from './types.js';
|
|
2
|
+
import { AtulyaClient } from './client.js';
|
|
3
|
+
/**
|
|
4
|
+
* Strip plugin-injected memory tags from content to prevent retain feedback loop.
|
|
5
|
+
* Removes <atulya_memories> and <relevant_memories> blocks that were injected
|
|
6
|
+
* during before_agent_start so they don't get re-stored into the memory bank.
|
|
7
|
+
*/
|
|
8
|
+
export declare function stripMemoryTags(content: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Extract sender_id from OpenClaw's injected inbound metadata blocks.
|
|
11
|
+
* Checks both "Conversation info (untrusted metadata)" and "Sender (untrusted metadata)" blocks.
|
|
12
|
+
* Returns the first sender_id / id string found, or undefined if none.
|
|
13
|
+
*/
|
|
14
|
+
export declare function extractSenderIdFromText(text: string): string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Strip OpenClaw sender/conversation metadata envelopes from message content.
|
|
17
|
+
* These blocks are injected by OpenClaw but are noise for memory storage and recall.
|
|
18
|
+
*/
|
|
19
|
+
export declare function stripMetadataEnvelopes(content: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Extract a recall query from a hook event's rawMessage or prompt.
|
|
22
|
+
*
|
|
23
|
+
* Prefers rawMessage (clean user text). Falls back to prompt, stripping
|
|
24
|
+
* envelope formatting (System: lines, [Channel ...] headers, [from: X] footers).
|
|
25
|
+
*
|
|
26
|
+
* Returns null when no usable query (< 5 chars) can be extracted.
|
|
27
|
+
*/
|
|
28
|
+
export declare function extractRecallQuery(rawMessage: string | undefined, prompt: string | undefined): string | null;
|
|
29
|
+
export declare function composeRecallQuery(latestQuery: string, messages: any[] | undefined, recallContextTurns: number, recallRoles?: Array<'user' | 'assistant' | 'system' | 'tool'>): string;
|
|
30
|
+
export declare function truncateRecallQuery(query: string, latestQuery: string, maxChars: number): string;
|
|
31
|
+
export declare function deriveBankId(ctx: PluginHookAgentContext | undefined, pluginConfig: PluginConfig): string;
|
|
32
|
+
export declare function formatMemories(results: MemoryResult[]): string;
|
|
33
|
+
export default function (api: MoltbotPluginAPI): void;
|
|
34
|
+
export declare function prepareRetentionTranscript(messages: any[], pluginConfig: PluginConfig, retainFullWindow?: boolean): {
|
|
35
|
+
transcript: string;
|
|
36
|
+
messageCount: number;
|
|
37
|
+
} | null;
|
|
38
|
+
export declare function sliceLastTurnsByUserBoundary(messages: any[], turns: number): any[];
|
|
39
|
+
export declare function getClient(): AtulyaClient | null;
|