@memtensor/memos-local-openclaw-plugin 1.0.8-beta.2 → 1.0.8-beta.3

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.
@@ -51,6 +51,9 @@ export class TaskProcessor {
51
51
  * Determines if a new task boundary was crossed and handles transition.
52
52
  */
53
53
  async onChunksIngested(sessionKey: string, latestTimestamp: number, owner?: string): Promise<void> {
54
+ if (sessionKey.startsWith("temp:") || sessionKey.startsWith("internal:") || sessionKey.startsWith("system:")) {
55
+ return;
56
+ }
54
57
  const resolvedOwner = owner ?? "agent:main";
55
58
  this.ctx.log.debug(`TaskProcessor.onChunksIngested called session=${sessionKey} ts=${latestTimestamp} owner=${resolvedOwner} processing=${this.processing}`);
56
59
  this.pendingEvents.push({ sessionKey, latestTimestamp, owner: resolvedOwner });
@@ -79,13 +82,19 @@ export class TaskProcessor {
79
82
  }
80
83
  }
81
84
 
85
+ private static extractAgentPrefix(sessionKey: string): string {
86
+ const parts = sessionKey.split(":");
87
+ return parts.length >= 3 ? parts.slice(0, 3).join(":") : sessionKey;
88
+ }
89
+
82
90
  private async detectAndProcess(sessionKey: string, latestTimestamp: number, owner: string): Promise<void> {
83
91
  this.ctx.log.debug(`TaskProcessor.detectAndProcess session=${sessionKey} owner=${owner}`);
84
92
 
93
+ const currentAgentPrefix = TaskProcessor.extractAgentPrefix(sessionKey);
85
94
  const allActive = this.store.getAllActiveTasks(owner);
86
95
  for (const t of allActive) {
87
- if (t.sessionKey !== sessionKey) {
88
- this.ctx.log.info(`Session changed: finalizing task=${t.id} from session=${t.sessionKey} (owner=${owner})`);
96
+ if (t.sessionKey !== sessionKey && TaskProcessor.extractAgentPrefix(t.sessionKey) === currentAgentPrefix) {
97
+ this.ctx.log.info(`Session changed within agent: finalizing task=${t.id} from session=${t.sessionKey} (owner=${owner})`);
89
98
  await this.finalizeTask(t);
90
99
  }
91
100
  }
@@ -179,26 +188,36 @@ export class TaskProcessor {
179
188
  continue;
180
189
  }
181
190
 
182
- // LLM topic judgment — check this single user message against full task context
183
- const context = this.buildContextSummary(currentTaskChunks);
191
+ // Structured topic classification
192
+ const taskState = this.buildTopicJudgeState(currentTaskChunks, userChunk);
184
193
  const newMsg = userChunk.content.slice(0, 500);
185
- this.ctx.log.info(`Topic judge: "${newMsg.slice(0, 60)}" vs ${existingUserCount} user turns`);
186
- const isNew = await this.summarizer.judgeNewTopic(context, newMsg);
187
- this.ctx.log.info(`Topic judge result: ${isNew === null ? "null(fallback)" : isNew ? "NEW" : "SAME"}`);
194
+ this.ctx.log.info(`Topic classify: "${newMsg.slice(0, 60)}" vs ${existingUserCount} user turns`);
195
+ const result = await this.summarizer.classifyTopic(taskState, newMsg);
196
+ this.ctx.log.info(`Topic classify: decision=${result?.decision ?? "null"} confidence=${result?.confidence ?? "?"} type=${result?.boundaryType ?? "?"} reason=${result?.reason ?? ""}`);
188
197
 
189
- if (isNew === null) {
198
+ if (!result || result.decision === "SAME") {
190
199
  this.assignChunksToTask(turn, currentTask.id);
191
200
  currentTaskChunks = currentTaskChunks.concat(turn);
192
201
  continue;
193
202
  }
194
203
 
195
- if (isNew) {
196
- this.ctx.log.info(`Task boundary at turn ${i}: LLM judged new topic. Msg: "${newMsg.slice(0, 80)}..."`);
197
- await this.finalizeTask(currentTask);
198
- currentTask = await this.createNewTaskReturn(sessionKey, userChunk.createdAt, owner);
199
- currentTaskChunks = [];
204
+ // Low-confidence NEW: second-pass arbitration
205
+ if (result.confidence < 0.65) {
206
+ this.ctx.log.info(`Low confidence NEW (${result.confidence}), running second-pass arbitration...`);
207
+ const secondResult = await this.summarizer.arbitrateTopicSplit(taskState, newMsg);
208
+ this.ctx.log.info(`Second-pass result: ${secondResult ?? "null(fallback->SAME)"}`);
209
+ if (!secondResult || secondResult !== "NEW") {
210
+ this.assignChunksToTask(turn, currentTask.id);
211
+ currentTaskChunks = currentTaskChunks.concat(turn);
212
+ continue;
213
+ }
200
214
  }
201
215
 
216
+ this.ctx.log.info(`Task boundary at turn ${i}: classifier judged NEW (confidence=${result.confidence}). Msg: "${newMsg.slice(0, 80)}..."`);
217
+ await this.finalizeTask(currentTask);
218
+ currentTask = await this.createNewTaskReturn(sessionKey, userChunk.createdAt, owner);
219
+ currentTaskChunks = [];
220
+
202
221
  this.assignChunksToTask(turn, currentTask.id);
203
222
  currentTaskChunks = currentTaskChunks.concat(turn);
204
223
  }
@@ -226,38 +245,39 @@ export class TaskProcessor {
226
245
  }
227
246
 
228
247
  /**
229
- * Build context from existing task chunks for the LLM topic judge.
230
- * Includes both the task's opening topic and recent exchanges,
231
- * so the LLM understands both what the task was originally about
232
- * and where the conversation currently is.
233
- *
234
- * For user messages, include full content (up to 500 chars) since
235
- * they carry the topic signal. For assistant messages, use summary
236
- * or truncated content since they mostly elaborate.
248
+ * Build compact task state for the LLM topic classifier.
249
+ * Includes: topic (first user msg), last 3 turn summaries,
250
+ * and optional assistant snippet for short/ambiguous messages.
237
251
  */
238
- private buildContextSummary(chunks: Chunk[]): string {
239
- const conversational = chunks.filter((c) => c.role === "user" || c.role === "assistant");
240
- if (conversational.length === 0) return "";
241
-
242
- const formatChunk = (c: Chunk) => {
243
- const label = c.role === "user" ? "User" : "Assistant";
244
- const maxLen = c.role === "user" ? 500 : 200;
245
- const text = c.summary || c.content.slice(0, maxLen);
246
- return `[${label}]: ${text}`;
247
- };
252
+ private buildTopicJudgeState(chunks: Chunk[], newUserChunk: Chunk): string {
253
+ const conv = chunks.filter((c) => c.role === "user" || c.role === "assistant");
254
+ if (conv.length === 0) return "";
255
+
256
+ const firstUser = conv.find((c) => c.role === "user");
257
+ const topic = firstUser?.summary || firstUser?.content.slice(0, 80) || "";
258
+
259
+ const turns: Array<{ u: string; a: string }> = [];
260
+ for (let j = 0; j < conv.length; j++) {
261
+ if (conv[j].role === "user") {
262
+ const u = conv[j].summary || conv[j].content.slice(0, 60);
263
+ const nextA = conv[j + 1]?.role === "assistant" ? conv[j + 1] : null;
264
+ const a = nextA ? (nextA.summary || nextA.content.slice(0, 60)) : "";
265
+ turns.push({ u, a });
266
+ }
267
+ }
268
+
269
+ const recent = turns.slice(-3);
270
+ const turnLines = recent.map((t, i) => `${i + 1}. U:${t.u} A:${t.a}`);
248
271
 
249
- if (conversational.length <= 10) {
250
- return conversational.map(formatChunk).join("\n");
272
+ let snippet = "";
273
+ if (newUserChunk.content.length < 30 || /^[那这它其还哪啥]/.test(newUserChunk.content.trim())) {
274
+ const lastA = [...conv].reverse().find((c) => c.role === "assistant");
275
+ if (lastA) snippet = lastA.content.slice(0, 200);
251
276
  }
252
277
 
253
- const opening = conversational.slice(0, 6).map(formatChunk);
254
- const recent = conversational.slice(-4).map(formatChunk);
255
- return [
256
- "--- Task opening ---",
257
- ...opening,
258
- "--- Recent exchanges ---",
259
- ...recent,
260
- ].join("\n");
278
+ const parts = [`topic:${topic}`, ...turnLines];
279
+ if (snippet) parts.push(`lastA:${snippet}`);
280
+ return parts.join("\n");
261
281
  }
262
282
 
263
283
  private async createNewTaskReturn(sessionKey: string, timestamp: number, owner: string = "agent:main"): Promise<Task> {
@@ -25,8 +25,14 @@ export class IngestWorker {
25
25
 
26
26
  getTaskProcessor(): TaskProcessor { return this.taskProcessor; }
27
27
 
28
+ private static isEphemeralSession(sessionKey: string): boolean {
29
+ return sessionKey.startsWith("temp:") || sessionKey.startsWith("internal:") || sessionKey.startsWith("system:");
30
+ }
31
+
28
32
  enqueue(messages: ConversationMessage[]): void {
29
- this.queue.push(...messages);
33
+ const filtered = messages.filter((m) => !IngestWorker.isEphemeralSession(m.sessionKey));
34
+ if (filtered.length === 0) return;
35
+ this.queue.push(...filtered);
30
36
  if (!this.processing) {
31
37
  this.processQueue().catch((err) => {
32
38
  this.ctx.log.error(`Ingest worker error: ${err}`);
@@ -150,14 +156,23 @@ export class IngestWorker {
150
156
  let mergeHistory = "[]";
151
157
 
152
158
  // Fast path: exact content_hash match within same owner (agent dimension)
159
+ // Strategy: retire the OLD chunk, keep the NEW one active (latest wins)
153
160
  const chunkOwner = msg.owner ?? "agent:main";
154
161
  const existingByHash = this.store.findActiveChunkByHash(content, chunkOwner);
155
162
  if (existingByHash) {
156
- this.ctx.log.debug(`Exact-dup (owner=${chunkOwner}): hash match → existing=${existingByHash}`);
163
+ this.ctx.log.debug(`Exact-dup (owner=${chunkOwner}): hash match → retiring old=${existingByHash}, keeping new=${chunkId}`);
157
164
  this.store.recordMergeHit(existingByHash, "DUPLICATE", "exact content hash match");
158
- dedupStatus = "duplicate";
159
- dedupTarget = existingByHash;
165
+ const oldChunk = this.store.getChunk(existingByHash);
166
+ this.store.markDedupStatus(existingByHash, "duplicate", chunkId, "exact content hash match");
167
+ this.store.deleteEmbedding(existingByHash);
168
+ mergedFromOld = existingByHash;
160
169
  dedupReason = "exact content hash match";
170
+ if (oldChunk) {
171
+ const oldHistory = JSON.parse(oldChunk.mergeHistory || "[]");
172
+ oldHistory.push({ action: "duplicate_superseded", at: Date.now(), reason: "exact content hash match", sourceChunkId: existingByHash });
173
+ mergeHistory = JSON.stringify(oldHistory);
174
+ mergeCount = (oldChunk.mergeCount || 0) + 1;
175
+ }
161
176
  }
162
177
 
163
178
  // Smart dedup: find Top-5 similar chunks, then ask LLM to judge
@@ -173,8 +188,9 @@ export class IngestWorker {
173
188
  index: i + 1,
174
189
  summary: chunk?.summary ?? "",
175
190
  chunkId: s.chunkId,
191
+ role: chunk?.role,
176
192
  };
177
- }).filter(c => c.summary);
193
+ }).filter(c => c.summary && c.role === msg.role);
178
194
 
179
195
  if (candidates.length > 0) {
180
196
  const dedupResult = await this.summarizer.judgeDedup(summary, candidates);
@@ -183,10 +199,18 @@ export class IngestWorker {
183
199
  const targetChunkId = candidates[dedupResult.targetIndex - 1]?.chunkId;
184
200
  if (targetChunkId) {
185
201
  this.store.recordMergeHit(targetChunkId, "DUPLICATE", dedupResult.reason);
186
- dedupStatus = "duplicate";
187
- dedupTarget = targetChunkId;
202
+ const oldChunk = this.store.getChunk(targetChunkId);
203
+ this.store.markDedupStatus(targetChunkId, "duplicate", chunkId, dedupResult.reason);
204
+ this.store.deleteEmbedding(targetChunkId);
205
+ mergedFromOld = targetChunkId;
188
206
  dedupReason = dedupResult.reason;
189
- this.ctx.log.debug(`Smart dedup: DUPLICATE → target=${targetChunkId}, storing with status=duplicate, reason: ${dedupResult.reason}`);
207
+ if (oldChunk) {
208
+ const oldHistory = JSON.parse(oldChunk.mergeHistory || "[]");
209
+ oldHistory.push({ action: "duplicate_superseded", at: Date.now(), reason: dedupResult.reason, sourceChunkId: targetChunkId });
210
+ mergeHistory = JSON.stringify(oldHistory);
211
+ mergeCount = (oldChunk.mergeCount || 0) + 1;
212
+ }
213
+ this.ctx.log.debug(`Smart dedup: DUPLICATE → retiring old=${targetChunkId}, keeping new=${chunkId} active, reason: ${dedupResult.reason}`);
190
214
  }
191
215
  }
192
216
 
@@ -266,9 +290,6 @@ export class IngestWorker {
266
290
  }
267
291
  this.ctx.log.debug(`Stored chunk=${chunkId} kind=${kind} role=${msg.role} dedup=${dedupStatus} len=${content.length} hasVec=${!!embedding && dedupStatus === "active"}`);
268
292
 
269
- if (dedupStatus === "duplicate") {
270
- return { action: "duplicate", summary, targetChunkId: dedupTarget ?? undefined, reason: dedupReason ?? undefined };
271
- }
272
293
  if (mergedFromOld) {
273
294
  return { action: "merged", chunkId, summary, targetChunkId: mergedFromOld, reason: dedupReason ?? undefined };
274
295
  }
@@ -234,6 +234,7 @@ export class RecallEngine {
234
234
  score: Math.round(candidate.score * 1000) / 1000,
235
235
  taskId: chunk.taskId,
236
236
  skillId: chunk.skillId,
237
+ owner: chunk.owner,
237
238
  origin: chunk.owner === "public" ? "local-shared" : "local",
238
239
  source: {
239
240
  ts: chunk.createdAt,
@@ -38,6 +38,7 @@ export interface HubSearchHit {
38
38
  hubRank: number;
39
39
  taskTitle: string | null;
40
40
  ownerName: string;
41
+ sourceAgent: string;
41
42
  groupName: string | null;
42
43
  visibility: SharedVisibility;
43
44
  source: {
@@ -110,10 +110,12 @@ export class SqliteStore {
110
110
  this.migrateOwnerFields();
111
111
  this.migrateSkillVisibility();
112
112
  this.migrateSkillEmbeddingsAndFts();
113
+ this.migrateTaskTopicColumn();
113
114
  this.migrateTaskEmbeddingsAndFts();
114
115
  this.migrateFtsToTrigram();
115
116
  this.migrateHubTables();
116
117
  this.migrateHubFtsToTrigram();
118
+ this.migrateHubMemorySourceAgent();
117
119
  this.migrateLocalSharedTasksOwner();
118
120
  this.migrateHubUserIdentityFields();
119
121
  this.migrateClientHubConnectionIdentityFields();
@@ -125,6 +127,16 @@ export class SqliteStore {
125
127
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_chunks_dedup_created ON chunks(dedup_status, created_at DESC)");
126
128
  }
127
129
 
130
+ private migrateHubMemorySourceAgent(): void {
131
+ try {
132
+ const cols = this.db.prepare("PRAGMA table_info(hub_memories)").all() as Array<{ name: string }>;
133
+ if (cols.length > 0 && !cols.some((c) => c.name === "source_agent")) {
134
+ this.db.exec("ALTER TABLE hub_memories ADD COLUMN source_agent TEXT NOT NULL DEFAULT ''");
135
+ this.log.info("Migrated: added source_agent column to hub_memories");
136
+ }
137
+ } catch { /* table may not exist yet */ }
138
+ }
139
+
128
140
  private migrateLocalSharedTasksOwner(): void {
129
141
  try {
130
142
  const cols = this.db.prepare("PRAGMA table_info(local_shared_tasks)").all() as Array<{ name: string }>;
@@ -557,6 +569,14 @@ export class SqliteStore {
557
569
  }
558
570
  }
559
571
 
572
+ private migrateTaskTopicColumn(): void {
573
+ const cols = this.db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
574
+ if (!cols.some((c) => c.name === "topic")) {
575
+ this.db.exec("ALTER TABLE tasks ADD COLUMN topic TEXT DEFAULT NULL");
576
+ this.log.info("Migrated: added topic column to tasks");
577
+ }
578
+ }
579
+
560
580
  private migrateTaskSkillMeta(): void {
561
581
  const cols = this.db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
562
582
  if (!cols.some((c) => c.name === "skill_status")) {
@@ -1042,6 +1062,7 @@ export class SqliteStore {
1042
1062
  id TEXT PRIMARY KEY,
1043
1063
  source_chunk_id TEXT NOT NULL,
1044
1064
  source_user_id TEXT NOT NULL,
1065
+ source_agent TEXT NOT NULL DEFAULT '',
1045
1066
  role TEXT NOT NULL,
1046
1067
  content TEXT NOT NULL,
1047
1068
  summary TEXT NOT NULL DEFAULT '',
@@ -1483,8 +1504,15 @@ export class SqliteStore {
1483
1504
 
1484
1505
  deleteAll(): number {
1485
1506
  this.db.exec("PRAGMA foreign_keys = OFF");
1507
+ try {
1508
+ this.db.exec("DROP TRIGGER IF EXISTS tasks_fts_ai");
1509
+ this.db.exec("DROP TRIGGER IF EXISTS tasks_fts_ad");
1510
+ this.db.exec("DROP TRIGGER IF EXISTS tasks_fts_au");
1511
+ this.db.exec("DELETE FROM tasks_fts");
1512
+ } catch (_) {}
1486
1513
  const tables = [
1487
1514
  "task_skills",
1515
+ "task_embeddings",
1488
1516
  "skill_embeddings",
1489
1517
  "skill_versions",
1490
1518
  "skills",
@@ -1507,6 +1535,7 @@ export class SqliteStore {
1507
1535
  }
1508
1536
  }
1509
1537
  this.db.exec("PRAGMA foreign_keys = ON");
1538
+ this.migrateTaskEmbeddingsAndFts();
1510
1539
  const remaining = this.countChunks();
1511
1540
  return remaining === 0 ? 1 : 0;
1512
1541
  }
@@ -2574,9 +2603,10 @@ export class SqliteStore {
2574
2603
 
2575
2604
  upsertHubMemory(memory: HubMemoryRecord): void {
2576
2605
  this.db.prepare(`
2577
- INSERT INTO hub_memories (id, source_chunk_id, source_user_id, role, content, summary, kind, group_id, visibility, created_at, updated_at)
2578
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2606
+ INSERT INTO hub_memories (id, source_chunk_id, source_user_id, source_agent, role, content, summary, kind, group_id, visibility, created_at, updated_at)
2607
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2579
2608
  ON CONFLICT(source_user_id, source_chunk_id) DO UPDATE SET
2609
+ source_agent = excluded.source_agent,
2580
2610
  role = excluded.role,
2581
2611
  content = excluded.content,
2582
2612
  summary = excluded.summary,
@@ -2585,7 +2615,7 @@ export class SqliteStore {
2585
2615
  visibility = excluded.visibility,
2586
2616
  created_at = excluded.created_at,
2587
2617
  updated_at = excluded.updated_at
2588
- `).run(memory.id, memory.sourceChunkId, memory.sourceUserId, memory.role, memory.content, memory.summary, memory.kind, memory.groupId, memory.visibility, memory.createdAt, memory.updatedAt);
2618
+ `).run(memory.id, memory.sourceChunkId, memory.sourceUserId, memory.sourceAgent, memory.role, memory.content, memory.summary, memory.kind, memory.groupId, memory.visibility, memory.createdAt, memory.updatedAt);
2589
2619
  }
2590
2620
 
2591
2621
  getHubMemoryBySource(sourceUserId: string, sourceChunkId: string): HubMemoryRecord | null {
@@ -2751,7 +2781,7 @@ export class SqliteStore {
2751
2781
  if (!sanitized) return [];
2752
2782
  const rows = this.db.prepare(`
2753
2783
  SELECT hm.id, hm.content, hm.summary, hm.role, hm.created_at, hm.visibility, '' as group_name, hu.username as owner_name,
2754
- bm25(hub_memories_fts) as rank
2784
+ COALESCE(hm.source_agent, '') as source_agent, bm25(hub_memories_fts) as rank
2755
2785
  FROM hub_memories_fts f
2756
2786
  JOIN hub_memories hm ON hm.rowid = f.rowid
2757
2787
  LEFT JOIN hub_users hu ON hu.id = hm.source_user_id
@@ -2767,7 +2797,7 @@ export class SqliteStore {
2767
2797
  getVisibleHubSearchHitByMemoryId(memoryId: string, userId: string): HubMemorySearchRow | null {
2768
2798
  const row = this.db.prepare(`
2769
2799
  SELECT hm.id, hm.content, hm.summary, hm.role, hm.created_at, hm.visibility, '' as group_name, hu.username as owner_name,
2770
- 0 as rank
2800
+ COALESCE(hm.source_agent, '') as source_agent, 0 as rank
2771
2801
  FROM hub_memories hm
2772
2802
  LEFT JOIN hub_users hu ON hu.id = hm.source_user_id
2773
2803
  WHERE hm.id = ?
@@ -3261,6 +3291,7 @@ export interface HubMemoryRecord {
3261
3291
  id: string;
3262
3292
  sourceChunkId: string;
3263
3293
  sourceUserId: string;
3294
+ sourceAgent: string;
3264
3295
  role: string;
3265
3296
  content: string;
3266
3297
  summary: string;
@@ -3275,6 +3306,7 @@ interface HubMemoryRow {
3275
3306
  id: string;
3276
3307
  source_chunk_id: string;
3277
3308
  source_user_id: string;
3309
+ source_agent: string;
3278
3310
  role: string;
3279
3311
  content: string;
3280
3312
  summary: string;
@@ -3290,6 +3322,7 @@ function rowToHubMemory(row: HubMemoryRow): HubMemoryRecord {
3290
3322
  id: row.id,
3291
3323
  sourceChunkId: row.source_chunk_id,
3292
3324
  sourceUserId: row.source_user_id,
3325
+ sourceAgent: row.source_agent || "",
3293
3326
  role: row.role,
3294
3327
  content: row.content,
3295
3328
  summary: row.summary,
@@ -3310,6 +3343,7 @@ interface HubMemorySearchRow {
3310
3343
  visibility: string;
3311
3344
  group_name: string | null;
3312
3345
  owner_name: string | null;
3346
+ source_agent: string;
3313
3347
  rank: number;
3314
3348
  }
3315
3349
 
package/src/types.ts CHANGED
@@ -322,6 +322,8 @@ export interface MemosLocalConfig {
322
322
  skillEvolution?: SkillEvolutionConfig;
323
323
  telemetry?: TelemetryConfig;
324
324
  sharing?: SharingConfig;
325
+ /** Hours of inactivity after which an active task is automatically finalized. 0 = disabled. Default 4. */
326
+ taskAutoFinalizeHours?: number;
325
327
  }
326
328
 
327
329
  // ─── Defaults ───
@@ -357,6 +359,7 @@ export const DEFAULTS = {
357
359
  skillAutoRecallLimit: 2,
358
360
  skillPreferUpgrade: true,
359
361
  skillRedactSensitive: true,
362
+ taskAutoFinalizeHours: 4,
360
363
  } as const;
361
364
 
362
365
  // ─── Plugin Hooks (OpenClaw integration) ───