twinclaw 1.3.2 → 1.4.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/dist/api/handlers/browser.js +2 -1
- package/dist/api/handlers/debug.js +2 -1
- package/dist/api/router.js +2 -1
- package/dist/core/channels-cli.js +3 -1
- package/dist/core/chat-commands.js +198 -0
- package/dist/core/command-router.js +290 -0
- package/dist/core/doctor.js +2 -2
- package/dist/core/logs-cli.js +12 -11
- package/dist/core/onboarding.js +5 -237
- package/dist/core/queue-cli.js +2 -2
- package/dist/core/status-cli.js +2 -2
- package/dist/interfaces/telegram_handler.js +4 -188
- package/dist/interfaces/whatsapp_handler.js +7 -132
- package/dist/services/db.js +20 -60
- package/dist/services/device-pairing.js +2 -1
- package/dist/services/dm-pairing.js +2 -1
- package/dist/services/hooks.js +12 -0
- package/dist/services/job-scheduler.js +3 -1
- package/dist/services/model-router.js +3 -0
- package/dist/services/semantic-memory.js +16 -26
- package/dist/skills/builtin.js +35 -0
- package/dist/tools/persona-editor.js +56 -0
- package/package.json +2 -3
package/dist/services/db.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
|
-
import * as sqliteVec from 'sqlite-vec';
|
|
3
2
|
import path from 'path';
|
|
4
3
|
import fs from 'fs';
|
|
5
4
|
import { getConfigValue } from '../config/json-config.js';
|
|
6
5
|
import { getDatabasePath, ensureWorkspaceSubdirs } from '../config/workspace.js';
|
|
7
6
|
ensureWorkspaceSubdirs();
|
|
8
7
|
const DB_PATH = getDatabasePath();
|
|
9
|
-
const MEMORY_EMBEDDING_DIM = Number(getConfigValue('MEMORY_EMBEDDING_DIM') ?? '1536') || 1536;
|
|
10
8
|
const DEFAULT_MEMORY_TOP_K = 5;
|
|
11
9
|
const MAX_MEMORY_TOP_K = 50;
|
|
12
10
|
const ALLOW_CROSS_SESSION_MEMORY_FALLBACK = getConfigValue('MEMORY_ALLOW_CROSS_SESSION_FALLBACK')?.trim().toLowerCase() === 'true';
|
|
@@ -14,8 +12,6 @@ if (!fs.existsSync(path.dirname(DB_PATH))) {
|
|
|
14
12
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
15
13
|
}
|
|
16
14
|
export const db = new Database(DB_PATH);
|
|
17
|
-
// Load sqlite-vec C extension
|
|
18
|
-
sqliteVec.load(db);
|
|
19
15
|
db.pragma('foreign_keys = ON');
|
|
20
16
|
db.exec(`
|
|
21
17
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
@@ -58,11 +54,11 @@ db.exec(`
|
|
|
58
54
|
FOREIGN KEY(session_id) REFERENCES sessions(session_id)
|
|
59
55
|
);
|
|
60
56
|
|
|
61
|
-
-- Virtual table for
|
|
62
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
57
|
+
-- Virtual table for full-text search (FTS5) memory
|
|
58
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS semantic_memory_fts USING fts5(
|
|
59
|
+
session_id UNINDEXED,
|
|
60
|
+
fact_text,
|
|
61
|
+
tokenize="porter unicode61"
|
|
66
62
|
);
|
|
67
63
|
|
|
68
64
|
CREATE TABLE IF NOT EXISTS reasoning_nodes (
|
|
@@ -298,13 +294,6 @@ db.exec(`
|
|
|
298
294
|
CREATE INDEX IF NOT EXISTS idx_runtime_budget_events_created
|
|
299
295
|
ON runtime_budget_events(created_at DESC);
|
|
300
296
|
`);
|
|
301
|
-
function serializeEmbedding(embedding) {
|
|
302
|
-
const sqliteVecWithSerializer = sqliteVec;
|
|
303
|
-
if (typeof sqliteVecWithSerializer.serializeFloat32 === 'function') {
|
|
304
|
-
return sqliteVecWithSerializer.serializeFloat32(embedding);
|
|
305
|
-
}
|
|
306
|
-
return Buffer.from(new Float32Array(embedding).buffer);
|
|
307
|
-
}
|
|
308
297
|
function normalizeTopK(topK) {
|
|
309
298
|
if (!Number.isFinite(topK)) {
|
|
310
299
|
return DEFAULT_MEMORY_TOP_K;
|
|
@@ -327,55 +316,26 @@ export function getSessionMessages(sessionId) {
|
|
|
327
316
|
const stmt = db.prepare('SELECT * FROM messages WHERE session_id = ? ORDER BY rowid ASC');
|
|
328
317
|
return stmt.all(sessionId);
|
|
329
318
|
}
|
|
330
|
-
export function
|
|
331
|
-
const stmt = db.prepare('INSERT INTO
|
|
332
|
-
const result = stmt.run(
|
|
319
|
+
export function saveMemoryFact(sessionId, factText) {
|
|
320
|
+
const stmt = db.prepare('INSERT INTO semantic_memory_fts (session_id, fact_text) VALUES (?, ?)');
|
|
321
|
+
const result = stmt.run(sessionId, factText);
|
|
333
322
|
return Number(result.lastInsertRowid);
|
|
334
323
|
}
|
|
335
|
-
export function
|
|
336
|
-
const boundedTopK = normalizeTopK(topK);
|
|
337
|
-
const matcher = serializeEmbedding(queryEmbedding);
|
|
338
|
-
const stmt = db.prepare('SELECT rowid AS memory_rowid, session_id, fact_text, distance FROM vec_memory WHERE embedding MATCH ? ORDER BY distance ASC LIMIT ?');
|
|
339
|
-
const rows = stmt.all(matcher, boundedTopK * 3);
|
|
340
|
-
if (!currentSessionId) {
|
|
341
|
-
return rows.slice(0, boundedTopK);
|
|
342
|
-
}
|
|
343
|
-
const scoped = rows.filter((row) => row.session_id === currentSessionId);
|
|
344
|
-
if (!ALLOW_CROSS_SESSION_MEMORY_FALLBACK) {
|
|
345
|
-
return scoped.slice(0, boundedTopK);
|
|
346
|
-
}
|
|
347
|
-
const global = rows.filter((row) => row.session_id !== currentSessionId);
|
|
348
|
-
return [...scoped, ...global].slice(0, boundedTopK);
|
|
349
|
-
}
|
|
350
|
-
/**
|
|
351
|
-
* Keyword-based fallback search when vector embeddings are unavailable.
|
|
352
|
-
* Uses simple SQL LIKE matching.
|
|
353
|
-
*/
|
|
354
|
-
export function keywordSearchMemories(query, topK = 5, currentSessionId) {
|
|
324
|
+
export function searchMemoriesFTS(query, topK = 5, currentSessionId) {
|
|
355
325
|
const boundedTopK = normalizeTopK(topK);
|
|
356
|
-
|
|
357
|
-
const tokens = query
|
|
326
|
+
const words = query
|
|
358
327
|
.toLowerCase()
|
|
359
328
|
.replace(/[^\w\s]/g, ' ')
|
|
360
329
|
.split(/\s+/)
|
|
361
|
-
.filter((
|
|
362
|
-
if (
|
|
330
|
+
.filter((w) => w.length > 2);
|
|
331
|
+
if (words.length === 0) {
|
|
363
332
|
return [];
|
|
364
333
|
}
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
const
|
|
368
|
-
const sql = `
|
|
369
|
-
SELECT rowid AS memory_rowid, session_id, fact_text, 1.0 AS distance
|
|
370
|
-
FROM vec_memory
|
|
371
|
-
WHERE ${placeholders}
|
|
372
|
-
ORDER BY rowid DESC
|
|
373
|
-
LIMIT ?
|
|
374
|
-
`;
|
|
334
|
+
// Use natural language query format or standard FTS query
|
|
335
|
+
const ftsQuery = words.map((w) => `"${w}"*`).join(' OR ');
|
|
336
|
+
const stmt = db.prepare('SELECT rowid AS memory_rowid, session_id, fact_text, rank AS distance FROM semantic_memory_fts WHERE semantic_memory_fts MATCH ? ORDER BY rank ASC LIMIT ?');
|
|
375
337
|
try {
|
|
376
|
-
const
|
|
377
|
-
const values = tokens.map((t) => `%${t}%`);
|
|
378
|
-
const rows = stmt.all(...values, boundedTopK * 3);
|
|
338
|
+
const rows = stmt.all(ftsQuery, boundedTopK * 3);
|
|
379
339
|
if (!currentSessionId) {
|
|
380
340
|
return rows.slice(0, boundedTopK);
|
|
381
341
|
}
|
|
@@ -386,10 +346,10 @@ export function keywordSearchMemories(query, topK = 5, currentSessionId) {
|
|
|
386
346
|
const global = rows.filter((row) => row.session_id !== currentSessionId);
|
|
387
347
|
return [...scoped, ...global].slice(0, boundedTopK);
|
|
388
348
|
}
|
|
389
|
-
catch (
|
|
390
|
-
const message =
|
|
391
|
-
console.
|
|
392
|
-
|
|
349
|
+
catch (err) {
|
|
350
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
351
|
+
console.warn(`[DB] FTS search failed for query '${ftsQuery}': ${message}`);
|
|
352
|
+
return [];
|
|
393
353
|
}
|
|
394
354
|
}
|
|
395
355
|
// Re-export domain-specific DB helpers for backwards compatibility.
|
|
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { logThought } from '../utils/logger.js';
|
|
5
|
+
import { getWorkspaceDir } from '../config/workspace.js';
|
|
5
6
|
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
6
7
|
const CODE_LENGTH = 8;
|
|
7
8
|
const DEFAULT_CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes for device pairing
|
|
@@ -33,7 +34,7 @@ export class DevicePairingService {
|
|
|
33
34
|
constructor(options = {}) {
|
|
34
35
|
this.#credentialsDir = options.credentialsDir
|
|
35
36
|
? path.resolve(options.credentialsDir)
|
|
36
|
-
: path.
|
|
37
|
+
: path.join(getWorkspaceDir(), 'memory', 'devices');
|
|
37
38
|
this.#codeTtlMs = options.codeTtlMs ?? DEFAULT_CODE_TTL_MS;
|
|
38
39
|
this.#maxPending = options.maxPendingPerChannel ?? DEFAULT_MAX_PENDING;
|
|
39
40
|
// Load existing devices
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { getWorkspaceDir } from '../config/workspace.js';
|
|
4
5
|
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
5
6
|
const CODE_LENGTH = 8;
|
|
6
7
|
const DEFAULT_CODE_TTL_MS = 60 * 60 * 1000;
|
|
@@ -66,7 +67,7 @@ export class DmPairingService {
|
|
|
66
67
|
constructor(options = {}) {
|
|
67
68
|
this.#credentialsDir = options.credentialsDir
|
|
68
69
|
? path.resolve(options.credentialsDir)
|
|
69
|
-
: path.
|
|
70
|
+
: path.join(getWorkspaceDir(), 'memory', 'credentials');
|
|
70
71
|
this.#codeTtlMs =
|
|
71
72
|
Number.isFinite(options.codeTtlMs) && (options.codeTtlMs ?? 0) > 0
|
|
72
73
|
? Number(options.codeTtlMs)
|
package/dist/services/hooks.js
CHANGED
|
@@ -93,6 +93,18 @@ class HooksService {
|
|
|
93
93
|
}
|
|
94
94
|
return results;
|
|
95
95
|
}
|
|
96
|
+
async executeHook(event, data = {}) {
|
|
97
|
+
const context = {
|
|
98
|
+
event,
|
|
99
|
+
timestamp: new Date().toISOString(),
|
|
100
|
+
data,
|
|
101
|
+
};
|
|
102
|
+
// Use the sessionId if provided in the data payload
|
|
103
|
+
if (typeof data.sessionId === 'string') {
|
|
104
|
+
context.sessionId = data.sessionId;
|
|
105
|
+
}
|
|
106
|
+
return this.runHook(event, context);
|
|
107
|
+
}
|
|
96
108
|
async #runScript(scriptPath, context) {
|
|
97
109
|
const fullPath = path.isAbsolute(scriptPath)
|
|
98
110
|
? scriptPath
|
|
@@ -2,6 +2,7 @@ import cron from 'node-cron';
|
|
|
2
2
|
import { logThought } from '../utils/logger.js';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { getWorkspaceDir } from '../config/workspace.js';
|
|
5
6
|
/**
|
|
6
7
|
* Centralized job scheduler for TwinClaw's proactive execution layer.
|
|
7
8
|
*
|
|
@@ -30,7 +31,8 @@ export class JobScheduler {
|
|
|
30
31
|
* @param persistenceDir - Directory to store job persistence files
|
|
31
32
|
*/
|
|
32
33
|
constructor(persistenceDir) {
|
|
33
|
-
|
|
34
|
+
const workspaceMemoryDir = path.join(getWorkspaceDir(), 'memory');
|
|
35
|
+
this.#persistencePath = path.resolve(persistenceDir || workspaceMemoryDir, 'scheduler-jobs.json');
|
|
34
36
|
this.#loadPersistedJobs();
|
|
35
37
|
}
|
|
36
38
|
/** Register a new repeating job. Throws if a job with the same ID already exists. */
|
|
@@ -290,6 +290,7 @@ export class ModelRouter {
|
|
|
290
290
|
if (lastTriedModelId && lastTriedModelId !== config.id) {
|
|
291
291
|
this.metrics.failoverCount += 1;
|
|
292
292
|
this.recordEvent('failover', config, `Automatic fallback ${lastTriedModelId} -> ${config.id}.`);
|
|
293
|
+
console.warn(`[ModelRouter] ⚠️ Fallback triggered: Switching from ${lastTriedModelId} to ${config.id}`);
|
|
293
294
|
}
|
|
294
295
|
const payload = {
|
|
295
296
|
model: config.model,
|
|
@@ -317,6 +318,7 @@ export class ModelRouter {
|
|
|
317
318
|
const retryWaitMs = Math.min(firstAttempt.rateLimitCooldownMs, this.intelligentPacingMaxWaitMs);
|
|
318
319
|
if (retryWaitMs > 0) {
|
|
319
320
|
this.recordEvent('cooldown_wait', config, `Intelligent pacing wait ${retryWaitMs}ms before retrying ${config.id}.`);
|
|
321
|
+
console.warn(`[ModelRouter] ⏳ Rate limited. Intelligent pacing: waiting ${retryWaitMs}ms before retrying ${config.id}...`);
|
|
320
322
|
await this.sleepFn(retryWaitMs);
|
|
321
323
|
}
|
|
322
324
|
const retryCooldown = this.getModelCooldownState(config.id);
|
|
@@ -400,6 +402,7 @@ export class ModelRouter {
|
|
|
400
402
|
if (lastTriedModelId && lastTriedModelId !== config.id) {
|
|
401
403
|
this.metrics.failoverCount += 1;
|
|
402
404
|
this.recordEvent('failover', config, `Automatic fallback ${lastTriedModelId} -> ${config.id}.`);
|
|
405
|
+
console.warn(`[ModelRouter] ⚠️ Streaming Fallback triggered: Switching from ${lastTriedModelId} to ${config.id}`);
|
|
403
406
|
}
|
|
404
407
|
const payload = {
|
|
405
408
|
model: config.model,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { chunkText
|
|
3
|
-
import {
|
|
2
|
+
import { chunkText } from './embedding-service.js';
|
|
3
|
+
import { searchMemoriesFTS, saveMemoryFact, } from './db.js';
|
|
4
4
|
import { getMemoryProvenanceRows, getReasoningEvidenceExpansion, getReasoningNodesByClaimKey, linkMemoryProvenance, upsertReasoningEdge, upsertReasoningNode, } from './db-reasoning.js';
|
|
5
|
-
|
|
5
|
+
import { getHooksService } from './hooks.js';
|
|
6
6
|
const NEGATION_PATTERN = /\b(no|not|never|without|cannot|can't|won't|dont|don't|didnt|didn't|isnt|isn't|arent|aren't|wasnt|wasn't|weren't)\b/i;
|
|
7
7
|
const CLAIM_KEY_STOPWORDS = /\b(a|an|the|and|or|to|of|for|in|on|at|with|is|are|was|were|be|been|being|do|does|did|this|that|it|as|by|from|no|not|never|without|cannot|cant|wont)\b/g;
|
|
8
8
|
const TASK_HINT_PATTERN = /\b(todo|task|implement|fix|build|create|refactor|ship|deploy)\b/i;
|
|
@@ -20,11 +20,7 @@ export async function indexConversationTurn(sessionId, role, content) {
|
|
|
20
20
|
const limitedChunks = chunks.slice(0, 2);
|
|
21
21
|
for (const chunk of limitedChunks) {
|
|
22
22
|
const taggedChunk = `${role.toUpperCase()}: ${chunk}`;
|
|
23
|
-
const
|
|
24
|
-
if (!embedding) {
|
|
25
|
-
continue;
|
|
26
|
-
}
|
|
27
|
-
const memoryRowId = saveMemoryEmbedding(sessionId, taggedChunk, embedding);
|
|
23
|
+
const memoryRowId = saveMemoryFact(sessionId, taggedChunk);
|
|
28
24
|
const node = buildReasoningNode(sessionId, role, taggedChunk);
|
|
29
25
|
upsertReasoningNode(node);
|
|
30
26
|
linkMemoryProvenance(memoryRowId, node.nodeId, sessionId);
|
|
@@ -41,6 +37,11 @@ export async function indexConversationTurn(sessionId, role, content) {
|
|
|
41
37
|
previousNodeId = node.nodeId;
|
|
42
38
|
reconcileClaimRelations(node);
|
|
43
39
|
}
|
|
40
|
+
// Trigger proactive context alignment hook
|
|
41
|
+
const hooks = getHooksService();
|
|
42
|
+
hooks.executeHook('afterIndexTurn', { sessionId, role, content }).catch((err) => {
|
|
43
|
+
console.warn(`[SemanticMemory] Hook execution failed for afterIndexTurn:`, err);
|
|
44
|
+
});
|
|
44
45
|
}
|
|
45
46
|
catch (err) {
|
|
46
47
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -48,24 +49,13 @@ export async function indexConversationTurn(sessionId, role, content) {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
export async function retrieveEvidenceAwareMemoryContext(sessionId, prompt, topK = 5) {
|
|
51
|
-
const embedding = await embeddingService.embedText(prompt);
|
|
52
52
|
const candidateLimit = Math.max(topK * 3, topK);
|
|
53
|
-
|
|
54
|
-
let fallbackUsed = false;
|
|
55
|
-
if (embedding) {
|
|
56
|
-
nearest = getNearestMemories(embedding, candidateLimit, sessionId);
|
|
57
|
-
}
|
|
58
|
-
else {
|
|
59
|
-
nearest = keywordSearchMemories(prompt, candidateLimit, sessionId);
|
|
60
|
-
fallbackUsed = true;
|
|
61
|
-
}
|
|
53
|
+
const nearest = searchMemoriesFTS(prompt, candidateLimit, sessionId);
|
|
62
54
|
if (nearest.length === 0) {
|
|
63
55
|
return {
|
|
64
56
|
context: '',
|
|
65
57
|
diagnostics: [
|
|
66
|
-
|
|
67
|
-
? 'Keyword-fallback memory retrieval found no candidates for the active session scope.'
|
|
68
|
-
: 'Memory retrieval found no nearest vector candidates for the active session scope.'
|
|
58
|
+
'Memory retrieval found no FTS candidates for the active session scope.'
|
|
69
59
|
],
|
|
70
60
|
conflictCount: 0,
|
|
71
61
|
hitCount: 0,
|
|
@@ -93,9 +83,7 @@ export async function retrieveEvidenceAwareMemoryContext(sessionId, prompt, topK
|
|
|
93
83
|
return {
|
|
94
84
|
context: `Retrieved evidence-backed memories:\n${lines.join('\n')}${conflictNote}`,
|
|
95
85
|
diagnostics: [
|
|
96
|
-
|
|
97
|
-
? `Keyword-fallback memory retrieval ranked ${nearest.length} candidates down to ${ranked.length} items.`
|
|
98
|
-
: `Hybrid memory retrieval ranked ${nearest.length} vector candidates down to ${ranked.length} evidence-backed items.`,
|
|
86
|
+
`FTS memory retrieval ranked ${nearest.length} candidates down to ${ranked.length} evidence-backed items.`,
|
|
99
87
|
`Graph traversal depth=${MAX_GRAPH_TRAVERSAL_DEPTH} collected ${expandedEdges.length} relation edge(s).`,
|
|
100
88
|
conflictCount > 0
|
|
101
89
|
? `Detected contradiction signals in ${conflictCount} candidate(s).`
|
|
@@ -207,7 +195,9 @@ function buildGraphRelationCounts(edges) {
|
|
|
207
195
|
}
|
|
208
196
|
function scoreCandidate(sessionId, factText, memoryRowId, distance, options) {
|
|
209
197
|
const provenance = options.provenance;
|
|
210
|
-
|
|
198
|
+
// SQLite FTS5 bm25 rank is negative. More negative means better match.
|
|
199
|
+
const ftsRelevance = Math.max(0, -distance);
|
|
200
|
+
const ftsScore = Math.min(1, ftsRelevance * 0.15); // Scale BM25 to 0.0 - 1.0 range
|
|
211
201
|
const graphCounts = provenance
|
|
212
202
|
? options.graphRelationCounts.get(provenance.nodeId) ?? {
|
|
213
203
|
supports: 0,
|
|
@@ -223,7 +213,7 @@ function scoreCandidate(sessionId, factText, memoryRowId, distance, options) {
|
|
|
223
213
|
const relationScore = Math.min(1, supports * 0.2 + depends * 0.11 + derived * 0.08);
|
|
224
214
|
const recencyScore = estimateRecencyScore(provenance?.updatedAt);
|
|
225
215
|
const contradictionPenalty = Math.min(0.35, contradicts * 0.08);
|
|
226
|
-
const score =
|
|
216
|
+
const score = ftsScore * 0.62 + relationScore * 0.26 + recencyScore * 0.12 - contradictionPenalty;
|
|
227
217
|
return {
|
|
228
218
|
sessionId,
|
|
229
219
|
factText,
|
package/dist/skills/builtin.js
CHANGED
|
@@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
5
5
|
import { executeProgram, executeShell } from './shell.js';
|
|
6
6
|
import { logToolCall } from '../utils/logger.js';
|
|
7
7
|
import { validatePathInWorkspace } from '../config/workspace.js';
|
|
8
|
+
import { getPersonaEditorTool } from '../tools/persona-editor.js';
|
|
8
9
|
function buildReadFileSkill() {
|
|
9
10
|
return {
|
|
10
11
|
name: 'fs.read',
|
|
@@ -1038,6 +1039,39 @@ function buildVenvSkill() {
|
|
|
1038
1039
|
},
|
|
1039
1040
|
};
|
|
1040
1041
|
}
|
|
1042
|
+
function buildPersonaEditorSkill() {
|
|
1043
|
+
return {
|
|
1044
|
+
name: 'persona.edit',
|
|
1045
|
+
group: 'group:core',
|
|
1046
|
+
aliases: ['edit_persona', 'update_user_profile', 'update_soul'],
|
|
1047
|
+
description: 'Programmatically edit the agent\'s identity (soul.md) or user profile (user.md).',
|
|
1048
|
+
parameters: {
|
|
1049
|
+
type: 'object',
|
|
1050
|
+
properties: {
|
|
1051
|
+
target: { type: 'string', enum: ['user', 'soul'] },
|
|
1052
|
+
operation: { type: 'string', enum: ['append', 'replace', 'clear'] },
|
|
1053
|
+
content: { type: 'string' },
|
|
1054
|
+
},
|
|
1055
|
+
required: ['target', 'operation'],
|
|
1056
|
+
},
|
|
1057
|
+
async execute(input) {
|
|
1058
|
+
if (input.operation !== 'clear' && typeof input.content !== 'string') {
|
|
1059
|
+
return { ok: false, output: 'Content must be provided for append/replace operations' };
|
|
1060
|
+
}
|
|
1061
|
+
const request = input;
|
|
1062
|
+
const editor = getPersonaEditorTool();
|
|
1063
|
+
const result = await editor.editPersona({
|
|
1064
|
+
target: request.target,
|
|
1065
|
+
operation: request.operation,
|
|
1066
|
+
content: request.content || '',
|
|
1067
|
+
});
|
|
1068
|
+
return {
|
|
1069
|
+
ok: result.success,
|
|
1070
|
+
output: result.message,
|
|
1071
|
+
};
|
|
1072
|
+
},
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1041
1075
|
export function createBuiltinSkills() {
|
|
1042
1076
|
return [
|
|
1043
1077
|
buildReadFileSkill(),
|
|
@@ -1073,5 +1107,6 @@ export function createBuiltinSkills() {
|
|
|
1073
1107
|
buildDefenderSkill(),
|
|
1074
1108
|
buildPowerShellISESkill(),
|
|
1075
1109
|
buildVenvSkill(),
|
|
1110
|
+
buildPersonaEditorSkill(),
|
|
1076
1111
|
];
|
|
1077
1112
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ensureIdentityFiles } from '../config/identity-bootstrap.js';
|
|
2
|
+
import { getWorkspaceDir } from '../config/workspace.js';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import { logThought } from '../utils/logger.js';
|
|
6
|
+
export class PersonaEditorTool {
|
|
7
|
+
async editPersona(request) {
|
|
8
|
+
try {
|
|
9
|
+
const workspaceDir = getWorkspaceDir();
|
|
10
|
+
const identityDir = path.join(workspaceDir, 'identity');
|
|
11
|
+
// Ensure the directory and foundation files exist
|
|
12
|
+
ensureIdentityFiles();
|
|
13
|
+
const targetFile = request.target === 'user' ? 'user.md' : 'soul.md';
|
|
14
|
+
const filePath = path.join(identityDir, targetFile);
|
|
15
|
+
let currentContent = '';
|
|
16
|
+
try {
|
|
17
|
+
currentContent = await fs.readFile(filePath, 'utf-8');
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
// If it doesn't exist, we treat it as empty
|
|
21
|
+
currentContent = '';
|
|
22
|
+
}
|
|
23
|
+
let newContent = currentContent;
|
|
24
|
+
switch (request.operation) {
|
|
25
|
+
case 'append':
|
|
26
|
+
// Append with an explicit timestamp so the agent knows when this was learned
|
|
27
|
+
const timestamp = new Date().toISOString();
|
|
28
|
+
newContent = `${currentContent.trim()}\n\n[Learned at ${timestamp}]\n${request.content.trim()}`;
|
|
29
|
+
break;
|
|
30
|
+
case 'replace':
|
|
31
|
+
newContent = request.content.trim();
|
|
32
|
+
break;
|
|
33
|
+
case 'clear':
|
|
34
|
+
newContent = '';
|
|
35
|
+
break;
|
|
36
|
+
default:
|
|
37
|
+
return { success: false, message: `Unknown operation: ${request.operation}` };
|
|
38
|
+
}
|
|
39
|
+
await fs.writeFile(filePath, newContent, 'utf-8');
|
|
40
|
+
await logThought(`[PersonaEditor] Updated ${targetFile} via operation '${request.operation}'. Let the system know.`);
|
|
41
|
+
return { success: true, message: `Successfully updated ${targetFile}` };
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
45
|
+
await logThought(`[PersonaEditor] Error updating persona: ${errorMsg}`);
|
|
46
|
+
return { success: false, message: `Failed to edit persona: ${errorMsg}` };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
let personaEditorTool = null;
|
|
51
|
+
export function getPersonaEditorTool() {
|
|
52
|
+
if (!personaEditorTool) {
|
|
53
|
+
personaEditorTool = new PersonaEditorTool();
|
|
54
|
+
}
|
|
55
|
+
return personaEditorTool;
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "twinclaw",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Eagle-eyed agentic AI gateway with multi-modal hooks and proactive memory.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -53,7 +53,6 @@
|
|
|
53
53
|
"playwright": "^1.58.2",
|
|
54
54
|
"playwright-core": "^1.58.2",
|
|
55
55
|
"qrcode-terminal": "^0.12.0",
|
|
56
|
-
"sqlite-vec": "^0.1.7-alpha.2",
|
|
57
56
|
"ts-node": "^10.9.2",
|
|
58
57
|
"tsx": "^4.21.0",
|
|
59
58
|
"typescript": "^5.9.3",
|
|
@@ -83,4 +82,4 @@
|
|
|
83
82
|
"request": "npm:@cypress/request@^3.0.10",
|
|
84
83
|
"request-promise": "npm:@cypress/request-promise@^5.0.0"
|
|
85
84
|
}
|
|
86
|
-
}
|
|
85
|
+
}
|