chainlesschain 0.45.75 → 0.45.76
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 +52 -15
- package/package.json +1 -1
- package/src/commands/learning.js +273 -0
- package/src/commands/lowcode.js +23 -8
- package/src/gateways/discord/discord-formatter.js +89 -0
- package/src/gateways/gateway-base.js +189 -0
- package/src/gateways/telegram/telegram-formatter.js +93 -0
- package/src/index.js +2 -0
- package/src/lib/app-builder.js +136 -8
- package/src/lib/autonomous-agent.js +8 -1
- package/src/lib/cli-context-engineering.js +15 -0
- package/src/lib/execution-backend.js +239 -0
- package/src/lib/hook-manager.js +2 -0
- package/src/lib/iteration-budget.js +175 -0
- package/src/lib/learning/learning-hooks.js +117 -0
- package/src/lib/learning/learning-tables.js +66 -0
- package/src/lib/learning/outcome-feedback.js +243 -0
- package/src/lib/learning/reflection-engine.js +323 -0
- package/src/lib/learning/skill-improver.js +536 -0
- package/src/lib/learning/skill-synthesizer.js +315 -0
- package/src/lib/learning/trajectory-store.js +409 -0
- package/src/lib/plugin-autodiscovery.js +224 -0
- package/src/lib/session-search.js +193 -0
- package/src/lib/sub-agent-context.js +7 -2
- package/src/lib/user-profile.js +172 -0
- package/src/lib/web-ui-server.js +1 -1
- package/src/repl/agent-repl.js +109 -0
- package/src/runtime/agent-core.js +75 -4
- package/src/runtime/coding-agent-contract-shared.cjs +35 -0
- package/src/runtime/coding-agent-policy.cjs +10 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Auto-Discovery — zero-friction file-drop plugin loading.
|
|
3
|
+
*
|
|
4
|
+
* Scans ~/.chainlesschain/plugins/*.js for auto-loadable plugins.
|
|
5
|
+
* Hermes-style: drop a .js file, restart agent, it's available.
|
|
6
|
+
*
|
|
7
|
+
* Plugin exports:
|
|
8
|
+
* { name, version?, description?, tools?, hooks?, commands? }
|
|
9
|
+
*
|
|
10
|
+
* - tools[] → injected via extraTools in agent-core
|
|
11
|
+
* - hooks{} → auto-registered in HookManager
|
|
12
|
+
* - commands{} → added to REPL slash commands
|
|
13
|
+
*
|
|
14
|
+
* DB-registered plugins (plugin-manager.js) override file-drop plugins
|
|
15
|
+
* with the same name.
|
|
16
|
+
*
|
|
17
|
+
* @module plugin-autodiscovery
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readdirSync, existsSync, mkdirSync } from "node:fs";
|
|
21
|
+
import { join, extname, basename } from "node:path";
|
|
22
|
+
import { pathToFileURL } from "node:url";
|
|
23
|
+
import { getHomeDir } from "./paths.js";
|
|
24
|
+
|
|
25
|
+
// ─── Constants ──────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const PLUGINS_DIR_NAME = "plugins";
|
|
28
|
+
|
|
29
|
+
// ─── Exported for test injection ────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export const _deps = {
|
|
32
|
+
readdirSync,
|
|
33
|
+
existsSync,
|
|
34
|
+
mkdirSync,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// ─── Path ───────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get the file-drop plugins directory path.
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function getPluginDir() {
|
|
44
|
+
return join(getHomeDir(), PLUGINS_DIR_NAME);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Validation ─────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate a plugin's exported shape.
|
|
51
|
+
* @param {object} mod - Module exports
|
|
52
|
+
* @param {string} filePath - Source file (for error messages)
|
|
53
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
54
|
+
*/
|
|
55
|
+
export function validatePluginExports(mod, filePath) {
|
|
56
|
+
const errors = [];
|
|
57
|
+
const fname = basename(filePath);
|
|
58
|
+
|
|
59
|
+
if (!mod || typeof mod !== "object") {
|
|
60
|
+
return { valid: false, errors: [`${fname}: exports is not an object`] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!mod.name || typeof mod.name !== "string") {
|
|
64
|
+
errors.push(`${fname}: missing or invalid 'name' (string required)`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (mod.tools && !Array.isArray(mod.tools)) {
|
|
68
|
+
errors.push(`${fname}: 'tools' must be an array`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (mod.hooks && typeof mod.hooks !== "object") {
|
|
72
|
+
errors.push(`${fname}: 'hooks' must be an object`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (mod.commands && typeof mod.commands !== "object") {
|
|
76
|
+
errors.push(`${fname}: 'commands' must be an object`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { valid: errors.length === 0, errors };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Scan ───────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Scan the plugins directory and return .js file paths.
|
|
86
|
+
* @returns {string[]} Array of absolute paths to .js files
|
|
87
|
+
*/
|
|
88
|
+
export function scanPluginDir() {
|
|
89
|
+
const dir = getPluginDir();
|
|
90
|
+
if (!_deps.existsSync(dir)) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const entries = _deps.readdirSync(dir);
|
|
96
|
+
return entries.filter((f) => extname(f) === ".js").map((f) => join(dir, f));
|
|
97
|
+
} catch (_err) {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Load ───────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Load a single file-drop plugin.
|
|
106
|
+
* @param {string} filePath - Absolute path to the .js file
|
|
107
|
+
* @returns {Promise<{ plugin: object|null, errors: string[] }>}
|
|
108
|
+
*/
|
|
109
|
+
export async function loadFileDropPlugin(filePath) {
|
|
110
|
+
try {
|
|
111
|
+
const fileUrl = pathToFileURL(filePath).href;
|
|
112
|
+
const mod = await import(fileUrl);
|
|
113
|
+
// Handle both default export and named exports
|
|
114
|
+
const exports = mod.default || mod;
|
|
115
|
+
|
|
116
|
+
const { valid, errors } = validatePluginExports(exports, filePath);
|
|
117
|
+
if (!valid) {
|
|
118
|
+
return { plugin: null, errors };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
plugin: {
|
|
123
|
+
...exports,
|
|
124
|
+
source: "file-drop",
|
|
125
|
+
filePath,
|
|
126
|
+
},
|
|
127
|
+
errors: [],
|
|
128
|
+
};
|
|
129
|
+
} catch (err) {
|
|
130
|
+
return {
|
|
131
|
+
plugin: null,
|
|
132
|
+
errors: [`${basename(filePath)}: load failed — ${err.message}`],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Discover All ───────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Discover and load all file-drop plugins.
|
|
141
|
+
* @param {object} [options]
|
|
142
|
+
* @param {string[]} [options.dbPluginNames] - Names of DB-registered plugins (these take priority)
|
|
143
|
+
* @param {function} [options.onWarn] - Warning callback (message) => void
|
|
144
|
+
* @returns {Promise<{ plugins: object[], errors: string[] }>}
|
|
145
|
+
*/
|
|
146
|
+
export async function getAutoDiscoveredPlugins(options = {}) {
|
|
147
|
+
const { dbPluginNames = [], onWarn } = options;
|
|
148
|
+
const dbNameSet = new Set(dbPluginNames);
|
|
149
|
+
const files = scanPluginDir();
|
|
150
|
+
const plugins = [];
|
|
151
|
+
const errors = [];
|
|
152
|
+
|
|
153
|
+
for (const filePath of files) {
|
|
154
|
+
const result = await loadFileDropPlugin(filePath);
|
|
155
|
+
if (result.errors.length > 0) {
|
|
156
|
+
errors.push(...result.errors);
|
|
157
|
+
if (onWarn) result.errors.forEach((e) => onWarn(e));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// DB-registered plugin with same name takes priority
|
|
162
|
+
if (dbNameSet.has(result.plugin.name)) {
|
|
163
|
+
if (onWarn) {
|
|
164
|
+
onWarn(
|
|
165
|
+
`Plugin "${result.plugin.name}" skipped (DB-registered version takes priority)`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
plugins.push(result.plugin);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { plugins, errors };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Tool extraction ────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Extract tool definitions from loaded file-drop plugins.
|
|
181
|
+
* Returns tools in OpenAI function-calling format.
|
|
182
|
+
* @param {object[]} plugins - Loaded plugins from getAutoDiscoveredPlugins
|
|
183
|
+
* @returns {object[]} Tool definitions ready for extraTools
|
|
184
|
+
*/
|
|
185
|
+
export function extractPluginTools(plugins) {
|
|
186
|
+
const tools = [];
|
|
187
|
+
for (const plugin of plugins) {
|
|
188
|
+
if (!plugin.tools || !Array.isArray(plugin.tools)) continue;
|
|
189
|
+
for (const tool of plugin.tools) {
|
|
190
|
+
if (tool?.function?.name) {
|
|
191
|
+
tools.push({ ...tool, _pluginSource: plugin.name });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return tools;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Extract REPL commands from loaded file-drop plugins.
|
|
200
|
+
* @param {object[]} plugins - Loaded plugins
|
|
201
|
+
* @returns {Map<string, { handler: function, description: string, pluginName: string }>}
|
|
202
|
+
*/
|
|
203
|
+
export function extractPluginCommands(plugins) {
|
|
204
|
+
const commands = new Map();
|
|
205
|
+
for (const plugin of plugins) {
|
|
206
|
+
if (!plugin.commands || typeof plugin.commands !== "object") continue;
|
|
207
|
+
for (const [cmdName, cmdDef] of Object.entries(plugin.commands)) {
|
|
208
|
+
if (typeof cmdDef === "function") {
|
|
209
|
+
commands.set(cmdName, {
|
|
210
|
+
handler: cmdDef,
|
|
211
|
+
description: "",
|
|
212
|
+
pluginName: plugin.name,
|
|
213
|
+
});
|
|
214
|
+
} else if (cmdDef && typeof cmdDef.handler === "function") {
|
|
215
|
+
commands.set(cmdName, {
|
|
216
|
+
handler: cmdDef.handler,
|
|
217
|
+
description: cmdDef.description || "",
|
|
218
|
+
pluginName: plugin.name,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return commands;
|
|
224
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Search Index — cross-session FTS5 full-text search.
|
|
3
|
+
*
|
|
4
|
+
* Enables searching across all past agent sessions using SQLite FTS5.
|
|
5
|
+
* Indexes message content on SessionEnd and provides search with
|
|
6
|
+
* snippet highlighting.
|
|
7
|
+
*
|
|
8
|
+
* Inspired by Hermes Agent's cross-session FTS5 search.
|
|
9
|
+
*
|
|
10
|
+
* @module session-search
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
readEvents,
|
|
15
|
+
listJsonlSessions,
|
|
16
|
+
} from "../harness/jsonl-session-store.js";
|
|
17
|
+
|
|
18
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const MAX_CONTENT_LENGTH = 10000; // per message, prevent bloat
|
|
21
|
+
const DEFAULT_SEARCH_LIMIT = 10;
|
|
22
|
+
|
|
23
|
+
// ─── SessionSearchIndex ─────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export class SessionSearchIndex {
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} db - better-sqlite3 database instance
|
|
28
|
+
*/
|
|
29
|
+
constructor(db) {
|
|
30
|
+
this._db = db;
|
|
31
|
+
this._initialized = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Ensure FTS5 virtual table exists.
|
|
36
|
+
*/
|
|
37
|
+
ensureTables() {
|
|
38
|
+
if (!this._db) return;
|
|
39
|
+
this._db.exec(`
|
|
40
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS session_fts USING fts5(
|
|
41
|
+
session_id UNINDEXED,
|
|
42
|
+
role UNINDEXED,
|
|
43
|
+
content,
|
|
44
|
+
timestamp UNINDEXED,
|
|
45
|
+
tokenize='unicode61'
|
|
46
|
+
)
|
|
47
|
+
`);
|
|
48
|
+
this._initialized = true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Extract text messages from a JSONL session.
|
|
53
|
+
* @param {string} sessionId
|
|
54
|
+
* @returns {Array<{role: string, content: string, timestamp: number}>}
|
|
55
|
+
*/
|
|
56
|
+
extractMessages(sessionId) {
|
|
57
|
+
const events = readEvents(sessionId);
|
|
58
|
+
const messages = [];
|
|
59
|
+
|
|
60
|
+
for (const event of events) {
|
|
61
|
+
if (event.type === "user_message" || event.type === "assistant_message") {
|
|
62
|
+
const content = event.data?.content;
|
|
63
|
+
if (content && typeof content === "string" && content.trim()) {
|
|
64
|
+
messages.push({
|
|
65
|
+
role:
|
|
66
|
+
event.data.role ||
|
|
67
|
+
(event.type === "user_message" ? "user" : "assistant"),
|
|
68
|
+
content: content.substring(0, MAX_CONTENT_LENGTH),
|
|
69
|
+
timestamp: event.timestamp || 0,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return messages;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Index a single session's messages into the FTS table.
|
|
80
|
+
* Removes existing entries for this session first (idempotent).
|
|
81
|
+
*
|
|
82
|
+
* @param {string} sessionId
|
|
83
|
+
* @returns {{ indexed: number }} count of messages indexed
|
|
84
|
+
*/
|
|
85
|
+
indexSession(sessionId) {
|
|
86
|
+
if (!this._db) return { indexed: 0 };
|
|
87
|
+
if (!this._initialized) this.ensureTables();
|
|
88
|
+
|
|
89
|
+
const messages = this.extractMessages(sessionId);
|
|
90
|
+
if (messages.length === 0) return { indexed: 0 };
|
|
91
|
+
|
|
92
|
+
// Remove existing entries for this session (idempotent re-index)
|
|
93
|
+
this._db.exec(
|
|
94
|
+
`DELETE FROM session_fts WHERE session_id = '${sessionId.replace(/'/g, "''")}'`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const insert = this._db.prepare(
|
|
98
|
+
`INSERT INTO session_fts (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const insertMany = this._db.transaction((msgs) => {
|
|
102
|
+
for (const msg of msgs) {
|
|
103
|
+
insert.run(sessionId, msg.role, msg.content, String(msg.timestamp));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
insertMany(messages);
|
|
108
|
+
return { indexed: messages.length };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Search across all indexed sessions.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} query - FTS5 search query
|
|
115
|
+
* @param {object} [options]
|
|
116
|
+
* @param {number} [options.limit=10] - Max results
|
|
117
|
+
* @returns {Array<{sessionId: string, role: string, snippet: string, timestamp: string, rank: number}>}
|
|
118
|
+
*/
|
|
119
|
+
search(query, options = {}) {
|
|
120
|
+
if (!this._db) return [];
|
|
121
|
+
if (!this._initialized) this.ensureTables();
|
|
122
|
+
if (!query || !query.trim()) return [];
|
|
123
|
+
|
|
124
|
+
const limit = options.limit || DEFAULT_SEARCH_LIMIT;
|
|
125
|
+
|
|
126
|
+
// Use FTS5 match with highlight for snippet extraction
|
|
127
|
+
const stmt = this._db.prepare(`
|
|
128
|
+
SELECT
|
|
129
|
+
session_id as sessionId,
|
|
130
|
+
role,
|
|
131
|
+
highlight(session_fts, 2, '>>>', '<<<') as snippet,
|
|
132
|
+
timestamp,
|
|
133
|
+
rank
|
|
134
|
+
FROM session_fts
|
|
135
|
+
WHERE session_fts MATCH ?
|
|
136
|
+
ORDER BY rank
|
|
137
|
+
LIMIT ?
|
|
138
|
+
`);
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
return stmt.all(query, limit);
|
|
142
|
+
} catch (_err) {
|
|
143
|
+
// FTS5 syntax error (e.g. special chars) — try as quoted phrase
|
|
144
|
+
try {
|
|
145
|
+
return stmt.all(`"${query.replace(/"/g, '""')}"`, limit);
|
|
146
|
+
} catch (_err2) {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Reindex all existing JSONL sessions into FTS.
|
|
154
|
+
* Useful for one-time backfill of historical sessions.
|
|
155
|
+
*
|
|
156
|
+
* @returns {{ sessions: number, messages: number }}
|
|
157
|
+
*/
|
|
158
|
+
reindexAll() {
|
|
159
|
+
if (!this._db) return { sessions: 0, messages: 0 };
|
|
160
|
+
if (!this._initialized) this.ensureTables();
|
|
161
|
+
|
|
162
|
+
// Clear all existing FTS data
|
|
163
|
+
this._db.exec(`DELETE FROM session_fts`);
|
|
164
|
+
|
|
165
|
+
const sessions = listJsonlSessions({ limit: 10000 });
|
|
166
|
+
let totalMessages = 0;
|
|
167
|
+
|
|
168
|
+
for (const session of sessions) {
|
|
169
|
+
const result = this.indexSession(session.id);
|
|
170
|
+
totalMessages += result.indexed;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { sessions: sessions.length, messages: totalMessages };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get index statistics.
|
|
178
|
+
* @returns {{ totalRows: number }}
|
|
179
|
+
*/
|
|
180
|
+
getStats() {
|
|
181
|
+
if (!this._db) return { totalRows: 0 };
|
|
182
|
+
if (!this._initialized) this.ensureTables();
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const row = this._db
|
|
186
|
+
.prepare(`SELECT COUNT(*) as cnt FROM session_fts`)
|
|
187
|
+
.get();
|
|
188
|
+
return { totalRows: row?.cnt || 0 };
|
|
189
|
+
} catch (_err) {
|
|
190
|
+
return { totalRows: 0 };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -42,7 +42,8 @@ export class SubAgentContext {
|
|
|
42
42
|
* @param {string} [options.parentId] - Parent context ID (null for root)
|
|
43
43
|
* @param {string|null} [options.inheritedContext] - Condensed context from parent
|
|
44
44
|
* @param {string[]} [options.allowedTools] - Tool whitelist (null = all tools)
|
|
45
|
-
* @param {number} [options.maxIterations] - Iteration limit
|
|
45
|
+
* @param {number} [options.maxIterations] - Iteration limit (fallback if no budget)
|
|
46
|
+
* @param {import('./iteration-budget.js').IterationBudget} [options.iterationBudget] - Shared iteration budget (takes priority over maxIterations)
|
|
46
47
|
* @param {number} [options.tokenBudget] - Optional token budget
|
|
47
48
|
* @param {object} [options.db] - Database instance
|
|
48
49
|
* @param {object} [options.permanentMemory] - Permanent memory instance
|
|
@@ -61,6 +62,7 @@ export class SubAgentContext {
|
|
|
61
62
|
this.role = options.role || "general";
|
|
62
63
|
this.task = options.task || "";
|
|
63
64
|
this.maxIterations = options.maxIterations || DEFAULT_MAX_ITERATIONS;
|
|
65
|
+
this.iterationBudget = options.iterationBudget || null; // shared budget from parent
|
|
64
66
|
this.tokenBudget = options.tokenBudget || null;
|
|
65
67
|
this.inheritedContext = options.inheritedContext || null;
|
|
66
68
|
this.allowedTools = options.allowedTools || null; // null = all
|
|
@@ -207,13 +209,16 @@ export class SubAgentContext {
|
|
|
207
209
|
// Build filtered tool list
|
|
208
210
|
const tools = this._getFilteredTools();
|
|
209
211
|
|
|
210
|
-
// Merge LLM options
|
|
212
|
+
// Merge LLM options — pass shared iteration budget if available
|
|
211
213
|
const options = {
|
|
212
214
|
...this._llmOptions,
|
|
213
215
|
contextEngine: this.contextEngine,
|
|
214
216
|
cwd: this.cwd,
|
|
215
217
|
...loopOptions,
|
|
216
218
|
};
|
|
219
|
+
if (this.iterationBudget) {
|
|
220
|
+
options.iterationBudget = this.iterationBudget;
|
|
221
|
+
}
|
|
217
222
|
|
|
218
223
|
try {
|
|
219
224
|
// Use a separate messages array for the agent loop
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User Profile — persistent USER.md for AI-curated user preferences.
|
|
3
|
+
*
|
|
4
|
+
* Stores user preferences, coding style, communication style, and tech stack
|
|
5
|
+
* in a global USER.md file. AI-curated with character limit and automatic
|
|
6
|
+
* consolidation.
|
|
7
|
+
*
|
|
8
|
+
* Inspired by Hermes Agent's USER.md user profile system.
|
|
9
|
+
*
|
|
10
|
+
* @module user-profile
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
14
|
+
import { join, dirname } from "node:path";
|
|
15
|
+
import { getHomeDir } from "./paths.js";
|
|
16
|
+
|
|
17
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const USER_PROFILE_FILENAME = "USER.md";
|
|
20
|
+
const MAX_PROFILE_LENGTH = 2000; // characters
|
|
21
|
+
|
|
22
|
+
// ─── Exported for test injection ────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export const _deps = {
|
|
25
|
+
readFileSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
existsSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ─── Path ───────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get the USER.md file path.
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function getUserProfilePath() {
|
|
38
|
+
return join(getHomeDir(), USER_PROFILE_FILENAME);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Read ───────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read the user profile content.
|
|
45
|
+
* @returns {string} Profile content, or empty string if not found
|
|
46
|
+
*/
|
|
47
|
+
export function readUserProfile() {
|
|
48
|
+
const profilePath = getUserProfilePath();
|
|
49
|
+
try {
|
|
50
|
+
if (_deps.existsSync(profilePath)) {
|
|
51
|
+
return _deps.readFileSync(profilePath, "utf-8");
|
|
52
|
+
}
|
|
53
|
+
} catch (_err) {
|
|
54
|
+
// Graceful degradation
|
|
55
|
+
}
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Write ──────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Update the user profile with new content.
|
|
63
|
+
* Enforces MAX_PROFILE_LENGTH character limit.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} content - New profile content
|
|
66
|
+
* @returns {{ written: boolean, truncated: boolean, length: number }}
|
|
67
|
+
*/
|
|
68
|
+
export function updateUserProfile(content) {
|
|
69
|
+
if (!content || typeof content !== "string") {
|
|
70
|
+
return { written: false, truncated: false, length: 0 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const profilePath = getUserProfilePath();
|
|
74
|
+
const dir = dirname(profilePath);
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
if (!_deps.existsSync(dir)) {
|
|
78
|
+
_deps.mkdirSync(dir, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let truncated = false;
|
|
82
|
+
let finalContent = content.trim();
|
|
83
|
+
if (finalContent.length > MAX_PROFILE_LENGTH) {
|
|
84
|
+
finalContent = finalContent.substring(0, MAX_PROFILE_LENGTH);
|
|
85
|
+
truncated = true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_deps.writeFileSync(profilePath, finalContent, "utf-8");
|
|
89
|
+
return { written: true, truncated, length: finalContent.length };
|
|
90
|
+
} catch (_err) {
|
|
91
|
+
return { written: false, truncated: false, length: 0 };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Append ─────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Append a line to the user profile.
|
|
99
|
+
* If the profile would exceed MAX_PROFILE_LENGTH, returns needsConsolidation: true.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} line - Line to append
|
|
102
|
+
* @returns {{ appended: boolean, needsConsolidation: boolean, length: number }}
|
|
103
|
+
*/
|
|
104
|
+
export function appendToUserProfile(line) {
|
|
105
|
+
if (!line || typeof line !== "string") {
|
|
106
|
+
return { appended: false, needsConsolidation: false, length: 0 };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const current = readUserProfile();
|
|
110
|
+
const newContent = current ? `${current}\n${line.trim()}` : line.trim();
|
|
111
|
+
|
|
112
|
+
if (newContent.length > MAX_PROFILE_LENGTH) {
|
|
113
|
+
return {
|
|
114
|
+
appended: false,
|
|
115
|
+
needsConsolidation: true,
|
|
116
|
+
length: current.length,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const result = updateUserProfile(newContent);
|
|
121
|
+
return {
|
|
122
|
+
appended: result.written,
|
|
123
|
+
needsConsolidation: false,
|
|
124
|
+
length: result.length,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Consolidation ──────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Consolidate the user profile using an LLM to stay within character limits.
|
|
132
|
+
* The LLM merges and summarizes the profile, preserving key preferences.
|
|
133
|
+
*
|
|
134
|
+
* @param {function} llmFn - async (prompt) => string — LLM call function
|
|
135
|
+
* @returns {Promise<{ consolidated: boolean, oldLength: number, newLength: number }>}
|
|
136
|
+
*/
|
|
137
|
+
export async function consolidateUserProfile(llmFn) {
|
|
138
|
+
const current = readUserProfile();
|
|
139
|
+
if (!current || current.length <= MAX_PROFILE_LENGTH) {
|
|
140
|
+
return {
|
|
141
|
+
consolidated: false,
|
|
142
|
+
oldLength: current.length,
|
|
143
|
+
newLength: current.length,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const prompt = `You are consolidating a user profile for an AI assistant. Merge and summarize the following user preferences into a concise profile under ${MAX_PROFILE_LENGTH} characters. Preserve the most important preferences, coding style, and tech stack information. Return ONLY the consolidated profile text, no explanations.\n\n---\n${current}\n---`;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const consolidated = await llmFn(prompt);
|
|
151
|
+
if (consolidated && typeof consolidated === "string") {
|
|
152
|
+
const result = updateUserProfile(consolidated);
|
|
153
|
+
return {
|
|
154
|
+
consolidated: true,
|
|
155
|
+
oldLength: current.length,
|
|
156
|
+
newLength: result.length,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
} catch (_err) {
|
|
160
|
+
// Consolidation is optional; failure is non-critical
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
consolidated: false,
|
|
165
|
+
oldLength: current.length,
|
|
166
|
+
newLength: current.length,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Exports ────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
export const MAX_USER_PROFILE_LENGTH = MAX_PROFILE_LENGTH;
|
package/src/lib/web-ui-server.js
CHANGED
|
@@ -571,7 +571,7 @@ function buildHtml({
|
|
|
571
571
|
const btnQuestionSubmit = $('btn-question-submit');
|
|
572
572
|
const tabBtns = document.querySelectorAll('.tab-btn');
|
|
573
573
|
|
|
574
|
-
// ── Init mode badge
|
|
574
|
+
// ── Init mode badge ──────────────────────────────────────────────────────
|
|
575
575
|
if (CFG.mode === 'project') {
|
|
576
576
|
modeBadge.innerHTML =
|
|
577
577
|
'<div id="project-badge">' +
|