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.
@@ -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;
@@ -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">' +