@memtensor/memos-local-openclaw-plugin 0.1.9 → 0.2.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.
Files changed (49) hide show
  1. package/.env.example +6 -0
  2. package/README.md +90 -25
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +8 -0
  5. package/dist/config.js.map +1 -1
  6. package/dist/embedding/local.d.ts.map +1 -1
  7. package/dist/embedding/local.js +3 -2
  8. package/dist/embedding/local.js.map +1 -1
  9. package/dist/skill/evaluator.js +1 -1
  10. package/dist/skill/evaluator.js.map +1 -1
  11. package/dist/skill/generator.js +1 -1
  12. package/dist/skill/generator.js.map +1 -1
  13. package/dist/skill/upgrader.js +1 -1
  14. package/dist/skill/upgrader.js.map +1 -1
  15. package/dist/skill/validator.js +1 -1
  16. package/dist/skill/validator.js.map +1 -1
  17. package/dist/storage/sqlite.d.ts +4 -0
  18. package/dist/storage/sqlite.d.ts.map +1 -1
  19. package/dist/storage/sqlite.js +19 -0
  20. package/dist/storage/sqlite.js.map +1 -1
  21. package/dist/telemetry.d.ts +37 -0
  22. package/dist/telemetry.d.ts.map +1 -0
  23. package/dist/telemetry.js +179 -0
  24. package/dist/telemetry.js.map +1 -0
  25. package/dist/types.d.ts +6 -1
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/types.js.map +1 -1
  28. package/dist/viewer/html.d.ts +1 -1
  29. package/dist/viewer/html.d.ts.map +1 -1
  30. package/dist/viewer/html.js +828 -52
  31. package/dist/viewer/html.js.map +1 -1
  32. package/dist/viewer/server.d.ts +25 -1
  33. package/dist/viewer/server.d.ts.map +1 -1
  34. package/dist/viewer/server.js +807 -0
  35. package/dist/viewer/server.js.map +1 -1
  36. package/index.ts +31 -3
  37. package/openclaw.plugin.json +3 -3
  38. package/package.json +4 -3
  39. package/src/config.ts +11 -0
  40. package/src/embedding/local.ts +3 -2
  41. package/src/skill/evaluator.ts +1 -1
  42. package/src/skill/generator.ts +1 -1
  43. package/src/skill/upgrader.ts +1 -1
  44. package/src/skill/validator.ts +1 -1
  45. package/src/storage/sqlite.ts +29 -0
  46. package/src/telemetry.ts +160 -0
  47. package/src/types.ts +7 -1
  48. package/src/viewer/html.ts +828 -52
  49. package/src/viewer/server.ts +818 -1
@@ -3,11 +3,18 @@ import crypto from "node:crypto";
3
3
  import { execSync } from "node:child_process";
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
+ import readline from "node:readline";
6
7
  import type { SqliteStore } from "../storage/sqlite";
7
8
  import type { Embedder } from "../embedding";
9
+ import { Summarizer } from "../ingest/providers";
10
+ import { findTopSimilar } from "../ingest/dedup";
8
11
  import { vectorSearch } from "../storage/vector";
9
- import type { Logger } from "../types";
12
+ import { TaskProcessor } from "../ingest/task-processor";
13
+ import { RecallEngine } from "../recall/engine";
14
+ import { SkillEvolver } from "../skill/evolver";
15
+ import type { Logger, Chunk, PluginContext } from "../types";
10
16
  import { viewerHTML } from "./html";
17
+ import { v4 as uuid } from "uuid";
11
18
 
12
19
  export interface ViewerServerOptions {
13
20
  store: SqliteStore;
@@ -15,6 +22,7 @@ export interface ViewerServerOptions {
15
22
  port: number;
16
23
  log: Logger;
17
24
  dataDir: string;
25
+ ctx?: PluginContext;
18
26
  }
19
27
 
20
28
  interface AuthState {
@@ -31,9 +39,31 @@ export class ViewerServer {
31
39
  private readonly dataDir: string;
32
40
  private readonly authFile: string;
33
41
  private readonly auth: AuthState;
42
+ private readonly ctx?: PluginContext;
34
43
 
35
44
  private static readonly SESSION_TTL = 24 * 60 * 60 * 1000;
36
45
  private resetToken: string;
46
+ private migrationRunning = false;
47
+ private migrationAbort = false;
48
+ private migrationState: {
49
+ phase: string;
50
+ stored: number;
51
+ skipped: number;
52
+ merged: number;
53
+ errors: number;
54
+ processed: number;
55
+ total: number;
56
+ lastItem: any;
57
+ done: boolean;
58
+ stopped: boolean;
59
+ } = { phase: "", stored: 0, skipped: 0, merged: 0, errors: 0, processed: 0, total: 0, lastItem: null, done: false, stopped: false };
60
+ private migrationSSEClients: http.ServerResponse[] = [];
61
+
62
+ private ppRunning = false;
63
+ private ppAbort = false;
64
+ private ppState: { running: boolean; done: boolean; stopped: boolean; processed: number; total: number; tasksCreated: number; skillsCreated: number; errors: number } =
65
+ { running: false, done: false, stopped: false, processed: 0, total: 0, tasksCreated: 0, skillsCreated: 0, errors: 0 };
66
+ private ppSSEClients: http.ServerResponse[] = [];
37
67
 
38
68
  constructor(opts: ViewerServerOptions) {
39
69
  this.store = opts.store;
@@ -41,6 +71,7 @@ export class ViewerServer {
41
71
  this.port = opts.port;
42
72
  this.log = opts.log;
43
73
  this.dataDir = opts.dataDir;
74
+ this.ctx = opts.ctx;
44
75
  this.authFile = path.join(opts.dataDir, "viewer-auth.json");
45
76
  this.auth = { passwordHash: null, sessions: new Map() };
46
77
  this.resetToken = crypto.randomBytes(16).toString("hex");
@@ -178,6 +209,15 @@ export class ViewerServer {
178
209
  else if (p === "/api/config" && req.method === "GET") this.serveConfig(res);
179
210
  else if (p === "/api/config" && req.method === "PUT") this.handleSaveConfig(req, res);
180
211
  else if (p === "/api/auth/logout" && req.method === "POST") this.handleLogout(req, res);
212
+ else if (p === "/api/migrate/scan" && req.method === "GET") this.handleMigrateScan(res);
213
+ else if (p === "/api/migrate/start" && req.method === "POST") this.handleMigrateStart(req, res);
214
+ else if (p === "/api/migrate/status" && req.method === "GET") this.handleMigrateStatus(res);
215
+ else if (p === "/api/migrate/stream" && req.method === "GET") this.handleMigrateStream(res);
216
+ else if (p === "/api/migrate/stop" && req.method === "POST") this.handleMigrateStop(res);
217
+ else if (p === "/api/migrate/postprocess" && req.method === "POST") this.handlePostprocess(req, res);
218
+ else if (p === "/api/migrate/postprocess/stream" && req.method === "GET") this.handlePostprocessStream(res);
219
+ else if (p === "/api/migrate/postprocess/stop" && req.method === "POST") this.handlePostprocessStop(res);
220
+ else if (p === "/api/migrate/postprocess/status" && req.method === "GET") this.handlePostprocessStatus(res);
181
221
  else {
182
222
  res.writeHead(404, { "Content-Type": "application/json" });
183
223
  res.end(JSON.stringify({ error: "not found" }));
@@ -766,6 +806,7 @@ export class ViewerServer {
766
806
  if (newCfg.summarizer) config.summarizer = newCfg.summarizer;
767
807
  if (newCfg.skillEvolution) config.skillEvolution = newCfg.skillEvolution;
768
808
  if (newCfg.viewerPort) config.viewerPort = newCfg.viewerPort;
809
+ if (newCfg.telemetry !== undefined) config.telemetry = newCfg.telemetry;
769
810
 
770
811
  fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
771
812
  fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
@@ -794,6 +835,782 @@ export class ViewerServer {
794
835
  this.jsonResponse(res, { tools });
795
836
  }
796
837
 
838
+ // ─── Migration: scan OpenClaw built-in memory ───
839
+
840
+ private getOpenClawHome(): string {
841
+ const home = process.env.HOME || process.env.USERPROFILE || "";
842
+ return path.join(home, ".openclaw");
843
+ }
844
+
845
+ private handleMigrateScan(res: http.ServerResponse): void {
846
+ try {
847
+ const ocHome = this.getOpenClawHome();
848
+ const memoryDir = path.join(ocHome, "memory");
849
+ const sessionsDir = path.join(ocHome, "agents", "main", "sessions");
850
+
851
+ const sqliteFiles: Array<{ file: string; chunks: number }> = [];
852
+ if (fs.existsSync(memoryDir)) {
853
+ for (const f of fs.readdirSync(memoryDir)) {
854
+ if (f.endsWith(".sqlite")) {
855
+ try {
856
+ const Database = require("better-sqlite3");
857
+ const db = new Database(path.join(memoryDir, f), { readonly: true });
858
+ const row = db.prepare("SELECT COUNT(*) as cnt FROM chunks").get() as { cnt: number };
859
+ sqliteFiles.push({ file: f, chunks: row.cnt });
860
+ db.close();
861
+ } catch { /* skip unreadable */ }
862
+ }
863
+ }
864
+ }
865
+
866
+ let sessionCount = 0;
867
+ let messageCount = 0;
868
+ if (fs.existsSync(sessionsDir)) {
869
+ const jsonlFiles = fs.readdirSync(sessionsDir).filter(f => f.includes(".jsonl"));
870
+ sessionCount = jsonlFiles.length;
871
+ for (const f of jsonlFiles) {
872
+ try {
873
+ const content = fs.readFileSync(path.join(sessionsDir, f), "utf-8");
874
+ const lines = content.split("\n").filter(l => l.trim());
875
+ for (const line of lines) {
876
+ try {
877
+ const obj = JSON.parse(line);
878
+ if (obj.type === "message") {
879
+ const role = obj.message?.role ?? obj.role;
880
+ if (role === "user" || role === "assistant") messageCount++;
881
+ }
882
+ } catch { /* skip bad lines */ }
883
+ }
884
+ } catch { /* skip unreadable */ }
885
+ }
886
+ }
887
+
888
+ const cfgPath = this.getOpenClawConfigPath();
889
+ let hasEmbedding = false;
890
+ let hasSummarizer = false;
891
+ if (fs.existsSync(cfgPath)) {
892
+ try {
893
+ const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
894
+ const pluginCfg = raw?.plugins?.entries?.["memos-local"]?.config ??
895
+ raw?.plugins?.entries?.["memos-local-openclaw-plugin"]?.config ?? {};
896
+ const emb = pluginCfg.embedding;
897
+ hasEmbedding = !!(emb && emb.provider);
898
+ const sum = pluginCfg.summarizer;
899
+ hasSummarizer = !!(sum && sum.provider);
900
+ } catch { /* ignore */ }
901
+ }
902
+
903
+ const importedSessions = this.store.getDistinctSessionKeys()
904
+ .filter((sk: string) => sk.startsWith("openclaw-import-") || sk.startsWith("openclaw-session-"));
905
+
906
+ this.jsonResponse(res, {
907
+ sqliteFiles,
908
+ sessions: { count: sessionCount, messages: messageCount },
909
+ totalItems: sqliteFiles.reduce((s, f) => s + f.chunks, 0) + messageCount,
910
+ configReady: hasEmbedding && hasSummarizer,
911
+ hasEmbedding,
912
+ hasSummarizer,
913
+ hasImportedData: importedSessions.length > 0,
914
+ importedSessionCount: importedSessions.length,
915
+ });
916
+ } catch (e) {
917
+ this.log.warn(`migrate/scan error: ${e}`);
918
+ res.writeHead(500, { "Content-Type": "application/json" });
919
+ res.end(JSON.stringify({ error: String(e) }));
920
+ }
921
+ }
922
+
923
+ // ─── Migration: start import with SSE progress ───
924
+
925
+ private broadcastSSE(event: string, data: unknown): void {
926
+ const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
927
+ this.migrationSSEClients = this.migrationSSEClients.filter(c => {
928
+ try { c.write(msg); return true; } catch { return false; }
929
+ });
930
+ }
931
+
932
+ private handleMigrateStatus(res: http.ServerResponse): void {
933
+ this.jsonResponse(res, {
934
+ running: this.migrationRunning,
935
+ ...this.migrationState,
936
+ });
937
+ }
938
+
939
+ private handleMigrateStop(res: http.ServerResponse): void {
940
+ if (!this.migrationRunning) {
941
+ this.jsonResponse(res, { ok: false, error: "not_running" });
942
+ return;
943
+ }
944
+ this.migrationAbort = true;
945
+ this.jsonResponse(res, { ok: true });
946
+ }
947
+
948
+ private handleMigrateStream(res: http.ServerResponse): void {
949
+ res.writeHead(200, {
950
+ "Content-Type": "text/event-stream",
951
+ "Cache-Control": "no-cache",
952
+ "Connection": "keep-alive",
953
+ "X-Accel-Buffering": "no",
954
+ });
955
+
956
+ if (this.migrationRunning) {
957
+ res.write(`event: state\ndata: ${JSON.stringify(this.migrationState)}\n\n`);
958
+ } else if (this.migrationState.done) {
959
+ res.write(`event: state\ndata: ${JSON.stringify(this.migrationState)}\n\n`);
960
+ res.write(`event: done\ndata: ${JSON.stringify({ ok: true })}\n\n`);
961
+ res.end();
962
+ return;
963
+ }
964
+
965
+ this.migrationSSEClients.push(res);
966
+ res.on("close", () => {
967
+ this.migrationSSEClients = this.migrationSSEClients.filter(c => c !== res);
968
+ });
969
+ }
970
+
971
+ private handleMigrateStart(req: http.IncomingMessage, res: http.ServerResponse): void {
972
+ if (this.migrationRunning) {
973
+ res.writeHead(200, {
974
+ "Content-Type": "text/event-stream",
975
+ "Cache-Control": "no-cache",
976
+ "Connection": "keep-alive",
977
+ "X-Accel-Buffering": "no",
978
+ });
979
+ res.write(`event: state\ndata: ${JSON.stringify(this.migrationState)}\n\n`);
980
+ this.migrationSSEClients.push(res);
981
+ res.on("close", () => {
982
+ this.migrationSSEClients = this.migrationSSEClients.filter(c => c !== res);
983
+ });
984
+ return;
985
+ }
986
+
987
+ this.readBody(req, (body) => {
988
+ let opts: { sources?: string[] } = {};
989
+ try { opts = JSON.parse(body); } catch { /* defaults */ }
990
+
991
+ res.writeHead(200, {
992
+ "Content-Type": "text/event-stream",
993
+ "Cache-Control": "no-cache",
994
+ "Connection": "keep-alive",
995
+ "X-Accel-Buffering": "no",
996
+ });
997
+
998
+ this.migrationSSEClients.push(res);
999
+ res.on("close", () => {
1000
+ this.migrationSSEClients = this.migrationSSEClients.filter(c => c !== res);
1001
+ });
1002
+
1003
+ this.migrationAbort = false;
1004
+ this.migrationState = { phase: "", stored: 0, skipped: 0, merged: 0, errors: 0, processed: 0, total: 0, lastItem: null, done: false, stopped: false };
1005
+
1006
+ const send = (event: string, data: unknown) => {
1007
+ if (event === "item") {
1008
+ const d = data as any;
1009
+ if (d.status === "stored") this.migrationState.stored++;
1010
+ else if (d.status === "skipped" || d.status === "duplicate") this.migrationState.skipped++;
1011
+ else if (d.status === "merged") this.migrationState.merged++;
1012
+ else if (d.status === "error") this.migrationState.errors++;
1013
+ this.migrationState.processed = d.index ?? this.migrationState.processed + 1;
1014
+ this.migrationState.total = d.total ?? this.migrationState.total;
1015
+ this.migrationState.lastItem = d;
1016
+ } else if (event === "phase") {
1017
+ this.migrationState.phase = (data as any).phase;
1018
+ } else if (event === "progress") {
1019
+ this.migrationState.total = (data as any).total ?? this.migrationState.total;
1020
+ }
1021
+ this.broadcastSSE(event, data);
1022
+ };
1023
+
1024
+ this.migrationRunning = true;
1025
+ this.runMigration(send, opts.sources).finally(() => {
1026
+ this.migrationRunning = false;
1027
+ this.migrationState.done = true;
1028
+ if (this.migrationAbort) {
1029
+ this.migrationState.stopped = true;
1030
+ this.broadcastSSE("stopped", { ok: true, ...this.migrationState });
1031
+ } else {
1032
+ this.broadcastSSE("done", { ok: true });
1033
+ }
1034
+ for (const c of this.migrationSSEClients) {
1035
+ try { c.end(); } catch { /* ignore */ }
1036
+ }
1037
+ this.migrationSSEClients = [];
1038
+ this.migrationAbort = false;
1039
+ });
1040
+ });
1041
+ }
1042
+
1043
+ private async runMigration(
1044
+ send: (event: string, data: unknown) => void,
1045
+ sources?: string[],
1046
+ ): Promise<void> {
1047
+ const ocHome = this.getOpenClawHome();
1048
+ const importSqlite = !sources || sources.includes("sqlite");
1049
+ const importSessions = !sources || sources.includes("sessions");
1050
+
1051
+ let totalProcessed = 0;
1052
+ let totalStored = 0;
1053
+ let totalSkipped = 0;
1054
+ let totalErrors = 0;
1055
+
1056
+ const cfgPath = this.getOpenClawConfigPath();
1057
+ let summarizerCfg: any;
1058
+ try {
1059
+ const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
1060
+ const pluginCfg = raw?.plugins?.entries?.["memos-local"]?.config ??
1061
+ raw?.plugins?.entries?.["memos-local-openclaw-plugin"]?.config ?? {};
1062
+ summarizerCfg = pluginCfg.summarizer;
1063
+ } catch { /* no config */ }
1064
+
1065
+ const summarizer = new Summarizer(summarizerCfg, this.log);
1066
+
1067
+ // Phase 1: Import SQLite memory chunks
1068
+ if (importSqlite) {
1069
+ const memoryDir = path.join(ocHome, "memory");
1070
+ if (fs.existsSync(memoryDir)) {
1071
+ const files = fs.readdirSync(memoryDir).filter(f => f.endsWith(".sqlite"));
1072
+ for (const file of files) {
1073
+ if (this.migrationAbort) break;
1074
+ send("phase", { phase: "sqlite", file });
1075
+ try {
1076
+ const Database = require("better-sqlite3");
1077
+ const db = new Database(path.join(memoryDir, file), { readonly: true });
1078
+ const rows = db.prepare("SELECT id, path, text, updated_at FROM chunks ORDER BY updated_at ASC").all() as Array<{
1079
+ id: string; path: string; text: string; updated_at: number;
1080
+ }>;
1081
+ db.close();
1082
+
1083
+ const agentId = file.replace(".sqlite", "");
1084
+ send("progress", { total: rows.length, processed: 0, phase: "sqlite", file });
1085
+
1086
+ for (let i = 0; i < rows.length; i++) {
1087
+ if (this.migrationAbort) break;
1088
+ const row = rows[i];
1089
+ totalProcessed++;
1090
+
1091
+ const contentHash = crypto.createHash("sha256").update(row.text).digest("hex");
1092
+ if (this.store.chunkExistsByContent(`openclaw-import-${agentId}`, "assistant", row.text)) {
1093
+ totalSkipped++;
1094
+ send("item", {
1095
+ index: i + 1,
1096
+ total: rows.length,
1097
+ status: "skipped",
1098
+ preview: row.text.slice(0, 120),
1099
+ source: file,
1100
+ reason: "duplicate",
1101
+ });
1102
+ continue;
1103
+ }
1104
+
1105
+ try {
1106
+ const summary = await summarizer.summarize(row.text);
1107
+ let embedding: number[] | null = null;
1108
+ try {
1109
+ [embedding] = await this.embedder.embed([summary]);
1110
+ } catch (err) {
1111
+ this.log.warn(`Migration embed failed: ${err}`);
1112
+ }
1113
+
1114
+ let dedupStatus: "active" | "duplicate" | "merged" = "active";
1115
+ let dedupTarget: string | null = null;
1116
+ let dedupReason: string | null = null;
1117
+
1118
+ if (embedding) {
1119
+ const topSimilar = findTopSimilar(this.store, embedding, 0.85, 3, this.log);
1120
+ if (topSimilar.length > 0) {
1121
+ const candidates = topSimilar.map((s, idx) => {
1122
+ const chunk = this.store.getChunk(s.chunkId);
1123
+ return { index: idx + 1, summary: chunk?.summary ?? "", chunkId: s.chunkId };
1124
+ }).filter(c => c.summary);
1125
+
1126
+ if (candidates.length > 0) {
1127
+ const dedupResult = await summarizer.judgeDedup(summary, candidates);
1128
+ if (dedupResult?.action === "DUPLICATE" && dedupResult.targetIndex) {
1129
+ const targetId = candidates[dedupResult.targetIndex - 1]?.chunkId;
1130
+ if (targetId) {
1131
+ dedupStatus = "duplicate";
1132
+ dedupTarget = targetId;
1133
+ dedupReason = dedupResult.reason;
1134
+ }
1135
+ } else if (dedupResult?.action === "UPDATE" && dedupResult.targetIndex && dedupResult.mergedSummary) {
1136
+ const targetId = candidates[dedupResult.targetIndex - 1]?.chunkId;
1137
+ if (targetId) {
1138
+ this.store.updateChunkSummaryAndContent(targetId, dedupResult.mergedSummary, row.text);
1139
+ try {
1140
+ const [newEmb] = await this.embedder.embed([dedupResult.mergedSummary]);
1141
+ if (newEmb) this.store.upsertEmbedding(targetId, newEmb);
1142
+ } catch { /* best-effort */ }
1143
+ dedupStatus = "merged";
1144
+ dedupTarget = targetId;
1145
+ dedupReason = dedupResult.reason;
1146
+ }
1147
+ }
1148
+ }
1149
+ }
1150
+ }
1151
+
1152
+ const chunkId = uuid();
1153
+ const chunk: Chunk = {
1154
+ id: chunkId,
1155
+ sessionKey: `openclaw-import-${agentId}`,
1156
+ turnId: `import-${row.id}`,
1157
+ seq: 0,
1158
+ role: "assistant",
1159
+ content: row.text,
1160
+ kind: "paragraph",
1161
+ summary,
1162
+ embedding: null,
1163
+ taskId: null,
1164
+ skillId: null,
1165
+ dedupStatus,
1166
+ dedupTarget,
1167
+ dedupReason,
1168
+ mergeCount: 0,
1169
+ lastHitAt: null,
1170
+ mergeHistory: "[]",
1171
+ createdAt: row.updated_at * 1000,
1172
+ updatedAt: row.updated_at * 1000,
1173
+ };
1174
+
1175
+ this.store.insertChunk(chunk);
1176
+ if (embedding && dedupStatus === "active") {
1177
+ this.store.upsertEmbedding(chunkId, embedding);
1178
+ }
1179
+
1180
+ totalStored++;
1181
+ send("item", {
1182
+ index: i + 1,
1183
+ total: rows.length,
1184
+ status: dedupStatus === "active" ? "stored" : dedupStatus,
1185
+ preview: row.text.slice(0, 120),
1186
+ summary: summary.slice(0, 80),
1187
+ source: file,
1188
+ });
1189
+ } catch (err) {
1190
+ totalErrors++;
1191
+ send("item", {
1192
+ index: i + 1,
1193
+ total: rows.length,
1194
+ status: "error",
1195
+ preview: row.text.slice(0, 120),
1196
+ source: file,
1197
+ error: String(err).slice(0, 200),
1198
+ });
1199
+ }
1200
+ }
1201
+ } catch (err) {
1202
+ send("error", { file, error: String(err) });
1203
+ totalErrors++;
1204
+ }
1205
+ }
1206
+ }
1207
+ }
1208
+
1209
+ // Phase 2: Import session JSONL files
1210
+ if (importSessions) {
1211
+ const sessionsDir = path.join(ocHome, "agents", "main", "sessions");
1212
+ if (fs.existsSync(sessionsDir)) {
1213
+ const jsonlFiles = fs.readdirSync(sessionsDir).filter(f => f.includes(".jsonl")).sort();
1214
+ send("phase", { phase: "sessions", files: jsonlFiles.length });
1215
+
1216
+ let globalMsgIdx = 0;
1217
+ let totalMsgs = 0;
1218
+ for (const f of jsonlFiles) {
1219
+ try {
1220
+ const raw = fs.readFileSync(path.join(sessionsDir, f), "utf-8");
1221
+ for (const line of raw.split("\n")) {
1222
+ if (!line.trim()) continue;
1223
+ try {
1224
+ const obj = JSON.parse(line);
1225
+ if (obj.type === "message") {
1226
+ const role = obj.message?.role ?? obj.role;
1227
+ if (role === "user" || role === "assistant") totalMsgs++;
1228
+ }
1229
+ } catch { /* skip */ }
1230
+ }
1231
+ } catch { /* skip */ }
1232
+ }
1233
+
1234
+ for (const file of jsonlFiles) {
1235
+ if (this.migrationAbort) break;
1236
+ const sessionId = file.replace(/\.jsonl.*$/, "");
1237
+ const filePath = path.join(sessionsDir, file);
1238
+ send("progress", { total: totalMsgs, processed: globalMsgIdx, phase: "sessions", file });
1239
+
1240
+ try {
1241
+ const fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
1242
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
1243
+
1244
+ for await (const line of rl) {
1245
+ if (this.migrationAbort) break;
1246
+ if (!line.trim()) continue;
1247
+ let obj: any;
1248
+ try { obj = JSON.parse(line); } catch { continue; }
1249
+ if (obj.type !== "message") continue;
1250
+ const msgRole = obj.message?.role ?? obj.role;
1251
+ if (msgRole !== "user" && msgRole !== "assistant") continue;
1252
+
1253
+ const msgContent = obj.message?.content ?? obj.content;
1254
+ let content: string;
1255
+ if (typeof msgContent === "string") {
1256
+ content = msgContent;
1257
+ } else if (Array.isArray(msgContent)) {
1258
+ content = msgContent
1259
+ .filter((p: any) => p.type === "text" && p.text)
1260
+ .map((p: any) => p.text)
1261
+ .join("\n");
1262
+ } else {
1263
+ content = JSON.stringify(msgContent);
1264
+ }
1265
+ if (!content || content.length < 10) continue;
1266
+
1267
+ globalMsgIdx++;
1268
+ totalProcessed++;
1269
+
1270
+ const sessionKey = `openclaw-session-${sessionId}`;
1271
+ if (this.store.chunkExistsByContent(sessionKey, msgRole, content)) {
1272
+ totalSkipped++;
1273
+ send("item", {
1274
+ index: globalMsgIdx,
1275
+ total: totalMsgs,
1276
+ status: "skipped",
1277
+ preview: content.slice(0, 120),
1278
+ source: file,
1279
+ role: msgRole,
1280
+ reason: "duplicate",
1281
+ });
1282
+ continue;
1283
+ }
1284
+
1285
+ try {
1286
+ const summary = await summarizer.summarize(content);
1287
+ let embedding: number[] | null = null;
1288
+ try {
1289
+ [embedding] = await this.embedder.embed([summary]);
1290
+ } catch (err) {
1291
+ this.log.warn(`Migration embed failed: ${err}`);
1292
+ }
1293
+
1294
+ let dedupStatus: "active" | "duplicate" | "merged" = "active";
1295
+ let dedupTarget: string | null = null;
1296
+ let dedupReason: string | null = null;
1297
+
1298
+ if (embedding) {
1299
+ const topSimilar = findTopSimilar(this.store, embedding, 0.85, 3, this.log);
1300
+ if (topSimilar.length > 0) {
1301
+ const candidates = topSimilar.map((s, idx) => {
1302
+ const chunk = this.store.getChunk(s.chunkId);
1303
+ return { index: idx + 1, summary: chunk?.summary ?? "", chunkId: s.chunkId };
1304
+ }).filter(c => c.summary);
1305
+
1306
+ if (candidates.length > 0) {
1307
+ const dedupResult = await summarizer.judgeDedup(summary, candidates);
1308
+ if (dedupResult?.action === "DUPLICATE" && dedupResult.targetIndex) {
1309
+ const targetId = candidates[dedupResult.targetIndex - 1]?.chunkId;
1310
+ if (targetId) {
1311
+ dedupStatus = "duplicate";
1312
+ dedupTarget = targetId;
1313
+ dedupReason = dedupResult.reason;
1314
+ }
1315
+ } else if (dedupResult?.action === "UPDATE" && dedupResult.targetIndex && dedupResult.mergedSummary) {
1316
+ const targetId = candidates[dedupResult.targetIndex - 1]?.chunkId;
1317
+ if (targetId) {
1318
+ this.store.updateChunkSummaryAndContent(targetId, dedupResult.mergedSummary, content);
1319
+ try {
1320
+ const [newEmb] = await this.embedder.embed([dedupResult.mergedSummary]);
1321
+ if (newEmb) this.store.upsertEmbedding(targetId, newEmb);
1322
+ } catch { /* best-effort */ }
1323
+ dedupStatus = "merged";
1324
+ dedupTarget = targetId;
1325
+ dedupReason = dedupResult.reason;
1326
+ }
1327
+ }
1328
+ }
1329
+ }
1330
+ }
1331
+
1332
+ const chunkId = uuid();
1333
+ const msgTs = obj.message?.timestamp ?? obj.timestamp;
1334
+ const ts = msgTs ? new Date(msgTs).getTime() : Date.now();
1335
+ const chunk: Chunk = {
1336
+ id: chunkId,
1337
+ sessionKey,
1338
+ turnId: `import-${sessionId}-${globalMsgIdx}`,
1339
+ seq: 0,
1340
+ role: msgRole as any,
1341
+ content,
1342
+ kind: "paragraph",
1343
+ summary,
1344
+ embedding: null,
1345
+ taskId: null,
1346
+ skillId: null,
1347
+ dedupStatus,
1348
+ dedupTarget,
1349
+ dedupReason,
1350
+ mergeCount: 0,
1351
+ lastHitAt: null,
1352
+ mergeHistory: "[]",
1353
+ createdAt: ts,
1354
+ updatedAt: ts,
1355
+ };
1356
+
1357
+ this.store.insertChunk(chunk);
1358
+ if (embedding && dedupStatus === "active") {
1359
+ this.store.upsertEmbedding(chunkId, embedding);
1360
+ }
1361
+
1362
+ totalStored++;
1363
+ send("item", {
1364
+ index: globalMsgIdx,
1365
+ total: totalMsgs,
1366
+ status: dedupStatus === "active" ? "stored" : dedupStatus,
1367
+ preview: content.slice(0, 120),
1368
+ summary: summary.slice(0, 80),
1369
+ source: file,
1370
+ role: msgRole,
1371
+ });
1372
+ } catch (err) {
1373
+ totalErrors++;
1374
+ send("item", {
1375
+ index: globalMsgIdx,
1376
+ total: totalMsgs,
1377
+ status: "error",
1378
+ preview: content.slice(0, 120),
1379
+ source: file,
1380
+ error: String(err).slice(0, 200),
1381
+ });
1382
+ }
1383
+ }
1384
+ } catch (err) {
1385
+ send("error", { file, error: String(err) });
1386
+ totalErrors++;
1387
+ }
1388
+ }
1389
+ }
1390
+ }
1391
+
1392
+ send("summary", { totalProcessed, totalStored, totalSkipped, totalErrors });
1393
+ }
1394
+
1395
+ // ─── Post-processing: independent task/skill generation ───
1396
+
1397
+ private handlePostprocess(req: http.IncomingMessage, res: http.ServerResponse): void {
1398
+ if (this.ppRunning) {
1399
+ res.writeHead(409, { "Content-Type": "application/json" });
1400
+ res.end(JSON.stringify({ error: "postprocess already running" }));
1401
+ return;
1402
+ }
1403
+ if (!this.ctx) {
1404
+ res.writeHead(500, { "Content-Type": "application/json" });
1405
+ res.end(JSON.stringify({ error: "plugin context not available — please restart the gateway" }));
1406
+ return;
1407
+ }
1408
+
1409
+ this.readBody(req, (body) => {
1410
+ let opts: { enableTasks?: boolean; enableSkills?: boolean } = {};
1411
+ try { opts = JSON.parse(body); } catch { /* defaults */ }
1412
+
1413
+ res.writeHead(200, {
1414
+ "Content-Type": "text/event-stream",
1415
+ "Cache-Control": "no-cache",
1416
+ "Connection": "keep-alive",
1417
+ "X-Accel-Buffering": "no",
1418
+ });
1419
+
1420
+ this.ppSSEClients.push(res);
1421
+ res.on("close", () => { this.ppSSEClients = this.ppSSEClients.filter(c => c !== res); });
1422
+
1423
+ this.ppAbort = false;
1424
+ this.ppState = { running: true, done: false, stopped: false, processed: 0, total: 0, tasksCreated: 0, skillsCreated: 0, errors: 0 };
1425
+
1426
+ const send = (event: string, data: unknown) => {
1427
+ this.broadcastPPSSE(event, data);
1428
+ };
1429
+
1430
+ this.ppRunning = true;
1431
+ this.runPostprocess(send, !!opts.enableTasks, !!opts.enableSkills).finally(() => {
1432
+ this.ppRunning = false;
1433
+ this.ppState.running = false;
1434
+ this.ppState.done = true;
1435
+ if (this.ppAbort) {
1436
+ this.ppState.stopped = true;
1437
+ this.broadcastPPSSE("stopped", { ...this.ppState });
1438
+ } else {
1439
+ this.broadcastPPSSE("done", { ...this.ppState });
1440
+ }
1441
+ for (const c of this.ppSSEClients) { try { c.end(); } catch { /* */ } }
1442
+ this.ppSSEClients = [];
1443
+ this.ppAbort = false;
1444
+ });
1445
+ });
1446
+ }
1447
+
1448
+ private handlePostprocessStream(res: http.ServerResponse): void {
1449
+ res.writeHead(200, {
1450
+ "Content-Type": "text/event-stream",
1451
+ "Cache-Control": "no-cache",
1452
+ "Connection": "keep-alive",
1453
+ "X-Accel-Buffering": "no",
1454
+ });
1455
+
1456
+ if (this.ppRunning) {
1457
+ res.write(`event: state\ndata: ${JSON.stringify(this.ppState)}\n\n`);
1458
+ this.ppSSEClients.push(res);
1459
+ res.on("close", () => { this.ppSSEClients = this.ppSSEClients.filter(c => c !== res); });
1460
+ } else if (this.ppState.done) {
1461
+ const evt = this.ppState.stopped ? "stopped" : "done";
1462
+ res.write(`event: ${evt}\ndata: ${JSON.stringify(this.ppState)}\n\n`);
1463
+ res.end();
1464
+ } else {
1465
+ res.end();
1466
+ }
1467
+ }
1468
+
1469
+ private handlePostprocessStop(res: http.ServerResponse): void {
1470
+ this.ppAbort = true;
1471
+ this.jsonResponse(res, { ok: true });
1472
+ }
1473
+
1474
+ private handlePostprocessStatus(res: http.ServerResponse): void {
1475
+ this.jsonResponse(res, this.ppState);
1476
+ }
1477
+
1478
+ private broadcastPPSSE(event: string, data: unknown): void {
1479
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
1480
+ for (const c of this.ppSSEClients) {
1481
+ try { c.write(payload); } catch { /* */ }
1482
+ }
1483
+ }
1484
+
1485
+ private async runPostprocess(
1486
+ send: (event: string, data: unknown) => void,
1487
+ enableTasks: boolean,
1488
+ enableSkills: boolean,
1489
+ ): Promise<void> {
1490
+ const ctx = this.ctx!;
1491
+ const taskProcessor = new TaskProcessor(this.store, ctx);
1492
+ let skillEvolver: SkillEvolver | null = null;
1493
+
1494
+ if (enableSkills) {
1495
+ const recallEngine = new RecallEngine(this.store, this.embedder, ctx);
1496
+ skillEvolver = new SkillEvolver(this.store, recallEngine, ctx);
1497
+ taskProcessor.onTaskCompleted(async (task) => {
1498
+ try {
1499
+ await skillEvolver!.onTaskCompleted(task);
1500
+ this.ppState.skillsCreated++;
1501
+ send("skill", { taskId: task.id, title: task.title });
1502
+ } catch (err) {
1503
+ this.log.warn(`Postprocess skill evolution error: ${err}`);
1504
+ }
1505
+ });
1506
+ }
1507
+
1508
+ const importSessions = this.store.getDistinctSessionKeys()
1509
+ .filter((sk: string) => sk.startsWith("openclaw-import-") || sk.startsWith("openclaw-session-"));
1510
+
1511
+ type PendingItem = { sessionKey: string; action: "full" | "skill-only" };
1512
+ const pendingItems: PendingItem[] = [];
1513
+ let skippedCount = 0;
1514
+
1515
+ for (const sk of importSessions) {
1516
+ const hasTask = this.store.hasTaskForSession(sk);
1517
+ const hasSkill = this.store.hasSkillForSessionTask(sk);
1518
+
1519
+ if (enableTasks && !hasTask) {
1520
+ pendingItems.push({ sessionKey: sk, action: "full" });
1521
+ } else if (enableSkills && hasTask && !hasSkill) {
1522
+ pendingItems.push({ sessionKey: sk, action: "skill-only" });
1523
+ } else {
1524
+ skippedCount++;
1525
+ }
1526
+ }
1527
+
1528
+ this.ppState.total = pendingItems.length;
1529
+ send("info", {
1530
+ totalSessions: importSessions.length,
1531
+ alreadyProcessed: skippedCount,
1532
+ pending: pendingItems.length,
1533
+ });
1534
+ send("progress", { processed: 0, total: pendingItems.length });
1535
+
1536
+ for (let i = 0; i < pendingItems.length; i++) {
1537
+ if (this.ppAbort) break;
1538
+ const { sessionKey, action } = pendingItems[i];
1539
+ this.ppState.processed = i + 1;
1540
+
1541
+ send("item", {
1542
+ index: i + 1,
1543
+ total: pendingItems.length,
1544
+ session: sessionKey,
1545
+ step: "processing",
1546
+ action,
1547
+ });
1548
+
1549
+ try {
1550
+ if (action === "full") {
1551
+ await taskProcessor.onChunksIngested(sessionKey, Date.now());
1552
+ const activeTask = this.store.getActiveTask(sessionKey);
1553
+ if (activeTask) {
1554
+ await taskProcessor.finalizeTask(activeTask);
1555
+ const finalized = this.store.getTask(activeTask.id);
1556
+ this.ppState.tasksCreated++;
1557
+ send("item", {
1558
+ index: i + 1,
1559
+ total: pendingItems.length,
1560
+ session: sessionKey,
1561
+ step: "done",
1562
+ taskTitle: finalized?.title || "",
1563
+ taskStatus: finalized?.status || "",
1564
+ });
1565
+ } else {
1566
+ send("item", {
1567
+ index: i + 1,
1568
+ total: pendingItems.length,
1569
+ session: sessionKey,
1570
+ step: "done",
1571
+ taskTitle: "(no chunks)",
1572
+ });
1573
+ }
1574
+ } else if (action === "skill-only" && skillEvolver) {
1575
+ const completedTasks = this.store.getCompletedTasksForSession(sessionKey);
1576
+ let skillGenerated = false;
1577
+ for (const task of completedTasks) {
1578
+ if (this.ppAbort) break;
1579
+ try {
1580
+ await skillEvolver.onTaskCompleted(task);
1581
+ this.ppState.skillsCreated++;
1582
+ skillGenerated = true;
1583
+ send("skill", { taskId: task.id, title: task.title });
1584
+ } catch (err) {
1585
+ this.log.warn(`Skill evolution error for task=${task.id}: ${err}`);
1586
+ }
1587
+ }
1588
+ send("item", {
1589
+ index: i + 1,
1590
+ total: pendingItems.length,
1591
+ session: sessionKey,
1592
+ step: "done",
1593
+ taskTitle: completedTasks[0]?.title || sessionKey,
1594
+ action: "skill-only",
1595
+ skillGenerated,
1596
+ });
1597
+ }
1598
+ } catch (err) {
1599
+ this.ppState.errors++;
1600
+ this.log.warn(`Postprocess error for ${sessionKey}: ${err}`);
1601
+ send("item", {
1602
+ index: i + 1,
1603
+ total: pendingItems.length,
1604
+ session: sessionKey,
1605
+ step: "error",
1606
+ error: String(err).slice(0, 200),
1607
+ });
1608
+ }
1609
+
1610
+ send("progress", { processed: i + 1, total: pendingItems.length });
1611
+ }
1612
+ }
1613
+
797
1614
  private readBody(req: http.IncomingMessage, cb: (body: string) => void): void {
798
1615
  let body = "";
799
1616
  req.on("data", (chunk: Buffer) => { body += chunk.toString(); });